tvdb_api_client 0.8.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,3 @@
1
+ from tvdb_api_client.client import TheTVDBClient
2
+
3
+ __all__ = ["TheTVDBClient"]
@@ -0,0 +1,3 @@
1
+ from importlib.metadata import version
2
+
3
+ __version__ = version("tvdb_api_client")
@@ -0,0 +1,177 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from base64 import urlsafe_b64decode
5
+ from http import HTTPStatus
6
+ from typing import TYPE_CHECKING, cast
7
+
8
+ import requests
9
+ from dj_settings import get_setting
10
+ from pyutilkit.date_utils import now
11
+
12
+ from tvdb_api_client.constants import BASE_API_URL
13
+ from tvdb_api_client.models import Episode, Series
14
+
15
+ if TYPE_CHECKING:
16
+ from tvdb_api_client.lib.type_defs import (
17
+ AbstractCache,
18
+ CleanedEpisodeData,
19
+ FullEpisodeRawData,
20
+ SeriesRawData,
21
+ )
22
+
23
+
24
+ class _Cache(dict): # type: ignore[type-arg]
25
+ def set(self, key: str, value: object) -> None:
26
+ self[key] = value
27
+
28
+
29
+ class TheTVDBClient:
30
+ __slots__ = ("_auth_data", "_cache")
31
+
32
+ def __init__(
33
+ self,
34
+ api_key: str | None = None,
35
+ cache: AbstractCache | None = None,
36
+ pin: str | None = None,
37
+ ) -> None:
38
+ self._cache = cache or _Cache()
39
+ self._auth_data = self._get_auth_data(api_key, pin)
40
+
41
+ @staticmethod
42
+ def _get_auth_data(
43
+ api_key: str | None = None, pin: str | None = None
44
+ ) -> dict[str, str]:
45
+ filename = "the_tvdb.yaml"
46
+ sections = ["client"]
47
+
48
+ if api_key is None:
49
+ api_key = get_setting(
50
+ "api_key",
51
+ filename=filename,
52
+ sections=sections,
53
+ use_env="TVDB_API_KEY_V4",
54
+ )
55
+ if api_key is None:
56
+ msg = "API Key is required." # type: ignore[unreachable]
57
+ raise ValueError(msg)
58
+ output = {"apikey": api_key}
59
+
60
+ if pin is None:
61
+ pin = get_setting(
62
+ "pin", filename=filename, sections=sections, use_env="TVDB_PIN_V4"
63
+ )
64
+ if pin is not None:
65
+ output["pin"] = pin
66
+
67
+ return output
68
+
69
+ @staticmethod
70
+ def _get_expiry(token: str | None) -> int:
71
+ if token is None:
72
+ return 0
73
+
74
+ _, payload, *_ = token.split(".")
75
+ padding = "=" * (4 - len(payload) % 4)
76
+ data = json.loads(urlsafe_b64decode(payload + padding).decode())
77
+ return cast("int", data["exp"])
78
+
79
+ def _generate_token(self) -> str:
80
+ login_endpoint = "login"
81
+ url = BASE_API_URL.join(login_endpoint)
82
+ headers = {"Content-Type": "application/json", "accept": "application/json"}
83
+ response = requests.post(
84
+ url.string,
85
+ headers=headers,
86
+ data=json.dumps(self._auth_data),
87
+ timeout=(60, 120),
88
+ )
89
+ if response.status_code == HTTPStatus.UNAUTHORIZED:
90
+ msg = "Invalid credentials."
91
+ raise ConnectionRefusedError(msg)
92
+
93
+ if response.status_code != HTTPStatus.OK:
94
+ msg = "Unexpected Response."
95
+ raise ConnectionError(msg)
96
+
97
+ return cast("str", response.json()["data"]["token"])
98
+
99
+ def get(self, path: str) -> dict[str, object]:
100
+ url = BASE_API_URL.join(path)
101
+ cache_token_key = "tvdb_v4_token" # noqa: S105
102
+ token = cast("str | None", self._cache.get(cache_token_key))
103
+ if self._get_expiry(token) < now().timestamp() + 60:
104
+ token = self._generate_token()
105
+ self._cache.set(cache_token_key, token)
106
+
107
+ headers = {"accept": "application/json", "Authorization": f"Bearer {token}"}
108
+ response = requests.get(url.string, headers=headers, timeout=(60, 120))
109
+
110
+ if response.status_code == HTTPStatus.OK:
111
+ return cast("dict[str, object]", response.json())
112
+
113
+ if response.status_code in {HTTPStatus.BAD_REQUEST, HTTPStatus.NOT_FOUND}:
114
+ msg = "There are no data for this term."
115
+ raise LookupError(msg)
116
+
117
+ if response.status_code == HTTPStatus.UNAUTHORIZED:
118
+ msg = "Invalid credentials."
119
+ raise ConnectionRefusedError(msg)
120
+
121
+ msg = "Unexpected Response."
122
+ raise ConnectionError(msg)
123
+
124
+ def get_raw_series_by_id(
125
+ self, tvdb_id: int, *, refresh_cache: bool = False
126
+ ) -> SeriesRawData:
127
+ """Get the series info by its tvdb ib as returned by the TVDB."""
128
+ key = f"get_series_by_id::tvdb_id:{tvdb_id}"
129
+ data = cast("SeriesRawData | None", self._cache.get(key))
130
+ if data is None or refresh_cache:
131
+ path = f"series/{tvdb_id}"
132
+ data = cast("SeriesRawData", self.get(path)["data"])
133
+ self._cache.set(key, data)
134
+ return data
135
+
136
+ def get_series_by_id(self, tvdb_id: int, *, refresh_cache: bool = False) -> Series:
137
+ raw_data = self.get_raw_series_by_id(tvdb_id, refresh_cache=refresh_cache)
138
+ return Series.from_raw_data(raw_data)
139
+
140
+ def get_raw_episodes_by_series(
141
+ self,
142
+ tvdb_id: int,
143
+ season_type: str = "default",
144
+ page: int = 0,
145
+ *,
146
+ refresh_cache: bool = False,
147
+ ) -> CleanedEpisodeData:
148
+ """Get all the episodes for a TV series as returned by the TVDB."""
149
+ key = f"get_episodes_by_series::tvdb_id:{tvdb_id}:{page}"
150
+ data = cast("CleanedEpisodeData | None", self._cache.get(key))
151
+ if data is None or refresh_cache:
152
+ path = f"series/{tvdb_id}/episodes/{season_type}?page={page}"
153
+ all_data = cast("FullEpisodeRawData", self.get(path))
154
+ episodes = all_data["data"]["episodes"]
155
+ data = {
156
+ "episodes": episodes,
157
+ "has_next_page": all_data["links"]["next"] is not None,
158
+ }
159
+ self._cache.set(key, data)
160
+ # Redundant cast to satisfy mypy & ty compatibility
161
+ return cast("CleanedEpisodeData", data) # type: ignore[redundant-cast]
162
+
163
+ def get_episodes_by_series(
164
+ self, tvdb_id: int, season_type: str = "default", *, refresh_cache: bool = False
165
+ ) -> list[Episode]:
166
+ """Get all the episodes for a TV series."""
167
+ next_page = True
168
+ page = 0
169
+ data = []
170
+ while next_page:
171
+ raw_data = self.get_raw_episodes_by_series(
172
+ tvdb_id, season_type, refresh_cache=refresh_cache, page=page
173
+ )
174
+ data.extend(raw_data["episodes"])
175
+ next_page = raw_data["has_next_page"]
176
+ page += 1
177
+ return [Episode.from_raw_data(episode_info) for episode_info in data]
@@ -0,0 +1,5 @@
1
+ from pathurl import URL
2
+
3
+ BASE_API_URL = URL("https://api4.thetvdb.com/v4/")
4
+ DATE_FORMAT = "%Y-%m-%d"
5
+ DATETIME_FORMAT = "%Y-%m-%d %H:%M:%S"
File without changes
@@ -0,0 +1,85 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Protocol, TypedDict
4
+
5
+
6
+ class AbstractCache(Protocol):
7
+ def set(self, key: str, value: object) -> None: ...
8
+ def get(self, key: str) -> Any: ... # type: ignore[explicit-any] # noqa: ANN401
9
+
10
+
11
+ class AliasRawData(TypedDict):
12
+ language: str
13
+ name: str
14
+
15
+
16
+ class StatusRawData(TypedDict):
17
+ id: int
18
+ name: str
19
+ recordType: str
20
+ keepUpdated: bool
21
+
22
+
23
+ class SeriesRawData(TypedDict):
24
+ id: int
25
+ name: str
26
+ slug: str
27
+ image: str | None
28
+ nameTranslations: list[str]
29
+ overviewTranslations: list[str]
30
+ aliases: list[AliasRawData]
31
+ firstAired: str
32
+ lastAired: str
33
+ nextAired: str
34
+ score: float
35
+ status: StatusRawData
36
+ originalCountry: str
37
+ originalLanguage: str
38
+ defaultSeasonType: int
39
+ isOrderRandomized: bool
40
+ lastUpdated: str
41
+ averageRuntime: int
42
+ overview: str
43
+ episodes: EpisodeRawData | None
44
+ year: str
45
+
46
+
47
+ class EpisodeRawData(TypedDict):
48
+ id: int
49
+ seriesId: int
50
+ name: str
51
+ aired: str
52
+ runtime: int
53
+ nameTranslations: list[str]
54
+ overview: str
55
+ overviewTranslations: list[str]
56
+ image: str
57
+ imageType: int
58
+ isMovie: int
59
+ number: int
60
+ seasonNumber: int
61
+ lastUpdated: str
62
+ finaleType: str
63
+ seasons: int | None
64
+ absoluteNumber: int
65
+ year: str
66
+
67
+
68
+ class LinksRawData(TypedDict):
69
+ next: str | None
70
+ prev: str | None
71
+
72
+
73
+ class _FullRawData(TypedDict):
74
+ series: SeriesRawData
75
+ episodes: list[EpisodeRawData]
76
+
77
+
78
+ class CleanedEpisodeData(TypedDict):
79
+ episodes: list[EpisodeRawData]
80
+ has_next_page: bool
81
+
82
+
83
+ class FullEpisodeRawData(TypedDict):
84
+ data: _FullRawData
85
+ links: LinksRawData
@@ -0,0 +1,133 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import TYPE_CHECKING
5
+
6
+ from pathurl import URL
7
+
8
+ from tvdb_api_client.utils import get_tvdb_date, get_tvdb_datetime
9
+
10
+ if TYPE_CHECKING:
11
+ from datetime import date, datetime
12
+
13
+ from tvdb_api_client.lib.type_defs import (
14
+ AliasRawData,
15
+ EpisodeRawData,
16
+ SeriesRawData,
17
+ StatusRawData,
18
+ )
19
+
20
+
21
+ @dataclass
22
+ class Alias:
23
+ language: str
24
+ name: str
25
+
26
+ @classmethod
27
+ def from_raw_data(cls, raw_data: AliasRawData) -> Alias:
28
+ return cls(language=raw_data["language"], name=raw_data["name"])
29
+
30
+
31
+ @dataclass
32
+ class Status:
33
+ id: int
34
+ name: str
35
+ record_type: str
36
+ keep_updated: bool
37
+
38
+ @classmethod
39
+ def from_raw_data(cls, raw_data: StatusRawData) -> Status:
40
+ return cls(
41
+ id=raw_data["id"],
42
+ name=raw_data["name"],
43
+ record_type=raw_data["recordType"],
44
+ keep_updated=raw_data["keepUpdated"],
45
+ )
46
+
47
+
48
+ @dataclass
49
+ class Series:
50
+ id: int
51
+ name: str
52
+ slug: str
53
+ image: URL | None
54
+ name_translations: list[str]
55
+ overview_translations: list[str]
56
+ aliases: list[Alias]
57
+ first_aired: date | None
58
+ last_aired: date | None
59
+ next_aired: date | None
60
+ score: float
61
+ status: Status
62
+ original_country: str
63
+ original_language: str
64
+ default_season_type: int
65
+ is_order_randomized: bool
66
+ last_updated: datetime | None
67
+ average_runtime: int
68
+ overview: str
69
+
70
+ @classmethod
71
+ def from_raw_data(cls, raw_data: SeriesRawData) -> Series:
72
+ image_url = raw_data["image"]
73
+ return cls(
74
+ id=raw_data["id"],
75
+ name=raw_data["name"],
76
+ slug=raw_data["slug"],
77
+ image=URL(image_url) if image_url else None,
78
+ name_translations=raw_data["nameTranslations"],
79
+ overview_translations=raw_data["overviewTranslations"],
80
+ aliases=[Alias.from_raw_data(alias) for alias in raw_data["aliases"]],
81
+ first_aired=get_tvdb_date(raw_data["firstAired"]),
82
+ last_aired=get_tvdb_date(raw_data["lastAired"]),
83
+ next_aired=get_tvdb_date(raw_data["nextAired"]),
84
+ score=raw_data["score"],
85
+ status=Status.from_raw_data(raw_data["status"]),
86
+ original_country=raw_data["originalCountry"],
87
+ original_language=raw_data["originalLanguage"],
88
+ default_season_type=raw_data["defaultSeasonType"],
89
+ is_order_randomized=raw_data["isOrderRandomized"],
90
+ last_updated=get_tvdb_datetime(raw_data["lastUpdated"]),
91
+ average_runtime=raw_data["averageRuntime"],
92
+ overview=raw_data["overview"],
93
+ )
94
+
95
+
96
+ @dataclass
97
+ class Episode:
98
+ id: int
99
+ series_id: int
100
+ name: str
101
+ aired: date | None
102
+ runtime: int
103
+ name_translations: list[str]
104
+ overview: str
105
+ overview_translations: list[str]
106
+ image: URL | None
107
+ image_type: int
108
+ is_movie: int
109
+ number: int
110
+ season_number: int
111
+ last_updated: datetime | None
112
+ finale_type: str
113
+
114
+ @classmethod
115
+ def from_raw_data(cls, raw_data: EpisodeRawData) -> Episode:
116
+ image_url = raw_data["image"]
117
+ return cls(
118
+ id=raw_data["id"],
119
+ series_id=raw_data["seriesId"],
120
+ name=raw_data["name"],
121
+ aired=get_tvdb_date(raw_data["aired"]),
122
+ runtime=raw_data["runtime"],
123
+ name_translations=raw_data["nameTranslations"],
124
+ overview=raw_data["overview"],
125
+ overview_translations=raw_data["overviewTranslations"],
126
+ image=URL(image_url) if image_url else None,
127
+ image_type=raw_data["imageType"],
128
+ is_movie=raw_data["isMovie"],
129
+ number=raw_data["number"],
130
+ season_number=raw_data["seasonNumber"],
131
+ last_updated=get_tvdb_datetime(raw_data["lastUpdated"]),
132
+ finale_type=raw_data["finaleType"],
133
+ )
File without changes
@@ -0,0 +1,23 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import date, datetime
4
+
5
+ from pyutilkit.date_utils import add_timezone
6
+
7
+ from tvdb_api_client.constants import DATE_FORMAT, DATETIME_FORMAT
8
+
9
+
10
+ def get_tvdb_date(date_string: str) -> date | None:
11
+ if not date_string:
12
+ return None
13
+
14
+ naive_datetime = datetime.strptime(date_string, DATE_FORMAT) # noqa: DTZ007
15
+ return add_timezone(naive_datetime).date()
16
+
17
+
18
+ def get_tvdb_datetime(datetime_string: str) -> datetime | None:
19
+ if not datetime_string:
20
+ return None
21
+
22
+ naive_datetime = datetime.strptime(datetime_string, DATETIME_FORMAT) # noqa: DTZ007
23
+ return add_timezone(naive_datetime)
@@ -0,0 +1,98 @@
1
+ Metadata-Version: 2.3
2
+ Name: tvdb_api_client
3
+ Version: 0.8.0
4
+ Summary: A python client for TVDB rest API
5
+ Keywords: tvdb,imdb,tv series
6
+ Author: Stephanos Kuma
7
+ Author-email: Stephanos Kuma <stephanos@kuma.ai>
8
+ License: BSD-3-Clause
9
+ Classifier: Development Status :: 4 - Beta
10
+ Classifier: Operating System :: OS Independent
11
+ Classifier: Programming Language :: Python :: 3 :: Only
12
+ Classifier: Intended Audience :: Developers
13
+ Requires-Dist: dj-settings~=8.0
14
+ Requires-Dist: pathurl~=0.8
15
+ Requires-Dist: pyutilkit~=0.11
16
+ Requires-Dist: requests>=2.34.2,<3.0
17
+ Requires-Python: >=3.10
18
+ Project-URL: homepage, https://tvdb-api-client.readthedocs.io/en/stable/
19
+ Project-URL: repository, https://github.com/spapanik/tvdb_api_client
20
+ Project-URL: documentation, https://tvdb-api-client.readthedocs.io/en/stable/
21
+ Description-Content-Type: text/markdown
22
+
23
+ # tvdb_api_client: an unofficial API for the TVDB
24
+
25
+ [![build][build_badge]][build_url]
26
+ [![lint][lint_badge]][lint_url]
27
+ [![tests][tests_badge]][tests_url]
28
+ [![license][licence_badge]][licence_url]
29
+ [![codecov][codecov_badge]][codecov_url]
30
+ [![readthedocs][readthedocs_badge]][readthedocs_url]
31
+ [![pypi][pypi_badge]][pypi_url]
32
+ [![downloads][pepy_badge]][pepy_url]
33
+ [![build automation: yam][yam_badge]][yam_url]
34
+ [![Lint: ruff][ruff_badge]][ruff_url]
35
+
36
+ `tvdb_api_client` is an unofficial API for the TVDB.
37
+
38
+ ## In a nutshell
39
+
40
+ ### Installation
41
+
42
+ [uv] is an extremely fast Python package installer.
43
+ You can use it to install `tvdb_api_client` and try it out:
44
+
45
+ ```console
46
+ $ uv pip install tvdb_api_client
47
+ ```
48
+
49
+ ### Usage
50
+
51
+ Initialise the client and fetch data:
52
+
53
+ ```python
54
+ from tvdb_api_client import TheTVDBClient
55
+
56
+ client = TheTVDBClient(api_key="your-api-key")
57
+
58
+ # Get a TV series by its TVDB id
59
+ series = client.get_series_by_id(81189) # Breaking Bad
60
+
61
+ # Get all episodes for a TV series
62
+ episodes = client.get_episodes_by_series(81189)
63
+ ```
64
+
65
+ Once the client has been initialised, you can use it to:
66
+
67
+ - get a TV series by its TVDB id
68
+ - get all episodes for a TV series by its TVDB id
69
+ - access raw API responses for custom processing
70
+
71
+ ## Links
72
+
73
+ - [Documentation]
74
+ - [Changelog]
75
+
76
+ [build_badge]: https://github.com/spapanik/tvdb_api_client/actions/workflows/build.yml/badge.svg
77
+ [build_url]: https://github.com/spapanik/tvdb_api_client/actions/workflows/build.yml
78
+ [lint_badge]: https://github.com/spapanik/tvdb_api_client/actions/workflows/lint.yml/badge.svg
79
+ [lint_url]: https://github.com/spapanik/tvdb_api_client/actions/workflows/lint.yml
80
+ [tests_badge]: https://github.com/spapanik/tvdb_api_client/actions/workflows/tests.yml/badge.svg
81
+ [tests_url]: https://github.com/spapanik/tvdb_api_client/actions/workflows/tests.yml
82
+ [licence_badge]: https://img.shields.io/pypi/l/tvdb-api-client
83
+ [licence_url]: https://tvdb-api-client.readthedocs.io/en/stable/LICENSE/
84
+ [codecov_badge]: https://codecov.io/github/spapanik/tvdb-api-client/graph/badge.svg?token=Q20F84BW72
85
+ [codecov_url]: https://codecov.io/github/spapanik/tvdb-api-client
86
+ [readthedocs_badge]: https://readthedocs.org/projects/tvdb-api-client/badge/?version=latest
87
+ [readthedocs_url]: https://tvdb-api-client.readthedocs.io/en/latest/
88
+ [pypi_badge]: https://img.shields.io/pypi/v/tvdb-api-client
89
+ [pypi_url]: https://pypi.org/project/tvdb-api-client
90
+ [pepy_badge]: https://pepy.tech/badge/tvdb-api-client
91
+ [pepy_url]: https://pepy.tech/project/tvdb-api-client
92
+ [yam_badge]: https://img.shields.io/badge/build%20automation-yamk-success
93
+ [yam_url]: https://github.com/spapanik/yamk
94
+ [ruff_badge]: https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/charliermarsh/ruff/main/assets/badge/v1.json
95
+ [ruff_url]: https://github.com/charliermarsh/ruff
96
+ [uv]: https://github.com/astral-sh/uv
97
+ [Documentation]: https://tvdb-api-client.readthedocs.io/en/stable/
98
+ [Changelog]: https://tvdb-api-client.readthedocs.io/en/stable/CHANGELOG/
@@ -0,0 +1,12 @@
1
+ tvdb_api_client/__init__.py,sha256=DQIblb3gZlfWeia4EGO0RsiNQT4lj6CHRdZlFWS_7oc,78
2
+ tvdb_api_client/__version__.py,sha256=P2pH0fxf71o-XEB1g9i3zVGtCdoGTdGf74OSPWyI2S4,81
3
+ tvdb_api_client/client.py,sha256=c_UBRyiI6qDD90F3YabVZTwnexq2cMKK-ZYtTaULltQ,6201
4
+ tvdb_api_client/constants.py,sha256=XgutJ_iA9uAyju6gUU2kFDYuEQsM1IQLndv5qhcUsbk,139
5
+ tvdb_api_client/lib/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
+ tvdb_api_client/lib/type_defs.py,sha256=L4bYpkzRdS91o7FypiWKPXHz2LcKmp66Rx-PVNFisGU,1680
7
+ tvdb_api_client/models.py,sha256=SQf6AiXYUf7Q7krEX0a1Z-boN2GEukgSYgNSE8fH4Dw,3924
8
+ tvdb_api_client/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
9
+ tvdb_api_client/utils.py,sha256=I2epHrpWnZK-v2SmYHzHSkk_Zxn2GRC4EcaEaJ8Van8,658
10
+ tvdb_api_client-0.8.0.dist-info/WHEEL,sha256=V5-3dKee3Zs8C4JP6swr6zdqriLsOpItBEQxe6_oWpY,81
11
+ tvdb_api_client-0.8.0.dist-info/METADATA,sha256=1Hj_9z8cKZ-zTtQQmBbkyceT-M4qiAC3-LysmbSN_MM,3789
12
+ tvdb_api_client-0.8.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.11.18
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any