Skip to content

API reference

Everything below is importable from the top-level aniliberty package unless noted.

Client

aniliberty.AniLiberty

AniLiberty(
    *,
    base_url: str = DEFAULT_BASE_URL,
    timeout: float = DEFAULT_TIMEOUT,
    max_concurrency: int = DEFAULT_MAX_CONCURRENCY,
    headers: Mapping[str, str] | None = None,
    http_client: AsyncClient | None = None,
    token: str | None = None,
    cache_dir: str | PathLike[str] | None = None,
    cache_ttl: float | None = None,
)

Async client for the AniLibria API.

Groups the API into resource namespaces that follow the tags of its OpenAPI spec (client.app, ...). Use it as an async context manager so the connection pool is closed when you're done, or call aclose() yourself.

Attributes:

Name Type Description
http

The low-level client every resource uses. client.http.request(...) is the escape hatch for endpoints the SDK doesn't cover yet — it returns raw JSON.

app

Service endpoints — API status and quick release search.

releases

Anime releases — latest, random, recommended, by ID/alias, members.

episodes

Episodes of releases, with HLS links.

catalog

The release catalog; catalog.references lists the values its filters accept.

schedule

The weekly airing schedule.

genres

Genres, and the releases of each.

franchises

Franchises — groups of related releases.

torrents

Torrents, .torrent file downloads and RSS feeds.

teams

AniLibria's own teams, roles and members.

media

Front-page promo banners and AniLibria's YouTube videos.

auth

Logging in and out — by password, one-time code or social network.

me

The logged-in user's own account — profile, favorites, collections, watch progress and history (needs a session).

Example
import asyncio

from aniliberty import AniLiberty


async def main() -> None:
    async with AniLiberty() as client:
        status = await client.app.status()
        print(status.is_alive)


asyncio.run(main())

Initialize the client.

Parameters:

Name Type Description Default
base_url str

API base URL including the version prefix. Defaults to the main server, https://aniliberty.top/api/v1; https://anilibria.top/api/v1 is a mirror.

DEFAULT_BASE_URL
timeout float

Per-request timeout, in seconds. Ignored when http_client is given.

DEFAULT_TIMEOUT
max_concurrency int

Maximum number of requests in flight at once, no matter how many coroutines share this client. Requests over the limit wait for a free slot. Consecutive requests are also spaced at least 0.1 s apart, and 429/5xx/network failures are retried with backoff — see HttpClient.

DEFAULT_MAX_CONCURRENCY
headers Mapping[str, str] | None

Extra headers sent with every request, merged over the SDK's defaults (Accept: application/json and the SDK's User-Agent).

None
http_client AsyncClient | None

A ready httpx.AsyncClient to send requests with, e.g. to set up a proxy. The SDK doesn't close a client it didn't create.

None
token str | None

Session token of an earlier login (client.token saved from it), for the account endpoints. Without one, call client.auth.login(). Public endpoints don't need it.

None
cache_dir str | PathLike[str] | None

Directory to cache raw API responses in, e.g. ".aniliberty_cache". None (the default) disables caching. Methods with a refresh parameter — releases, franchises, genres, search, the catalog and its references, the schedule, teams, videos — answer repeated identical calls from the cache; random picks, episodes (HLS links), torrents and everything account-related always go to the API, and so does every request while token is set. See the caching guide.

None
cache_ttl float | None

Seconds after which a cached response is stale and fetched again, e.g. 6 * 3600. None means cached responses never expire on their own — pass refresh=True to a method to fetch anew anyway.

None

Raises:

Type Description
ValueError

max_concurrency is less than 1 or cache_ttl is negative.

Source code in src/aniliberty/client.py
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
def __init__(
    self,
    *,
    base_url: str = DEFAULT_BASE_URL,
    timeout: float = DEFAULT_TIMEOUT,
    max_concurrency: int = DEFAULT_MAX_CONCURRENCY,
    headers: Mapping[str, str] | None = None,
    http_client: httpx.AsyncClient | None = None,
    token: str | None = None,
    cache_dir: str | os.PathLike[str] | None = None,
    cache_ttl: float | None = None,
) -> None:
    """Initialize the client.

    Args:
        base_url: API base URL including the version prefix. Defaults to the main server,
            ``https://aniliberty.top/api/v1``; ``https://anilibria.top/api/v1`` is a
            mirror.
        timeout: Per-request timeout, in seconds. Ignored when ``http_client`` is given.
        max_concurrency: Maximum number of requests in flight at once, no matter how many
            coroutines share this client. Requests over the limit wait for a free slot.
            Consecutive requests are also spaced at least 0.1 s apart, and 429/5xx/network
            failures are retried with backoff — see ``HttpClient``.
        headers: Extra headers sent with every request, merged over the SDK's defaults
            (``Accept: application/json`` and the SDK's ``User-Agent``).
        http_client: A ready ``httpx.AsyncClient`` to send requests with, e.g. to set up
            a proxy. The SDK doesn't close a client it didn't create.
        token: Session token of an earlier login (``client.token`` saved from it), for
            the account endpoints. Without one, call ``client.auth.login()``. Public
            endpoints don't need it.
        cache_dir: Directory to cache raw API responses in, e.g. ``".aniliberty_cache"``.
            ``None`` (the default) disables caching. Methods with a ``refresh``
            parameter — releases, franchises, genres, search, the catalog and its
            references, the schedule, teams, videos — answer repeated identical calls
            from the cache; random picks, episodes (HLS links), torrents and everything
            account-related always go to the API, and so does every request while
            ``token`` is set. See the caching guide.
        cache_ttl: Seconds after which a cached response is stale and fetched again,
            e.g. ``6 * 3600``. ``None`` means cached responses never expire on their
            own — pass ``refresh=True`` to a method to fetch anew anyway.

    Raises:
        ValueError: ``max_concurrency`` is less than 1 or ``cache_ttl`` is negative.
    """
    self.http = HttpClient(
        base_url=base_url,
        timeout=timeout,
        headers=headers,
        http_client=http_client,
        token=token,
        max_concurrency=max_concurrency,
        cache_dir=cache_dir,
        cache_ttl=cache_ttl,
    )
    self.app = AppResource(self.http)
    self.releases = ReleasesResource(self.http)
    self.episodes = EpisodesResource(self.http)
    self.catalog = CatalogResource(self.http)
    self.schedule = ScheduleResource(self.http)
    self.genres = GenresResource(self.http)
    self.franchises = FranchisesResource(self.http)
    self.torrents = TorrentsResource(self.http)
    self.teams = TeamsResource(self.http)
    self.media = MediaResource(self.http)
    self.auth = AuthResource(self.http)
    self.me = MeResource(self.http)

token property writable

token: str | None

The session token sent with every request, or None when not logged in.

Set by client.auth.login() (and the other login methods), cleared by client.auth.logout(). Save it to reuse the session later as AniLiberty(token=...); assign None to stop sending it without ending the session. It is a secret — don't log it.

aclose async

aclose() -> None

Close the connection pool. Safe to call more than once.

Source code in src/aniliberty/client.py
171
172
173
async def aclose(self) -> None:
    """Close the connection pool. Safe to call more than once."""
    await self.http.aclose()

Resources

The namespaces of an AniLiberty instance: client.app, client.releases, client.releases.ratings, client.episodes, client.catalog (with client.catalog.references), client.schedule, client.genres, client.franchises, client.torrents, client.teams, client.media, client.auth (with client.auth.otp, client.auth.social, client.auth.password), client.me (with client.me.favorites, client.me.collections and their references, client.me.views). Not meant to be created directly — import from aniliberty.resources only for type annotations.

aniliberty.resources.AppResource

AppResource(http: HttpClient)

Bases: BaseResource

Service endpoints of the API, available as client.app.

Source code in src/aniliberty/resources/_base.py
28
29
def __init__(self, http: HttpClient) -> None:
    self._http = http

status async

status() -> ApiStatus

Get the status of the API.

A cheap request that is handy as a health check. The response also lists the hosts the API is available at, which can be used as base_url mirrors.

Returns:

Type Description
ApiStatus

The API status.

Raises:

Type Description
APIError

The API answered with an error.

TransportError

The request failed without a response.

ResponseParseError

The response didn't match ApiStatus.

Example
async with AniLiberty() as client:
    status = await client.app.status()
    print(status.is_alive, status.available_api_endpoints)
Source code in src/aniliberty/resources/app.py
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
async def status(self) -> ApiStatus:
    """Get the status of the API.

    A cheap request that is handy as a health check. The response also lists the hosts
    the API is available at, which can be used as ``base_url`` mirrors.

    Returns:
        The API status.

    Raises:
        APIError: The API answered with an error.
        TransportError: The request failed without a response.
        ResponseParseError: The response didn't match ``ApiStatus``.

    Example:
        ```python
        async with AniLiberty() as client:
            status = await client.app.status()
            print(status.is_alive, status.available_api_endpoints)
        ```
    """
    return await self._get("/app/status", ApiStatus)

search_releases async

search_releases(
    query: str, *, refresh: bool = False
) -> list[Release]

Quick search of releases by name — the search box of the site.

Matches Russian and English names and aliases, fuzzily: "frieren", "Фрирен" and "sousou-no-frieren" find the same release. There is no paging and no limit: the API returns a short list (at most 20 releases in every query checked), in an order it chooses. For filtered, paginated search use the catalog instead.

Parameters:

Name Type Description Default
query str

Search text.

required
refresh bool

Fetch from the API even if the response is cached (see AniLiberty(cache_dir=...)); the new response replaces the cached one.

False

Returns:

Type Description
list[Release]

Matching releases, without the per-endpoint extras (genres etc. are empty);

list[Release]

an empty list when nothing matches.

Raises:

Type Description
ValidationError

query is empty.

APIError

The API answered with another error.

TransportError

The request failed without a response.

ResponseParseError

The response didn't match Release.

Example
for release in await client.app.search_releases("frieren"):
    print(release.id, release.name.main)
Source code in src/aniliberty/resources/app.py
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
async def search_releases(self, query: str, *, refresh: bool = False) -> list[Release]:
    """Quick search of releases by name — the search box of the site.

    Matches Russian and English names and aliases, fuzzily: ``"frieren"``,
    ``"Фрирен"`` and ``"sousou-no-frieren"`` find the same release. There is no paging
    and no ``limit``: the API returns a short list (at most 20 releases in every query
    checked), in an order it chooses. For filtered, paginated search use the catalog
    instead.

    Args:
        query: Search text.
        refresh: Fetch from the API even if the response is cached (see
            ``AniLiberty(cache_dir=...)``); the new response replaces the cached one.

    Returns:
        Matching releases, without the per-endpoint extras (``genres`` etc. are empty);
        an empty list when nothing matches.

    Raises:
        ValidationError: ``query`` is empty.
        APIError: The API answered with another error.
        TransportError: The request failed without a response.
        ResponseParseError: The response didn't match ``Release``.

    Example:
        ```python
        for release in await client.app.search_releases("frieren"):
            print(release.id, release.name.main)
        ```
    """
    return await self._get(
        "/app/search/releases",
        list[Release],
        params={"query": query},
        cache=True,
        refresh=refresh,
    )

aniliberty.resources.ReleasesResource

ReleasesResource(http: HttpClient)

Bases: BaseResource

Anime releases, available as client.releases.

For filtering and sorting the whole catalog, see client.catalog; this namespace covers single releases and the ready-made selections.

limit on every method here must be between 1 and 50 — the API answers anything else with a 422, raised as ValidationError (see docs/api-notes.md).

Attributes:

Name Type Description
ratings

The logged-in user's own scores of releases (needs a session).

Source code in src/aniliberty/resources/releases.py
40
41
42
def __init__(self, http: HttpClient) -> None:
    super().__init__(http)
    self.ratings = RatingsResource(http)

latest async

latest(
    *, limit: int | None = None, refresh: bool = False
) -> builtins.list[Release]

Get the latest releases — the ones that got new episodes most recently.

Parameters:

Name Type Description Default
limit int | None

How many releases to return, 1 to 50. Omitted, the API picks the number.

None
refresh bool

Fetch from the API even if the response is cached (see AniLiberty(cache_dir=...)); the new response replaces the cached one.

False

Returns:

Type Description
list[Release]

The releases, with genres filled in.

Raises:

Type Description
ValidationError

limit is out of range.

APIError

The API answered with another error.

TransportError

The request failed without a response.

ResponseParseError

The response didn't match Release.

Example
for release in await client.releases.latest(limit=5):
    print(release.fresh_at, release.name.main)
Source code in src/aniliberty/resources/releases.py
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
async def latest(
    self, *, limit: int | None = None, refresh: bool = False
) -> builtins.list[Release]:
    """Get the latest releases — the ones that got new episodes most recently.

    Args:
        limit: How many releases to return, 1 to 50. Omitted, the API picks the number.
        refresh: Fetch from the API even if the response is cached (see
            ``AniLiberty(cache_dir=...)``); the new response replaces the cached one.

    Returns:
        The releases, with ``genres`` filled in.

    Raises:
        ValidationError: ``limit`` is out of range.
        APIError: The API answered with another error.
        TransportError: The request failed without a response.
        ResponseParseError: The response didn't match ``Release``.

    Example:
        ```python
        for release in await client.releases.latest(limit=5):
            print(release.fresh_at, release.name.main)
        ```
    """
    return await self._get(
        "/anime/releases/latest",
        builtins.list[Release],
        params={"limit": limit},
        cache=True,
        refresh=refresh,
    )

random async

random(
    *, limit: int | None = None
) -> builtins.list[Release]

Get random releases.

Parameters:

Name Type Description Default
limit int | None

How many releases to return, 1 to 50.

None

Returns:

Type Description
list[Release]

The releases, with genres and background_covers filled in.

Raises:

Type Description
ValidationError

limit is out of range.

APIError

The API answered with another error.

TransportError

The request failed without a response.

ResponseParseError

The response didn't match Release.

Example
[release] = await client.releases.random(limit=1)
print(release.name.main, release.year)
Source code in src/aniliberty/resources/releases.py
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
async def random(self, *, limit: int | None = None) -> builtins.list[Release]:
    """Get random releases.

    Args:
        limit: How many releases to return, 1 to 50.

    Returns:
        The releases, with ``genres`` and ``background_covers`` filled in.

    Raises:
        ValidationError: ``limit`` is out of range.
        APIError: The API answered with another error.
        TransportError: The request failed without a response.
        ResponseParseError: The response didn't match ``Release``.

    Example:
        ```python
        [release] = await client.releases.random(limit=1)
        print(release.name.main, release.year)
        ```
    """
    return await self._get(
        "/anime/releases/random", builtins.list[Release], params={"limit": limit}
    )

recommended async

recommended(
    *,
    release: int | Release | None = None,
    limit: int | None = None,
) -> builtins.list[Release]

Get recommended releases, optionally ones similar to a given release.

Parameters:

Name Type Description Default
release int | Release | None

Release ID, or a Release, to base recommendations on. Without it, the API returns general recommendations.

None
limit int | None

How many releases to return, 1 to 50.

None

Returns:

Type Description
list[Release]

The releases, with genres filled in.

Raises:

Type Description
TypeError

release is not an int or a Release (aliases don't work here).

ValidationError

limit is out of range.

APIError

The API answered with another error.

TransportError

The request failed without a response.

ResponseParseError

The response didn't match Release.

Example
similar = await client.releases.recommended(release=release, limit=5)
Source code in src/aniliberty/resources/releases.py
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
async def recommended(
    self, *, release: int | Release | None = None, limit: int | None = None
) -> builtins.list[Release]:
    """Get recommended releases, optionally ones similar to a given release.

    Args:
        release: Release ID, or a ``Release``, to base recommendations on. Without it,
            the API returns general recommendations.
        limit: How many releases to return, 1 to 50.

    Returns:
        The releases, with ``genres`` filled in.

    Raises:
        TypeError: ``release`` is not an ``int`` or a ``Release`` (aliases don't work
            here).
        ValidationError: ``limit`` is out of range.
        APIError: The API answered with another error.
        TransportError: The request failed without a response.
        ResponseParseError: The response didn't match ``Release``.

    Example:
        ```python
        similar = await client.releases.recommended(release=release, limit=5)
        ```
    """
    release_id = None if release is None else numeric_release_id(release)
    return await self._get(
        "/anime/releases/recommended",
        builtins.list[Release],
        params={"release_id": release_id, "limit": limit},
    )

list async

list(
    *,
    ids: Sequence[int] | None = None,
    aliases: Sequence[str] | None = None,
    page: int | None = None,
    limit: int | None = None,
    refresh: bool = False,
) -> Page[Release]

Get several releases at once by their IDs and/or aliases.

Releases given both by ID and by alias are returned once. The order of the result doesn't follow the order of ids/aliases. To fetch all pages, use iter_list().

Parameters:

Name Type Description Default
ids Sequence[int] | None

Release IDs.

None
aliases Sequence[str] | None

Release aliases.

None
page int | None

Page number, starting at 1.

None
limit int | None

Page size, 1 to 50.

None
refresh bool

Fetch from the API even if the response is cached (see AniLiberty(cache_dir=...)); the new response replaces the cached one.

False

Returns:

Type Description
Page[Release]

One page of releases, with genres, members and sponsors filled in.

Raises:

Type Description
ValueError

Neither ids nor aliases was given (the API requires one).

ValidationError

page or limit is out of range.

APIError

The API answered with another error.

TransportError

The request failed without a response.

ResponseParseError

The response didn't match Release.

Example
page = await client.releases.list(ids=[10172, 10297], aliases=["sousou-no-frieren"])
for release in page.items:
    print(release.id, release.name.main)
Source code in src/aniliberty/resources/releases.py
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
async def list(
    self,
    *,
    ids: Sequence[int] | None = None,
    aliases: Sequence[str] | None = None,
    page: int | None = None,
    limit: int | None = None,
    refresh: bool = False,
) -> Page[Release]:
    """Get several releases at once by their IDs and/or aliases.

    Releases given both by ID and by alias are returned once. The order of the result
    doesn't follow the order of ``ids``/``aliases``. To fetch all pages, use
    ``iter_list()``.

    Args:
        ids: Release IDs.
        aliases: Release aliases.
        page: Page number, starting at 1.
        limit: Page size, 1 to 50.
        refresh: Fetch from the API even if the response is cached (see
            ``AniLiberty(cache_dir=...)``); the new response replaces the cached one.

    Returns:
        One page of releases, with ``genres``, ``members`` and ``sponsors`` filled in.

    Raises:
        ValueError: Neither ``ids`` nor ``aliases`` was given (the API requires one).
        ValidationError: ``page`` or ``limit`` is out of range.
        APIError: The API answered with another error.
        TransportError: The request failed without a response.
        ResponseParseError: The response didn't match ``Release``.

    Example:
        ```python
        page = await client.releases.list(ids=[10172, 10297], aliases=["sousou-no-frieren"])
        for release in page.items:
            print(release.id, release.name.main)
        ```
    """
    if not ids and not aliases:
        raise ValueError("Pass ids, aliases, or both")
    return await self._get(
        "/anime/releases/list",
        Page[Release],
        params={"ids": ids, "aliases": aliases, "page": page, "limit": limit},
        cache=True,
        refresh=refresh,
    )

iter_list

iter_list(
    *,
    ids: Sequence[int] | None = None,
    aliases: Sequence[str] | None = None,
    limit: int | None = None,
    refresh: bool = False,
) -> AsyncIterator[Release]

Iterate over all releases matching ids/aliases, fetching page by page.

Same as calling list() with page=1, 2, ... until the last page; the next page is requested only once the current one has been consumed.

Parameters:

Name Type Description Default
ids Sequence[int] | None

Release IDs.

None
aliases Sequence[str] | None

Release aliases.

None
limit int | None

Page size, 1 to 50 — affects how many requests are made, not the result.

None
refresh bool

Fetch from the API even if the response is cached (see AniLiberty(cache_dir=...)); the new response replaces the cached one.

False

Returns:

Type Description
AsyncIterator[Release]

An async iterator over the releases.

Raises:

Type Description
ValueError

Neither ids nor aliases was given — raised right away, not when iteration starts.

Example
async for release in client.releases.iter_list(ids=favorite_ids, limit=50):
    print(release.name.main)
Source code in src/aniliberty/resources/releases.py
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
def iter_list(
    self,
    *,
    ids: Sequence[int] | None = None,
    aliases: Sequence[str] | None = None,
    limit: int | None = None,
    refresh: bool = False,
) -> AsyncIterator[Release]:
    """Iterate over all releases matching ``ids``/``aliases``, fetching page by page.

    Same as calling ``list()`` with ``page=1, 2, ...`` until the last page; the next page
    is requested only once the current one has been consumed.

    Args:
        ids: Release IDs.
        aliases: Release aliases.
        limit: Page size, 1 to 50 — affects how many requests are made, not the result.
        refresh: Fetch from the API even if the response is cached (see
            ``AniLiberty(cache_dir=...)``); the new response replaces the cached one.

    Returns:
        An async iterator over the releases.

    Raises:
        ValueError: Neither ``ids`` nor ``aliases`` was given — raised right away, not
            when iteration starts.

    Example:
        ```python
        async for release in client.releases.iter_list(ids=favorite_ids, limit=50):
            print(release.name.main)
        ```
    """
    if not ids and not aliases:
        raise ValueError("Pass ids, aliases, or both")

    async def fetch(page: int) -> Page[Release]:
        return await self.list(
            ids=ids, aliases=aliases, page=page, limit=limit, refresh=refresh
        )

    return iterate_pages(fetch)

get async

get(
    release: int | str | Release, *, refresh: bool = False
) -> Release

Get one release by its ID or alias.

Parameters:

Name Type Description Default
release int | str | Release

Release ID (10297), alias ("sousou-no-frieren"), or a Release (e.g. from a list, to get its full details).

required
refresh bool

Fetch from the API even if the response is cached (see AniLiberty(cache_dir=...)); the new response replaces the cached one.

False

Returns:

Type Description
Release

The release, with genres, members, sponsors and

Release

background_covers filled in.

Raises:

Type Description
NotFoundError

No release with this ID or alias.

APIError

The API answered with another error.

TransportError

The request failed without a response.

ResponseParseError

The response didn't match Release.

Example
release = await client.releases.get("tensei-shitara-ken-deshita-2nd-season")
print(release.name.main, release.episodes_total)
Source code in src/aniliberty/resources/releases.py
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
async def get(self, release: int | str | Release, *, refresh: bool = False) -> Release:
    """Get one release by its ID or alias.

    Args:
        release: Release ID (``10297``), alias (``"sousou-no-frieren"``), or a
            ``Release`` (e.g. from a list, to get its full details).
        refresh: Fetch from the API even if the response is cached (see
            ``AniLiberty(cache_dir=...)``); the new response replaces the cached one.

    Returns:
        The release, with ``genres``, ``members``, ``sponsors`` and
        ``background_covers`` filled in.

    Raises:
        NotFoundError: No release with this ID or alias.
        APIError: The API answered with another error.
        TransportError: The request failed without a response.
        ResponseParseError: The response didn't match ``Release``.

    Example:
        ```python
        release = await client.releases.get("tensei-shitara-ken-deshita-2nd-season")
        print(release.name.main, release.episodes_total)
        ```
    """
    return await self._get(
        f"/anime/releases/{_release_segment(release)}", Release, cache=True, refresh=refresh
    )

members async

members(
    release: int | str | Release, *, refresh: bool = False
) -> builtins.list[ReleaseMember]

Get the people who worked on a release (voice actors, translators, ...).

Parameters:

Name Type Description Default
release int | str | Release

Release ID, alias, or a Release.

required
refresh bool

Fetch from the API even if the response is cached (see AniLiberty(cache_dir=...)); the new response replaces the cached one.

False

Returns:

Type Description
list[ReleaseMember]

The release's members.

Raises:

Type Description
NotFoundError

No release with this ID or alias.

APIError

The API answered with another error.

TransportError

The request failed without a response.

ResponseParseError

The response didn't match ReleaseMember.

Example
for member in await client.releases.members(10297):
    print(member.nickname, member.role.description if member.role else "")
Source code in src/aniliberty/resources/releases.py
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
async def members(
    self, release: int | str | Release, *, refresh: bool = False
) -> builtins.list[ReleaseMember]:
    """Get the people who worked on a release (voice actors, translators, ...).

    Args:
        release: Release ID, alias, or a ``Release``.
        refresh: Fetch from the API even if the response is cached (see
            ``AniLiberty(cache_dir=...)``); the new response replaces the cached one.

    Returns:
        The release's members.

    Raises:
        NotFoundError: No release with this ID or alias.
        APIError: The API answered with another error.
        TransportError: The request failed without a response.
        ResponseParseError: The response didn't match ``ReleaseMember``.

    Example:
        ```python
        for member in await client.releases.members(10297):
            print(member.nickname, member.role.description if member.role else "")
        ```
    """
    return await self._get(
        f"/anime/releases/{_release_segment(release)}/members",
        builtins.list[ReleaseMember],
        cache=True,
        refresh=refresh,
    )

timecodes async

timecodes(
    release: int | str | Release,
) -> builtins.list[EpisodeView]

Get the logged-in user's watch records of a release's episodes. Needs a session.

Records appear about a minute after client.me.views.save_timecodes().

Parameters:

Name Type Description Default
release int | str | Release

Release ID, alias, or a Release.

required

Returns:

Type Description
list[EpisodeView]

One record per episode the user has started; empty if none.

Raises:

Type Description
NotFoundError

No release with this ID or alias.

AuthRequiredError

No valid session.

APIError

The API answered with another error.

TransportError

The request failed without a response.

ResponseParseError

The response didn't match EpisodeView.

Example
watched = {v.release_episode_id for v in await client.releases.timecodes(9839)
           if v.is_watched}
Source code in src/aniliberty/resources/releases.py
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
async def timecodes(self, release: int | str | Release) -> builtins.list[EpisodeView]:
    """Get the logged-in user's watch records of a release's episodes. Needs a session.

    Records appear about a minute after ``client.me.views.save_timecodes()``.

    Args:
        release: Release ID, alias, or a ``Release``.

    Returns:
        One record per episode the user has started; empty if none.

    Raises:
        NotFoundError: No release with this ID or alias.
        AuthRequiredError: No valid session.
        APIError: The API answered with another error.
        TransportError: The request failed without a response.
        ResponseParseError: The response didn't match ``EpisodeView``.

    Example:
        ```python
        watched = {v.release_episode_id for v in await client.releases.timecodes(9839)
                   if v.is_watched}
        ```
    """
    return await self._get(
        f"/anime/releases/{_release_segment(release)}/episodes/timecodes",
        builtins.list[EpisodeView],
    )

aniliberty.resources.RatingsResource

RatingsResource(http: HttpClient)

Bases: BaseResource

The logged-in user's own scores of releases, available as client.releases.ratings.

Needs a session (client.auth.login() or AniLiberty(token=...)); without a valid one every method raises AuthRequiredError. Releases are identified by numeric ID only: the API answers an alias with a 404, so a str is rejected.

The release's overall Release.rating is computed by the API on its own schedule: it didn't change within several minutes of a score being set (see docs/api-notes.md).

Source code in src/aniliberty/resources/_base.py
28
29
def __init__(self, http: HttpClient) -> None:
    self._http = http

get async

get(release: int | Release) -> UserRating

Get the user's score for a release.

Parameters:

Name Type Description Default
release int | Release

Release ID, or a Release.

required

Returns:

Type Description
UserRating

The score; score is None if the user hasn't rated the release.

Raises:

Type Description
TypeError

release is not an int or a Release.

AuthRequiredError

No valid session.

NotFoundError

No release with this ID.

APIError

The API answered with another error.

TransportError

The request failed without a response.

ResponseParseError

The response didn't match UserRating.

Example
mine = await client.releases.ratings.get(9839)
print(mine.score)  # e.g. 8, or None
Source code in src/aniliberty/resources/ratings.py
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
async def get(self, release: int | Release) -> UserRating:
    """Get the user's score for a release.

    Args:
        release: Release ID, or a ``Release``.

    Returns:
        The score; ``score`` is ``None`` if the user hasn't rated the release.

    Raises:
        TypeError: ``release`` is not an ``int`` or a ``Release``.
        AuthRequiredError: No valid session.
        NotFoundError: No release with this ID.
        APIError: The API answered with another error.
        TransportError: The request failed without a response.
        ResponseParseError: The response didn't match ``UserRating``.

    Example:
        ```python
        mine = await client.releases.ratings.get(9839)
        print(mine.score)  # e.g. 8, or None
        ```
    """
    return await self._get(_path(release), UserRating)

set async

set(release: int | Release, *, score: int) -> UserRating

Rate a release, or change the user's score for it.

Parameters:

Name Type Description Default
release int | Release

Release ID, or a Release.

required
score int

Whole number from 1 to 10.

required

Returns:

Type Description
UserRating

The saved score.

Raises:

Type Description
TypeError

release is not an int or a Release, or score isn't an int.

ValueError

score is outside 1..10.

AuthRequiredError

No valid session.

NotFoundError

No release with this ID.

APIError

The API answered with another error.

TransportError

The request failed without a response.

ResponseParseError

The response didn't match UserRating.

Example
await client.releases.ratings.set(release, score=9)
Source code in src/aniliberty/resources/ratings.py
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
async def set(self, release: int | Release, *, score: int) -> UserRating:
    """Rate a release, or change the user's score for it.

    Args:
        release: Release ID, or a ``Release``.
        score: Whole number from 1 to 10.

    Returns:
        The saved score.

    Raises:
        TypeError: ``release`` is not an ``int`` or a ``Release``, or ``score`` isn't an
            ``int``.
        ValueError: ``score`` is outside 1..10.
        AuthRequiredError: No valid session.
        NotFoundError: No release with this ID.
        APIError: The API answered with another error.
        TransportError: The request failed without a response.
        ResponseParseError: The response didn't match ``UserRating``.

    Example:
        ```python
        await client.releases.ratings.set(release, score=9)
        ```
    """
    if isinstance(score, bool) or not isinstance(score, int):
        raise TypeError(f"score must be an int from 1 to 10, got {score!r}")
    if not 1 <= score <= 10:
        raise ValueError(f"score must be from 1 to 10, got {score}")
    path = _path(release)
    payload = await self._http.request("POST", path, json={"score": score})
    return self._parse(payload, UserRating, path=path)

remove async

remove(release: int | Release) -> UserRating

Withdraw the user's score for a release. A no-op if there is none.

Parameters:

Name Type Description Default
release int | Release

Release ID, or a Release.

required

Returns:

Type Description
UserRating

The rating afterwards, with score None.

Raises:

Type Description
TypeError

release is not an int or a Release.

AuthRequiredError

No valid session.

NotFoundError

No release with this ID.

APIError

The API answered with another error.

TransportError

The request failed without a response.

ResponseParseError

The response didn't match UserRating.

Example
await client.releases.ratings.remove(9839)
Source code in src/aniliberty/resources/ratings.py
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
async def remove(self, release: int | Release) -> UserRating:
    """Withdraw the user's score for a release. A no-op if there is none.

    Args:
        release: Release ID, or a ``Release``.

    Returns:
        The rating afterwards, with ``score`` ``None``.

    Raises:
        TypeError: ``release`` is not an ``int`` or a ``Release``.
        AuthRequiredError: No valid session.
        NotFoundError: No release with this ID.
        APIError: The API answered with another error.
        TransportError: The request failed without a response.
        ResponseParseError: The response didn't match ``UserRating``.

    Example:
        ```python
        await client.releases.ratings.remove(9839)
        ```
    """
    path = _path(release)
    payload = await self._http.request("DELETE", path)
    return self._parse(payload, UserRating, path=path)

aniliberty.resources.EpisodesResource

EpisodesResource(http: HttpClient)

Bases: BaseResource

Episodes of releases, available as client.episodes.

Episode IDs come from Release.episodes (client.releases.get()/list()) or Release.latest_episode (client.releases.latest()).

Source code in src/aniliberty/resources/_base.py
28
29
def __init__(self, http: HttpClient) -> None:
    self._http = http

get async

get(episode: str | Episode) -> Episode

Get one episode, with its HLS links and its release.

Parameters:

Name Type Description Default
episode str | Episode

Episode ID, a UUID string such as "a159552a-9fa7-4737-867b-d22cf7115602", or an Episode (e.g. from Release.episodes, to get it with its release).

required

Returns:

Type Description
Episode

The episode, with release filled in — the release (without genres) and

Episode

all of its episodes.

Raises:

Type Description
NotFoundError

No episode with this ID (also for IDs that aren't UUIDs).

APIError

The API answered with another error.

TransportError

The request failed without a response.

ResponseParseError

The response didn't match Episode.

Example
release = await client.releases.get("tensei-shitara-ken-deshita-2nd-season")
episode = await client.episodes.get(release.episodes[0].id)
print(episode.ordinal, episode.hls_1080 or episode.hls_720)
Source code in src/aniliberty/resources/episodes.py
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
async def get(self, episode: str | Episode) -> Episode:
    """Get one episode, with its HLS links and its release.

    Args:
        episode: Episode ID, a UUID string such as
            ``"a159552a-9fa7-4737-867b-d22cf7115602"``, or an ``Episode`` (e.g. from
            ``Release.episodes``, to get it with its release).

    Returns:
        The episode, with ``release`` filled in — the release (without ``genres``) and
        all of its episodes.

    Raises:
        NotFoundError: No episode with this ID (also for IDs that aren't UUIDs).
        APIError: The API answered with another error.
        TransportError: The request failed without a response.
        ResponseParseError: The response didn't match ``Episode``.

    Example:
        ```python
        release = await client.releases.get("tensei-shitara-ken-deshita-2nd-season")
        episode = await client.episodes.get(release.episodes[0].id)
        print(episode.ordinal, episode.hls_1080 or episode.hls_720)
        ```
    """
    return await self._get(f"/anime/releases/episodes/{_episode_segment(episode)}", Episode)

timecode async

timecode(episode: str | Episode) -> EpisodeView

Get the logged-in user's watch record of an episode. Needs a session.

Records appear about a minute after client.me.views.save_timecodes(); for the positions saved just now use client.me.views.timecodes().

Parameters:

Name Type Description Default
episode str | Episode

Episode ID (a UUID string), or an Episode.

required

Returns:

Type Description
EpisodeView

Where the user stopped in the episode and whether they finished it.

Raises:

Type Description
NotFoundError

The user has no record for this episode (yet), or there is no such episode.

AuthRequiredError

No valid session.

APIError

The API answered with another error.

TransportError

The request failed without a response.

ResponseParseError

The response didn't match EpisodeView.

Example
view = await client.episodes.timecode(episode.id)
print(view.time, view.is_watched)
Source code in src/aniliberty/resources/episodes.py
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
async def timecode(self, episode: str | Episode) -> EpisodeView:
    """Get the logged-in user's watch record of an episode. Needs a session.

    Records appear about a minute after ``client.me.views.save_timecodes()``; for the
    positions saved just now use ``client.me.views.timecodes()``.

    Args:
        episode: Episode ID (a UUID string), or an ``Episode``.

    Returns:
        Where the user stopped in the episode and whether they finished it.

    Raises:
        NotFoundError: The user has no record for this episode (yet), or there is no
            such episode.
        AuthRequiredError: No valid session.
        APIError: The API answered with another error.
        TransportError: The request failed without a response.
        ResponseParseError: The response didn't match ``EpisodeView``.

    Example:
        ```python
        view = await client.episodes.timecode(episode.id)
        print(view.time, view.is_watched)
        ```
    """
    return await self._get(
        f"/anime/releases/episodes/{_episode_segment(episode)}/timecode", EpisodeView
    )

aniliberty.resources.CatalogResource

CatalogResource(http: HttpClient)

Bases: BaseResource

The release catalog with filters and sorting, available as client.catalog.

How the filters combine (checked against the live API, see docs/api-notes.md):

  • genres: a release must have all of the given genres (AND);
  • types, seasons, age_ratings, publish_statuses, production_statuses: any of the given values (OR);
  • different filters: all of them must match (AND);
  • year_from/year_to: inclusive on both ends;
  • search: the same matching as client.app.search_releases(), and likewise capped at about 20 matches in total, whatever the other filters and the page size.

Without sorting, releases come in "FRESH_AT_DESC" order (recently updated first).

Attributes:

Name Type Description
references

Values the catalog filters accept.

Source code in src/aniliberty/resources/catalog.py
293
294
295
def __init__(self, http: HttpClient) -> None:
    super().__init__(http)
    self.references = CatalogReferencesResource(http)

releases async

releases(
    *,
    genres: Sequence[int | Genre] | None = None,
    types: Sequence[ReleaseType | ValueDescription[str]]
    | None = None,
    seasons: Sequence[Season | ValueDescription[str]]
    | None = None,
    year_from: int | None = None,
    year_to: int | None = None,
    search: str | None = None,
    sorting: CatalogSorting | SortingOption | None = None,
    age_ratings: Sequence[
        AgeRatingValue | ValueDescription[str]
    ]
    | None = None,
    publish_statuses: Sequence[
        PublishStatus | ValueDescription[str]
    ]
    | None = None,
    production_statuses: Sequence[
        ProductionStatus | ValueDescription[str]
    ]
    | None = None,
    page: int | None = None,
    limit: int | None = None,
    refresh: bool = False,
) -> Page[Release]

Get one page of catalog releases matching the filters.

Every filter is optional; without any, this is the whole catalog. Values can be passed as plain strings/IDs or as the items client.catalog.references returns. To go through all pages, use iter_releases().

Parameters:

Name Type Description Default
genres Sequence[int | Genre] | None

Genre IDs (or Genre objects); a release must have all of them.

None
types Sequence[ReleaseType | ValueDescription[str]] | None

Release types, e.g. ["TV", "MOVIE"]; any of them.

None
seasons Sequence[Season | ValueDescription[str]] | None

Seasons, e.g. ["winter"]; any of them.

None
year_from int | None

Earliest release year, inclusive.

None
year_to int | None

Latest release year, inclusive.

None
search str | None

Text to search in names (capped at about 20 matches by the API).

None
sorting CatalogSorting | SortingOption | None

Sort order, e.g. "RATING_DESC". Default: "FRESH_AT_DESC".

None
age_ratings Sequence[AgeRatingValue | ValueDescription[str]] | None

Age ratings, e.g. ["R16_PLUS"]; any of them.

None
publish_statuses Sequence[PublishStatus | ValueDescription[str]] | None

"IS_ONGOING" and/or "IS_NOT_ONGOING".

None
production_statuses Sequence[ProductionStatus | ValueDescription[str]] | None

"IS_IN_PRODUCTION" and/or "IS_NOT_IN_PRODUCTION".

None
page int | None

Page number, starting at 1. A page past the last one is empty.

None
limit int | None

Page size, 1 to 50.

None
refresh bool

Fetch from the API even if the response is cached (see AniLiberty(cache_dir=...)); the new response replaces the cached one.

False

Returns:

Type Description
Page[Release]

One page of releases, with genres filled in.

Raises:

Type Description
ValueError

A filter value isn't one the API knows, or year_from is after year_to. The API itself would silently return no releases (or ignore the filter) instead of reporting these, so they are checked here.

TypeError

A list filter got a single string (types="TV") or a value of the wrong type.

ValidationError

page or limit is out of range.

APIError

The API answered with another error.

TransportError

The request failed without a response.

ResponseParseError

The response didn't match Release.

Example
page = await client.catalog.releases(
    genres=[14], types=["TV"], year_from=2020, sorting="RATING_DESC", limit=25
)
print(f"{page.total} releases, page 1 of {page.total_pages}")
Source code in src/aniliberty/resources/catalog.py
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
async def releases(
    self,
    *,
    genres: Sequence[int | Genre] | None = None,
    types: Sequence[ReleaseType | ValueDescription[str]] | None = None,
    seasons: Sequence[Season | ValueDescription[str]] | None = None,
    year_from: int | None = None,
    year_to: int | None = None,
    search: str | None = None,
    sorting: CatalogSorting | SortingOption | None = None,
    age_ratings: Sequence[AgeRatingValue | ValueDescription[str]] | None = None,
    publish_statuses: Sequence[PublishStatus | ValueDescription[str]] | None = None,
    production_statuses: Sequence[ProductionStatus | ValueDescription[str]] | None = None,
    page: int | None = None,
    limit: int | None = None,
    refresh: bool = False,
) -> Page[Release]:
    """Get one page of catalog releases matching the filters.

    Every filter is optional; without any, this is the whole catalog. Values can be
    passed as plain strings/IDs or as the items ``client.catalog.references`` returns.
    To go through all pages, use ``iter_releases()``.

    Args:
        genres: Genre IDs (or ``Genre`` objects); a release must have all of them.
        types: Release types, e.g. ``["TV", "MOVIE"]``; any of them.
        seasons: Seasons, e.g. ``["winter"]``; any of them.
        year_from: Earliest release year, inclusive.
        year_to: Latest release year, inclusive.
        search: Text to search in names (capped at about 20 matches by the API).
        sorting: Sort order, e.g. ``"RATING_DESC"``. Default: ``"FRESH_AT_DESC"``.
        age_ratings: Age ratings, e.g. ``["R16_PLUS"]``; any of them.
        publish_statuses: ``"IS_ONGOING"`` and/or ``"IS_NOT_ONGOING"``.
        production_statuses: ``"IS_IN_PRODUCTION"`` and/or ``"IS_NOT_IN_PRODUCTION"``.
        page: Page number, starting at 1. A page past the last one is empty.
        limit: Page size, 1 to 50.
        refresh: Fetch from the API even if the response is cached (see
            ``AniLiberty(cache_dir=...)``); the new response replaces the cached one.

    Returns:
        One page of releases, with ``genres`` filled in.

    Raises:
        ValueError: A filter value isn't one the API knows, or ``year_from`` is after
            ``year_to``. The API itself would silently return no releases (or ignore
            the filter) instead of reporting these, so they are checked here.
        TypeError: A list filter got a single string (``types="TV"``) or a value of
            the wrong type.
        ValidationError: ``page`` or ``limit`` is out of range.
        APIError: The API answered with another error.
        TransportError: The request failed without a response.
        ResponseParseError: The response didn't match ``Release``.

    Example:
        ```python
        page = await client.catalog.releases(
            genres=[14], types=["TV"], year_from=2020, sorting="RATING_DESC", limit=25
        )
        print(f"{page.total} releases, page 1 of {page.total_pages}")
        ```
    """
    params = _catalog_params(
        genres=genres,
        types=types,
        seasons=seasons,
        year_from=year_from,
        year_to=year_to,
        search=search,
        sorting=sorting,
        age_ratings=age_ratings,
        publish_statuses=publish_statuses,
        production_statuses=production_statuses,
    )
    return await self._fetch_page(params, page=page, limit=limit, refresh=refresh)

iter_releases

iter_releases(
    *,
    genres: Sequence[int | Genre] | None = None,
    types: Sequence[ReleaseType | ValueDescription[str]]
    | None = None,
    seasons: Sequence[Season | ValueDescription[str]]
    | None = None,
    year_from: int | None = None,
    year_to: int | None = None,
    search: str | None = None,
    sorting: CatalogSorting | SortingOption | None = None,
    age_ratings: Sequence[
        AgeRatingValue | ValueDescription[str]
    ]
    | None = None,
    publish_statuses: Sequence[
        PublishStatus | ValueDescription[str]
    ]
    | None = None,
    production_statuses: Sequence[
        ProductionStatus | ValueDescription[str]
    ]
    | None = None,
    limit: int | None = None,
    refresh: bool = False,
) -> AsyncIterator[Release]

Iterate over all catalog releases matching the filters, fetching page by page.

Takes the same filters as releases(). The next page is requested only once the current one has been consumed, so breaking out of the loop early stops the requests. The whole catalog is a couple of thousand releases — iterate all of it only when you really need everything.

Parameters:

Name Type Description Default
genres Sequence[int | Genre] | None

See releases().

None
types Sequence[ReleaseType | ValueDescription[str]] | None

See releases().

None
seasons Sequence[Season | ValueDescription[str]] | None

See releases().

None
year_from int | None

See releases().

None
year_to int | None

See releases().

None
search str | None

See releases().

None
sorting CatalogSorting | SortingOption | None

See releases().

None
age_ratings Sequence[AgeRatingValue | ValueDescription[str]] | None

See releases().

None
publish_statuses Sequence[PublishStatus | ValueDescription[str]] | None

See releases().

None
production_statuses Sequence[ProductionStatus | ValueDescription[str]] | None

See releases().

None
limit int | None

Page size, 1 to 50 — affects how many requests are made, not the result.

None
refresh bool

Fetch from the API even if the response is cached (see AniLiberty(cache_dir=...)); the new response replaces the cached one.

False

Returns:

Type Description
AsyncIterator[Release]

An async iterator over the releases.

Raises:

Type Description
ValueError

Invalid filter values — raised right away, not when iteration starts.

TypeError

A list filter got a single string or a value of the wrong type.

Example
async for release in client.catalog.iter_releases(types=["MOVIE"], limit=50):
    print(release.year, release.name.main)
Source code in src/aniliberty/resources/catalog.py
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
def iter_releases(
    self,
    *,
    genres: Sequence[int | Genre] | None = None,
    types: Sequence[ReleaseType | ValueDescription[str]] | None = None,
    seasons: Sequence[Season | ValueDescription[str]] | None = None,
    year_from: int | None = None,
    year_to: int | None = None,
    search: str | None = None,
    sorting: CatalogSorting | SortingOption | None = None,
    age_ratings: Sequence[AgeRatingValue | ValueDescription[str]] | None = None,
    publish_statuses: Sequence[PublishStatus | ValueDescription[str]] | None = None,
    production_statuses: Sequence[ProductionStatus | ValueDescription[str]] | None = None,
    limit: int | None = None,
    refresh: bool = False,
) -> AsyncIterator[Release]:
    """Iterate over all catalog releases matching the filters, fetching page by page.

    Takes the same filters as ``releases()``. The next page is requested only once the
    current one has been consumed, so breaking out of the loop early stops the requests.
    The whole catalog is a couple of thousand releases — iterate all of it only when you
    really need everything.

    Args:
        genres: See ``releases()``.
        types: See ``releases()``.
        seasons: See ``releases()``.
        year_from: See ``releases()``.
        year_to: See ``releases()``.
        search: See ``releases()``.
        sorting: See ``releases()``.
        age_ratings: See ``releases()``.
        publish_statuses: See ``releases()``.
        production_statuses: See ``releases()``.
        limit: Page size, 1 to 50 — affects how many requests are made, not the result.
        refresh: Fetch from the API even if the response is cached (see
            ``AniLiberty(cache_dir=...)``); the new response replaces the cached one.

    Returns:
        An async iterator over the releases.

    Raises:
        ValueError: Invalid filter values — raised right away, not when iteration starts.
        TypeError: A list filter got a single string or a value of the wrong type.

    Example:
        ```python
        async for release in client.catalog.iter_releases(types=["MOVIE"], limit=50):
            print(release.year, release.name.main)
        ```
    """
    params = _catalog_params(
        genres=genres,
        types=types,
        seasons=seasons,
        year_from=year_from,
        year_to=year_to,
        search=search,
        sorting=sorting,
        age_ratings=age_ratings,
        publish_statuses=publish_statuses,
        production_statuses=production_statuses,
    )

    async def fetch(page: int) -> Page[Release]:
        return await self._fetch_page(params, page=page, limit=limit, refresh=refresh)

    return iterate_pages(fetch)

aniliberty.resources.CatalogReferencesResource

CatalogReferencesResource(http: HttpClient)

Bases: BaseResource

Values the catalog filters accept, available as client.catalog.references.

Each method returns the list the site shows in its catalog filters. Use them instead of hardcoding values: the value (or id for genres, the number for years) of an item is exactly what the corresponding catalog filter takes.

The lists are small and change rarely — fetching them once per program run is enough.

Example
genres = await client.catalog.references.genres()
action = next(g for g in genres if g.name == "Экшен")
print(action.id)
Source code in src/aniliberty/resources/_base.py
28
29
def __init__(self, http: HttpClient) -> None:
    self._http = http

genres async

genres(*, refresh: bool = False) -> list[Genre]

Get the genres the catalog can be filtered by.

Parameters:

Name Type Description Default
refresh bool

Fetch from the API even if the response is cached (see AniLiberty(cache_dir=...)); the new response replaces the cached one.

False

Returns:

Type Description
list[Genre]

Genres with id and name only — image and total_releases are

list[Genre]

None here, this list doesn't include them.

Raises:

Type Description
APIError

The API answered with an error.

TransportError

The request failed without a response.

ResponseParseError

The response didn't match Genre.

Example
genres = await client.catalog.references.genres()
action = next(genre for genre in genres if genre.name == "Экшен")
page = await client.catalog.releases(genres=[action])
Source code in src/aniliberty/resources/catalog.py
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
async def genres(self, *, refresh: bool = False) -> list[Genre]:
    """Get the genres the catalog can be filtered by.

    Args:
        refresh: Fetch from the API even if the response is cached (see
            ``AniLiberty(cache_dir=...)``); the new response replaces the cached one.

    Returns:
        Genres with ``id`` and ``name`` only — ``image`` and ``total_releases`` are
        ``None`` here, this list doesn't include them.

    Raises:
        APIError: The API answered with an error.
        TransportError: The request failed without a response.
        ResponseParseError: The response didn't match ``Genre``.

    Example:
        ```python
        genres = await client.catalog.references.genres()
        action = next(genre for genre in genres if genre.name == "Экшен")
        page = await client.catalog.releases(genres=[action])
        ```
    """
    return await self._get(
        "/anime/catalog/references/genres", list[Genre], cache=True, refresh=refresh
    )

types async

types(
    *, refresh: bool = False
) -> list[ValueDescription[str]]

Get the release types, e.g. value="TV", description="ТВ".

Parameters:

Name Type Description Default
refresh bool

Fetch from the API even if the response is cached (see AniLiberty(cache_dir=...)); the new response replaces the cached one.

False

Returns:

Type Description
list[ValueDescription[str]]

The release types.

Raises:

Type Description
APIError

The API answered with an error.

TransportError

The request failed without a response.

ResponseParseError

The response didn't match ValueDescription.

Example
types = await client.catalog.references.types()
print([t.value for t in types])  # ['TV', 'ONA', 'WEB', ...]
Source code in src/aniliberty/resources/catalog.py
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
async def types(self, *, refresh: bool = False) -> list[ValueDescription[str]]:
    """Get the release types, e.g. ``value="TV"``, ``description="ТВ"``.

    Args:
        refresh: Fetch from the API even if the response is cached (see
            ``AniLiberty(cache_dir=...)``); the new response replaces the cached one.

    Returns:
        The release types.

    Raises:
        APIError: The API answered with an error.
        TransportError: The request failed without a response.
        ResponseParseError: The response didn't match ``ValueDescription``.

    Example:
        ```python
        types = await client.catalog.references.types()
        print([t.value for t in types])  # ['TV', 'ONA', 'WEB', ...]
        ```
    """
    return await self._get(
        "/anime/catalog/references/types",
        list[ValueDescription[str]],
        cache=True,
        refresh=refresh,
    )

seasons async

seasons(
    *, refresh: bool = False
) -> list[ValueDescription[str]]

Get the seasons, e.g. value="winter", description="Зима".

Parameters:

Name Type Description Default
refresh bool

Fetch from the API even if the response is cached (see AniLiberty(cache_dir=...)); the new response replaces the cached one.

False

Returns:

Type Description
list[ValueDescription[str]]

The seasons.

Raises:

Type Description
APIError

The API answered with an error.

TransportError

The request failed without a response.

ResponseParseError

The response didn't match ValueDescription.

Example
for season in await client.catalog.references.seasons():
    print(season.value, season.description)
Source code in src/aniliberty/resources/catalog.py
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
async def seasons(self, *, refresh: bool = False) -> list[ValueDescription[str]]:
    """Get the seasons, e.g. ``value="winter"``, ``description="Зима"``.

    Args:
        refresh: Fetch from the API even if the response is cached (see
            ``AniLiberty(cache_dir=...)``); the new response replaces the cached one.

    Returns:
        The seasons.

    Raises:
        APIError: The API answered with an error.
        TransportError: The request failed without a response.
        ResponseParseError: The response didn't match ``ValueDescription``.

    Example:
        ```python
        for season in await client.catalog.references.seasons():
            print(season.value, season.description)
        ```
    """
    return await self._get(
        "/anime/catalog/references/seasons",
        list[ValueDescription[str]],
        cache=True,
        refresh=refresh,
    )

years async

years(*, refresh: bool = False) -> list[int]

Get the years that have releases in the catalog, oldest first.

Only years with at least one release are listed, so the range can have gaps.

Parameters:

Name Type Description Default
refresh bool

Fetch from the API even if the response is cached (see AniLiberty(cache_dir=...)); the new response replaces the cached one.

False

Returns:

Type Description
list[int]

The years.

Raises:

Type Description
APIError

The API answered with an error.

TransportError

The request failed without a response.

ResponseParseError

The response wasn't a list of integers.

Example
years = await client.catalog.references.years()
print(years[0], years[-1])  # the oldest and the newest year
Source code in src/aniliberty/resources/catalog.py
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
async def years(self, *, refresh: bool = False) -> list[int]:
    """Get the years that have releases in the catalog, oldest first.

    Only years with at least one release are listed, so the range can have gaps.

    Args:
        refresh: Fetch from the API even if the response is cached (see
            ``AniLiberty(cache_dir=...)``); the new response replaces the cached one.

    Returns:
        The years.

    Raises:
        APIError: The API answered with an error.
        TransportError: The request failed without a response.
        ResponseParseError: The response wasn't a list of integers.

    Example:
        ```python
        years = await client.catalog.references.years()
        print(years[0], years[-1])  # the oldest and the newest year
        ```
    """
    return await self._get(
        "/anime/catalog/references/years", list[int], cache=True, refresh=refresh
    )

age_ratings async

age_ratings(*, refresh: bool = False) -> list[AgeRating]

Get the age ratings, e.g. value="R16_PLUS", label="16+".

Parameters:

Name Type Description Default
refresh bool

Fetch from the API even if the response is cached (see AniLiberty(cache_dir=...)); the new response replaces the cached one.

False

Returns:

Type Description
list[AgeRating]

The age ratings (is_adult is None here — this list doesn't include it).

Raises:

Type Description
APIError

The API answered with an error.

TransportError

The request failed without a response.

ResponseParseError

The response didn't match AgeRating.

Example
for rating in await client.catalog.references.age_ratings():
    print(rating.value, rating.label)
Source code in src/aniliberty/resources/catalog.py
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
async def age_ratings(self, *, refresh: bool = False) -> list[AgeRating]:
    """Get the age ratings, e.g. ``value="R16_PLUS"``, ``label="16+"``.

    Args:
        refresh: Fetch from the API even if the response is cached (see
            ``AniLiberty(cache_dir=...)``); the new response replaces the cached one.

    Returns:
        The age ratings (``is_adult`` is ``None`` here — this list doesn't include it).

    Raises:
        APIError: The API answered with an error.
        TransportError: The request failed without a response.
        ResponseParseError: The response didn't match ``AgeRating``.

    Example:
        ```python
        for rating in await client.catalog.references.age_ratings():
            print(rating.value, rating.label)
        ```
    """
    return await self._get(
        "/anime/catalog/references/age-ratings", list[AgeRating], cache=True, refresh=refresh
    )

publish_statuses async

publish_statuses(
    *, refresh: bool = False
) -> list[ValueDescription[str]]

Get the airing statuses, e.g. value="IS_ONGOING", description="Онгоинг".

Parameters:

Name Type Description Default
refresh bool

Fetch from the API even if the response is cached (see AniLiberty(cache_dir=...)); the new response replaces the cached one.

False

Returns:

Type Description
list[ValueDescription[str]]

The publish statuses.

Raises:

Type Description
APIError

The API answered with an error.

TransportError

The request failed without a response.

ResponseParseError

The response didn't match ValueDescription.

Example
statuses = await client.catalog.references.publish_statuses()
ongoing = next(s for s in statuses if s.value == "IS_ONGOING")
page = await client.catalog.releases(publish_statuses=[ongoing])
Source code in src/aniliberty/resources/catalog.py
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
async def publish_statuses(self, *, refresh: bool = False) -> list[ValueDescription[str]]:
    """Get the airing statuses, e.g. ``value="IS_ONGOING"``, ``description="Онгоинг"``.

    Args:
        refresh: Fetch from the API even if the response is cached (see
            ``AniLiberty(cache_dir=...)``); the new response replaces the cached one.

    Returns:
        The publish statuses.

    Raises:
        APIError: The API answered with an error.
        TransportError: The request failed without a response.
        ResponseParseError: The response didn't match ``ValueDescription``.

    Example:
        ```python
        statuses = await client.catalog.references.publish_statuses()
        ongoing = next(s for s in statuses if s.value == "IS_ONGOING")
        page = await client.catalog.releases(publish_statuses=[ongoing])
        ```
    """
    return await self._get(
        "/anime/catalog/references/publish-statuses",
        list[ValueDescription[str]],
        cache=True,
        refresh=refresh,
    )

production_statuses async

production_statuses(
    *, refresh: bool = False
) -> list[ValueDescription[str]]

Get the dubbing statuses, e.g. value="IS_IN_PRODUCTION".

Parameters:

Name Type Description Default
refresh bool

Fetch from the API even if the response is cached (see AniLiberty(cache_dir=...)); the new response replaces the cached one.

False

Returns:

Type Description
list[ValueDescription[str]]

The production statuses.

Raises:

Type Description
APIError

The API answered with an error.

TransportError

The request failed without a response.

ResponseParseError

The response didn't match ValueDescription.

Example
for status in await client.catalog.references.production_statuses():
    print(status.value, status.description)
Source code in src/aniliberty/resources/catalog.py
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
async def production_statuses(self, *, refresh: bool = False) -> list[ValueDescription[str]]:
    """Get the dubbing statuses, e.g. ``value="IS_IN_PRODUCTION"``.

    Args:
        refresh: Fetch from the API even if the response is cached (see
            ``AniLiberty(cache_dir=...)``); the new response replaces the cached one.

    Returns:
        The production statuses.

    Raises:
        APIError: The API answered with an error.
        TransportError: The request failed without a response.
        ResponseParseError: The response didn't match ``ValueDescription``.

    Example:
        ```python
        for status in await client.catalog.references.production_statuses():
            print(status.value, status.description)
        ```
    """
    return await self._get(
        "/anime/catalog/references/production-statuses",
        list[ValueDescription[str]],
        cache=True,
        refresh=refresh,
    )

sorting async

sorting(*, refresh: bool = False) -> list[SortingOption]

Get the ways the catalog can be sorted, e.g. value="FRESH_AT_DESC".

Parameters:

Name Type Description Default
refresh bool

Fetch from the API even if the response is cached (see AniLiberty(cache_dir=...)); the new response replaces the cached one.

False

Returns:

Type Description
list[SortingOption]

The sorting options.

Raises:

Type Description
APIError

The API answered with an error.

TransportError

The request failed without a response.

ResponseParseError

The response didn't match SortingOption.

Example
for option in await client.catalog.references.sorting():
    print(option.value, option.label)
Source code in src/aniliberty/resources/catalog.py
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
async def sorting(self, *, refresh: bool = False) -> list[SortingOption]:
    """Get the ways the catalog can be sorted, e.g. ``value="FRESH_AT_DESC"``.

    Args:
        refresh: Fetch from the API even if the response is cached (see
            ``AniLiberty(cache_dir=...)``); the new response replaces the cached one.

    Returns:
        The sorting options.

    Raises:
        APIError: The API answered with an error.
        TransportError: The request failed without a response.
        ResponseParseError: The response didn't match ``SortingOption``.

    Example:
        ```python
        for option in await client.catalog.references.sorting():
            print(option.value, option.label)
        ```
    """
    return await self._get(
        "/anime/catalog/references/sorting", list[SortingOption], cache=True, refresh=refresh
    )

aniliberty.resources.ScheduleResource

ScheduleResource(http: HttpClient)

Bases: BaseResource

The weekly airing schedule of ongoing releases, available as client.schedule.

Source code in src/aniliberty/resources/_base.py
28
29
def __init__(self, http: HttpClient) -> None:
    self._http = http

now async

now(*, refresh: bool = False) -> ScheduleNow

Get the releases scheduled for yesterday, today and tomorrow.

Parameters:

Name Type Description Default
refresh bool

Fetch from the API even if the response is cached (see AniLiberty(cache_dir=...)); the new response replaces the cached one.

False

Returns:

Type Description
ScheduleNow

The schedule for the three days. Each item says whether this week's episode is

ScheduleNow

already out (published_release_episode) and which one comes next.

Raises:

Type Description
APIError

The API answered with an error.

TransportError

The request failed without a response.

ResponseParseError

The response didn't match ScheduleNow.

Example
schedule = await client.schedule.now()
for item in schedule.today:
    episode = item.published_release_episode
    status = f"episode {episode.ordinal} is out" if episode else "not out yet"
    print(item.release.name.main, "—", status)
Source code in src/aniliberty/resources/schedule.py
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
async def now(self, *, refresh: bool = False) -> ScheduleNow:
    """Get the releases scheduled for yesterday, today and tomorrow.

    Args:
        refresh: Fetch from the API even if the response is cached (see
            ``AniLiberty(cache_dir=...)``); the new response replaces the cached one.

    Returns:
        The schedule for the three days. Each item says whether this week's episode is
        already out (``published_release_episode``) and which one comes next.

    Raises:
        APIError: The API answered with an error.
        TransportError: The request failed without a response.
        ResponseParseError: The response didn't match ``ScheduleNow``.

    Example:
        ```python
        schedule = await client.schedule.now()
        for item in schedule.today:
            episode = item.published_release_episode
            status = f"episode {episode.ordinal} is out" if episode else "not out yet"
            print(item.release.name.main, "—", status)
        ```
    """
    return await self._get("/anime/schedule/now", ScheduleNow, cache=True, refresh=refresh)

week async

week(*, refresh: bool = False) -> list[ScheduledRelease]

Get the schedule for the whole current week.

The list is not ordered by weekday; group or sort it by item.release.publish_day.value (1 = Monday ... 7 = Sunday) as needed.

Parameters:

Name Type Description Default
refresh bool

Fetch from the API even if the response is cached (see AniLiberty(cache_dir=...)); the new response replaces the cached one.

False

Returns:

Type Description
list[ScheduledRelease]

All releases in this week's schedule.

Raises:

Type Description
APIError

The API answered with an error.

TransportError

The request failed without a response.

ResponseParseError

The response didn't match ScheduledRelease.

Example
by_day: dict[int, list[ScheduledRelease]] = {}
for item in await client.schedule.week():
    day = item.release.publish_day.value if item.release.publish_day else 0
    by_day.setdefault(day, []).append(item)
Source code in src/aniliberty/resources/schedule.py
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
async def week(self, *, refresh: bool = False) -> list[ScheduledRelease]:
    """Get the schedule for the whole current week.

    The list is not ordered by weekday; group or sort it by
    ``item.release.publish_day.value`` (1 = Monday ... 7 = Sunday) as needed.

    Args:
        refresh: Fetch from the API even if the response is cached (see
            ``AniLiberty(cache_dir=...)``); the new response replaces the cached one.

    Returns:
        All releases in this week's schedule.

    Raises:
        APIError: The API answered with an error.
        TransportError: The request failed without a response.
        ResponseParseError: The response didn't match ``ScheduledRelease``.

    Example:
        ```python
        by_day: dict[int, list[ScheduledRelease]] = {}
        for item in await client.schedule.week():
            day = item.release.publish_day.value if item.release.publish_day else 0
            by_day.setdefault(day, []).append(item)
        ```
    """
    path = "/anime/schedule/week"
    payload = await self._http.request("GET", path, cache=True, refresh=refresh)
    # The spec documents {"data": [...]}, but the live API returns the list itself (see
    # docs/api-notes.md, "Schedule"). Accept both, in case the API is aligned later.
    if isinstance(payload, dict) and "data" in payload:
        payload = payload["data"]
    return self._parse(payload, list[ScheduledRelease], path=path)

aniliberty.resources.GenresResource

GenresResource(http: HttpClient)

Bases: BaseResource

Anime genres with their images and release counts, available as client.genres.

To filter the catalog by several genres at once, use client.catalog.releases(genres=...); releases() here lists the releases of one genre.

Source code in src/aniliberty/resources/_base.py
28
29
def __init__(self, http: HttpClient) -> None:
    self._http = http

list async

list(*, refresh: bool = False) -> builtins.list[Genre]

Get all genres.

Parameters:

Name Type Description Default
refresh bool

Fetch from the API even if the response is cached (see AniLiberty(cache_dir=...)); the new response replaces the cached one.

False

Returns:

Type Description
list[Genre]

Every genre, with image and total_releases filled in.

Raises:

Type Description
APIError

The API answered with an error.

TransportError

The request failed without a response.

ResponseParseError

The response didn't match Genre.

Example
for genre in await client.genres.list():
    print(genre.name, genre.total_releases)
Source code in src/aniliberty/resources/genres.py
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
async def list(self, *, refresh: bool = False) -> builtins.list[Genre]:
    """Get all genres.

    Args:
        refresh: Fetch from the API even if the response is cached (see
            ``AniLiberty(cache_dir=...)``); the new response replaces the cached one.

    Returns:
        Every genre, with ``image`` and ``total_releases`` filled in.

    Raises:
        APIError: The API answered with an error.
        TransportError: The request failed without a response.
        ResponseParseError: The response didn't match ``Genre``.

    Example:
        ```python
        for genre in await client.genres.list():
            print(genre.name, genre.total_releases)
        ```
    """
    return await self._get("/anime/genres", builtins.list[Genre], cache=True, refresh=refresh)

get async

get(genre: int | Genre, *, refresh: bool = False) -> Genre

Get one genre.

Parameters:

Name Type Description Default
genre int | Genre

Genre ID, or a Genre (e.g. from client.catalog.references.genres(), which has no image) to fetch its full data.

required
refresh bool

Fetch from the API even if the response is cached (see AniLiberty(cache_dir=...)); the new response replaces the cached one.

False

Returns:

Type Description
Genre

The genre, with image and total_releases.

Raises:

Type Description
NotFoundError

No genre with this ID.

APIError

The API answered with another error.

TransportError

The request failed without a response.

ResponseParseError

The response didn't match Genre.

Example
genre = await client.genres.get(14)
print(genre.name, genre.total_releases)
Source code in src/aniliberty/resources/genres.py
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
async def get(self, genre: int | Genre, *, refresh: bool = False) -> Genre:
    """Get one genre.

    Args:
        genre: Genre ID, or a ``Genre`` (e.g. from ``client.catalog.references.genres()``,
            which has no image) to fetch its full data.
        refresh: Fetch from the API even if the response is cached (see
            ``AniLiberty(cache_dir=...)``); the new response replaces the cached one.

    Returns:
        The genre, with ``image`` and ``total_releases``.

    Raises:
        NotFoundError: No genre with this ID.
        APIError: The API answered with another error.
        TransportError: The request failed without a response.
        ResponseParseError: The response didn't match ``Genre``.

    Example:
        ```python
        genre = await client.genres.get(14)
        print(genre.name, genre.total_releases)
        ```
    """
    return await self._get(
        f"/anime/genres/{_genre_id(genre)}", Genre, cache=True, refresh=refresh
    )

random async

random(*, limit: int | None = None) -> builtins.list[Genre]

Get random genres.

Parameters:

Name Type Description Default
limit int | None

How many genres to return. A value outside 1..(number of genres) isn't an error: the API returns all genres instead.

None

Returns:

Type Description
list[Genre]

The genres, in random order.

Raises:

Type Description
ValidationError

limit isn't a number.

APIError

The API answered with another error.

TransportError

The request failed without a response.

ResponseParseError

The response didn't match Genre.

Example
for genre in await client.genres.random(limit=3):
    print(genre.name)
Source code in src/aniliberty/resources/genres.py
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
async def random(self, *, limit: int | None = None) -> builtins.list[Genre]:
    """Get random genres.

    Args:
        limit: How many genres to return. A value outside 1..(number of genres) isn't an
            error: the API returns all genres instead.

    Returns:
        The genres, in random order.

    Raises:
        ValidationError: ``limit`` isn't a number.
        APIError: The API answered with another error.
        TransportError: The request failed without a response.
        ResponseParseError: The response didn't match ``Genre``.

    Example:
        ```python
        for genre in await client.genres.random(limit=3):
            print(genre.name)
        ```
    """
    return await self._get(
        "/anime/genres/random", builtins.list[Genre], params={"limit": limit}
    )

releases async

releases(
    genre: int | Genre,
    *,
    page: int | None = None,
    limit: int | None = None,
    refresh: bool = False,
) -> Page[Release]

Get one page of the releases of a genre.

The order is the API's own (neither by update date nor by year). For sorting and for combining genres with other filters, use client.catalog.releases(genres=[...]).

Parameters:

Name Type Description Default
genre int | Genre

Genre ID or Genre.

required
page int | None

Page number, starting at 1. The API treats 0 and negative numbers as 1.

None
limit int | None

Page size, 1 to 30 here (not 50 as in the catalog). Omitted, the API uses 15.

None
refresh bool

Fetch from the API even if the response is cached (see AniLiberty(cache_dir=...)); the new response replaces the cached one.

False

Returns:

Type Description
Page[Release]

One page of releases, with genres filled in.

Raises:

Type Description
NotFoundError

No genre with this ID.

ValidationError

limit is out of range.

APIError

The API answered with another error.

TransportError

The request failed without a response.

ResponseParseError

The response didn't match Release.

Example
page = await client.genres.releases(14, limit=30)
print(f"{page.total} releases in this genre")
Source code in src/aniliberty/resources/genres.py
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
async def releases(
    self,
    genre: int | Genre,
    *,
    page: int | None = None,
    limit: int | None = None,
    refresh: bool = False,
) -> Page[Release]:
    """Get one page of the releases of a genre.

    The order is the API's own (neither by update date nor by year). For sorting and for
    combining genres with other filters, use ``client.catalog.releases(genres=[...])``.

    Args:
        genre: Genre ID or ``Genre``.
        page: Page number, starting at 1. The API treats 0 and negative numbers as 1.
        limit: Page size, 1 to **30** here (not 50 as in the catalog). Omitted, the API
            uses 15.
        refresh: Fetch from the API even if the response is cached (see
            ``AniLiberty(cache_dir=...)``); the new response replaces the cached one.

    Returns:
        One page of releases, with ``genres`` filled in.

    Raises:
        NotFoundError: No genre with this ID.
        ValidationError: ``limit`` is out of range.
        APIError: The API answered with another error.
        TransportError: The request failed without a response.
        ResponseParseError: The response didn't match ``Release``.

    Example:
        ```python
        page = await client.genres.releases(14, limit=30)
        print(f"{page.total} releases in this genre")
        ```
    """
    return await self._get(
        f"/anime/genres/{_genre_id(genre)}/releases",
        Page[Release],
        params={"page": page, "limit": limit},
        cache=True,
        refresh=refresh,
    )

iter_releases

iter_releases(
    genre: int | Genre,
    *,
    limit: int | None = None,
    refresh: bool = False,
) -> AsyncIterator[Release]

Iterate over all releases of a genre, fetching page by page.

Parameters:

Name Type Description Default
genre int | Genre

Genre ID or Genre.

required
limit int | None

Page size, 1 to 30 — affects how many requests are made, not the result.

None
refresh bool

Fetch from the API even if the response is cached (see AniLiberty(cache_dir=...)); the new response replaces the cached one.

False

Returns:

Type Description
AsyncIterator[Release]

An async iterator over the releases.

Raises:

Type Description
TypeError

genre isn't an ID or a Genre — raised right away.

Example
async for release in client.genres.iter_releases(14, limit=30):
    print(release.name.main)
Source code in src/aniliberty/resources/genres.py
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
def iter_releases(
    self,
    genre: int | Genre,
    *,
    limit: int | None = None,
    refresh: bool = False,
) -> AsyncIterator[Release]:
    """Iterate over all releases of a genre, fetching page by page.

    Args:
        genre: Genre ID or ``Genre``.
        limit: Page size, 1 to 30 — affects how many requests are made, not the result.
        refresh: Fetch from the API even if the response is cached (see
            ``AniLiberty(cache_dir=...)``); the new response replaces the cached one.

    Returns:
        An async iterator over the releases.

    Raises:
        TypeError: ``genre`` isn't an ID or a ``Genre`` — raised right away.

    Example:
        ```python
        async for release in client.genres.iter_releases(14, limit=30):
            print(release.name.main)
        ```
    """
    genre_id = _genre_id(genre)

    async def fetch(page: int) -> Page[Release]:
        return await self.releases(genre_id, page=page, limit=limit, refresh=refresh)

    return iterate_pages(fetch)

aniliberty.resources.FranchisesResource

FranchisesResource(http: HttpClient)

Bases: BaseResource

Franchises — groups of related releases, available as client.franchises.

Source code in src/aniliberty/resources/_base.py
28
29
def __init__(self, http: HttpClient) -> None:
    self._http = http

list async

list(*, refresh: bool = False) -> builtins.list[Franchise]

Get all franchises (a few hundred, in one response).

Parameters:

Name Type Description Default
refresh bool

Fetch from the API even if the response is cached (see AniLiberty(cache_dir=...)); the new response replaces the cached one.

False

Returns:

Type Description
list[Franchise]

Every franchise, without franchise_releases.

Raises:

Type Description
APIError

The API answered with an error.

TransportError

The request failed without a response.

ResponseParseError

The response didn't match Franchise.

Example
franchises = await client.franchises.list()
biggest = max(franchises, key=lambda f: f.total_releases or 0)
Source code in src/aniliberty/resources/franchises.py
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
async def list(self, *, refresh: bool = False) -> builtins.list[Franchise]:
    """Get all franchises (a few hundred, in one response).

    Args:
        refresh: Fetch from the API even if the response is cached (see
            ``AniLiberty(cache_dir=...)``); the new response replaces the cached one.

    Returns:
        Every franchise, without ``franchise_releases``.

    Raises:
        APIError: The API answered with an error.
        TransportError: The request failed without a response.
        ResponseParseError: The response didn't match ``Franchise``.

    Example:
        ```python
        franchises = await client.franchises.list()
        biggest = max(franchises, key=lambda f: f.total_releases or 0)
        ```
    """
    return await self._get(
        "/anime/franchises", builtins.list[Franchise], cache=True, refresh=refresh
    )

get async

get(
    franchise: str | Franchise, *, refresh: bool = False
) -> Franchise

Get one franchise with its releases.

Parameters:

Name Type Description Default
franchise str | Franchise

Franchise ID (a UUID string), or a Franchise from list() / random() to fetch its releases.

required
refresh bool

Fetch from the API even if the response is cached (see AniLiberty(cache_dir=...)); the new response replaces the cached one.

False

Returns:

Type Description
Franchise

The franchise, with franchise_releases in order.

Raises:

Type Description
NotFoundError

No franchise with this ID.

APIError

The API answered with another error.

TransportError

The request failed without a response.

ResponseParseError

The response didn't match Franchise.

Example
franchise = (await client.franchises.list())[0]
full = await client.franchises.get(franchise)
print(full.name, [fr.release.name.main for fr in full.franchise_releases])
Source code in src/aniliberty/resources/franchises.py
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
async def get(self, franchise: str | Franchise, *, refresh: bool = False) -> Franchise:
    """Get one franchise with its releases.

    Args:
        franchise: Franchise ID (a UUID string), or a ``Franchise`` from ``list()`` /
            ``random()`` to fetch its releases.
        refresh: Fetch from the API even if the response is cached (see
            ``AniLiberty(cache_dir=...)``); the new response replaces the cached one.

    Returns:
        The franchise, with ``franchise_releases`` in order.

    Raises:
        NotFoundError: No franchise with this ID.
        APIError: The API answered with another error.
        TransportError: The request failed without a response.
        ResponseParseError: The response didn't match ``Franchise``.

    Example:
        ```python
        franchise = (await client.franchises.list())[0]
        full = await client.franchises.get(franchise)
        print(full.name, [fr.release.name.main for fr in full.franchise_releases])
        ```
    """
    franchise_id = franchise.id if isinstance(franchise, Franchise) else franchise
    if not isinstance(franchise_id, str):
        raise TypeError(f"Expected a franchise ID (str) or a Franchise, got {franchise!r}")
    return await self._get(
        f"/anime/franchises/{path_segment(franchise_id, what='franchise ID')}",
        Franchise,
        cache=True,
        refresh=refresh,
    )

random async

random(
    *, limit: int | None = None
) -> builtins.list[Franchise]

Get random franchises.

Parameters:

Name Type Description Default
limit int | None

How many franchises to return. Omitted, 0, or more than there are, the API returns all franchises (a few hundred) — pass a limit.

None

Returns:

Type Description
list[Franchise]

The franchises, without franchise_releases.

Raises:

Type Description
APIError

The API answered with an error.

TransportError

The request failed without a response.

ResponseParseError

The response didn't match Franchise.

Example
for franchise in await client.franchises.random(limit=3):
    print(franchise.name, franchise.first_year, franchise.last_year)
Source code in src/aniliberty/resources/franchises.py
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
async def random(self, *, limit: int | None = None) -> builtins.list[Franchise]:
    """Get random franchises.

    Args:
        limit: How many franchises to return. **Omitted, 0, or more than there are, the
            API returns all franchises** (a few hundred) — pass a limit.

    Returns:
        The franchises, without ``franchise_releases``.

    Raises:
        APIError: The API answered with an error.
        TransportError: The request failed without a response.
        ResponseParseError: The response didn't match ``Franchise``.

    Example:
        ```python
        for franchise in await client.franchises.random(limit=3):
            print(franchise.name, franchise.first_year, franchise.last_year)
        ```
    """
    return await self._get(
        "/anime/franchises/random", builtins.list[Franchise], params={"limit": limit}
    )

for_release async

for_release(
    release: int | Release, *, refresh: bool = False
) -> builtins.list[Franchise]

Get the franchises a release belongs to.

Parameters:

Name Type Description Default
release int | Release

Release ID, or a Release. Only numeric IDs work here: the API answers an alias with a 404 even for an existing release, so a str is rejected.

required
refresh bool

Fetch from the API even if the response is cached (see AniLiberty(cache_dir=...)); the new response replaces the cached one.

False

Returns:

Type Description
list[Franchise]

The franchises (usually one), with franchise_releases; an empty list if the

list[Franchise]

release isn't part of any franchise.

Raises:

Type Description
TypeError

release is not an int or a Release.

NotFoundError

No release with this ID.

APIError

The API answered with another error.

TransportError

The request failed without a response.

ResponseParseError

The response didn't match Franchise.

Example
release = await client.releases.get("sousou-no-frieren")
for franchise in await client.franchises.for_release(release):
    print(franchise.name, [r.release.name.main for r in franchise.franchise_releases])
Source code in src/aniliberty/resources/franchises.py
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
async def for_release(
    self, release: int | Release, *, refresh: bool = False
) -> builtins.list[Franchise]:
    """Get the franchises a release belongs to.

    Args:
        release: Release ID, or a ``Release``. Only numeric IDs work here: the API answers
            an alias with a 404 even for an existing release, so a ``str`` is rejected.
        refresh: Fetch from the API even if the response is cached (see
            ``AniLiberty(cache_dir=...)``); the new response replaces the cached one.

    Returns:
        The franchises (usually one), with ``franchise_releases``; an empty list if the
        release isn't part of any franchise.

    Raises:
        TypeError: ``release`` is not an ``int`` or a ``Release``.
        NotFoundError: No release with this ID.
        APIError: The API answered with another error.
        TransportError: The request failed without a response.
        ResponseParseError: The response didn't match ``Franchise``.

    Example:
        ```python
        release = await client.releases.get("sousou-no-frieren")
        for franchise in await client.franchises.for_release(release):
            print(franchise.name, [r.release.name.main for r in franchise.franchise_releases])
        ```
    """
    release_id = numeric_release_id(release)
    return await self._get(
        f"/anime/franchises/release/{release_id}",
        builtins.list[Franchise],
        cache=True,
        refresh=refresh,
    )

aniliberty.resources.TorrentsResource

TorrentsResource(http: HttpClient)

Bases: BaseResource

Torrents of releases, their .torrent files and RSS feeds, as client.torrents.

A torrent is identified by its numeric id or its info hash; every method that takes a torrent also accepts a Torrent object.

Source code in src/aniliberty/resources/_base.py
28
29
def __init__(self, http: HttpClient) -> None:
    self._http = http

list async

list(
    *, page: int | None = None, limit: int | None = None
) -> Page[Torrent]

Get one page of all torrents.

Parameters:

Name Type Description Default
page int | None

Page number, starting at 1.

None
limit int | None

Page size, 1 to 50.

None

Returns:

Type Description
Page[Torrent]

One page of torrents, each with its release, most recently updated first.

Raises:

Type Description
ValidationError

page or limit is out of range.

APIError

The API answered with another error.

TransportError

The request failed without a response.

ResponseParseError

The response didn't match Torrent.

Example
page = await client.torrents.list(limit=10)
for torrent in page.items:
    print(torrent.label, torrent.size)
Source code in src/aniliberty/resources/torrents.py
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
async def list(self, *, page: int | None = None, limit: int | None = None) -> Page[Torrent]:
    """Get one page of all torrents.

    Args:
        page: Page number, starting at 1.
        limit: Page size, 1 to 50.

    Returns:
        One page of torrents, each with its ``release``, most recently updated first.

    Raises:
        ValidationError: ``page`` or ``limit`` is out of range.
        APIError: The API answered with another error.
        TransportError: The request failed without a response.
        ResponseParseError: The response didn't match ``Torrent``.

    Example:
        ```python
        page = await client.torrents.list(limit=10)
        for torrent in page.items:
            print(torrent.label, torrent.size)
        ```
    """
    return await self._get(
        "/anime/torrents", Page[Torrent], params={"page": page, "limit": limit}
    )

iter_list

iter_list(
    *, limit: int | None = None
) -> AsyncIterator[Torrent]

Iterate over all torrents (a few thousand), fetching page by page.

Parameters:

Name Type Description Default
limit int | None

Page size, 1 to 50 — affects how many requests are made, not the result.

None

Returns:

Type Description
AsyncIterator[Torrent]

An async iterator over the torrents.

Raises:

Type Description
ValidationError

limit is out of range — raised once iteration starts.

APIError

The API answered with another error, while iterating.

TransportError

A request failed without a response, while iterating.

Example
async for torrent in client.torrents.iter_list(limit=50):
    print(torrent.hash, torrent.seeders)
Source code in src/aniliberty/resources/torrents.py
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
def iter_list(self, *, limit: int | None = None) -> AsyncIterator[Torrent]:
    """Iterate over all torrents (a few thousand), fetching page by page.

    Args:
        limit: Page size, 1 to 50 — affects how many requests are made, not the result.

    Returns:
        An async iterator over the torrents.

    Raises:
        ValidationError: ``limit`` is out of range — raised once iteration starts.
        APIError: The API answered with another error, while iterating.
        TransportError: A request failed without a response, while iterating.

    Example:
        ```python
        async for torrent in client.torrents.iter_list(limit=50):
            print(torrent.hash, torrent.seeders)
        ```
    """

    async def fetch(page: int) -> Page[Torrent]:
        return await self.list(page=page, limit=limit)

    return iterate_pages(fetch)

get async

get(torrent: int | str | Torrent) -> Torrent

Get one torrent by its ID or info hash.

Parameters:

Name Type Description Default
torrent int | str | Torrent

Torrent ID, info hash (any letter case), or a Torrent.

required

Returns:

Type Description
Torrent

The torrent, with release and torrent_members.

Raises:

Type Description
NotFoundError

No torrent with this ID or hash.

APIError

The API answered with another error.

TransportError

The request failed without a response.

ResponseParseError

The response didn't match Torrent.

Example
torrent = await client.torrents.get("17ceda4b3c2b8823b7f1139dca0b49aea5fff933")
print(torrent.label, torrent.release.name.main if torrent.release else None)
Source code in src/aniliberty/resources/torrents.py
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
async def get(self, torrent: int | str | Torrent) -> Torrent:
    """Get one torrent by its ID or info hash.

    Args:
        torrent: Torrent ID, info hash (any letter case), or a ``Torrent``.

    Returns:
        The torrent, with ``release`` and ``torrent_members``.

    Raises:
        NotFoundError: No torrent with this ID or hash.
        APIError: The API answered with another error.
        TransportError: The request failed without a response.
        ResponseParseError: The response didn't match ``Torrent``.

    Example:
        ```python
        torrent = await client.torrents.get("17ceda4b3c2b8823b7f1139dca0b49aea5fff933")
        print(torrent.label, torrent.release.name.main if torrent.release else None)
        ```
    """
    return await self._get(f"/anime/torrents/{_torrent_key(torrent)}", Torrent)

for_release async

for_release(
    release: int | Release,
) -> builtins.list[Torrent]

Get the torrents of a release.

Parameters:

Name Type Description Default
release int | Release

Release ID, or a Release. Only numeric IDs work here: the API answers an alias with a 404 even for an existing release, so a str is rejected.

required

Returns:

Type Description
list[Torrent]

The release's torrents; an empty list if it has none.

Raises:

Type Description
TypeError

release is not an int or a Release.

NotFoundError

No release with this ID.

APIError

The API answered with another error.

TransportError

The request failed without a response.

ResponseParseError

The response didn't match Torrent.

Example
release = await client.releases.get("sousou-no-frieren")
for torrent in await client.torrents.for_release(release):
    print(torrent.label, torrent.size, torrent.seeders)
Source code in src/aniliberty/resources/torrents.py
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
async def for_release(self, release: int | Release) -> builtins.list[Torrent]:
    """Get the torrents of a release.

    Args:
        release: Release ID, or a ``Release``. Only numeric IDs work here: the API answers
            an alias with a 404 even for an existing release, so a ``str`` is rejected.

    Returns:
        The release's torrents; an empty list if it has none.

    Raises:
        TypeError: ``release`` is not an ``int`` or a ``Release``.
        NotFoundError: No release with this ID.
        APIError: The API answered with another error.
        TransportError: The request failed without a response.
        ResponseParseError: The response didn't match ``Torrent``.

    Example:
        ```python
        release = await client.releases.get("sousou-no-frieren")
        for torrent in await client.torrents.for_release(release):
            print(torrent.label, torrent.size, torrent.seeders)
        ```
    """
    return await self._get(
        f"/anime/torrents/release/{numeric_release_id(release)}", builtins.list[Torrent]
    )

file async

file(torrent: int | str | Torrent) -> TorrentFile

Download a .torrent file into memory.

The files checked were 22 to 388 KB. To save one to disk without holding it in memory, use download().

Parameters:

Name Type Description Default
torrent int | str | Torrent

Torrent ID, info hash, or a Torrent.

required

Returns:

Type Description
TorrentFile

The file's bytes and the file name the API suggests.

Raises:

Type Description
NotFoundError

No torrent with this ID or hash.

APIError

The API answered with another error.

TransportError

The request failed without a response.

Example
torrent_file = await client.torrents.file(torrent)
Path(torrent_file.filename or "release.torrent").write_bytes(torrent_file.content)
Source code in src/aniliberty/resources/torrents.py
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
async def file(self, torrent: int | str | Torrent) -> TorrentFile:
    """Download a ``.torrent`` file into memory.

    The files checked were 22 to 388 KB. To save one to disk without holding it in
    memory, use ``download()``.

    Args:
        torrent: Torrent ID, info hash, or a ``Torrent``.

    Returns:
        The file's bytes and the file name the API suggests.

    Raises:
        NotFoundError: No torrent with this ID or hash.
        APIError: The API answered with another error.
        TransportError: The request failed without a response.

    Example:
        ```python
        torrent_file = await client.torrents.file(torrent)
        Path(torrent_file.filename or "release.torrent").write_bytes(torrent_file.content)
        ```
    """
    response = await self._http.request_raw(
        "GET", f"/anime/torrents/{_torrent_key(torrent)}/file"
    )
    return TorrentFile(filename=_suggested_filename(response), content=response.content)

download async

download(
    torrent: int | str | Torrent,
    *,
    path: str | PathLike[str],
) -> Path

Download a .torrent file to disk, streaming it in chunks.

The file is written to a temporary <name>.part next to the target and renamed only once complete, so a failed download never leaves a truncated .torrent behind. An existing file at the target path is overwritten.

Parameters:

Name Type Description Default
torrent int | str | Torrent

Torrent ID, info hash, or a Torrent.

required
path str | PathLike[str]

Where to save it: a file path, or an existing directory — then the file is named as the API suggests (falling back to <id or hash>.torrent).

required

Returns:

Type Description
Path

The path of the saved file.

Raises:

Type Description
NotFoundError

No torrent with this ID or hash.

APIError

The API answered with another error.

TransportError

The request failed without a response.

OSError

The file couldn't be written.

Example
saved = await client.torrents.download(torrent, path="downloads/")
print(saved)  # downloads/One Piece - AniLiberty.TOP [WEBRip 1080p][AVC][1-24].torrent
Source code in src/aniliberty/resources/torrents.py
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
async def download(self, torrent: int | str | Torrent, *, path: str | os.PathLike[str]) -> Path:
    """Download a ``.torrent`` file to disk, streaming it in chunks.

    The file is written to a temporary ``<name>.part`` next to the target and renamed
    only once complete, so a failed download never leaves a truncated ``.torrent``
    behind. An existing file at the target path is overwritten.

    Args:
        torrent: Torrent ID, info hash, or a ``Torrent``.
        path: Where to save it: a file path, or an existing directory — then the file is
            named as the API suggests (falling back to ``<id or hash>.torrent``).

    Returns:
        The path of the saved file.

    Raises:
        NotFoundError: No torrent with this ID or hash.
        APIError: The API answered with another error.
        TransportError: The request failed without a response.
        OSError: The file couldn't be written.

    Example:
        ```python
        saved = await client.torrents.download(torrent, path="downloads/")
        print(saved)  # downloads/One Piece - AniLiberty.TOP [WEBRip 1080p][AVC][1-24].torrent
        ```
    """
    key = _torrent_key(torrent)
    target = Path(path)
    async with self._http.stream("GET", f"/anime/torrents/{key}/file") as response:
        if target.is_dir():
            target = target / (_suggested_filename(response) or f"{key}.torrent")
        partial = target.with_name(target.name + ".part")
        try:
            with partial.open("wb") as file:
                async for chunk in response.aiter_bytes():
                    file.write(chunk)
        except BaseException:
            partial.unlink(missing_ok=True)
            raise
    partial.replace(target)
    return target

rss async

rss(*, limit: int | None = None) -> str

Get the RSS feed of the latest torrents, as XML text.

The feed is meant for torrent clients and feed readers; each <item> has the torrent's hash (<guid>), <torrentId>, <releaseId> and an <enclosure> linking to the .torrent file. For the same data as models, use list().

Parameters:

Name Type Description Default
limit int | None

How many torrents to include. Omitted, the API includes 10.

None

Returns:

Type Description
str

The RSS 2.0 document.

Raises:

Type Description
APIError

The API answered with an error.

TransportError

The request failed without a response.

Example
xml = await client.torrents.rss(limit=10)
print(xml[:60])  # <?xml version="1.0" encoding="UTF-8"?>...
Source code in src/aniliberty/resources/torrents.py
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
async def rss(self, *, limit: int | None = None) -> str:
    """Get the RSS feed of the latest torrents, as XML text.

    The feed is meant for torrent clients and feed readers; each ``<item>`` has the
    torrent's hash (``<guid>``), ``<torrentId>``, ``<releaseId>`` and an ``<enclosure>``
    linking to the ``.torrent`` file. For the same data as models, use ``list()``.

    Args:
        limit: How many torrents to include. Omitted, the API includes 10.

    Returns:
        The RSS 2.0 document.

    Raises:
        APIError: The API answered with an error.
        TransportError: The request failed without a response.

    Example:
        ```python
        xml = await client.torrents.rss(limit=10)
        print(xml[:60])  # <?xml version="1.0" encoding="UTF-8"?>...
        ```
    """
    response = await self._http.request_raw(
        "GET", "/anime/torrents/rss", params={"limit": limit} if limit is not None else None
    )
    return response.text

rss_for_release async

rss_for_release(release: int | Release) -> str

Get the RSS feed of a release's torrents, as XML text.

Parameters:

Name Type Description Default
release int | Release

Release ID, or a Release (aliases don't work here, as in for_release()).

required

Returns:

Type Description
str

The RSS 2.0 document.

Raises:

Type Description
TypeError

release is not an int or a Release.

NotFoundError

No release with this ID.

APIError

The API answered with another error.

TransportError

The request failed without a response.

Example
xml = await client.torrents.rss_for_release(release)
Source code in src/aniliberty/resources/torrents.py
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
async def rss_for_release(self, release: int | Release) -> str:
    """Get the RSS feed of a release's torrents, as XML text.

    Args:
        release: Release ID, or a ``Release`` (aliases don't work here, as in
            ``for_release()``).

    Returns:
        The RSS 2.0 document.

    Raises:
        TypeError: ``release`` is not an ``int`` or a ``Release``.
        NotFoundError: No release with this ID.
        APIError: The API answered with another error.
        TransportError: The request failed without a response.

    Example:
        ```python
        xml = await client.torrents.rss_for_release(release)
        ```
    """
    response = await self._http.request_raw(
        "GET", f"/anime/torrents/rss/release/{numeric_release_id(release)}"
    )
    return response.text

aniliberty.resources.TeamsResource

TeamsResource(http: HttpClient)

Bases: BaseResource

AniLibria's own teams, roles and members, available as client.teams.

Each endpoint returns its whole list in one response (a few dozen teams and roles, a couple of hundred members); there is no pagination or filtering, so filter in Python.

Source code in src/aniliberty/resources/_base.py
28
29
def __init__(self, http: HttpClient) -> None:
    self._http = http

list async

list(*, refresh: bool = False) -> builtins.list[Team]

Get all teams.

Parameters:

Name Type Description Default
refresh bool

Fetch from the API even if the response is cached (see AniLiberty(cache_dir=...)); the new response replaces the cached one.

False

Returns:

Type Description
list[Team]

Every team, ordered by sort_order.

Raises:

Type Description
APIError

The API answered with an error.

TransportError

The request failed without a response.

ResponseParseError

The response didn't match Team.

Example
for team in await client.teams.list():
    print(team.title, team.description)
Source code in src/aniliberty/resources/teams.py
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
async def list(self, *, refresh: bool = False) -> builtins.list[Team]:
    """Get all teams.

    Args:
        refresh: Fetch from the API even if the response is cached (see
            ``AniLiberty(cache_dir=...)``); the new response replaces the cached one.

    Returns:
        Every team, ordered by ``sort_order``.

    Raises:
        APIError: The API answered with an error.
        TransportError: The request failed without a response.
        ResponseParseError: The response didn't match ``Team``.

    Example:
        ```python
        for team in await client.teams.list():
            print(team.title, team.description)
        ```
    """
    # The spec spells this path with a trailing slash; both forms answer the same.
    return await self._get("/teams/", builtins.list[Team], cache=True, refresh=refresh)

roles async

roles(*, refresh: bool = False) -> builtins.list[TeamRole]

Get all roles team members can have.

Parameters:

Name Type Description Default
refresh bool

Fetch from the API even if the response is cached (see AniLiberty(cache_dir=...)); the new response replaces the cached one.

False

Returns:

Type Description
list[TeamRole]

Every role, ordered by sort_order.

Raises:

Type Description
APIError

The API answered with an error.

TransportError

The request failed without a response.

ResponseParseError

The response didn't match TeamRole.

Example
for role in await client.teams.roles():
    print(role.title, role.color)
Source code in src/aniliberty/resources/teams.py
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
async def roles(self, *, refresh: bool = False) -> builtins.list[TeamRole]:
    """Get all roles team members can have.

    Args:
        refresh: Fetch from the API even if the response is cached (see
            ``AniLiberty(cache_dir=...)``); the new response replaces the cached one.

    Returns:
        Every role, ordered by ``sort_order``.

    Raises:
        APIError: The API answered with an error.
        TransportError: The request failed without a response.
        ResponseParseError: The response didn't match ``TeamRole``.

    Example:
        ```python
        for role in await client.teams.roles():
            print(role.title, role.color)
        ```
    """
    return await self._get("/teams/roles", builtins.list[TeamRole], cache=True, refresh=refresh)

users async

users(
    *, refresh: bool = False
) -> builtins.list[TeamMember]

Get all team members, each with their team and roles.

Parameters:

Name Type Description Default
refresh bool

Fetch from the API even if the response is cached (see AniLiberty(cache_dir=...)); the new response replaces the cached one.

False

Returns:

Type Description
list[TeamMember]

Every member. Not sorted by team: the API interleaves teams, so group by

list[TeamMember]

member.team and sort by member.sort_order yourself if order matters.

Raises:

Type Description
APIError

The API answered with an error.

TransportError

The request failed without a response.

ResponseParseError

The response didn't match TeamMember.

Example
members = await client.teams.users()
actors = [m.nickname for m in members if any(r.title == "Актёр" for r in m.roles)]
Source code in src/aniliberty/resources/teams.py
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
async def users(self, *, refresh: bool = False) -> builtins.list[TeamMember]:
    """Get all team members, each with their team and roles.

    Args:
        refresh: Fetch from the API even if the response is cached (see
            ``AniLiberty(cache_dir=...)``); the new response replaces the cached one.

    Returns:
        Every member. **Not sorted** by team: the API interleaves teams, so group by
        ``member.team`` and sort by ``member.sort_order`` yourself if order matters.

    Raises:
        APIError: The API answered with an error.
        TransportError: The request failed without a response.
        ResponseParseError: The response didn't match ``TeamMember``.

    Example:
        ```python
        members = await client.teams.users()
        actors = [m.nickname for m in members if any(r.title == "Актёр" for r in m.roles)]
        ```
    """
    return await self._get(
        "/teams/users", builtins.list[TeamMember], cache=True, refresh=refresh
    )

aniliberty.resources.MediaResource

MediaResource(http: HttpClient)

Bases: BaseResource

Front-page promo banners and AniLibria's YouTube videos, available as client.media.

The ads endpoints that live under /media as well (/media/vasts, /media/manifest.xml, spec tag Реклама.Vasts) are deliberately not covered.

Source code in src/aniliberty/resources/_base.py
28
29
def __init__(self, http: HttpClient) -> None:
    self._http = http

promotions async

promotions() -> list[Promotion]

Get the promo banners shown on the site's front page.

Returns:

Type Description
list[Promotion]

Every current promotion (a handful), in random order — a new order on each

list[Promotion]

call. Paid ads are included; filter them out with promo.is_ad.

Raises:

Type Description
APIError

The API answered with an error.

TransportError

The request failed without a response.

ResponseParseError

The response didn't match Promotion.

Example
promos = [p for p in await client.media.promotions() if not p.is_ad]
Source code in src/aniliberty/resources/media.py
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
async def promotions(self) -> list[Promotion]:
    """Get the promo banners shown on the site's front page.

    Returns:
        Every current promotion (a handful), **in random order** — a new order on each
        call. Paid ads are included; filter them out with ``promo.is_ad``.

    Raises:
        APIError: The API answered with an error.
        TransportError: The request failed without a response.
        ResponseParseError: The response didn't match ``Promotion``.

    Example:
        ```python
        promos = [p for p in await client.media.promotions() if not p.is_ad]
        ```
    """
    return await self._get_list("/media/promotions", list[Promotion])

videos async

videos(
    *, limit: int | None = None, refresh: bool = False
) -> list[Video]

Get the latest videos from AniLibria's YouTube playlists, newest first.

Parameters:

Name Type Description Default
limit int | None

How many videos to return, 1 to 50. Omitted, the API returns 10. 0 or a negative number returns all videos (over a thousand, more than 1 MB of JSON); more than 50 is a ValidationError.

None
refresh bool

Fetch from the API even if the response is cached (see AniLiberty(cache_dir=...)); the new response replaces the cached one.

False

Returns:

Type Description
list[Video]

The videos, ordered by created_at from newest.

Raises:

Type Description
ValidationError

limit is over 50.

APIError

The API answered with another error.

TransportError

The request failed without a response.

ResponseParseError

The response didn't match Video.

Example
for video in await client.media.videos(limit=5):
    print(video.created_at.date() if video.created_at else "?", video.title)
Source code in src/aniliberty/resources/media.py
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
async def videos(self, *, limit: int | None = None, refresh: bool = False) -> list[Video]:
    """Get the latest videos from AniLibria's YouTube playlists, newest first.

    Args:
        limit: How many videos to return, 1 to 50. Omitted, the API returns 10.
            **0 or a negative number returns all videos** (over a thousand, more than
            1 MB of JSON); more than 50 is a ``ValidationError``.
        refresh: Fetch from the API even if the response is cached (see
            ``AniLiberty(cache_dir=...)``); the new response replaces the cached one.

    Returns:
        The videos, ordered by ``created_at`` from newest.

    Raises:
        ValidationError: ``limit`` is over 50.
        APIError: The API answered with another error.
        TransportError: The request failed without a response.
        ResponseParseError: The response didn't match ``Video``.

    Example:
        ```python
        for video in await client.media.videos(limit=5):
            print(video.created_at.date() if video.created_at else "?", video.title)
        ```
    """
    return await self._get_list(
        "/media/videos", list[Video], params={"limit": limit}, cache=True, refresh=refresh
    )

aniliberty.resources.AuthResource

AuthResource(http: HttpClient)

Bases: _SessionResource

Logging in and out, available as client.auth.

A successful login stores the session token on the client (client.token), so account methods work right after it. To reuse a session across runs, save client.token and pass it back as AniLiberty(token=...) — logging in on every run piles up sessions on the account.

Attributes:

Name Type Description
otp

Logging a device in with a one-time code.

social

Logging in with a social network.

password

Recovering a forgotten password.

Source code in src/aniliberty/resources/auth.py
51
52
53
54
55
def __init__(self, http: HttpClient) -> None:
    super().__init__(http)
    self.otp = OtpResource(http)
    self.social = SocialAuthResource(http)
    self.password = PasswordResource(http)

login async

login(*, login: str, password: str) -> AuthToken

Log in with a login (or e-mail) and password, and use the session from now on.

Parameters:

Name Type Description Default
login str

The account's login or e-mail; the login is case-insensitive.

required
password str

The account's password.

required

Returns:

Type Description
AuthToken

The new session's token, also stored as client.token.

Raises:

Type Description
AuthRequiredError

Wrong login or password (HTTP 401); the API's reason is in message.

ValidationError

login or password is empty.

APIError

The API answered with another error.

TransportError

The request failed without a response. Not retried: login is a POST.

ResponseParseError

The response didn't match AuthToken.

Example
async with AniLiberty() as client:
    await client.auth.login(login="me", password=os.environ["ANILIBERTY_PASSWORD"])
    print(client.token is not None)  # True
Source code in src/aniliberty/resources/auth.py
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
async def login(self, *, login: str, password: str) -> AuthToken:
    """Log in with a login (or e-mail) and password, and use the session from now on.

    Args:
        login: The account's login or e-mail; the login is case-insensitive.
        password: The account's password.

    Returns:
        The new session's token, also stored as ``client.token``.

    Raises:
        AuthRequiredError: Wrong login or password (HTTP 401); the API's reason is in
            ``message``.
        ValidationError: ``login`` or ``password`` is empty.
        APIError: The API answered with another error.
        TransportError: The request failed without a response. Not retried: login is a
            ``POST``.
        ResponseParseError: The response didn't match ``AuthToken``.

    Example:
        ```python
        async with AniLiberty() as client:
            await client.auth.login(login="me", password=os.environ["ANILIBERTY_PASSWORD"])
            print(client.token is not None)  # True
        ```
    """
    return await self._start_session(
        "/accounts/users/auth/login", {"login": login, "password": password}
    )

logout async

logout() -> None

End the client's session and stop sending its token.

Only this session is ended; other sessions of the account (other devices, other logins) stay valid. client.token is None afterwards, also when the API rejects the token as already invalid.

Raises:

Type Description
AuthRequiredError

The client has no token, or the API no longer accepts it (logged out or expired) — the session is over either way (HTTP 403).

APIError

The API answered with another error; the token is kept.

TransportError

The request failed without a response; the token is kept.

Example
await client.auth.logout()
print(client.token)  # None
Source code in src/aniliberty/resources/auth.py
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
async def logout(self) -> None:
    """End the client's session and stop sending its token.

    Only this session is ended; other sessions of the account (other devices, other
    logins) stay valid. ``client.token`` is ``None`` afterwards, also when the API
    rejects the token as already invalid.

    Raises:
        AuthRequiredError: The client has no token, or the API no longer accepts it
            (logged out or expired) — the session is over either way (HTTP 403).
        APIError: The API answered with another error; the token is kept.
        TransportError: The request failed without a response; the token is kept.

    Example:
        ```python
        await client.auth.logout()
        print(client.token)  # None
        ```
    """
    try:
        await self._http.request("POST", "/accounts/users/auth/logout")
    except AuthRequiredError:
        self._http.token = None
        raise
    self._http.token = None

aniliberty.resources.OtpResource

OtpResource(http: HttpClient)

Bases: _SessionResource

Logging a device in with a one-time code, available as client.auth.otp.

Meant for devices where typing a password is awkward (a TV app):

  1. The device calls issue(device_id=...) and shows the code.
  2. The user enters the code on a device where they are logged in, which calls accept(code).
  3. The device calls login(code=..., device_id=...) and gets its own session.

A code lives 5 minutes and logs in once.

Source code in src/aniliberty/resources/_base.py
28
29
def __init__(self, http: HttpClient) -> None:
    self._http = http

issue async

issue(*, device_id: str) -> IssuedOtp

Issue a one-time code for a device. No session needed.

Asking again for the same device_id while the code is alive returns the same code.

Parameters:

Name Type Description Default
device_id str

Any string identifying the device, e.g. a UUID it keeps; the same value must be passed to login().

required

Returns:

Type Description
IssuedOtp

The code and the seconds it has left.

Raises:

Type Description
ValidationError

device_id is empty.

APIError

The API answered with another error.

TransportError

The request failed without a response.

ResponseParseError

The response didn't match IssuedOtp.

Example
issued = await client.auth.otp.issue(device_id=str(uuid.uuid4()))
print(issued.otp.code, issued.remaining_time)
Source code in src/aniliberty/resources/auth.py
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
async def issue(self, *, device_id: str) -> IssuedOtp:
    """Issue a one-time code for a device. No session needed.

    Asking again for the same ``device_id`` while the code is alive returns the same code.

    Args:
        device_id: Any string identifying the device, e.g. a UUID it keeps; the same
            value must be passed to ``login()``.

    Returns:
        The code and the seconds it has left.

    Raises:
        ValidationError: ``device_id`` is empty.
        APIError: The API answered with another error.
        TransportError: The request failed without a response.
        ResponseParseError: The response didn't match ``IssuedOtp``.

    Example:
        ```python
        issued = await client.auth.otp.issue(device_id=str(uuid.uuid4()))
        print(issued.otp.code, issued.remaining_time)
        ```
    """
    payload = await self._http.request(
        "POST", "/accounts/otp/get", json={"device_id": device_id}
    )
    return self._parse(payload, IssuedOtp, path="/accounts/otp/get")

accept async

accept(code: str | int) -> None

Approve a one-time code with the client's session, letting its device log in.

Parameters:

Name Type Description Default
code str | int

The code the other device shows. Pass it as the string issue() returned — as an int a leading 0 would be lost.

required

Raises:

Type Description
AuthRequiredError

The client has no valid session.

NotFoundError

No live code like that.

ValidationError

code is malformed.

APIError

The API answered with another error.

TransportError

The request failed without a response.

Example
await client.auth.otp.accept("058701")
Source code in src/aniliberty/resources/auth.py
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
async def accept(self, code: str | int) -> None:
    """Approve a one-time code with the client's session, letting its device log in.

    Args:
        code: The code the other device shows. Pass it as the string ``issue()``
            returned — as an ``int`` a leading ``0`` would be lost.

    Raises:
        AuthRequiredError: The client has no valid session.
        NotFoundError: No live code like that.
        ValidationError: ``code`` is malformed.
        APIError: The API answered with another error.
        TransportError: The request failed without a response.

    Example:
        ```python
        await client.auth.otp.accept("058701")
        ```
    """
    await self._http.request("POST", "/accounts/otp/accept", json={"code": code})

login async

login(*, code: str | int, device_id: str) -> AuthToken

Log the device in with an accepted code, and use the session from now on.

Sent without the client's current token, if it has one: the API refuses this request from a logged-in caller (403). The new token replaces the old one.

Parameters:

Name Type Description Default
code str | int

The code from issue(), after it was accepted.

required
device_id str

The same device_id that was passed to issue().

required

Returns:

Type Description
AuthToken

The new session's token, also stored as client.token.

Raises:

Type Description
NotFoundError

No live code for this device — wrong code or device_id, expired, or already used.

ServerError

The code hasn't been accepted yet — the API answers 500 to that.

APIError

The API answered with another error.

TransportError

The request failed without a response.

ResponseParseError

The response didn't match AuthToken.

Example
await client.auth.otp.login(code=issued.otp.code, device_id=device_id)
Source code in src/aniliberty/resources/auth.py
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
async def login(self, *, code: str | int, device_id: str) -> AuthToken:
    """Log the device in with an accepted code, and use the session from now on.

    Sent without the client's current token, if it has one: the API refuses this request
    from a logged-in caller (403). The new token replaces the old one.

    Args:
        code: The code from ``issue()``, after it was accepted.
        device_id: The same ``device_id`` that was passed to ``issue()``.

    Returns:
        The new session's token, also stored as ``client.token``.

    Raises:
        NotFoundError: No live code for this device — wrong code or ``device_id``,
            expired, or already used.
        ServerError: The code hasn't been accepted yet — the API answers 500 to that.
        APIError: The API answered with another error.
        TransportError: The request failed without a response.
        ResponseParseError: The response didn't match ``AuthToken``.

    Example:
        ```python
        await client.auth.otp.login(code=issued.otp.code, device_id=device_id)
        ```
    """
    return await self._start_session(
        "/accounts/otp/login", {"code": code, "device_id": device_id}, with_token=False
    )

aniliberty.resources.SocialAuthResource

SocialAuthResource(http: HttpClient)

Bases: _SessionResource

Logging in with a social network, available as client.auth.social.

  1. login(provider) returns an OAuth url and a state key.
  2. The user opens url in a browser and logs in with the social network.
  3. authenticate(state) exchanges the key for a session.
Source code in src/aniliberty/resources/_base.py
28
29
def __init__(self, http: HttpClient) -> None:
    self._http = http

login async

login(provider: SocialProvider) -> SocialLogin

Start logging in with a social network.

Parameters:

Name Type Description Default
provider SocialProvider

"vk", "google", "patreon" or "discord".

required

Returns:

Type Description
SocialLogin

The OAuth page to send the user to, and the key to finish with.

Raises:

Type Description
ValueError

provider isn't one of the above — checked before sending (the API answers 500 to unknown names).

APIError

The API answered with an error.

TransportError

The request failed without a response.

ResponseParseError

The response didn't match SocialLogin.

Example
start = await client.auth.social.login("discord")
print("Open in a browser:", start.url)
Source code in src/aniliberty/resources/auth.py
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
async def login(self, provider: SocialProvider) -> SocialLogin:
    """Start logging in with a social network.

    Args:
        provider: ``"vk"``, ``"google"``, ``"patreon"`` or ``"discord"``.

    Returns:
        The OAuth page to send the user to, and the key to finish with.

    Raises:
        ValueError: ``provider`` isn't one of the above — checked before sending (the
            API answers 500 to unknown names).
        APIError: The API answered with an error.
        TransportError: The request failed without a response.
        ResponseParseError: The response didn't match ``SocialLogin``.

    Example:
        ```python
        start = await client.auth.social.login("discord")
        print("Open in a browser:", start.url)
        ```
    """
    if provider not in get_args(SocialProvider):
        allowed = ", ".join(get_args(SocialProvider))
        raise ValueError(f"Unknown social provider {provider!r}; expected one of: {allowed}")
    return await self._get(f"/accounts/users/auth/social/{provider}/login", SocialLogin)

authenticate async

authenticate(state: str) -> AuthToken

Finish a social login, and use the session from now on.

Parameters:

Name Type Description Default
state str

SocialLogin.state from login(), after the user logged in on the OAuth page.

required

Returns:

Type Description
AuthToken

The new session's token, also stored as client.token.

Raises:

Type Description
NotFoundError

Unknown state, or the user hasn't finished logging in yet.

ValidationError

state is empty.

APIError

The API answered with another error.

TransportError

The request failed without a response.

ResponseParseError

The response didn't match AuthToken.

Example
await client.auth.social.authenticate(start.state)
Source code in src/aniliberty/resources/auth.py
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
async def authenticate(self, state: str) -> AuthToken:
    """Finish a social login, and use the session from now on.

    Args:
        state: ``SocialLogin.state`` from ``login()``, after the user logged in on the
            OAuth page.

    Returns:
        The new session's token, also stored as ``client.token``.

    Raises:
        NotFoundError: Unknown ``state``, or the user hasn't finished logging in yet.
        ValidationError: ``state`` is empty.
        APIError: The API answered with another error.
        TransportError: The request failed without a response.
        ResponseParseError: The response didn't match ``AuthToken``.

    Example:
        ```python
        await client.auth.social.authenticate(start.state)
        ```
    """
    token = await self._get(
        "/accounts/users/auth/social/authenticate", AuthToken, params={"state": state}
    )
    return self._store(token)

aniliberty.resources.PasswordResource

PasswordResource(http: HttpClient)

Bases: BaseResource

Recovering a forgotten password, available as client.auth.password.

Source code in src/aniliberty/resources/_base.py
28
29
def __init__(self, http: HttpClient) -> None:
    self._http = http

forget async

forget(*, email: str) -> None

Ask the API to e-mail a password reset link.

The answer is the same whether or not an account has this e-mail, so it can't be used to check for one.

Parameters:

Name Type Description Default
email str

The account's e-mail.

required

Raises:

Type Description
ValidationError

email is empty or not an e-mail address.

APIError

The API answered with another error.

TransportError

The request failed without a response.

Example
await client.auth.password.forget(email="me@example.com")
Source code in src/aniliberty/resources/auth.py
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
async def forget(self, *, email: str) -> None:
    """Ask the API to e-mail a password reset link.

    The answer is the same whether or not an account has this e-mail, so it can't be
    used to check for one.

    Args:
        email: The account's e-mail.

    Raises:
        ValidationError: ``email`` is empty or not an e-mail address.
        APIError: The API answered with another error.
        TransportError: The request failed without a response.

    Example:
        ```python
        await client.auth.password.forget(email="me@example.com")
        ```
    """
    await self._http.request(
        "POST", "/accounts/users/auth/password/forget", json={"email": email}
    )

reset async

reset(*, token: str, password: str) -> None

Set a new password with the token from the reset e-mail.

Doesn't log in: call client.auth.login() with the new password afterwards.

Parameters:

Name Type Description Default
token str

The token from the link in the e-mail sent by forget().

required
password str

The new password (sent as both password and its confirmation).

required

Raises:

Type Description
NotFoundError

Unknown or used token.

ValidationError

The password doesn't meet the API's rules, or a field is empty.

APIError

The API answered with another error.

TransportError

The request failed without a response.

Example
await client.auth.password.reset(token=token_from_email, password=new_password)
Source code in src/aniliberty/resources/auth.py
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
async def reset(self, *, token: str, password: str) -> None:
    """Set a new password with the token from the reset e-mail.

    Doesn't log in: call ``client.auth.login()`` with the new password afterwards.

    Args:
        token: The token from the link in the e-mail sent by ``forget()``.
        password: The new password (sent as both ``password`` and its confirmation).

    Raises:
        NotFoundError: Unknown or used ``token``.
        ValidationError: The password doesn't meet the API's rules, or a field is empty.
        APIError: The API answered with another error.
        TransportError: The request failed without a response.

    Example:
        ```python
        await client.auth.password.reset(token=token_from_email, password=new_password)
        ```
    """
    await self._http.request(
        "POST",
        "/accounts/users/auth/password/reset",
        json={"token": token, "password": password, "password_confirmation": password},
    )

aniliberty.resources.MeResource

MeResource(http: HttpClient)

Bases: BaseResource

The logged-in user's own account, available as client.me.

Every method needs a session: log in with client.auth.login() or pass AniLiberty(token=...). Without a valid one the API answers 403, raised as AuthRequiredError.

Attributes:

Name Type Description
favorites

The user's favorite releases.

collections

The user's collections (planned, watched, watching, ...).

views

The user's watch progress (timecodes) and history.

Source code in src/aniliberty/resources/me.py
28
29
30
31
32
def __init__(self, http: HttpClient) -> None:
    super().__init__(http)
    self.favorites = FavoritesResource(http)
    self.collections = CollectionsResource(http)
    self.views = ViewsResource(http)

profile async

profile() -> UserProfile

Get the logged-in user's profile.

Returns:

Type Description
UserProfile

The profile, with the account's e-mail and tracker passkey.

Raises:

Type Description
AuthRequiredError

No session token, or the API doesn't accept it (logged out or expired).

APIError

The API answered with another error.

TransportError

The request failed without a response.

ResponseParseError

The response didn't match UserProfile.

Example
async with AniLiberty(token=saved_token) as client:
    me = await client.me.profile()
    print(me.nickname, me.created_at)
Source code in src/aniliberty/resources/me.py
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
async def profile(self) -> UserProfile:
    """Get the logged-in user's profile.

    Returns:
        The profile, with the account's e-mail and tracker passkey.

    Raises:
        AuthRequiredError: No session token, or the API doesn't accept it (logged out or
            expired).
        APIError: The API answered with another error.
        TransportError: The request failed without a response.
        ResponseParseError: The response didn't match ``UserProfile``.

    Example:
        ```python
        async with AniLiberty(token=saved_token) as client:
            me = await client.me.profile()
            print(me.nickname, me.created_at)
        ```
    """
    return await self._get("/accounts/users/me/profile", UserProfile)

aniliberty.resources.FavoritesResource

FavoritesResource(http: HttpClient)

Bases: BaseResource

The logged-in user's favorite releases, available as client.me.favorites.

Needs a session (client.auth.login() or AniLiberty(token=...)); without a valid one every method raises AuthRequiredError.

The API checks little here (see docs/api-notes.md): add() accepts IDs of releases that don't exist — they end up in ids(), though never in releases() — and adding a release twice or removing one that isn't there is silently a no-op.

Attributes:

Name Type Description
references

Values the favorites filters accept.

Source code in src/aniliberty/resources/favorites.py
87
88
89
def __init__(self, http: HttpClient) -> None:
    super().__init__(http)
    self.references = FavoritesReferencesResource(http)

ids async

ids() -> list[int]

Get the IDs of all releases in favorites, in the order they were added.

Cheaper than releases() for checking whether a release is a favorite.

Returns:

Type Description
list[int]

Release IDs.

Raises:

Type Description
AuthRequiredError

No valid session.

APIError

The API answered with another error.

TransportError

The request failed without a response.

ResponseParseError

The response wasn't a list of integers.

Example
if release.id in await client.me.favorites.ids():
    print("already a favorite")
Source code in src/aniliberty/resources/favorites.py
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
async def ids(self) -> list[int]:
    """Get the IDs of all releases in favorites, in the order they were added.

    Cheaper than ``releases()`` for checking whether a release is a favorite.

    Returns:
        Release IDs.

    Raises:
        AuthRequiredError: No valid session.
        APIError: The API answered with another error.
        TransportError: The request failed without a response.
        ResponseParseError: The response wasn't a list of integers.

    Example:
        ```python
        if release.id in await client.me.favorites.ids():
            print("already a favorite")
        ```
    """
    return await self._get(f"{_PATH}/ids", list[int])

add async

add(*releases: int | Release) -> list[int]

Add releases to favorites.

Parameters:

Name Type Description Default
*releases int | Release

Release IDs or Release objects, one or more. Releases already in favorites are skipped.

()

Returns:

Type Description
list[int]

IDs of all releases in favorites afterwards, like ids().

Raises:

Type Description
ValueError

No releases given.

TypeError

A value is neither an int nor a Release.

AuthRequiredError

No valid session.

APIError

The API answered with another error.

TransportError

The request failed without a response. Not retried: it's a POST, though adding again would do no harm.

ResponseParseError

The response wasn't a list of integers.

Example
await client.me.favorites.add(9839, release)
Source code in src/aniliberty/resources/favorites.py
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
async def add(self, *releases: int | Release) -> list[int]:
    """Add releases to favorites.

    Args:
        *releases: Release IDs or ``Release`` objects, one or more. Releases already in
            favorites are skipped.

    Returns:
        IDs of all releases in favorites afterwards, like ``ids()``.

    Raises:
        ValueError: No releases given.
        TypeError: A value is neither an ``int`` nor a ``Release``.
        AuthRequiredError: No valid session.
        APIError: The API answered with another error.
        TransportError: The request failed without a response. Not retried: it's a
            ``POST``, though adding again would do no harm.
        ResponseParseError: The response wasn't a list of integers.

    Example:
        ```python
        await client.me.favorites.add(9839, release)
        ```
    """
    body = [{"release_id": release_id} for release_id in release_ids(releases)]
    payload = await self._http.request("POST", _PATH, json=body)
    return self._parse(payload, list[int], path=_PATH)

remove async

remove(*releases: int | Release) -> list[int]

Remove releases from favorites.

Parameters:

Name Type Description Default
*releases int | Release

Release IDs or Release objects, one or more. Releases that aren't in favorites are skipped.

()

Returns:

Type Description
list[int]

IDs of the releases left in favorites, like ids().

Raises:

Type Description
ValueError

No releases given.

TypeError

A value is neither an int nor a Release.

AuthRequiredError

No valid session.

APIError

The API answered with another error.

TransportError

The request failed without a response.

ResponseParseError

The response wasn't a list of integers.

Example
left = await client.me.favorites.remove(9839)
Source code in src/aniliberty/resources/favorites.py
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
async def remove(self, *releases: int | Release) -> list[int]:
    """Remove releases from favorites.

    Args:
        *releases: Release IDs or ``Release`` objects, one or more. Releases that aren't
            in favorites are skipped.

    Returns:
        IDs of the releases left in favorites, like ``ids()``.

    Raises:
        ValueError: No releases given.
        TypeError: A value is neither an ``int`` nor a ``Release``.
        AuthRequiredError: No valid session.
        APIError: The API answered with another error.
        TransportError: The request failed without a response.
        ResponseParseError: The response wasn't a list of integers.

    Example:
        ```python
        left = await client.me.favorites.remove(9839)
        ```
    """
    body = [{"release_id": release_id} for release_id in release_ids(releases)]
    payload = await self._http.request("DELETE", _PATH, json=body)
    return self._parse(payload, list[int], path=_PATH)

releases async

releases(
    *,
    genres: Sequence[int | Genre] | None = None,
    types: Sequence[ReleaseType | ValueDescription[str]]
    | None = None,
    years: Sequence[int] | None = None,
    search: str | None = None,
    sorting: FavoritesSorting | SortingOption | None = None,
    age_ratings: Sequence[
        AgeRatingValue | ValueDescription[str]
    ]
    | None = None,
    page: int | None = None,
    limit: int | None = None,
) -> Page[Release]

Get one page of favorite releases matching the filters.

The filters combine like the catalog's: a release must have all of genres and any of the types, years and age_ratings; different filters must all match. To go through all pages, use iter_releases().

Parameters:

Name Type Description Default
genres Sequence[int | Genre] | None

Genre IDs (or Genre objects); a release must have all of them.

None
types Sequence[ReleaseType | ValueDescription[str]] | None

Release types, e.g. ["TV", "MOVIE"]; any of them.

None
years Sequence[int] | None

Release years; any of them. A range works too: range(2020, 2026).

None
search str | None

Text to search in names.

None
sorting FavoritesSorting | SortingOption | None

Sort order. Default: "CREATED_AT_DESC" (added to favorites most recently first). "RATING_*" sorts by how many users have the release in favorites.

None
age_ratings Sequence[AgeRatingValue | ValueDescription[str]] | None

Age ratings, e.g. ["R16_PLUS"]; any of them.

None
page int | None

Page number, starting at 1. A page past the last one is empty.

None
limit int | None

Page size, 1 to 50. Default: 25.

None

Returns:

Type Description
Page[Release]

One page of releases, with genres and episodes filled in.

Raises:

Type Description
ValueError

A filter value isn't one the API knows (it would silently return nothing or ignore the filter instead).

TypeError

A list filter got a single value (types="TV", years=2024) or a value of the wrong type.

AuthRequiredError

No valid session.

ValidationError

page or limit is out of range.

APIError

The API answered with another error.

TransportError

The request failed without a response.

ResponseParseError

The response didn't match Release.

Example
page = await client.me.favorites.releases(types=["TV"], sorting="YEAR_DESC")
for release in page.items:
    print(release.year, release.name.main)
Source code in src/aniliberty/resources/favorites.py
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
async def releases(
    self,
    *,
    genres: Sequence[int | Genre] | None = None,
    types: Sequence[ReleaseType | ValueDescription[str]] | None = None,
    years: Sequence[int] | None = None,
    search: str | None = None,
    sorting: FavoritesSorting | SortingOption | None = None,
    age_ratings: Sequence[AgeRatingValue | ValueDescription[str]] | None = None,
    page: int | None = None,
    limit: int | None = None,
) -> Page[Release]:
    """Get one page of favorite releases matching the filters.

    The filters combine like the catalog's: a release must have **all** of ``genres``
    and **any** of the ``types``, ``years`` and ``age_ratings``; different filters must
    all match. To go through all pages, use ``iter_releases()``.

    Args:
        genres: Genre IDs (or ``Genre`` objects); a release must have all of them.
        types: Release types, e.g. ``["TV", "MOVIE"]``; any of them.
        years: Release years; any of them. A range works too: ``range(2020, 2026)``.
        search: Text to search in names.
        sorting: Sort order. Default: ``"CREATED_AT_DESC"`` (added to favorites most
            recently first). ``"RATING_*"`` sorts by how many users have the release in
            favorites.
        age_ratings: Age ratings, e.g. ``["R16_PLUS"]``; any of them.
        page: Page number, starting at 1. A page past the last one is empty.
        limit: Page size, 1 to 50. Default: 25.

    Returns:
        One page of releases, with ``genres`` and ``episodes`` filled in.

    Raises:
        ValueError: A filter value isn't one the API knows (it would silently return
            nothing or ignore the filter instead).
        TypeError: A list filter got a single value (``types="TV"``, ``years=2024``) or
            a value of the wrong type.
        AuthRequiredError: No valid session.
        ValidationError: ``page`` or ``limit`` is out of range.
        APIError: The API answered with another error.
        TransportError: The request failed without a response.
        ResponseParseError: The response didn't match ``Release``.

    Example:
        ```python
        page = await client.me.favorites.releases(types=["TV"], sorting="YEAR_DESC")
        for release in page.items:
            print(release.year, release.name.main)
        ```
    """
    params = _favorites_params(
        genres=genres,
        types=types,
        years=years,
        search=search,
        sorting=sorting,
        age_ratings=age_ratings,
    )
    return await self._fetch_page(params, page=page, limit=limit)

iter_releases

iter_releases(
    *,
    genres: Sequence[int | Genre] | None = None,
    types: Sequence[ReleaseType | ValueDescription[str]]
    | None = None,
    years: Sequence[int] | None = None,
    search: str | None = None,
    sorting: FavoritesSorting | SortingOption | None = None,
    age_ratings: Sequence[
        AgeRatingValue | ValueDescription[str]
    ]
    | None = None,
    limit: int | None = None,
) -> AsyncIterator[Release]

Iterate over all favorite releases matching the filters, fetching page by page.

Takes the same filters as releases(); the next page is requested only once the current one has been consumed.

Parameters:

Name Type Description Default
genres Sequence[int | Genre] | None

See releases().

None
types Sequence[ReleaseType | ValueDescription[str]] | None

See releases().

None
years Sequence[int] | None

See releases().

None
search str | None

See releases().

None
sorting FavoritesSorting | SortingOption | None

See releases().

None
age_ratings Sequence[AgeRatingValue | ValueDescription[str]] | None

See releases().

None
limit int | None

Page size, 1 to 50 — affects how many requests are made, not the result.

None

Returns:

Type Description
AsyncIterator[Release]

An async iterator over the releases.

Raises:

Type Description
ValueError

Invalid filter values — raised right away, not when iteration starts.

TypeError

A list filter got a single value or a value of the wrong type.

Example
async for release in client.me.favorites.iter_releases(limit=50):
    print(release.name.main)
Source code in src/aniliberty/resources/favorites.py
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
def iter_releases(
    self,
    *,
    genres: Sequence[int | Genre] | None = None,
    types: Sequence[ReleaseType | ValueDescription[str]] | None = None,
    years: Sequence[int] | None = None,
    search: str | None = None,
    sorting: FavoritesSorting | SortingOption | None = None,
    age_ratings: Sequence[AgeRatingValue | ValueDescription[str]] | None = None,
    limit: int | None = None,
) -> AsyncIterator[Release]:
    """Iterate over all favorite releases matching the filters, fetching page by page.

    Takes the same filters as ``releases()``; the next page is requested only once the
    current one has been consumed.

    Args:
        genres: See ``releases()``.
        types: See ``releases()``.
        years: See ``releases()``.
        search: See ``releases()``.
        sorting: See ``releases()``.
        age_ratings: See ``releases()``.
        limit: Page size, 1 to 50 — affects how many requests are made, not the result.

    Returns:
        An async iterator over the releases.

    Raises:
        ValueError: Invalid filter values — raised right away, not when iteration starts.
        TypeError: A list filter got a single value or a value of the wrong type.

    Example:
        ```python
        async for release in client.me.favorites.iter_releases(limit=50):
            print(release.name.main)
        ```
    """
    params = _favorites_params(
        genres=genres,
        types=types,
        years=years,
        search=search,
        sorting=sorting,
        age_ratings=age_ratings,
    )

    async def fetch(page: int) -> Page[Release]:
        return await self._fetch_page(params, page=page, limit=limit)

    return iterate_pages(fetch)

aniliberty.resources.FavoritesReferencesResource

FavoritesReferencesResource(http: HttpClient)

Bases: _OwnListReferencesResource

Values the favorites filters accept, available as client.me.favorites.references.

Unlike client.catalog.references, the lists only contain what occurs in the user's own favorites: the genres, types, years and age ratings of the releases added there. With empty favorites they are empty — except sorting(), which is always the full list.

Source code in src/aniliberty/resources/_base.py
28
29
def __init__(self, http: HttpClient) -> None:
    self._http = http

genres async

genres() -> list[Genre]

Get the genres of the releases in the list.

Returns:

Type Description
list[Genre]

Genres with id and name only.

Raises:

Type Description
AuthRequiredError

No valid session.

APIError

The API answered with another error.

TransportError

The request failed without a response.

ResponseParseError

The response didn't match Genre.

Example
# The same for client.me.collections.references.
genres = await client.me.favorites.references.genres()
print([genre.name for genre in genres])
Source code in src/aniliberty/resources/_lists.py
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
async def genres(self) -> list[Genre]:
    """Get the genres of the releases in the list.

    Returns:
        Genres with ``id`` and ``name`` only.

    Raises:
        AuthRequiredError: No valid session.
        APIError: The API answered with another error.
        TransportError: The request failed without a response.
        ResponseParseError: The response didn't match ``Genre``.

    Example:
        ```python
        # The same for client.me.collections.references.
        genres = await client.me.favorites.references.genres()
        print([genre.name for genre in genres])
        ```
    """
    return await self._get(f"{self._REFERENCES_PATH}/genres", list[Genre])

types async

types() -> list[ValueDescription[str]]

Get the release types in the list, e.g. value="TV", description="ТВ".

Returns:

Type Description
list[ValueDescription[str]]

The release types.

Raises:

Type Description
AuthRequiredError

No valid session.

APIError

The API answered with another error.

TransportError

The request failed without a response.

ResponseParseError

The response didn't match ValueDescription.

Example
types = await client.me.favorites.references.types()
page = await client.me.favorites.releases(types=types)
Source code in src/aniliberty/resources/_lists.py
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
async def types(self) -> list[ValueDescription[str]]:
    """Get the release types in the list, e.g. ``value="TV"``, ``description="ТВ"``.

    Returns:
        The release types.

    Raises:
        AuthRequiredError: No valid session.
        APIError: The API answered with another error.
        TransportError: The request failed without a response.
        ResponseParseError: The response didn't match ``ValueDescription``.

    Example:
        ```python
        types = await client.me.favorites.references.types()
        page = await client.me.favorites.releases(types=types)
        ```
    """
    return await self._get(f"{self._REFERENCES_PATH}/types", list[ValueDescription[str]])

years async

years() -> list[int]

Get the release years in the list, newest first.

Returns:

Type Description
list[int]

The years.

Raises:

Type Description
AuthRequiredError

No valid session.

APIError

The API answered with another error.

TransportError

The request failed without a response.

ResponseParseError

The response wasn't a list of integers.

Example
years = await client.me.favorites.references.years()
page = await client.me.favorites.releases(years=years[:1])  # the newest year
Source code in src/aniliberty/resources/_lists.py
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
async def years(self) -> list[int]:
    """Get the release years in the list, newest first.

    Returns:
        The years.

    Raises:
        AuthRequiredError: No valid session.
        APIError: The API answered with another error.
        TransportError: The request failed without a response.
        ResponseParseError: The response wasn't a list of integers.

    Example:
        ```python
        years = await client.me.favorites.references.years()
        page = await client.me.favorites.releases(years=years[:1])  # the newest year
        ```
    """
    return await self._get(f"{self._REFERENCES_PATH}/years", list[int])

age_ratings async

age_ratings() -> list[AgeRating]

Get the age ratings in the list, e.g. value="R16_PLUS", label="16+".

Returns:

Type Description
list[AgeRating]

The age ratings (is_adult is None here).

Raises:

Type Description
AuthRequiredError

No valid session.

APIError

The API answered with another error.

TransportError

The request failed without a response.

ResponseParseError

The response didn't match AgeRating.

Example
for rating in await client.me.collections.references.age_ratings():
    print(rating.value, rating.label)
Source code in src/aniliberty/resources/_lists.py
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
async def age_ratings(self) -> list[AgeRating]:
    """Get the age ratings in the list, e.g. ``value="R16_PLUS"``, ``label="16+"``.

    Returns:
        The age ratings (``is_adult`` is ``None`` here).

    Raises:
        AuthRequiredError: No valid session.
        APIError: The API answered with another error.
        TransportError: The request failed without a response.
        ResponseParseError: The response didn't match ``AgeRating``.

    Example:
        ```python
        for rating in await client.me.collections.references.age_ratings():
            print(rating.value, rating.label)
        ```
    """
    return await self._get(f"{self._REFERENCES_PATH}/age-ratings", list[AgeRating])

sorting async

sorting() -> list[SortingOption]

Get the ways favorites can be sorted, e.g. value="CREATED_AT_DESC".

Returns:

Type Description
list[SortingOption]

All sorting options, whatever is in favorites.

Raises:

Type Description
AuthRequiredError

No valid session.

APIError

The API answered with another error.

TransportError

The request failed without a response.

ResponseParseError

The response didn't match SortingOption.

Example
for option in await client.me.favorites.references.sorting():
    print(option.value, option.label)
Source code in src/aniliberty/resources/favorites.py
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
async def sorting(self) -> list[SortingOption]:
    """Get the ways favorites can be sorted, e.g. ``value="CREATED_AT_DESC"``.

    Returns:
        All sorting options, whatever is in favorites.

    Raises:
        AuthRequiredError: No valid session.
        APIError: The API answered with another error.
        TransportError: The request failed without a response.
        ResponseParseError: The response didn't match ``SortingOption``.

    Example:
        ```python
        for option in await client.me.favorites.references.sorting():
            print(option.value, option.label)
        ```
    """
    return await self._get(f"{_PATH}/references/sorting", list[SortingOption])

aniliberty.resources.CollectionsResource

CollectionsResource(http: HttpClient)

Bases: BaseResource

The user's collections, available as client.me.collections.

There are five collections (CollectionType): "PLANNED", "WATCHED", "WATCHING", "POSTPONED" and "ABANDONED". A release is in at most one of them: adding it to another collection moves it there. Collections are separate from favorites.

Needs a session (client.auth.login() or AniLiberty(token=...)); without a valid one every method raises AuthRequiredError. As with favorites, the API accepts IDs of releases that don't exist, and removing a release that isn't in any collection is a no-op.

The API also applies collection changes in the background (see docs/api-notes.md): a release removed right after being added can be back about a minute later, and removing a release can take minutes to stick. To be sure a change held, check ids() again a minute later.

Attributes:

Name Type Description
references

Values the collections filters accept.

Source code in src/aniliberty/resources/collections.py
73
74
75
def __init__(self, http: HttpClient) -> None:
    super().__init__(http)
    self.references = CollectionsReferencesResource(http)

ids async

ids() -> list[CollectionItem]

Get every release in the collections, with the collection it's in.

Returns:

Type Description
list[CollectionItem]

One item per release, in the order the releases were added.

Raises:

Type Description
AuthRequiredError

No valid session.

APIError

The API answered with another error.

TransportError

The request failed without a response.

ResponseParseError

The response didn't match CollectionItem.

Example
where = {item.release_id: item.collection for item in await client.me.collections.ids()}
print(where.get(9839))  # e.g. "WATCHED", or None
Source code in src/aniliberty/resources/collections.py
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
async def ids(self) -> list[CollectionItem]:
    """Get every release in the collections, with the collection it's in.

    Returns:
        One item per release, in the order the releases were added.

    Raises:
        AuthRequiredError: No valid session.
        APIError: The API answered with another error.
        TransportError: The request failed without a response.
        ResponseParseError: The response didn't match ``CollectionItem``.

    Example:
        ```python
        where = {item.release_id: item.collection for item in await client.me.collections.ids()}
        print(where.get(9839))  # e.g. "WATCHED", or None
        ```
    """
    return await self._get(f"{_PATH}/ids", list[CollectionItem])

add async

add(
    *releases: int | Release, to: CollectionType
) -> list[CollectionItem]

Put releases into a collection, moving them out of the one they were in.

Parameters:

Name Type Description Default
*releases int | Release

Release IDs or Release objects, one or more.

()
to CollectionType

The collection, e.g. "WATCHING".

required

Returns:

Type Description
list[CollectionItem]

Every release in the collections afterwards, like ids().

Raises:

Type Description
ValueError

No releases given, or to isn't a collection type.

TypeError

A release is neither an int nor a Release.

AuthRequiredError

No valid session.

APIError

The API answered with another error.

TransportError

The request failed without a response.

ResponseParseError

The response didn't match CollectionItem.

Example
await client.me.collections.add(9839, release, to="WATCHING")
await client.me.collections.add(9839, to="WATCHED")  # moved
Source code in src/aniliberty/resources/collections.py
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
async def add(self, *releases: int | Release, to: CollectionType) -> list[CollectionItem]:
    """Put releases into a collection, moving them out of the one they were in.

    Args:
        *releases: Release IDs or ``Release`` objects, one or more.
        to: The collection, e.g. ``"WATCHING"``.

    Returns:
        Every release in the collections afterwards, like ``ids()``.

    Raises:
        ValueError: No releases given, or ``to`` isn't a collection type.
        TypeError: A release is neither an ``int`` nor a ``Release``.
        AuthRequiredError: No valid session.
        APIError: The API answered with another error.
        TransportError: The request failed without a response.
        ResponseParseError: The response didn't match ``CollectionItem``.

    Example:
        ```python
        await client.me.collections.add(9839, release, to="WATCHING")
        await client.me.collections.add(9839, to="WATCHED")  # moved
        ```
    """
    collection = enum_value("collection", to, get_args(CollectionType))
    body = [
        {"release_id": release_id, "type_of_collection": collection}
        for release_id in release_ids(releases)
    ]
    payload = await self._http.request("POST", _PATH, json=body)
    return self._parse(payload, list[CollectionItem], path=_PATH)

remove async

remove(*releases: int | Release) -> list[CollectionItem]

Take releases out of whatever collection they are in.

Parameters:

Name Type Description Default
*releases int | Release

Release IDs or Release objects, one or more.

()

Returns:

Type Description
list[CollectionItem]

The releases left in the collections, like ids().

Raises:

Type Description
ValueError

No releases given.

TypeError

A release is neither an int nor a Release.

AuthRequiredError

No valid session.

APIError

The API answered with another error.

TransportError

The request failed without a response.

ResponseParseError

The response didn't match CollectionItem.

Example
await client.me.collections.remove(9839)
Source code in src/aniliberty/resources/collections.py
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
async def remove(self, *releases: int | Release) -> list[CollectionItem]:
    """Take releases out of whatever collection they are in.

    Args:
        *releases: Release IDs or ``Release`` objects, one or more.

    Returns:
        The releases left in the collections, like ``ids()``.

    Raises:
        ValueError: No releases given.
        TypeError: A release is neither an ``int`` nor a ``Release``.
        AuthRequiredError: No valid session.
        APIError: The API answered with another error.
        TransportError: The request failed without a response.
        ResponseParseError: The response didn't match ``CollectionItem``.

    Example:
        ```python
        await client.me.collections.remove(9839)
        ```
    """
    body = [{"release_id": release_id} for release_id in release_ids(releases)]
    payload = await self._http.request("DELETE", _PATH, json=body)
    return self._parse(payload, list[CollectionItem], path=_PATH)

releases async

releases(
    collection: CollectionType,
    *,
    genres: Sequence[int | Genre] | None = None,
    types: Sequence[ReleaseType | ValueDescription[str]]
    | None = None,
    years: Sequence[int] | None = None,
    search: str | None = None,
    age_ratings: Sequence[
        AgeRatingValue | ValueDescription[str]
    ]
    | None = None,
    page: int | None = None,
    limit: int | None = None,
) -> Page[Release]

Get one page of the releases in a collection, matching the filters.

Releases come most recently added first; the API offers no other order. The filters combine like the favorites' ones: all of genres, any of the types, years and age_ratings. To go through all pages, use iter_releases().

Parameters:

Name Type Description Default
collection CollectionType

The collection, e.g. "PLANNED".

required
genres Sequence[int | Genre] | None

Genre IDs (or Genre objects); a release must have all of them.

None
types Sequence[ReleaseType | ValueDescription[str]] | None

Release types, e.g. ["TV", "MOVIE"]; any of them.

None
years Sequence[int] | None

Release years; any of them. A range works too: range(2020, 2026).

None
search str | None

Text to search in names.

None
age_ratings Sequence[AgeRatingValue | ValueDescription[str]] | None

Age ratings, e.g. ["R16_PLUS"]; any of them.

None
page int | None

Page number, starting at 1. A page past the last one is empty.

None
limit int | None

Page size, 1 to 50. Default: 25.

None

Returns:

Type Description
Page[Release]

One page of releases, with genres and episodes filled in.

Raises:

Type Description
ValueError

collection or a filter value isn't one the API knows.

TypeError

A list filter got a single value or a value of the wrong type.

AuthRequiredError

No valid session.

ValidationError

page or limit is out of range.

APIError

The API answered with another error.

TransportError

The request failed without a response.

ResponseParseError

The response didn't match Release.

Example
page = await client.me.collections.releases("WATCHING", types=["TV"])
for release in page.items:
    print(release.name.main)
Source code in src/aniliberty/resources/collections.py
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
async def releases(
    self,
    collection: CollectionType,
    *,
    genres: Sequence[int | Genre] | None = None,
    types: Sequence[ReleaseType | ValueDescription[str]] | None = None,
    years: Sequence[int] | None = None,
    search: str | None = None,
    age_ratings: Sequence[AgeRatingValue | ValueDescription[str]] | None = None,
    page: int | None = None,
    limit: int | None = None,
) -> Page[Release]:
    """Get one page of the releases in a collection, matching the filters.

    Releases come most recently added first; the API offers no other order. The filters
    combine like the favorites' ones: all of ``genres``, any of the ``types``, ``years``
    and ``age_ratings``. To go through all pages, use ``iter_releases()``.

    Args:
        collection: The collection, e.g. ``"PLANNED"``.
        genres: Genre IDs (or ``Genre`` objects); a release must have all of them.
        types: Release types, e.g. ``["TV", "MOVIE"]``; any of them.
        years: Release years; any of them. A range works too: ``range(2020, 2026)``.
        search: Text to search in names.
        age_ratings: Age ratings, e.g. ``["R16_PLUS"]``; any of them.
        page: Page number, starting at 1. A page past the last one is empty.
        limit: Page size, 1 to 50. Default: 25.

    Returns:
        One page of releases, with ``genres`` and ``episodes`` filled in.

    Raises:
        ValueError: ``collection`` or a filter value isn't one the API knows.
        TypeError: A list filter got a single value or a value of the wrong type.
        AuthRequiredError: No valid session.
        ValidationError: ``page`` or ``limit`` is out of range.
        APIError: The API answered with another error.
        TransportError: The request failed without a response.
        ResponseParseError: The response didn't match ``Release``.

    Example:
        ```python
        page = await client.me.collections.releases("WATCHING", types=["TV"])
        for release in page.items:
            print(release.name.main)
        ```
    """
    params = _collection_params(
        collection,
        genres=genres,
        types=types,
        years=years,
        search=search,
        age_ratings=age_ratings,
    )
    return await self._fetch_page(params, page=page, limit=limit)

iter_releases

iter_releases(
    collection: CollectionType,
    *,
    genres: Sequence[int | Genre] | None = None,
    types: Sequence[ReleaseType | ValueDescription[str]]
    | None = None,
    years: Sequence[int] | None = None,
    search: str | None = None,
    age_ratings: Sequence[
        AgeRatingValue | ValueDescription[str]
    ]
    | None = None,
    limit: int | None = None,
) -> AsyncIterator[Release]

Iterate over all releases in a collection matching the filters, page by page.

Takes the same arguments as releases(); the next page is requested only once the current one has been consumed.

Parameters:

Name Type Description Default
collection CollectionType

See releases().

required
genres Sequence[int | Genre] | None

See releases().

None
types Sequence[ReleaseType | ValueDescription[str]] | None

See releases().

None
years Sequence[int] | None

See releases().

None
search str | None

See releases().

None
age_ratings Sequence[AgeRatingValue | ValueDescription[str]] | None

See releases().

None
limit int | None

Page size, 1 to 50 — affects how many requests are made, not the result.

None

Returns:

Type Description
AsyncIterator[Release]

An async iterator over the releases.

Raises:

Type Description
ValueError

Invalid collection or filter values — raised right away, not when iteration starts.

TypeError

A list filter got a single value or a value of the wrong type.

Example
async for release in client.me.collections.iter_releases("WATCHED"):
    print(release.name.main)
Source code in src/aniliberty/resources/collections.py
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
def iter_releases(
    self,
    collection: CollectionType,
    *,
    genres: Sequence[int | Genre] | None = None,
    types: Sequence[ReleaseType | ValueDescription[str]] | None = None,
    years: Sequence[int] | None = None,
    search: str | None = None,
    age_ratings: Sequence[AgeRatingValue | ValueDescription[str]] | None = None,
    limit: int | None = None,
) -> AsyncIterator[Release]:
    """Iterate over all releases in a collection matching the filters, page by page.

    Takes the same arguments as ``releases()``; the next page is requested only once the
    current one has been consumed.

    Args:
        collection: See ``releases()``.
        genres: See ``releases()``.
        types: See ``releases()``.
        years: See ``releases()``.
        search: See ``releases()``.
        age_ratings: See ``releases()``.
        limit: Page size, 1 to 50 — affects how many requests are made, not the result.

    Returns:
        An async iterator over the releases.

    Raises:
        ValueError: Invalid ``collection`` or filter values — raised right away, not when
            iteration starts.
        TypeError: A list filter got a single value or a value of the wrong type.

    Example:
        ```python
        async for release in client.me.collections.iter_releases("WATCHED"):
            print(release.name.main)
        ```
    """
    params = _collection_params(
        collection,
        genres=genres,
        types=types,
        years=years,
        search=search,
        age_ratings=age_ratings,
    )

    async def fetch(page: int) -> Page[Release]:
        return await self._fetch_page(params, page=page, limit=limit)

    return iterate_pages(fetch)

aniliberty.resources.CollectionsReferencesResource

CollectionsReferencesResource(http: HttpClient)

Bases: _OwnListReferencesResource

Values the collections filters accept, available as client.me.collections.references.

The lists cover all the user's collections together — the API has no per-collection variant (a type_of_collection parameter is ignored). Collections can't be sorted, so unlike favorites there is no sorting().

Source code in src/aniliberty/resources/_base.py
28
29
def __init__(self, http: HttpClient) -> None:
    self._http = http

genres async

genres() -> list[Genre]

Get the genres of the releases in the list.

Returns:

Type Description
list[Genre]

Genres with id and name only.

Raises:

Type Description
AuthRequiredError

No valid session.

APIError

The API answered with another error.

TransportError

The request failed without a response.

ResponseParseError

The response didn't match Genre.

Example
# The same for client.me.collections.references.
genres = await client.me.favorites.references.genres()
print([genre.name for genre in genres])
Source code in src/aniliberty/resources/_lists.py
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
async def genres(self) -> list[Genre]:
    """Get the genres of the releases in the list.

    Returns:
        Genres with ``id`` and ``name`` only.

    Raises:
        AuthRequiredError: No valid session.
        APIError: The API answered with another error.
        TransportError: The request failed without a response.
        ResponseParseError: The response didn't match ``Genre``.

    Example:
        ```python
        # The same for client.me.collections.references.
        genres = await client.me.favorites.references.genres()
        print([genre.name for genre in genres])
        ```
    """
    return await self._get(f"{self._REFERENCES_PATH}/genres", list[Genre])

types async

types() -> list[ValueDescription[str]]

Get the release types in the list, e.g. value="TV", description="ТВ".

Returns:

Type Description
list[ValueDescription[str]]

The release types.

Raises:

Type Description
AuthRequiredError

No valid session.

APIError

The API answered with another error.

TransportError

The request failed without a response.

ResponseParseError

The response didn't match ValueDescription.

Example
types = await client.me.favorites.references.types()
page = await client.me.favorites.releases(types=types)
Source code in src/aniliberty/resources/_lists.py
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
async def types(self) -> list[ValueDescription[str]]:
    """Get the release types in the list, e.g. ``value="TV"``, ``description="ТВ"``.

    Returns:
        The release types.

    Raises:
        AuthRequiredError: No valid session.
        APIError: The API answered with another error.
        TransportError: The request failed without a response.
        ResponseParseError: The response didn't match ``ValueDescription``.

    Example:
        ```python
        types = await client.me.favorites.references.types()
        page = await client.me.favorites.releases(types=types)
        ```
    """
    return await self._get(f"{self._REFERENCES_PATH}/types", list[ValueDescription[str]])

years async

years() -> list[int]

Get the release years in the list, newest first.

Returns:

Type Description
list[int]

The years.

Raises:

Type Description
AuthRequiredError

No valid session.

APIError

The API answered with another error.

TransportError

The request failed without a response.

ResponseParseError

The response wasn't a list of integers.

Example
years = await client.me.favorites.references.years()
page = await client.me.favorites.releases(years=years[:1])  # the newest year
Source code in src/aniliberty/resources/_lists.py
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
async def years(self) -> list[int]:
    """Get the release years in the list, newest first.

    Returns:
        The years.

    Raises:
        AuthRequiredError: No valid session.
        APIError: The API answered with another error.
        TransportError: The request failed without a response.
        ResponseParseError: The response wasn't a list of integers.

    Example:
        ```python
        years = await client.me.favorites.references.years()
        page = await client.me.favorites.releases(years=years[:1])  # the newest year
        ```
    """
    return await self._get(f"{self._REFERENCES_PATH}/years", list[int])

age_ratings async

age_ratings() -> list[AgeRating]

Get the age ratings in the list, e.g. value="R16_PLUS", label="16+".

Returns:

Type Description
list[AgeRating]

The age ratings (is_adult is None here).

Raises:

Type Description
AuthRequiredError

No valid session.

APIError

The API answered with another error.

TransportError

The request failed without a response.

ResponseParseError

The response didn't match AgeRating.

Example
for rating in await client.me.collections.references.age_ratings():
    print(rating.value, rating.label)
Source code in src/aniliberty/resources/_lists.py
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
async def age_ratings(self) -> list[AgeRating]:
    """Get the age ratings in the list, e.g. ``value="R16_PLUS"``, ``label="16+"``.

    Returns:
        The age ratings (``is_adult`` is ``None`` here).

    Raises:
        AuthRequiredError: No valid session.
        APIError: The API answered with another error.
        TransportError: The request failed without a response.
        ResponseParseError: The response didn't match ``AgeRating``.

    Example:
        ```python
        for rating in await client.me.collections.references.age_ratings():
            print(rating.value, rating.label)
        ```
    """
    return await self._get(f"{self._REFERENCES_PATH}/age-ratings", list[AgeRating])

aniliberty.resources.ViewsResource

ViewsResource(http: HttpClient)

Bases: BaseResource

The user's watch progress and history, available as client.me.views.

Needs a session (client.auth.login() or AniLiberty(token=...)); without a valid one every method raises AuthRequiredError.

The API stores timecodes in two steps (see docs/api-notes.md):

  • save_timecodes() shows up in timecodes() at once, but in history(), client.episodes.timecode() and client.releases.timecodes() only about a minute later.
  • remove_timecodes() likewise takes about a minute to reach the history, and timecodes() can keep listing the deleted entries after that; deleting them once more after the minute clears them.
Example
await client.me.views.save_timecodes(
    Timecode(release_episode_id=episode.id, time=743.5, is_watched=False)
)
progress = {t.release_episode_id: t for t in await client.me.views.timecodes()}
Source code in src/aniliberty/resources/_base.py
28
29
def __init__(self, http: HttpClient) -> None:
    self._http = http

timecodes async

timecodes(
    *, since: datetime | None = None
) -> list[Timecode]

Get the user's timecodes — the position in every episode they started.

Parameters:

Name Type Description Default
since datetime | None

Only timecodes saved at or after this moment. Must be timezone-aware (e.g. datetime.now(UTC) - timedelta(days=7)).

None

Returns:

Type Description
list[Timecode]

The timecodes, in no documented order (updating one doesn't move it).

Raises:

Type Description
ValueError

since has no time zone.

AuthRequiredError

No valid session.

APIError

The API answered with another error.

TransportError

The request failed without a response.

ResponseParseError

The response didn't match Timecode.

Example
for timecode in await client.me.views.timecodes():
    print(timecode.release_episode_id, timecode.time, timecode.is_watched)
Source code in src/aniliberty/resources/views.py
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
async def timecodes(self, *, since: datetime | None = None) -> list[Timecode]:
    """Get the user's timecodes — the position in every episode they started.

    Args:
        since: Only timecodes saved at or after this moment. Must be timezone-aware
            (e.g. ``datetime.now(UTC) - timedelta(days=7)``).

    Returns:
        The timecodes, in no documented order (updating one doesn't move it).

    Raises:
        ValueError: ``since`` has no time zone.
        AuthRequiredError: No valid session.
        APIError: The API answered with another error.
        TransportError: The request failed without a response.
        ResponseParseError: The response didn't match ``Timecode``.

    Example:
        ```python
        for timecode in await client.me.views.timecodes():
            print(timecode.release_episode_id, timecode.time, timecode.is_watched)
        ```
    """
    if since is not None and (since.tzinfo is None or since.utcoffset() is None):
        raise ValueError("since must be timezone-aware, e.g. datetime(..., tzinfo=UTC)")
    params = None if since is None else {"since": since.isoformat()}
    return await self._get(f"{_PATH}/timecodes", list[Timecode], params=params)

save_timecodes async

save_timecodes(*timecodes: Timecode) -> None

Save the position in one or more episodes, replacing what was saved for them.

Parameters:

Name Type Description Default
*timecodes Timecode

One Timecode per episode.

()

Raises:

Type Description
ValueError

No timecodes given.

TypeError

A value isn't a Timecode.

AuthRequiredError

No valid session.

ValidationError

An episode ID isn't a UUID. An ID of an episode that doesn't exist is accepted, and dropped later by the API.

APIError

The API answered with another error.

TransportError

The request failed without a response.

Example
await client.me.views.save_timecodes(
    Timecode(release_episode_id=episode.id, time=episode.duration, is_watched=True)
)
Source code in src/aniliberty/resources/views.py
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
async def save_timecodes(self, *timecodes: Timecode) -> None:
    """Save the position in one or more episodes, replacing what was saved for them.

    Args:
        *timecodes: One ``Timecode`` per episode.

    Raises:
        ValueError: No timecodes given.
        TypeError: A value isn't a ``Timecode``.
        AuthRequiredError: No valid session.
        ValidationError: An episode ID isn't a UUID. An ID of an episode that doesn't
            exist is accepted, and dropped later by the API.
        APIError: The API answered with another error.
        TransportError: The request failed without a response.

    Example:
        ```python
        await client.me.views.save_timecodes(
            Timecode(release_episode_id=episode.id, time=episode.duration, is_watched=True)
        )
        ```
    """
    if not timecodes:
        raise ValueError("Pass at least one Timecode")
    for timecode in timecodes:
        if not isinstance(timecode, Timecode):
            raise TypeError(f"Expected a Timecode, got {timecode!r}")
    body = [timecode.model_dump(mode="json") for timecode in timecodes]
    await self._http.request("POST", f"{_PATH}/timecodes", json=body)

remove_timecodes async

remove_timecodes(
    *episodes: str | Episode | Timecode,
) -> None

Delete the saved position in one or more episodes (and their history entries).

Episodes without a timecode are skipped. See the class docs: the deletion takes about a minute, and timecodes() may need a second call after that.

Parameters:

Name Type Description Default
*episodes str | Episode | Timecode

Episode IDs, or Episode/Timecode objects.

()

Raises:

Type Description
ValueError

No episodes given.

TypeError

A value isn't an episode ID, Episode or Timecode.

AuthRequiredError

No valid session.

ValidationError

An episode ID isn't a UUID.

APIError

The API answered with another error.

TransportError

The request failed without a response.

Example
await client.me.views.remove_timecodes(*await client.me.views.timecodes())
Source code in src/aniliberty/resources/views.py
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
async def remove_timecodes(self, *episodes: str | Episode | Timecode) -> None:
    """Delete the saved position in one or more episodes (and their history entries).

    Episodes without a timecode are skipped. See the class docs: the deletion takes about
    a minute, and ``timecodes()`` may need a second call after that.

    Args:
        *episodes: Episode IDs, or ``Episode``/``Timecode`` objects.

    Raises:
        ValueError: No episodes given.
        TypeError: A value isn't an episode ID, ``Episode`` or ``Timecode``.
        AuthRequiredError: No valid session.
        ValidationError: An episode ID isn't a UUID.
        APIError: The API answered with another error.
        TransportError: The request failed without a response.

    Example:
        ```python
        await client.me.views.remove_timecodes(*await client.me.views.timecodes())
        ```
    """
    if not episodes:
        raise ValueError("Pass at least one episode (an ID, Episode or Timecode)")
    body = [{"release_episode_id": _episode_id(episode)} for episode in episodes]
    await self._http.request("DELETE", f"{_PATH}/timecodes", json=body)

history async

history(
    *, page: int | None = None, limit: int | None = None
) -> Page[HistoryEntry]

Get one page of the watch history — one entry per episode, most recent first.

Parameters:

Name Type Description Default
page int | None

Page number, starting at 1. A page past the last one is empty.

None
limit int | None

Page size, 1 to 50. Default: 25.

None

Returns:

Type Description
Page[HistoryEntry]

One page of entries, each with its episode and the episode's release.

Raises:

Type Description
AuthRequiredError

No valid session.

ValidationError

page or limit is out of range.

APIError

The API answered with another error.

TransportError

The request failed without a response.

ResponseParseError

The response didn't match HistoryEntry.

Example
page = await client.me.views.history(limit=10)
for entry in page.items:
    episode = entry.release_episode
    print(episode.release.name.main, episode.ordinal, entry.time)
Source code in src/aniliberty/resources/views.py
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
async def history(
    self, *, page: int | None = None, limit: int | None = None
) -> Page[HistoryEntry]:
    """Get one page of the watch history — one entry per episode, most recent first.

    Args:
        page: Page number, starting at 1. A page past the last one is empty.
        limit: Page size, 1 to 50. Default: 25.

    Returns:
        One page of entries, each with its episode and the episode's release.

    Raises:
        AuthRequiredError: No valid session.
        ValidationError: ``page`` or ``limit`` is out of range.
        APIError: The API answered with another error.
        TransportError: The request failed without a response.
        ResponseParseError: The response didn't match ``HistoryEntry``.

    Example:
        ```python
        page = await client.me.views.history(limit=10)
        for entry in page.items:
            episode = entry.release_episode
            print(episode.release.name.main, episode.ordinal, entry.time)
        ```
    """
    return await self._get(
        f"{_PATH}/history", Page[HistoryEntry], params={"page": page, "limit": limit}
    )

iter_history

iter_history(
    *, limit: int | None = None
) -> AsyncIterator[HistoryEntry]

Iterate over the whole watch history, fetching page by page.

Parameters:

Name Type Description Default
limit int | None

Page size, 1 to 50 — affects how many requests are made, not the result.

None

Returns:

Type Description
AsyncIterator[HistoryEntry]

An async iterator over the entries, most recent first.

Raises:

Type Description
AuthRequiredError

No valid session — raised once iteration starts.

ValidationError

limit is out of range — raised once iteration starts.

APIError

The API answered with another error, while iterating.

TransportError

A request failed without a response, while iterating.

Example
async for entry in client.me.views.iter_history(limit=50):
    print(entry.updated_at, entry.release_episode_id)
Source code in src/aniliberty/resources/views.py
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
def iter_history(self, *, limit: int | None = None) -> AsyncIterator[HistoryEntry]:
    """Iterate over the whole watch history, fetching page by page.

    Args:
        limit: Page size, 1 to 50 — affects how many requests are made, not the result.

    Returns:
        An async iterator over the entries, most recent first.

    Raises:
        AuthRequiredError: No valid session — raised once iteration starts.
        ValidationError: ``limit`` is out of range — raised once iteration starts.
        APIError: The API answered with another error, while iterating.
        TransportError: A request failed without a response, while iterating.

    Example:
        ```python
        async for entry in client.me.views.iter_history(limit=50):
            print(entry.updated_at, entry.release_episode_id)
        ```
    """

    async def fetch(page: int) -> Page[HistoryEntry]:
        return await self.history(page=page, limit=limit)

    return iterate_pages(fetch)

Pagination

aniliberty.Page

Bases: AniLibertyModel, Generic[T]

One page of results from a paginated endpoint.

Paginated endpoints answer with {"data": [...], "meta": {"pagination": {...}}} (see docs/api-notes.md); Page flattens that into the fields below. Pages are numbered from 1. To walk through all pages, use the matching iter_*() method instead of calling the paginated one in a loop.

Attributes:

Name Type Description
items list[T]

Items on this page.

page int

Number of this page, starting at 1.

limit int

Page size that was requested (per_page in the API).

total int

Number of items on all pages.

total_pages int

Number of pages.

Example
page = await client.catalog.releases(page=1, limit=25)
print(f"page {page.page} of {page.total_pages}, {page.total} releases in total")
for release in page.items:
    print(release.name.main)

has_next_page property

has_next_page: bool

Whether there are more pages after this one.

Models

Releases

aniliberty.Release

Bases: AniLibertyModel

An anime release.

Spec schema: models.anime.releases.v1.release plus the extra fields each endpoint adds. List endpoints fill only part of the extras: genres comes with every endpoint except Episode.release, background_covers with random() and get(), members/sponsors with get() and list(), episodes with get(), list() and Episode.release, latest_episode with latest() only. An extra the endpoint doesn't send is an empty list or None.

Attributes:

Name Type Description
id int

Release ID.

alias str

URL slug, e.g. "tensei-shitara-ken-deshita-2nd-season"; get() accepts it as well as the ID.

name ReleaseName

Release names.

type Annotated[ValueDescription[str] | None, EMPTY_VALUE_AS_NONE]

Release type; type.value is one of TV, ONA, WEB, OVA, OAD, MOVIE, DORAMA, SPECIAL (spec enum enums.anime.releases.release.type).

year int | None

Release year.

season Annotated[ValueDescription[str] | None, EMPTY_VALUE_AS_NONE]

Release season; season.value is one of winter, spring, summer, autumn.

poster Image | None

Release poster.

description str | None

Synopsis, in Russian.

notification str | None

Important notice shown on the release page, if any.

age_rating Annotated[AgeRating | None, EMPTY_VALUE_AS_NONE]

Age rating.

publish_day Annotated[ValueDescription[int] | None, EMPTY_VALUE_AS_NONE]

Weekday new episodes come out on; publish_day.value is 1 (Monday) to 7 (Sunday).

is_ongoing bool

Whether the anime is still airing in its home country.

is_in_production bool

Whether AniLibria is still dubbing it.

is_blocked_by_geo bool

Whether it's blocked in the requester's region.

is_blocked_by_copyrights bool

Whether it's blocked by the copyright holder.

episodes_total int | None

Number of episodes, if known.

average_duration_of_episode int | None

Average episode length, in minutes, if known.

external_player str | None

Link to an external player, if any.

fresh_at datetime | None

When the release was last bumped (e.g. by a new episode).

created_at datetime | None

When the release was added.

updated_at datetime | None

When the release was last edited.

added_in_users_favorites int

How many users added it to favorites.

added_in_planned_collection int

How many users put it in their "planned" collection.

added_in_watched_collection int

How many users put it in "watched".

added_in_watching_collection int

How many users put it in "watching".

added_in_postponed_collection int

How many users put it in "postponed".

added_in_abandoned_collection int

How many users put it in "abandoned".

shikimori ExternalRating | None

Entry on Shikimori.

mal ExternalRating | None

Entry on MyAnimeList.

rating ReleaseRating | None

Score from AniLibria users.

genres list[Genre]

Genres of the release.

background_covers list[ImageSet]

Wide cover images for a page background.

members list[ReleaseMember]

People who worked on the release.

sponsors list[Sponsor]

Sponsors shown on the release page.

episodes list[Episode]

All episodes of the release, in order.

latest_episode Episode | None

The newest episode — only filled in by client.releases.latest().

aniliberty.ReleaseName

Bases: AniLibertyModel

Names of a release.

Attributes:

Name Type Description
main str

The main (Russian) title.

english str | None

The English or romanized title.

alternative str | None

Other titles, comma-separated, if any.

aniliberty.AgeRating

Bases: ValueDescription[str]

Age rating of a release, e.g. value="R16_PLUS", label="16+".

value is one of R0_PLUS, R6_PLUS, R12_PLUS, R16_PLUS, R18_PLUS (spec enum enums.anime.releases.release.ageRating).

Attributes:

Name Type Description
label str | None

Short label, e.g. "16+".

is_adult bool | None

Whether the release is for adults only.

aniliberty.ReleaseRating

Bases: AniLibertyModel

Score given to a release by AniLibria users.

Attributes:

Name Type Description
average float | None

Average score from 1 to 10, None if nobody rated it yet.

votes int

Number of people who rated it.

distribution dict[int, int]

Number of votes per score, for every score from 1 to 10.

aniliberty.UserRating

Bases: AniLibertyModel

The logged-in user's own score for a release, from client.releases.ratings.

Spec schema: models.anime.releases.v1.release.rating.own.

Attributes:

Name Type Description
release_id int

Release ID.

score int | None

The user's score, 1 to 10; None if they haven't rated the release.

aniliberty.ExternalRating

Bases: AniLibertyModel

A release's entry and score on an external site (Shikimori or MyAnimeList).

All fields are None when the release isn't linked to that site.

Attributes:

Name Type Description
id int | None

ID of the anime on the external site.

url str | None

Link to the anime's page there.

rating float | None

Average score there, from 1 to 10.

votes int | None

Number of people who rated it there.

aniliberty.ReleaseMember

Bases: AniLibertyModel

A person who worked on a release (voice actor, translator, timer, ...).

Spec schema: models.anime.releases.v1.release.member.

Attributes:

Name Type Description
id str

Member entry ID (a UUID string).

nickname str

The member's nickname.

role Annotated[ValueDescription[str] | None, EMPTY_VALUE_AS_NONE]

What they did. role.value is one of poster, timing, voicing, editing, decorating, translating, hevc (spec enum enums.anime.releases.release.member.role).

user LinkedAccount | None

Linked site account, if any (usually None).

aniliberty.Sponsor

Bases: AniLibertyModel

A sponsor shown on a release page.

Spec schema: models.anime.sponsors.v1.sponsor.

Attributes:

Name Type Description
id str

Sponsor ID.

title str | None

Sponsor name.

description str | None

Sponsor description.

url str | None

Sponsor link.

url_title str | None

Text of the sponsor link.

aniliberty.Genre

Bases: AniLibertyModel

An anime genre.

Spec schema: models.anime.genres.v1.genre.

Attributes:

Name Type Description
id int

Genre ID — the value to pass to catalog genre filters.

name str

Genre name, in Russian (e.g. "Экшен").

image Image | None

Genre cover image.

total_releases int | None

Number of releases with this genre.

Episodes

aniliberty.Episode

Bases: AniLibertyModel

An episode of a release, with links to its HLS streams.

Spec schema: models.anime.releases.v1.release.episode. HLS links are absolute URLs of .m3u8 playlists on the video CDN, returned exactly as the API sends them — their query string (country, ads flags, ...) is part of the link. Downloading the video itself is out of scope for the SDK: hand the playlist to a player or to ffmpeg.

Attributes:

Name Type Description
id str

Episode ID (a UUID string) — pass it to client.episodes.get().

release_id int

ID of the release the episode belongs to.

ordinal int | float

Episode number. Usually an int; the spec allows fractions such as 12.5 for recap/special episodes, which come as a float.

sort_order int

Position of the episode in the release's list.

name str | None

Episode title, in Russian, if it has one (most don't).

name_english str | None

Episode title in English, if any.

duration int

Length, in seconds.

opening Annotated[SkipRange | None, BeforeValidator(_empty_skip_as_none)]

Where the opening is, None if not marked.

ending Annotated[SkipRange | None, BeforeValidator(_empty_skip_as_none)]

Where the ending is, None if not marked.

preview Image | None

Still image from the episode. Often present with all paths None.

hls_480 str | None

HLS playlist, 480p.

hls_720 str | None

HLS playlist, 720p.

hls_1080 str | None

HLS playlist, 1080p — missing on some episodes.

rutube_id str | None

ID of the episode on Rutube, if it's published there.

youtube_id str | None

ID of the episode on YouTube, if it's published there.

updated_at datetime | None

When the episode was last updated.

release Release | None

The release with all its episodes — only filled in by client.episodes.get().

Example
episode = await client.episodes.get(release.episodes[0].id)
playlist = episode.hls_1080 or episode.hls_720 or episode.hls_480

aniliberty.SkipRange

Bases: AniLibertyModel

Where an opening or ending is, so a player can offer to skip it.

The spec describes the numbers as "seconds from the start or the end of the episode"; in every live sample checked they were seconds from the start (an ending of a 1502-second episode at 1326–1502), see docs/api-notes.md.

Attributes:

Name Type Description
start int | None

Where the segment starts, in seconds.

stop int | None

Where the segment ends, in seconds.

Franchises

aniliberty.Franchise

Bases: AniLibertyModel

A franchise: a group of related releases (seasons, movies, spin-offs).

Spec schema: models.anime.franchises.v1.franchise. The totals are precomputed by the API and can be missing or stale — two franchises with total_releases=None still had two releases each — so count franchise_releases when the exact number matters.

Attributes:

Name Type Description
id str

Franchise ID (a UUID string).

name str

Name in Russian.

name_english str | None

Name in English or romanized.

image Image | None

Franchise cover image (its paths can be None).

rating float | None

Franchise rating — None for every franchise at the time of writing.

first_year int | None

Year of the first release.

last_year int | None

Year of the latest release.

total_releases int | None

Number of releases.

total_episodes int | None

Number of episodes across all releases.

total_duration str | None

Total watch time as human-readable Russian text, e.g. "10 часов".

total_duration_in_seconds int | None

Total watch time, in seconds.

franchise_releases list[FranchiseRelease]

The releases, ordered by sort_order — only filled in by client.franchises.get() and client.franchises.for_release().

Example
franchise = await client.franchises.get(franchise_id)
for item in franchise.franchise_releases:
    print(item.sort_order, item.release.year, item.release.name.main)

aniliberty.FranchiseRelease

Bases: AniLibertyModel

One release of a franchise, with its place in the franchise.

Spec schema: models.anime.franchises.v1.franchise.release.

Attributes:

Name Type Description
id str

ID of this release-to-franchise link (a UUID string).

franchise_id str

ID of the franchise.

release_id int

ID of the release.

sort_order int

Position of the release in the franchise, starting at 1 — in every franchise checked, the chronological order.

release Release

The release itself (without genres and episodes; fetch it with client.releases.get() for those).

Torrents

aniliberty.Torrent

Bases: AniLibertyModel

A torrent of a release.

Spec schema: models.anime.torrents.v1.torrent.

Attributes:

Name Type Description
id int

Torrent ID.

hash str

Info hash (40 hex characters); client.torrents.get() accepts it as well as the ID, in any letter case.

label str

Full title, e.g. "One Piece - AniLiberty.TOP [WEBRip 1080p][AVC][1-24]".

filename str

Name of the .torrent file.

description str

Which episodes it contains, e.g. "1-24".

magnet str

Magnet link.

size int

Total size of the files the torrent downloads, in bytes (not the size of the .torrent file itself).

type Annotated[ValueDescription[str] | None, EMPTY_VALUE_AS_NONE]

Source, e.g. "WEB-DL", "BDRip" (spec enum enums.anime.torrents.torrent.type).

quality Annotated[ValueDescription[str] | None, EMPTY_VALUE_AS_NONE]

Resolution, e.g. "1080p" (spec enum enums.anime.torrents.torrent.quality).

codec Annotated[TorrentCodec | None, EMPTY_VALUE_AS_NONE]

Video codec.

color Annotated[ValueDescription[str] | None, EMPTY_VALUE_AS_NONE]

Color depth, "8bit" or "10Bit" (spec enum enums.anime.torrents.torrent.color).

bitrate int | None

Bitrate in kbit/s — usually None.

is_hardsub bool

Whether the subtitles are burned into the video.

seeders int

Number of seeders.

leechers int

Number of leechers.

completed_times int

How many times it was downloaded completely.

sort_order int

Position among the release's torrents.

created_at datetime | None

When the torrent was added.

updated_at datetime | None

When the torrent was last updated.

release Release | None

The release (without genres and episodes).

torrent_members list[TorrentMember]

People credited on the torrent; often empty.

aniliberty.TorrentCodec

Bases: ValueDescription[str]

Video codec of a torrent, e.g. value="x265/HEVC", label="HEVC".

value is one of AV1, x264/AVC, x265/HEVC, x265hq/HEVC-HQ (spec enum enums.anime.torrents.torrent.codec).

Attributes:

Name Type Description
label str | None

Short label the site shows, e.g. "HEVC".

label_color str | None

Color of that label as #RRGGBB, if it has one.

label_is_visible bool | None

Whether the site shows the label.

aniliberty.TorrentMember

Bases: AniLibertyModel

A person credited on a torrent (e.g. who encoded the HEVC version).

Attributes:

Name Type Description
id str

Member entry ID (a UUID string).

nickname str

The member's nickname.

role Annotated[ValueDescription[str] | None, EMPTY_VALUE_AS_NONE]

What they did; role.value was "HEVC" on every torrent checked.

external_url str | None

The member's own link (e.g. a Telegram channel), if any.

user LinkedAccount | None

Linked site account, if any.

aniliberty.TorrentFile

Bases: AniLibertyModel

A downloaded .torrent file, from client.torrents.file().

Attributes:

Name Type Description
filename str | None

The file name the API suggests (from Content-Disposition), if it sent one.

content bytes

The file's bytes.

Example
torrent_file = await client.torrents.file(torrent)
Path(torrent_file.filename or "release.torrent").write_bytes(torrent_file.content)

Teams

aniliberty.Team

Bases: AniLibertyModel

An AniLibria team — one of the "leagues" the project's members are grouped into.

Spec schema: models.teams.v1.team.

Attributes:

Name Type Description
id str

Team ID (a UUID string).

title str

Team name in Russian, e.g. "II Лига".

description str | None

Short description in Russian, e.g. "Высшая" — None for some teams.

sort_order int

Position of the team in the list, from 0 (project leadership) up.

aniliberty.TeamRole

Bases: AniLibertyModel

A role a team member can have: voice actor, translator, timer, ...

Spec schema: models.teams.v1.team.role.

Attributes:

Name Type Description
id str

Role ID (a UUID string).

title str

Role name in Russian, e.g. "Актёр".

color str | None

The role's display color as a CSS hex string, e.g. "#339966" — None for most roles, and not normalized (both "#B523C5" and "#b523c5" occur).

sort_order int

Position of the role in the list.

aniliberty.TeamMember

Bases: AniLibertyModel

A member of an AniLibria team, with the team and their roles.

Spec schema: items of responses.api.v1.teams.users (models.teams.v1.team.user with team, user and roles added).

Attributes:

Name Type Description
id str

Member ID (a UUID string).

nickname str

The member's nickname in the team — the same name that appears in a release's members.

is_intern bool

Whether the member is an intern.

is_vacation bool

Whether the member is on vacation.

sort_order int

Position of the member within their team, from 1.

team Team

The team the member belongs to.

roles list[TeamRole]

The member's roles (at least one for every member at the time of writing).

user LinkedAccount | None

The linked site account — None for most members.

Example
for member in await client.teams.users():
    print(member.nickname, member.team.title, [role.title for role in member.roles])

Media

aniliberty.Promotion

Bases: AniLibertyModel

A promo banner from the site's front page: a featured release, a news post or an ad.

Spec schema: models.media.promotions.v1.promotion. What a promotion links to depends on its kind: a featured release has release and no url; a news post or an ad has url (and usually url_label) and no release.

Attributes:

Name Type Description
id str

Promotion ID (a UUID string).

title str | None

Banner title, e.g. the release name — can be None.

description str | None

Banner text — can be None.

image Image | None

Banner image.

url str | None

External link the banner points to (absolute), or None.

url_label str | None

Text for the link button, e.g. "Подписаться на канал", or None.

release Release | None

The featured release (with genres, without episodes), or None.

is_ad bool

Whether this is a paid ad rather than the site's own promo.

has_overlay bool

Whether the site darkens the image under the text.

ad_erid str | None

Ad registry token (ERID) of an ad — None on every promotion at the time of writing.

ad_origin str | None

Advertiser of an ad — None on every promotion at the time of writing.

Example
for promo in await client.media.promotions():
    if promo.release:
        print("Featured:", promo.release.name.main)

aniliberty.Video

Bases: AniLibertyModel

A video from one of AniLibria's YouTube playlists: trailers, reviews, announcements.

Spec schema: items of responses.v1.media.videos (models.media.videos.v1.video.content with origin added).

Attributes:

Name Type Description
id int

Video ID on the site.

title str

Video title.

url str

Link to watch the video (https://www.youtube.com/watch?v=...).

video_id str

The video's YouTube ID.

image Image | None

Preview image (its paths can be None).

views int | None

View count, as last synced from YouTube — can be None.

comments int | None

Comment count, as last synced from YouTube — can be None.

is_announce bool

Whether the video is a season announcement.

origin VideoOrigin | None

The playlist the video comes from.

created_at datetime | None

When the video was published.

updated_at datetime | None

When the site last synced the video's data.

aniliberty.VideoOrigin

Bases: AniLibertyModel

Where a video comes from: a YouTube playlist the site tracks.

Spec schema: models.media.videos.v1.video.origin.

Attributes:

Name Type Description
id str

Origin ID (a UUID string).

title str | None

Playlist or channel name, e.g. "AniLiberty".

description str | None

Playlist description in Russian.

url str | None

Playlist URL.

type Annotated[ValueDescription[str] | None, EMPTY_VALUE_AS_NONE]

Source type. type.value is "YOUTUBE_PLAYLIST" — the only value in the spec (enum enums.media.videos.video.origin.type) and on every video seen.

is_announce bool

Whether the playlist holds season announcements.

Schedule

aniliberty.ScheduledRelease

Bases: AniLibertyModel

A release in the airing schedule, with where it stands this week.

Spec schema: models.anime.schedule.v1.releaseInSchedule. The weekday a release comes out on is release.publish_day (value 1 = Monday ... 7 = Sunday).

Attributes:

Name Type Description
release Release

The release, with genres filled in.

published_release_episode Episode | None

The episode that came out during the current week (Monday to Sunday), or None if this week's episode isn't out yet. It can come out on a different day than publish_day — e.g. a Saturday release whose episode was out on Tuesday.

next_release_episode_number int | None

Number of the next episode to come out, None when no further episode is expected.

full_season_is_released bool

Whether all episodes of the season are already out.

Example
for item in await client.schedule.week():
    day = item.release.publish_day.description if item.release.publish_day else "?"
    print(day, item.release.name.main, item.next_release_episode_number)

aniliberty.ScheduleNow

Bases: AniLibertyModel

Releases scheduled for yesterday, today and tomorrow.

Spec schema: responses.v1.anime.schedule.now. Each list holds the releases whose publish_day is that day of the week.

Attributes:

Name Type Description
yesterday list[ScheduledRelease]

Releases that came out (or were due) yesterday.

today list[ScheduledRelease]

Releases due today.

tomorrow list[ScheduledRelease]

Releases due tomorrow.

Catalog

aniliberty.SortingOption

Bases: ValueDescription[str]

A way to sort a list, from client.catalog.references.sorting() or client.me.favorites.references.sorting().

value is what the matching sorting filter takes, e.g. "FRESH_AT_DESC".

Attributes:

Name Type Description
label str | None

Short name, in Russian, e.g. "Обновлены недавно".

Catalog models (spec tags Аниме.Каталог and Аниме.Каталог.Справочники).

ReleaseType module-attribute

ReleaseType = Literal[
    "TV",
    "ONA",
    "WEB",
    "OVA",
    "OAD",
    "MOVIE",
    "DORAMA",
    "SPECIAL",
]

Release type. Spec: enums.anime.releases.release.type.

Season module-attribute

Season = Literal['winter', 'spring', 'summer', 'autumn']

Release season. Spec: enums.anime.releases.release.season.

AgeRatingValue module-attribute

AgeRatingValue = Literal[
    "R0_PLUS", "R6_PLUS", "R12_PLUS", "R16_PLUS", "R18_PLUS"
]

Age rating. Spec: enums.anime.releases.release.ageRating.

PublishStatus module-attribute

PublishStatus = Literal['IS_ONGOING', 'IS_NOT_ONGOING']

Whether the anime is still airing. Spec: enums.anime.catalog.filter.publishStatus.

ProductionStatus module-attribute

ProductionStatus = Literal[
    "IS_IN_PRODUCTION", "IS_NOT_IN_PRODUCTION"
]

Whether AniLibria is still dubbing it. Spec: enums.anime.catalog.filter.productionStatus.

CatalogSorting module-attribute

CatalogSorting = Literal[
    "FRESH_AT_DESC",
    "FRESH_AT_ASC",
    "RATING_DESC",
    "RATING_ASC",
    "YEAR_DESC",
    "YEAR_ASC",
]

Catalog sort order. Spec: enums.anime.catalog.filter.sorting.

Common

aniliberty.ValueDescription

Bases: AniLibertyModel, Generic[T]

A machine value with its human-readable (Russian) description.

The API returns many enumerations this way — a release's type, season, publish day, a torrent's quality, ... — e.g. {"value": "TV", "description": "ТВ"} or {"value": 5, "description": "Пятница"} (publish_day uses weekday numbers). Fields with extra keys next to these two (age_rating's label/is_adult, a torrent codec's label_*) use subclasses.

Attributes:

Name Type Description
value T

The machine value — use it for comparisons and catalog filters.

description str | None

The value's description in Russian, for display.

Example
if release.type and release.type.value == "MOVIE":
    print(release.type.description)  # "Фильм"

aniliberty.Image

Bases: ImageSet

An image in its original format plus optimized (WebP) variants.

Spec schema: commons.v1.models.components.image.withOptimized — used for release posters, episode previews, genre and franchise images, user avatars.

Attributes:

Name Type Description
optimized ImageSet | None

The same image in WebP, usually smaller.

Example
poster = release.poster
print(poster.preview_url)            # https://aniliberty.top/storage/....jpg
print(poster.optimized.preview_url)  # https://aniliberty.top/storage/....webp

aniliberty.ImageSet

Bases: AniLibertyModel

Links to one image in several sizes.

The API sends relative paths such as /storage/releases/posters/1/abc.jpg; they resolve against the site's origin, not the /api/v1 base URL (see docs/api-notes.md). The raw paths are kept in src/preview/thumbnail, and the *_url properties return absolute URLs on the host the client talked to (or on https://aniliberty.top for a model built by hand).

Any of the paths can be None — e.g. a user without an avatar gets an image object whose paths are all None.

Attributes:

Name Type Description
src str | None

Original image path. Only posters and episode previews have it.

preview str | None

Path of the medium-size image.

thumbnail str | None

Path of the small image.

src_url property

src_url: str | None

Absolute URL of src.

preview_url property

preview_url: str | None

Absolute URL of preview.

thumbnail_url property

thumbnail_url: str | None

Absolute URL of thumbnail.

aniliberty.LinkedAccount

Bases: AniLibertyModel

The site account linked to a person in the credits or in a team.

The user of ReleaseMember, TorrentMember and TeamMember — set when the person has an account on the site, None otherwise. Spec schemas: models.teams.v1.team.user.account and the user of release/torrent members.

Attributes:

Name Type Description
id int

User ID on the site.

nickname str | None

The account's nickname. Only team members' accounts have it (it can differ from the team nickname); for release and torrent members it's None — their own nickname is on the member.

avatar Image | None

The account's avatar (its paths can be None).

Building blocks shared by many models: value + description pairs and images.

EMPTY_VALUE_AS_NONE module-attribute

EMPTY_VALUE_AS_NONE = BeforeValidator(_empty_value_as_none)

Turns {"value": null, ...} into None for an optional ValueDescription field.

The API sometimes sends a value + description object with both keys null — seen on season of releases in the schedule — instead of omitting it. Annotate such fields as::

season: Annotated[ValueDescription[str] | None, EMPTY_VALUE_AS_NONE] = None

Service

aniliberty.ApiStatus

Bases: AniLibertyModel

Status of the API, returned by client.app.status().

Spec schema: responses.v1.app.status.

Attributes:

Name Type Description
is_alive bool

Whether the API reports itself as working.

available_api_endpoints list[str]

Hosts the API can be reached at, e.g. ["https://aniliberty.top"] — origins without the /api/v1 suffix.

request RequestInfo | None

What the API knows about the caller, if it sent that.

aniliberty.RequestInfo

Bases: AniLibertyModel

What the API knows about the client that sent the request (geolocated by IP).

Spec schema: responses.v1.app.status → request. All fields are optional: the spec marks none of them required, and geolocation can fail.

Attributes:

Name Type Description
ip str | None

The client's IP address as seen by the API.

country str | None

Country name, in English (e.g. "The Netherlands").

iso_code str | None

ISO 3166-1 alpha-2 country code (e.g. "NL").

timezone str | None

IANA time zone name (e.g. "Europe/Amsterdam").

Authentication

aniliberty.AuthToken

Bases: AniLibertyModel

A session token, returned by the login methods of client.auth.

Spec schemas: responses.v1.accounts.users.auth.login, responses.api.v1.accounts.otp.login, responses.v1.accounts.users.auth.social.authenticate.

The token is an opaque string (32 characters at the time of writing) with no expiry inside; the API has no refresh endpoint. Every login creates a new, independent session, and client.auth.logout() ends only the client's own.

Attributes:

Name Type Description
token str

The session token. Keep it secret — it gives full access to the account.

aniliberty.IssuedOtp

Bases: AniLibertyModel

A one-time code issued by client.auth.otp.issue().

Spec schema: responses.api.v1.accounts.otp.get.

Attributes:

Name Type Description
otp Otp

The code.

remaining_time float

Seconds until the code expires (fractional; about 300 for a new code).

aniliberty.Otp

Bases: AniLibertyModel

A one-time code for logging in a device without typing the password.

Spec schema: models.accounts.otp.v1.otp.

Attributes:

Name Type Description
code str

The code to show to the user, as a string — it can start with 0.

device_id str

The device the code was issued for.

user_id int | None

The account that accepted the code; None until it's accepted.

expired_at datetime

When the code expires.

aniliberty.SocialLogin

Bases: AniLibertyModel

Where to send the user to log in with a social network.

Spec schema: responses.v1.accounts.users.auth.social.login.

Attributes:

Name Type Description
url str

The OAuth page of the social network, to open in a browser.

state str

Key to pass to client.auth.social.authenticate() once the user has logged in there.

Authentication models (spec tags Аккаунты.Пользователи.Авторизация.* and Аккаунты.ОдноразовыеПароли).

SocialProvider module-attribute

SocialProvider = Literal[
    "vk", "google", "patreon", "discord"
]

Social networks that can be used to log in.

Account

aniliberty.UserProfile

Bases: AniLibertyModel

The logged-in user's own profile, returned by client.me.profile().

Spec schema: models.users.v1.user. email and torrents.passkey are only sent for the user's own profile.

Attributes:

Name Type Description
id int

User ID.

login str

Login, used to log in (together with email).

email str | None

E-mail of the account.

nickname str | None

Display name; defaults to the login for a new account.

avatar Image | None

Avatar; all paths are None without one.

torrents TorrentStats | None

Tracker passkey and traffic.

is_banned bool | None

Whether the user is banned on the site.

created_at datetime | None

When the account was created.

is_with_ads bool | None

Whether ads are shown to the user — in the spec, but not sent by the API at the time of writing (see the three flags below).

is_with_video_ads bool | None

Whether video ads are shown (not in the spec).

is_with_content_ads bool | None

Whether ads are shown among the site's content (not in the spec).

is_with_video_ads_always bool | None

An ad flag the API doesn't document (not in the spec); it matches the isWithVideoAdsAlways parameter of the user's HLS links.

aniliberty.TorrentStats

Bases: AniLibertyModel

The account's torrent tracker data.

Spec schema: models.users.v1.user → torrents.

Attributes:

Name Type Description
passkey str | None

The account's tracker passkey — the pk of .torrent and RSS links, also put into magnet links of torrents when logged in. A secret: it's left out of the model's repr.

uploaded int | None

Total uploaded, in bytes; None until the account has uploaded anything.

downloaded int | None

Total downloaded, in bytes; None until the account has downloaded anything.

aniliberty.CollectionItem

Bases: AniLibertyModel

A release in one of the user's collections, from client.me.collections.ids().

Spec schema: responses.api.v1.accounts.users.me.collections.ids.item — the API sends a [release_id, collection] pair, e.g. [9839, "WATCHED"]; it's validated into this model (a dict with the field names works too).

Attributes:

Name Type Description
release_id int

Release ID.

collection CollectionType

The collection the release is in. A release is in one collection at most.

User account models (spec tags Аккаунты.Пользователи.Мое.*).

FavoritesSorting module-attribute

FavoritesSorting = Literal[
    "CREATED_AT_DESC",
    "CREATED_AT_ASC",
    "FRESH_AT_DESC",
    "FRESH_AT_ASC",
    "RATING_DESC",
    "RATING_ASC",
    "YEAR_DESC",
    "YEAR_ASC",
]

Sort order of favorites; CREATED_AT_* is when the release was added to favorites.

CollectionType module-attribute

CollectionType = Literal[
    "PLANNED",
    "WATCHED",
    "WATCHING",
    "POSTPONED",
    "ABANDONED",
]

A user's collection: planned, watched, watching, postponed or abandoned.

Watch progress

aniliberty.Timecode

Bases: AniLibertyModel

Where the user stopped in an episode, and whether they finished it.

What client.me.views.save_timecodes() takes and client.me.views.timecodes() returns. Spec schema: responses.v1.accounts.users.me.views.timecodes.item — the API sends an [episode_id, time, is_watched] triple, which is validated into this model (a dict with the field names works too).

Attributes:

Name Type Description
release_episode_id str

Episode ID (a UUID string), as in Episode.id.

time float

Position in the episode, in seconds from its start (fractions allowed, not negative).

is_watched bool

Whether the episode counts as watched.

Example
Timecode(release_episode_id=episode.id, time=743.5, is_watched=False)

aniliberty.EpisodeView

Bases: AniLibertyModel

The stored watch record of one episode.

Returned by client.episodes.timecode() and client.releases.timecodes(). Spec schema: models.accounts.users.v1.user.view. Records are written by the API about a minute after client.me.views.save_timecodes() (see docs/api-notes.md).

Attributes:

Name Type Description
id int

Record ID.

release_episode_id str

Episode ID (a UUID string).

time float

Position in the episode, in seconds from its start.

is_watched bool

Whether the episode counts as watched.

updated_at datetime | None

When the record was last saved.

user_id int | None

The user the record belongs to.

aniliberty.HistoryEntry

Bases: EpisodeView

An entry of the watch history, from client.me.views.history().

Spec schema: responses.v1.accounts.users.me.views.history → data[].

Attributes:

Name Type Description
release_episode Episode | None

The episode, with its release in release_episode.release.

Exceptions

aniliberty.AniLibertyError

Bases: Exception

Base class for all errors raised by the SDK.

aniliberty.APIError

APIError(
    message: str,
    *,
    status_code: int,
    method: str,
    url: str,
    payload: Any = None,
)

Bases: AniLibertyError

The API responded with an error status.

Raised as is for statuses without a dedicated subclass (e.g. 400, 405, 409); otherwise one of the subclasses below is raised, so except APIError catches all of them.

Attributes:

Name Type Description
status_code

HTTP status code of the response.

message

Error message — the message field of the API's JSON body when it has one, otherwise the HTTP reason phrase.

method

HTTP method of the failed request.

url

Full URL of the failed request.

payload

The decoded JSON body of the response, or its text if it wasn't JSON.

Source code in src/aniliberty/exceptions.py
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
def __init__(
    self,
    message: str,
    *,
    status_code: int,
    method: str,
    url: str,
    payload: Any = None,
) -> None:
    self.message = message
    self.status_code = status_code
    self.method = method
    self.url = url
    self.payload = payload
    super().__init__(f"HTTP {status_code}: {message} ({method} {url})")

aniliberty.NotFoundError

NotFoundError(
    message: str,
    *,
    status_code: int,
    method: str,
    url: str,
    payload: Any = None,
)

Bases: APIError

The requested resource doesn't exist (404).

The API's own message for this status is an internal Laravel lookup string such as "No query results for model [...]" — it's kept in payload, while message is a plain "Not found".

Source code in src/aniliberty/exceptions.py
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
def __init__(
    self,
    message: str,
    *,
    status_code: int,
    method: str,
    url: str,
    payload: Any = None,
) -> None:
    self.message = message
    self.status_code = status_code
    self.method = method
    self.url = url
    self.payload = payload
    super().__init__(f"HTTP {status_code}: {message} ({method} {url})")

aniliberty.ValidationError

ValidationError(
    message: str,
    *,
    status_code: int,
    method: str,
    url: str,
    payload: Any = None,
    errors: dict[str, list[str]] | None = None,
)

Bases: APIError

The API rejected the request parameters (422).

Not to be confused with pydantic.ValidationError: this one means the request was invalid, e.g. limit above the endpoint's maximum. A response that doesn't fit the SDK's models raises ResponseParseError instead.

Attributes:

Name Type Description
errors dict[str, list[str]]

Messages per parameter, as the API returns them (in Russian), e.g. {"limit": ["Поле limit не может быть больше 50."]}.

Source code in src/aniliberty/exceptions.py
81
82
83
84
85
86
87
88
89
90
91
92
def __init__(
    self,
    message: str,
    *,
    status_code: int,
    method: str,
    url: str,
    payload: Any = None,
    errors: dict[str, list[str]] | None = None,
) -> None:
    self.errors: dict[str, list[str]] = errors or {}
    super().__init__(message, status_code=status_code, method=method, url=url, payload=payload)

aniliberty.RateLimitError

RateLimitError(
    message: str,
    *,
    status_code: int,
    method: str,
    url: str,
    payload: Any = None,
    retry_after: float | None = None,
)

Bases: APIError

The API answered 429 Too Many Requests.

Attributes:

Name Type Description
retry_after

The response's Retry-After value, in seconds, if it sent one.

Source code in src/aniliberty/exceptions.py
102
103
104
105
106
107
108
109
110
111
112
113
def __init__(
    self,
    message: str,
    *,
    status_code: int,
    method: str,
    url: str,
    payload: Any = None,
    retry_after: float | None = None,
) -> None:
    self.retry_after = retry_after
    super().__init__(message, status_code=status_code, method=method, url=url, payload=payload)

aniliberty.AuthRequiredError

AuthRequiredError(
    message: str,
    *,
    status_code: int,
    method: str,
    url: str,
    payload: Any = None,
)

Bases: APIError

The request needs a valid session, or the credentials were wrong (401/403 JSON).

The API answers 403 "This action is unauthorized." to an account endpoint called without a session token, with a malformed one, or with one that was logged out or has expired — it doesn't say which. Log in again (client.auth.login()) or pass a fresh token to AniLiberty. Wrong credentials at client.auth.login() are a 401; the API's reason is in message.

Source code in src/aniliberty/exceptions.py
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
def __init__(
    self,
    message: str,
    *,
    status_code: int,
    method: str,
    url: str,
    payload: Any = None,
) -> None:
    self.message = message
    self.status_code = status_code
    self.method = method
    self.url = url
    self.payload = payload
    super().__init__(f"HTTP {status_code}: {message} ({method} {url})")

aniliberty.ServerError

ServerError(
    message: str,
    *,
    status_code: int,
    method: str,
    url: str,
    payload: Any = None,
)

Bases: APIError

The API (or the CDN in front of it) failed with a 5xx status.

Source code in src/aniliberty/exceptions.py
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
def __init__(
    self,
    message: str,
    *,
    status_code: int,
    method: str,
    url: str,
    payload: Any = None,
) -> None:
    self.message = message
    self.status_code = status_code
    self.method = method
    self.url = url
    self.payload = payload
    super().__init__(f"HTTP {status_code}: {message} ({method} {url})")

aniliberty.AccessBlockedError

AccessBlockedError(
    message: str,
    *,
    status_code: int,
    method: str,
    url: str,
    payload: Any = None,
)

Bases: APIError

Something in front of the API answered with an HTML page instead of JSON.

The SDK always sends Accept: application/json, and with that header the API itself answers with JSON even for errors (see docs/api-notes.md). An HTML page therefore comes from the Cloudflare edge in front of the API — a bot challenge or a block of the network/IP. Resending the same request gets the same answer, so this is not retried.

HTML on a 5xx status is not this error but ServerError: Cloudflare renders its own origin errors (502, 520, ...) as HTML pages too, and those are worth retrying.

Source code in src/aniliberty/exceptions.py
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
def __init__(
    self,
    message: str,
    *,
    status_code: int,
    method: str,
    url: str,
    payload: Any = None,
) -> None:
    self.message = message
    self.status_code = status_code
    self.method = method
    self.url = url
    self.payload = payload
    super().__init__(f"HTTP {status_code}: {message} ({method} {url})")

aniliberty.TransportError

TransportError(message: str, *, method: str, url: str)

Bases: AniLibertyError

The request failed before any response arrived (connection error, timeout, ...).

The underlying httpx exception is available as __cause__.

Attributes:

Name Type Description
method

HTTP method of the failed request.

url

Full URL of the failed request.

Source code in src/aniliberty/exceptions.py
154
155
156
157
def __init__(self, message: str, *, method: str, url: str) -> None:
    self.method = method
    self.url = url
    super().__init__(f"{message} ({method} {url})")

aniliberty.ResponseParseError

ResponseParseError(
    message: str, *, url: str, payload: Any = None
)

Bases: AniLibertyError

A successful response couldn't be decoded or didn't match the SDK's model.

Usually means the API changed its response format. The raw data is kept so it isn't lost: payload holds the decoded JSON (or the body text if it wasn't JSON), and the original json/pydantic exception is available as __cause__.

Attributes:

Name Type Description
url

Full URL of the request.

payload

Decoded JSON body, or the body text if it wasn't valid JSON.

Source code in src/aniliberty/exceptions.py
172
173
174
175
def __init__(self, message: str, *, url: str, payload: Any = None) -> None:
    self.url = url
    self.payload = payload
    super().__init__(f"{message} ({url})")

Low-level HTTP client

Available as client.http; client.http.request(...) is the escape hatch for endpoints the SDK doesn't cover yet.

aniliberty.HttpClient

HttpClient(
    *,
    base_url: str = DEFAULT_BASE_URL,
    timeout: float = DEFAULT_TIMEOUT,
    headers: Mapping[str, str] | None = None,
    http_client: AsyncClient | None = None,
    token: str | None = None,
    max_concurrency: int = DEFAULT_MAX_CONCURRENCY,
    request_delay: float = DEFAULT_REQUEST_DELAY,
    max_retries: int = DEFAULT_MAX_RETRIES,
    retry_base_delay: float = DEFAULT_RETRY_BASE_DELAY,
    max_retry_after: float = DEFAULT_MAX_RETRY_AFTER,
    cache_dir: str | PathLike[str] | None = None,
    cache_ttl: float | None = None,
    sleep: Callable[
        [float], Awaitable[None]
    ] = asyncio.sleep,
    clock: Callable[[], float] = time.monotonic,
    cache_clock: Callable[[], float] = time.time,
)

Sends requests to the API and turns responses into JSON or SDK exceptions.

Every resource of AniLiberty goes through this class; it is also the escape hatch for endpoints the SDK doesn't cover yet: await client.http.request("GET", "/teams/roles") returns the decoded JSON as is, with the same headers, limits, retries and error handling as the rest of the SDK.

Request paths are relative to base_url and joined as strings, so "/app/status" with the default base URL becomes https://aniliberty.top/api/v1/app/status. Absolute URLs are not accepted.

Being polite to the API is built in:

  • Concurrency — at most max_concurrency requests are in flight at once, however many coroutines call the client (e.g. via asyncio.gather).
  • Pacing — consecutive requests start at least request_delay seconds apart.
  • Retries — 429 is retried for any method, 5xx and network errors/timeouts only for idempotent methods (IDEMPOTENT_METHODS). The delay is the response's Retry-After when it has one, otherwise retry_base_delay doubled per attempt (1 s, 2 s, 4 s by default). Other 4xx and HTML block pages are never retried.

With cache_dir set, request(..., cache=True) keeps the decoded JSON of successful GET responses on disk and answers repeated identical requests from there until cache_ttl passes — see request() for what is and isn't cached.

Initialize the client.

Parameters:

Name Type Description Default
base_url str

API base URL including the version prefix, e.g. "https://anilibria.top/api/v1" for the mirror.

DEFAULT_BASE_URL
timeout float

Per-request timeout, in seconds. Ignored when http_client is given — that client's own timeout applies.

DEFAULT_TIMEOUT
headers Mapping[str, str] | None

Extra headers merged over DEFAULT_HEADERS; a key given here replaces the default one, matched case-insensitively. Overriding Accept breaks error handling (see DEFAULT_HEADERS) — only do it knowingly.

None
http_client AsyncClient | None

A ready httpx.AsyncClient to send requests with (custom transport, proxy, limits, ...). The SDK's headers are added to each request on top of the client's own, and the client is not closed by aclose() — its owner is responsible for that.

None
token str | None

Session token sent as Authorization: Bearer <token> with every request; see the token property.

None
max_concurrency int

Maximum number of requests in flight at once.

DEFAULT_MAX_CONCURRENCY
request_delay float

Minimum time, in seconds, between the starts of two consecutive requests. 0 disables pacing.

DEFAULT_REQUEST_DELAY
max_retries int

How many times to retry a retryable failure before raising. 0 disables retries.

DEFAULT_MAX_RETRIES
retry_base_delay float

Delay before the first retry, in seconds, when the response has no Retry-After; doubled for every next retry.

DEFAULT_RETRY_BASE_DELAY
max_retry_after float

Longest Retry-After, in seconds, worth waiting for. A 429 asking for more raises RateLimitError right away instead of blocking.

DEFAULT_MAX_RETRY_AFTER
cache_dir str | PathLike[str] | None

Directory for the response cache, created on the first write. None (the default) disables caching.

None
cache_ttl float | None

Seconds after which a cached response is stale and fetched again. None means cached responses never expire on their own. Ignored without cache_dir.

None
sleep Callable[[float], Awaitable[None]]

Async sleep function used for pacing and backoff. Replaceable in tests.

sleep
clock Callable[[], float]

Monotonic time source used for pacing. Replaceable in tests.

monotonic
cache_clock Callable[[], float]

Wall-clock time source used to stamp and expire cache entries. Replaceable in tests.

time

Raises:

Type Description
ValueError

A limit is out of range (max_concurrency < 1 or a negative delay, retry count or cache_ttl).

Source code in src/aniliberty/_http.py
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
def __init__(
    self,
    *,
    base_url: str = DEFAULT_BASE_URL,
    timeout: float = DEFAULT_TIMEOUT,
    headers: Mapping[str, str] | None = None,
    http_client: httpx.AsyncClient | None = None,
    token: str | None = None,
    max_concurrency: int = DEFAULT_MAX_CONCURRENCY,
    request_delay: float = DEFAULT_REQUEST_DELAY,
    max_retries: int = DEFAULT_MAX_RETRIES,
    retry_base_delay: float = DEFAULT_RETRY_BASE_DELAY,
    max_retry_after: float = DEFAULT_MAX_RETRY_AFTER,
    cache_dir: str | os.PathLike[str] | None = None,
    cache_ttl: float | None = None,
    sleep: Callable[[float], Awaitable[None]] = asyncio.sleep,
    clock: Callable[[], float] = time.monotonic,
    cache_clock: Callable[[], float] = time.time,
) -> None:
    """Initialize the client.

    Args:
        base_url: API base URL including the version prefix, e.g.
            ``"https://anilibria.top/api/v1"`` for the mirror.
        timeout: Per-request timeout, in seconds. Ignored when ``http_client`` is given —
            that client's own timeout applies.
        headers: Extra headers merged over ``DEFAULT_HEADERS``; a key given here replaces
            the default one, matched case-insensitively. Overriding ``Accept`` breaks
            error handling (see ``DEFAULT_HEADERS``) — only do it knowingly.
        http_client: A ready ``httpx.AsyncClient`` to send requests with (custom
            transport, proxy, limits, ...). The SDK's headers are added to each request
            on top of the client's own, and the client is **not** closed by ``aclose()``
            — its owner is responsible for that.
        token: Session token sent as ``Authorization: Bearer <token>`` with every
            request; see the ``token`` property.
        max_concurrency: Maximum number of requests in flight at once.
        request_delay: Minimum time, in seconds, between the starts of two consecutive
            requests. ``0`` disables pacing.
        max_retries: How many times to retry a retryable failure before raising. ``0``
            disables retries.
        retry_base_delay: Delay before the first retry, in seconds, when the response
            has no ``Retry-After``; doubled for every next retry.
        max_retry_after: Longest ``Retry-After``, in seconds, worth waiting for. A 429
            asking for more raises ``RateLimitError`` right away instead of blocking.
        cache_dir: Directory for the response cache, created on the first write.
            ``None`` (the default) disables caching.
        cache_ttl: Seconds after which a cached response is stale and fetched again.
            ``None`` means cached responses never expire on their own. Ignored without
            ``cache_dir``.
        sleep: Async sleep function used for pacing and backoff. Replaceable in tests.
        clock: Monotonic time source used for pacing. Replaceable in tests.
        cache_clock: Wall-clock time source used to stamp and expire cache entries.
            Replaceable in tests.

    Raises:
        ValueError: A limit is out of range (``max_concurrency < 1`` or a negative
            delay, retry count or ``cache_ttl``).
    """
    if max_concurrency < 1:
        raise ValueError(f"max_concurrency must be at least 1, got {max_concurrency}")
    for name, value in (
        ("request_delay", request_delay),
        ("max_retries", max_retries),
        ("retry_base_delay", retry_base_delay),
        ("max_retry_after", max_retry_after),
    ):
        if value < 0:
            raise ValueError(f"{name} must not be negative, got {value}")
    self._base_url = base_url.rstrip("/")
    self._headers = httpx.Headers(DEFAULT_HEADERS)
    if headers is not None:
        self._headers.update(headers)
    self._token = token or None
    self._owns_client = http_client is None
    self._client = http_client or httpx.AsyncClient(timeout=timeout)
    self._closed = False
    self._semaphore = asyncio.Semaphore(max_concurrency)
    self._request_delay = request_delay
    self._max_retries = max_retries
    self._retry_base_delay = retry_base_delay
    self._max_retry_after = max_retry_after
    self._sleep = sleep
    self._clock = clock
    self._pacing_lock = asyncio.Lock()
    self._last_request_at: float | None = None
    self._cache = (
        None if cache_dir is None else DiskCache(cache_dir, ttl=cache_ttl, clock=cache_clock)
    )

base_url property

base_url: str

The API base URL, without a trailing slash.

site_url property

site_url: str

Origin of base_url (scheme and host), e.g. https://aniliberty.top.

Relative paths in responses, such as image paths, resolve against it.

cache_dir property

cache_dir: Path | None

The response cache directory, or None when caching is off.

token property writable

token: str | None

Session token sent as Authorization: Bearer <token>, or None for none.

Set it to start or stop sending the token; it replaces an Authorization header given in headers. Public endpoints work with or without it (even with an expired one); account endpoints answer 403 without a valid one.

closed property

closed: bool

Whether aclose() has been called.

aclose async

aclose() -> None

Close the underlying httpx.AsyncClient, unless it was passed in by the caller.

Safe to call more than once.

Source code in src/aniliberty/_http.py
247
248
249
250
251
252
253
254
255
256
async def aclose(self) -> None:
    """Close the underlying ``httpx.AsyncClient``, unless it was passed in by the caller.

    Safe to call more than once.
    """
    if self._closed:
        return
    self._closed = True
    if self._owns_client:
        await self._client.aclose()

url

url(path: str) -> str

Return the full URL for an API path such as "/app/status".

Source code in src/aniliberty/_http.py
258
259
260
def url(self, path: str) -> str:
    """Return the full URL for an API ``path`` such as ``"/app/status"``."""
    return f"{self._base_url}/{path.lstrip('/')}"

request async

request(
    method: str,
    path: str,
    *,
    params: Mapping[str, Any] | None = None,
    json: Any = None,
    with_token: bool = True,
    cache: bool = False,
    refresh: bool = False,
) -> Any

Send a request and return the decoded JSON body.

Retries and limits described on the class apply.

A response is read from and saved to the cache only when all of these hold: the client has a cache_dir, cache=True, the method is GET, and no session token goes with the request — so nothing that depends on the logged-in user (or carries their torrent passkey) ever lands on disk. The cache key is the method, the full URL and the query parameters in sorted order. Only successful responses with a body are cached; errors are raised as usual and never stored.

Parameters:

Name Type Description Default
method str

HTTP method, e.g. "GET".

required
path str

Path relative to base_url, e.g. "/anime/releases/latest".

required
params Mapping[str, Any] | None

Query parameters, passed to httpx as is.

None
json Any

JSON request body.

None
with_token bool

Whether to send token. False for the endpoints that refuse a logged-in caller (POST /accounts/otp/login answers 403 to one).

True
cache bool

Whether the response may be read from and saved to the cache (see above). The SDK's own methods pass it for endpoints whose data doesn't change from call to call.

False
refresh bool

Skip reading the cache and fetch from the API; the fresh response still replaces the cached one.

False

Returns:

Type Description
Any

The decoded JSON body (dict, list, ...), or None for an empty body.

Raises:

Type Description
APIError

The API answered with an error status — one of its subclasses for 404, 422, 429, 401/403, 5xx and HTML pages. 429 and 5xx are raised only once retries are exhausted.

TransportError

The request failed without a response (network error, timeout), after retries for idempotent methods.

ResponseParseError

A successful response wasn't valid JSON.

RuntimeError

The client has been closed.

Example
roles = await client.http.request("GET", "/teams/roles", cache=True)
Source code in src/aniliberty/_http.py
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
async def request(
    self,
    method: str,
    path: str,
    *,
    params: Mapping[str, Any] | None = None,
    json: Any = None,
    with_token: bool = True,
    cache: bool = False,
    refresh: bool = False,
) -> Any:
    """Send a request and return the decoded JSON body.

    Retries and limits described on the class apply.

    A response is read from and saved to the cache only when all of these hold: the
    client has a ``cache_dir``, ``cache=True``, the method is ``GET``, and no session
    token goes with the request — so nothing that depends on the logged-in user (or
    carries their torrent passkey) ever lands on disk. The cache key is the method, the
    full URL and the query parameters in sorted order. Only successful responses with a
    body are cached; errors are raised as usual and never stored.

    Args:
        method: HTTP method, e.g. ``"GET"``.
        path: Path relative to ``base_url``, e.g. ``"/anime/releases/latest"``.
        params: Query parameters, passed to ``httpx`` as is.
        json: JSON request body.
        with_token: Whether to send ``token``. ``False`` for the endpoints that refuse a
            logged-in caller (``POST /accounts/otp/login`` answers 403 to one).
        cache: Whether the response may be read from and saved to the cache (see
            above). The SDK's own methods pass it for endpoints whose data doesn't
            change from call to call.
        refresh: Skip reading the cache and fetch from the API; the fresh response
            still replaces the cached one.

    Returns:
        The decoded JSON body (``dict``, ``list``, ...), or ``None`` for an empty body.

    Raises:
        APIError: The API answered with an error status — one of its subclasses for
            404, 422, 429, 401/403, 5xx and HTML pages. 429 and 5xx are raised only once
            retries are exhausted.
        TransportError: The request failed without a response (network error, timeout),
            after retries for idempotent methods.
        ResponseParseError: A successful response wasn't valid JSON.
        RuntimeError: The client has been closed.

    Example:
        ```python
        roles = await client.http.request("GET", "/teams/roles", cache=True)
        ```
    """
    store = self._cache
    cacheable = (
        cache
        and store is not None
        and method.upper() == "GET"
        and json is None
        and not (with_token and self._token is not None)
    )
    if not cacheable or store is None:
        response = await self._send_with_retries(
            method, path, params=params, json=json, with_token=with_token
        )
        return _handle_response(response)

    query = sorted(httpx.QueryParams(params).multi_items()) if params else []
    key = jsonlib.dumps(["GET", self.url(path), query], ensure_ascii=False)
    if not refresh:
        cached = store.get(key)
        if cached is not None:
            logger.debug("GET %s: from cache", httpx.URL(self.url(path), params=params))
            return cached
    response = await self._send_with_retries(
        method, path, params=params, json=None, with_token=with_token
    )
    data = _handle_response(response)
    if data is not None:
        try:
            store.set(key, data)
        except OSError as exc:  # the data is in hand; a cache must not fail the request
            logger.warning(
                "Couldn't write to the response cache in %s: %s", store.directory, exc
            )
    return data

request_raw async

request_raw(
    method: str,
    path: str,
    *,
    params: Mapping[str, Any] | None = None,
) -> httpx.Response

Send a request for a non-JSON resource (a file, an XML feed) and return the response.

Same limits, retries and error handling as request(); the body is fully read, so use stream() instead for large downloads.

Parameters:

Name Type Description Default
method str

HTTP method, e.g. "GET".

required
path str

Path relative to base_url.

required
params Mapping[str, Any] | None

Query parameters, passed to httpx as is.

None

Returns:

Type Description
Response

The successful httpx.Response, with content/text and headers.

Raises:

Type Description
APIError

The API answered with an error status — see request().

TransportError

The request failed without a response.

RuntimeError

The client has been closed.

Example
response = await client.http.request_raw("GET", "/anime/torrents/rss")
print(response.headers["Content-Type"], len(response.text))
Source code in src/aniliberty/_http.py
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
async def request_raw(
    self, method: str, path: str, *, params: Mapping[str, Any] | None = None
) -> httpx.Response:
    """Send a request for a non-JSON resource (a file, an XML feed) and return the response.

    Same limits, retries and error handling as ``request()``; the body is fully read, so
    use ``stream()`` instead for large downloads.

    Args:
        method: HTTP method, e.g. ``"GET"``.
        path: Path relative to ``base_url``.
        params: Query parameters, passed to ``httpx`` as is.

    Returns:
        The successful ``httpx.Response``, with ``content``/``text`` and headers.

    Raises:
        APIError: The API answered with an error status — see ``request()``.
        TransportError: The request failed without a response.
        RuntimeError: The client has been closed.

    Example:
        ```python
        response = await client.http.request_raw("GET", "/anime/torrents/rss")
        print(response.headers["Content-Type"], len(response.text))
        ```
    """
    response = await self._send_with_retries(method, path, params=params, json=None)
    _raise_if_failed(response)
    return response

stream async

stream(
    method: str,
    path: str,
    *,
    params: Mapping[str, Any] | None = None,
) -> AsyncIterator[httpx.Response]

Send a request and stream the response body, for downloads.

Limits and retries apply until the response headers arrive: a 429/5xx is retried before anything is yielded, and an error status raises like request(). The body is then read by the caller (response.aiter_bytes()); the response is closed when the async with block exits.

Parameters:

Name Type Description Default
method str

HTTP method, e.g. "GET".

required
path str

Path relative to base_url.

required
params Mapping[str, Any] | None

Query parameters, passed to httpx as is.

None

Yields:

Type Description
AsyncIterator[Response]

The successful httpx.Response, body not read yet.

Raises:

Type Description
APIError

The API answered with an error status — see request().

TransportError

The request failed without a response.

RuntimeError

The client has been closed.

Example
async with client.http.stream("GET", "/anime/torrents/40407/file") as response:
    async for chunk in response.aiter_bytes():
        ...
Source code in src/aniliberty/_http.py
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
@contextlib.asynccontextmanager
async def stream(
    self, method: str, path: str, *, params: Mapping[str, Any] | None = None
) -> AsyncIterator[httpx.Response]:
    """Send a request and stream the response body, for downloads.

    Limits and retries apply until the response headers arrive: a 429/5xx is retried
    before anything is yielded, and an error status raises like ``request()``. The body is
    then read by the caller (``response.aiter_bytes()``); the response is closed when the
    ``async with`` block exits.

    Args:
        method: HTTP method, e.g. ``"GET"``.
        path: Path relative to ``base_url``.
        params: Query parameters, passed to ``httpx`` as is.

    Yields:
        The successful ``httpx.Response``, body not read yet.

    Raises:
        APIError: The API answered with an error status — see ``request()``.
        TransportError: The request failed without a response.
        RuntimeError: The client has been closed.

    Example:
        ```python
        async with client.http.stream("GET", "/anime/torrents/40407/file") as response:
            async for chunk in response.aiter_bytes():
                ...
        ```
    """
    response = await self._send_with_retries(
        method, path, params=params, json=None, stream=True
    )
    try:
        if not response.is_success or _media_type(response) == "text/html":
            await response.aread()
            _raise_if_failed(response)
        yield response
    finally:
        await response.aclose()