tvdb_api_client 0.8.0__tar.gz
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.
- tvdb_api_client-0.8.0/PKG-INFO +98 -0
- tvdb_api_client-0.8.0/docs/README.md +76 -0
- tvdb_api_client-0.8.0/pyproject.toml +172 -0
- tvdb_api_client-0.8.0/src/tvdb_api_client/__init__.py +3 -0
- tvdb_api_client-0.8.0/src/tvdb_api_client/__version__.py +3 -0
- tvdb_api_client-0.8.0/src/tvdb_api_client/client.py +177 -0
- tvdb_api_client-0.8.0/src/tvdb_api_client/constants.py +5 -0
- tvdb_api_client-0.8.0/src/tvdb_api_client/lib/__init__.py +0 -0
- tvdb_api_client-0.8.0/src/tvdb_api_client/lib/type_defs.py +85 -0
- tvdb_api_client-0.8.0/src/tvdb_api_client/models.py +133 -0
- tvdb_api_client-0.8.0/src/tvdb_api_client/py.typed +0 -0
- tvdb_api_client-0.8.0/src/tvdb_api_client/utils.py +23 -0
|
@@ -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,76 @@
|
|
|
1
|
+
# tvdb_api_client: an unofficial API for the TVDB
|
|
2
|
+
|
|
3
|
+
[![build][build_badge]][build_url]
|
|
4
|
+
[![lint][lint_badge]][lint_url]
|
|
5
|
+
[![tests][tests_badge]][tests_url]
|
|
6
|
+
[![license][licence_badge]][licence_url]
|
|
7
|
+
[![codecov][codecov_badge]][codecov_url]
|
|
8
|
+
[![readthedocs][readthedocs_badge]][readthedocs_url]
|
|
9
|
+
[![pypi][pypi_badge]][pypi_url]
|
|
10
|
+
[![downloads][pepy_badge]][pepy_url]
|
|
11
|
+
[![build automation: yam][yam_badge]][yam_url]
|
|
12
|
+
[![Lint: ruff][ruff_badge]][ruff_url]
|
|
13
|
+
|
|
14
|
+
`tvdb_api_client` is an unofficial API for the TVDB.
|
|
15
|
+
|
|
16
|
+
## In a nutshell
|
|
17
|
+
|
|
18
|
+
### Installation
|
|
19
|
+
|
|
20
|
+
[uv] is an extremely fast Python package installer.
|
|
21
|
+
You can use it to install `tvdb_api_client` and try it out:
|
|
22
|
+
|
|
23
|
+
```console
|
|
24
|
+
$ uv pip install tvdb_api_client
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
### Usage
|
|
28
|
+
|
|
29
|
+
Initialise the client and fetch data:
|
|
30
|
+
|
|
31
|
+
```python
|
|
32
|
+
from tvdb_api_client import TheTVDBClient
|
|
33
|
+
|
|
34
|
+
client = TheTVDBClient(api_key="your-api-key")
|
|
35
|
+
|
|
36
|
+
# Get a TV series by its TVDB id
|
|
37
|
+
series = client.get_series_by_id(81189) # Breaking Bad
|
|
38
|
+
|
|
39
|
+
# Get all episodes for a TV series
|
|
40
|
+
episodes = client.get_episodes_by_series(81189)
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Once the client has been initialised, you can use it to:
|
|
44
|
+
|
|
45
|
+
- get a TV series by its TVDB id
|
|
46
|
+
- get all episodes for a TV series by its TVDB id
|
|
47
|
+
- access raw API responses for custom processing
|
|
48
|
+
|
|
49
|
+
## Links
|
|
50
|
+
|
|
51
|
+
- [Documentation]
|
|
52
|
+
- [Changelog]
|
|
53
|
+
|
|
54
|
+
[build_badge]: https://github.com/spapanik/tvdb_api_client/actions/workflows/build.yml/badge.svg
|
|
55
|
+
[build_url]: https://github.com/spapanik/tvdb_api_client/actions/workflows/build.yml
|
|
56
|
+
[lint_badge]: https://github.com/spapanik/tvdb_api_client/actions/workflows/lint.yml/badge.svg
|
|
57
|
+
[lint_url]: https://github.com/spapanik/tvdb_api_client/actions/workflows/lint.yml
|
|
58
|
+
[tests_badge]: https://github.com/spapanik/tvdb_api_client/actions/workflows/tests.yml/badge.svg
|
|
59
|
+
[tests_url]: https://github.com/spapanik/tvdb_api_client/actions/workflows/tests.yml
|
|
60
|
+
[licence_badge]: https://img.shields.io/pypi/l/tvdb-api-client
|
|
61
|
+
[licence_url]: https://tvdb-api-client.readthedocs.io/en/stable/LICENSE/
|
|
62
|
+
[codecov_badge]: https://codecov.io/github/spapanik/tvdb-api-client/graph/badge.svg?token=Q20F84BW72
|
|
63
|
+
[codecov_url]: https://codecov.io/github/spapanik/tvdb-api-client
|
|
64
|
+
[readthedocs_badge]: https://readthedocs.org/projects/tvdb-api-client/badge/?version=latest
|
|
65
|
+
[readthedocs_url]: https://tvdb-api-client.readthedocs.io/en/latest/
|
|
66
|
+
[pypi_badge]: https://img.shields.io/pypi/v/tvdb-api-client
|
|
67
|
+
[pypi_url]: https://pypi.org/project/tvdb-api-client
|
|
68
|
+
[pepy_badge]: https://pepy.tech/badge/tvdb-api-client
|
|
69
|
+
[pepy_url]: https://pepy.tech/project/tvdb-api-client
|
|
70
|
+
[yam_badge]: https://img.shields.io/badge/build%20automation-yamk-success
|
|
71
|
+
[yam_url]: https://github.com/spapanik/yamk
|
|
72
|
+
[ruff_badge]: https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/charliermarsh/ruff/main/assets/badge/v1.json
|
|
73
|
+
[ruff_url]: https://github.com/charliermarsh/ruff
|
|
74
|
+
[uv]: https://github.com/astral-sh/uv
|
|
75
|
+
[Documentation]: https://tvdb-api-client.readthedocs.io/en/stable/
|
|
76
|
+
[Changelog]: https://tvdb-api-client.readthedocs.io/en/stable/CHANGELOG/
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = [
|
|
3
|
+
"uv_build>=0.11.0,<0.12.0",
|
|
4
|
+
]
|
|
5
|
+
build-backend = "uv_build"
|
|
6
|
+
|
|
7
|
+
[project]
|
|
8
|
+
name = "tvdb_api_client"
|
|
9
|
+
version = "0.8.0"
|
|
10
|
+
|
|
11
|
+
authors = [
|
|
12
|
+
{ name = "Stephanos Kuma", email = "stephanos@kuma.ai" },
|
|
13
|
+
]
|
|
14
|
+
license = { text = "BSD-3-Clause" }
|
|
15
|
+
|
|
16
|
+
readme = "docs/README.md"
|
|
17
|
+
description = "A python client for TVDB rest API"
|
|
18
|
+
keywords = [
|
|
19
|
+
"tvdb",
|
|
20
|
+
"imdb",
|
|
21
|
+
"tv series",
|
|
22
|
+
]
|
|
23
|
+
classifiers = [
|
|
24
|
+
"Development Status :: 4 - Beta",
|
|
25
|
+
"Operating System :: OS Independent",
|
|
26
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
27
|
+
"Intended Audience :: Developers",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
requires-python = ">=3.10"
|
|
31
|
+
dependencies = [
|
|
32
|
+
"dj_settings~=8.0",
|
|
33
|
+
"pathurl~=0.8",
|
|
34
|
+
"pyutilkit~=0.11",
|
|
35
|
+
"requests>=2.34.2,<3.0",
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
[project.urls]
|
|
39
|
+
homepage = "https://tvdb-api-client.readthedocs.io/en/stable/"
|
|
40
|
+
repository = "https://github.com/spapanik/tvdb_api_client"
|
|
41
|
+
documentation = "https://tvdb-api-client.readthedocs.io/en/stable/"
|
|
42
|
+
|
|
43
|
+
[dependency-groups]
|
|
44
|
+
dev = [
|
|
45
|
+
"ipdb~=0.13",
|
|
46
|
+
"ipython~=8.39",
|
|
47
|
+
"ptpython~=3.0",
|
|
48
|
+
{ include-group = "lint" },
|
|
49
|
+
{ include-group = "test" },
|
|
50
|
+
{ include-group = "docs" },
|
|
51
|
+
]
|
|
52
|
+
lint = [
|
|
53
|
+
"mypy~=2.1", # Keep mypy for CI/CD
|
|
54
|
+
"ruff~=0.15",
|
|
55
|
+
"ty~=0.0.42",
|
|
56
|
+
"types-requests~=2.33",
|
|
57
|
+
{ include-group = "test_core" },
|
|
58
|
+
]
|
|
59
|
+
test = [
|
|
60
|
+
"pytest-cov~=7.1",
|
|
61
|
+
{ include-group = "test_core" },
|
|
62
|
+
]
|
|
63
|
+
test_core = [
|
|
64
|
+
"pytest~=9.0",
|
|
65
|
+
]
|
|
66
|
+
docs = [
|
|
67
|
+
"mkdocs~=1.6",
|
|
68
|
+
"mkdocs-material~=9.7",
|
|
69
|
+
"mkdocs-material-extensions~=1.3",
|
|
70
|
+
"pygments~=2.20",
|
|
71
|
+
"pymdown-extensions~=10.21",
|
|
72
|
+
]
|
|
73
|
+
|
|
74
|
+
[tool.mypy]
|
|
75
|
+
# Keep mypy for CI/CD
|
|
76
|
+
check_untyped_defs = true
|
|
77
|
+
disallow_any_decorated = true
|
|
78
|
+
disallow_any_explicit = true
|
|
79
|
+
disallow_any_expr = false # many builtins are Any
|
|
80
|
+
disallow_any_generics = true
|
|
81
|
+
disallow_any_unimported = true
|
|
82
|
+
disallow_incomplete_defs = true
|
|
83
|
+
disallow_subclassing_any = true
|
|
84
|
+
disallow_untyped_calls = true
|
|
85
|
+
disallow_untyped_decorators = true
|
|
86
|
+
disallow_untyped_defs = true
|
|
87
|
+
extra_checks = true
|
|
88
|
+
ignore_missing_imports = true
|
|
89
|
+
no_implicit_reexport = true
|
|
90
|
+
show_column_numbers = true
|
|
91
|
+
show_error_codes = true
|
|
92
|
+
strict_equality = true
|
|
93
|
+
warn_redundant_casts = true
|
|
94
|
+
warn_return_any = true
|
|
95
|
+
warn_unused_configs = true
|
|
96
|
+
warn_unused_ignores = true
|
|
97
|
+
warn_unreachable = true
|
|
98
|
+
|
|
99
|
+
[[tool.mypy.overrides]]
|
|
100
|
+
# Keep mypy for CI/CD
|
|
101
|
+
module = "tests.*"
|
|
102
|
+
disallow_any_decorated = false # mock.MagicMock is Any
|
|
103
|
+
|
|
104
|
+
[tool.ruff]
|
|
105
|
+
src = [
|
|
106
|
+
"src",
|
|
107
|
+
]
|
|
108
|
+
target-version = "py310"
|
|
109
|
+
|
|
110
|
+
[tool.ruff.lint]
|
|
111
|
+
select = [
|
|
112
|
+
"ALL",
|
|
113
|
+
]
|
|
114
|
+
ignore = [
|
|
115
|
+
"C901", # Adding a limit to complexity is too arbitrary
|
|
116
|
+
"COM812", # Avoid conflicts with the formatter
|
|
117
|
+
"D10", # Not everything needs a docstring
|
|
118
|
+
"D203", # Prefer `no-blank-line-before-class` (D211)
|
|
119
|
+
"D213", # Prefer `multi-line-summary-first-line` (D212)
|
|
120
|
+
"PLR09", # Adding a limit to complexity is too arbitrary
|
|
121
|
+
]
|
|
122
|
+
|
|
123
|
+
[tool.ruff.lint.per-file-ignores]
|
|
124
|
+
"tests/**" = [
|
|
125
|
+
"FBT001", # Test arguments are handled by pytest
|
|
126
|
+
"PLR2004", # Tests should contain magic number comparisons
|
|
127
|
+
"S101", # Pytest needs assert statements
|
|
128
|
+
]
|
|
129
|
+
|
|
130
|
+
[tool.ruff.lint.flake8-tidy-imports]
|
|
131
|
+
ban-relative-imports = "all"
|
|
132
|
+
|
|
133
|
+
[tool.ruff.lint.flake8-tidy-imports.banned-api]
|
|
134
|
+
"mock".msg = "Use unittest.mock"
|
|
135
|
+
"pytz".msg = "Use zoneinfo"
|
|
136
|
+
|
|
137
|
+
[tool.ruff.lint.isort]
|
|
138
|
+
combine-as-imports = true
|
|
139
|
+
forced-separate = [
|
|
140
|
+
"tests",
|
|
141
|
+
]
|
|
142
|
+
split-on-trailing-comma = false
|
|
143
|
+
|
|
144
|
+
[tool.pytest]
|
|
145
|
+
minversion = "9.0"
|
|
146
|
+
strict = true
|
|
147
|
+
addopts = ["-ra", "-v"]
|
|
148
|
+
testpaths = [
|
|
149
|
+
"tests",
|
|
150
|
+
]
|
|
151
|
+
|
|
152
|
+
[tool.coverage.run]
|
|
153
|
+
branch = true
|
|
154
|
+
source = [
|
|
155
|
+
"src/",
|
|
156
|
+
]
|
|
157
|
+
data_file = ".cov_cache/coverage.dat"
|
|
158
|
+
omit = [
|
|
159
|
+
"src/**/type_defs.py",
|
|
160
|
+
]
|
|
161
|
+
|
|
162
|
+
[tool.coverage.report]
|
|
163
|
+
exclude_also = [
|
|
164
|
+
"if TYPE_CHECKING:",
|
|
165
|
+
"raise NotImplementedError",
|
|
166
|
+
"raise UnreachableCodeError",
|
|
167
|
+
]
|
|
168
|
+
fail_under = 100
|
|
169
|
+
precision = 2
|
|
170
|
+
show_missing = true
|
|
171
|
+
skip_covered = true
|
|
172
|
+
skip_empty = true
|
|
@@ -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]
|
|
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)
|