mydramalist-client 0.1.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.
Files changed (61) hide show
  1. mydramalist_client-0.1.0/.github/dependabot.yml +17 -0
  2. mydramalist_client-0.1.0/.github/workflows/ci.yml +67 -0
  3. mydramalist_client-0.1.0/.gitignore +25 -0
  4. mydramalist_client-0.1.0/LICENSE +21 -0
  5. mydramalist_client-0.1.0/PKG-INFO +170 -0
  6. mydramalist_client-0.1.0/README.md +137 -0
  7. mydramalist_client-0.1.0/docs/api-key-extraction.md +48 -0
  8. mydramalist_client-0.1.0/docs/index.md +63 -0
  9. mydramalist_client-0.1.0/mkdocs.yml +19 -0
  10. mydramalist_client-0.1.0/pyproject.toml +80 -0
  11. mydramalist_client-0.1.0/scripts/build_sync.py +54 -0
  12. mydramalist_client-0.1.0/scripts/check_sync.py +51 -0
  13. mydramalist_client-0.1.0/src/mdl/__init__.py +59 -0
  14. mydramalist_client-0.1.0/src/mdl/_async/__init__.py +5 -0
  15. mydramalist_client-0.1.0/src/mdl/_async/_transport.py +117 -0
  16. mydramalist_client-0.1.0/src/mdl/_async/client.py +113 -0
  17. mydramalist_client-0.1.0/src/mdl/_async/resources/__init__.py +1 -0
  18. mydramalist_client-0.1.0/src/mdl/_async/resources/auth.py +276 -0
  19. mydramalist_client-0.1.0/src/mdl/_async/resources/base.py +96 -0
  20. mydramalist_client-0.1.0/src/mdl/_async/resources/lists.py +205 -0
  21. mydramalist_client-0.1.0/src/mdl/_async/resources/misc.py +118 -0
  22. mydramalist_client-0.1.0/src/mdl/_async/resources/social.py +217 -0
  23. mydramalist_client-0.1.0/src/mdl/_async/resources/titles.py +241 -0
  24. mydramalist_client-0.1.0/src/mdl/_request.py +123 -0
  25. mydramalist_client-0.1.0/src/mdl/_sync/__init__.py +5 -0
  26. mydramalist_client-0.1.0/src/mdl/_sync/_transport.py +117 -0
  27. mydramalist_client-0.1.0/src/mdl/_sync/client.py +113 -0
  28. mydramalist_client-0.1.0/src/mdl/_sync/resources/__init__.py +1 -0
  29. mydramalist_client-0.1.0/src/mdl/_sync/resources/auth.py +276 -0
  30. mydramalist_client-0.1.0/src/mdl/_sync/resources/base.py +96 -0
  31. mydramalist_client-0.1.0/src/mdl/_sync/resources/lists.py +205 -0
  32. mydramalist_client-0.1.0/src/mdl/_sync/resources/misc.py +118 -0
  33. mydramalist_client-0.1.0/src/mdl/_sync/resources/social.py +217 -0
  34. mydramalist_client-0.1.0/src/mdl/_sync/resources/titles.py +241 -0
  35. mydramalist_client-0.1.0/src/mdl/_version.py +3 -0
  36. mydramalist_client-0.1.0/src/mdl/auth.py +142 -0
  37. mydramalist_client-0.1.0/src/mdl/config.py +117 -0
  38. mydramalist_client-0.1.0/src/mdl/errors.py +79 -0
  39. mydramalist_client-0.1.0/src/mdl/models/__init__.py +169 -0
  40. mydramalist_client-0.1.0/src/mdl/models/auth.py +131 -0
  41. mydramalist_client-0.1.0/src/mdl/models/base.py +63 -0
  42. mydramalist_client-0.1.0/src/mdl/models/feeds.py +166 -0
  43. mydramalist_client-0.1.0/src/mdl/models/lists.py +111 -0
  44. mydramalist_client-0.1.0/src/mdl/models/misc.py +188 -0
  45. mydramalist_client-0.1.0/src/mdl/models/reviews.py +155 -0
  46. mydramalist_client-0.1.0/src/mdl/models/social.py +132 -0
  47. mydramalist_client-0.1.0/src/mdl/models/titles.py +188 -0
  48. mydramalist_client-0.1.0/src/mdl/pagination.py +34 -0
  49. mydramalist_client-0.1.0/src/mdl/py.typed +0 -0
  50. mydramalist_client-0.1.0/tests/conftest.py +44 -0
  51. mydramalist_client-0.1.0/tests/test_account_users.py +153 -0
  52. mydramalist_client-0.1.0/tests/test_auth.py +60 -0
  53. mydramalist_client-0.1.0/tests/test_calendar_misc.py +117 -0
  54. mydramalist_client-0.1.0/tests/test_core.py +181 -0
  55. mydramalist_client-0.1.0/tests/test_lists.py +100 -0
  56. mydramalist_client-0.1.0/tests/test_request.py +74 -0
  57. mydramalist_client-0.1.0/tests/test_social.py +156 -0
  58. mydramalist_client-0.1.0/tests/test_titles.py +95 -0
  59. mydramalist_client-0.1.0/tests/test_transport.py +104 -0
  60. mydramalist_client-0.1.0/tests/test_watchlist_and_errors.py +75 -0
  61. mydramalist_client-0.1.0/uv.lock +2169 -0
@@ -0,0 +1,17 @@
1
+ version: 2
2
+ updates:
3
+ - package-ecosystem: pip
4
+ directory: /
5
+ schedule:
6
+ interval: weekly
7
+ target-branch: "master"
8
+ cooldown:
9
+ default-days: 7
10
+ semver-major-days: 7
11
+ - package-ecosystem: github-actions
12
+ directory: /
13
+ schedule:
14
+ interval: weekly
15
+ target-branch: "master"
16
+ cooldown:
17
+ default-days: 7
@@ -0,0 +1,67 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [master]
6
+ tags: ["v*"]
7
+ pull_request:
8
+
9
+ jobs:
10
+ test:
11
+ runs-on: ubuntu-latest
12
+ permissions:
13
+ contents: read
14
+ strategy:
15
+ fail-fast: false
16
+ matrix:
17
+ python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"]
18
+ steps:
19
+ # Pinned to commit SHAs; see each comment for the moving tag it replaces.
20
+ - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
21
+ with:
22
+ persist-credentials: false
23
+ - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
24
+ with:
25
+ python-version: ${{ matrix.python-version }}
26
+ - name: Install
27
+ run: |
28
+ python -m pip install --upgrade pip
29
+ pip install -e ".[dev]"
30
+ - name: Ruff
31
+ run: ruff check src tests
32
+ - name: Check generated _sync is up to date
33
+ run: python scripts/check_sync.py
34
+ - name: Mypy
35
+ if: matrix.python-version != '3.9'
36
+ run: mypy
37
+ - name: Pytest
38
+ run: pytest -q --cov=mdl --cov-report=xml
39
+ - name: Upload coverage to Codecov
40
+ if: matrix.python-version == '3.12'
41
+ uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
42
+ with:
43
+ token: ${{ secrets.CODECOV_TOKEN }}
44
+ files: coverage.xml
45
+ fail_ci_if_error: false
46
+
47
+ publish:
48
+ needs: test
49
+ if: startsWith(github.ref, 'refs/tags/v')
50
+ runs-on: ubuntu-latest
51
+ environment: pypi
52
+ permissions:
53
+ contents: read
54
+ id-token: write # trusted publishing
55
+ steps:
56
+ - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
57
+ with:
58
+ persist-credentials: false
59
+ - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
60
+ with:
61
+ python-version: "3.12"
62
+ - name: Build
63
+ run: |
64
+ python -m pip install --upgrade pip build
65
+ python -m build
66
+ - name: Publish to PyPI
67
+ uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # release/v1
@@ -0,0 +1,25 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ .eggs/
6
+ build/
7
+ dist/
8
+ .venv/
9
+ venv/
10
+
11
+ # Tooling caches
12
+ .pytest_cache/
13
+ .mypy_cache/
14
+ .ruff_cache/
15
+ .coverage
16
+ coverage.xml
17
+ htmlcov/
18
+
19
+ # Env / secrets
20
+ .env
21
+
22
+ # Reverse-engineering artifacts (repo-root only — leading slash anchors the match so it
23
+ # does NOT also ignore the package's own src/mdl/**/resources/ subpackages)
24
+ /resources/
25
+ /blutter/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 pymdl contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,170 @@
1
+ Metadata-Version: 2.4
2
+ Name: mydramalist-client
3
+ Version: 0.1.0
4
+ Summary: Unofficial fully-typed sync + async Python client for the MyDramaList app API
5
+ Project-URL: Homepage, https://github.com/danieyal/pymdl
6
+ Project-URL: Documentation, https://github.com/danieyal/pymdl#readme
7
+ Project-URL: Source, https://github.com/danieyal/pymdl
8
+ Author: pymdl contributors
9
+ License: MIT
10
+ License-File: LICENSE
11
+ Keywords: api,async,client,curl_cffi,drama,kdrama,mdl,mydramalist
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3 :: Only
17
+ Classifier: Typing :: Typed
18
+ Requires-Python: >=3.9
19
+ Requires-Dist: curl-cffi>=0.7
20
+ Requires-Dist: pydantic>=2.13.4
21
+ Provides-Extra: dev
22
+ Requires-Dist: httpx>=0.24; extra == 'dev'
23
+ Requires-Dist: mypy>=1.5; extra == 'dev'
24
+ Requires-Dist: pytest-asyncio>=0.21; extra == 'dev'
25
+ Requires-Dist: pytest-cov>=4; extra == 'dev'
26
+ Requires-Dist: pytest>=7; extra == 'dev'
27
+ Requires-Dist: respx>=0.23.1; extra == 'dev'
28
+ Requires-Dist: ruff>=0.1; extra == 'dev'
29
+ Requires-Dist: unasync>=0.6.0; extra == 'dev'
30
+ Provides-Extra: docs
31
+ Requires-Dist: mkdocs-material>=9; extra == 'docs'
32
+ Description-Content-Type: text/markdown
33
+
34
+ # mdl
35
+
36
+ An **unofficial**, fully-typed, sync **and** async Python client for the private
37
+ MyDramaList app API (`https://app-api.mydramalist.com/v1`), reconstructed from the app's
38
+ reverse-engineered spec. Built on [curl_cffi](https://github.com/lexiforest/curl_cffi) and
39
+ [Pydantic v2](https://docs.pydantic.dev/).
40
+
41
+ Install as `mydramalist-client`; import as `mdl`.
42
+
43
+ > **Interoperability / research tool.** This library targets an **undocumented, private**
44
+ > API discovered by decompiling the MyDramaList Flutter app. Endpoint paths and field
45
+ > names are inferred from machine code and may change without notice; models tolerate
46
+ > unknown/renamed fields but nothing is guaranteed. Use responsibly and in accordance with
47
+ > MyDramaList's Terms of Service. Not affiliated with or endorsed by MyDramaList.
48
+
49
+ ## Install
50
+
51
+ ```bash
52
+ pip install mydramalist-client
53
+ ```
54
+
55
+ ## The `mdl-api-key`
56
+
57
+ Despite its name, `mdl-api-key` is **not a secret** — the app fills it with a random 20-char
58
+ string generated on each launch, and the server does not validate it. This library generates
59
+ a valid one for you, so you do not need to extract or supply anything. You may still pin a
60
+ value via `api_key=` or `MDL_API_KEY` for reproducible requests, but it is optional. See
61
+ [docs/api-key-extraction.md](docs/api-key-extraction.md).
62
+
63
+ > **Heads up — Cloudflare:** production sits behind Cloudflare bot protection that fingerprints
64
+ > the client's TLS handshake. A plain `httpx`/`requests` client is served a `403 "Just a
65
+ moment..."` challenge; only a client impersonating a real mobile/browser TLS fingerprint
66
+ > gets through. See [Transport](#transport).
67
+
68
+ ## Quick start (sync)
69
+
70
+ ```python
71
+ from mdl import MDLClient
72
+
73
+ with MDLClient() as client: # api_key generated automatically
74
+ title = client.titles.get_title(686)
75
+ print(title.title, title.rating)
76
+
77
+ results = client.search.titles("signal")
78
+ for movie in results:
79
+ print(movie.id, movie.title)
80
+ ```
81
+
82
+ ## Quick start (async)
83
+
84
+ ```python
85
+ import asyncio
86
+ from mdl import AsyncMDLClient
87
+
88
+ async def main():
89
+ async with AsyncMDLClient() as client:
90
+ title = await client.titles.get_title(686)
91
+ print(title.title)
92
+
93
+ asyncio.run(main())
94
+ ```
95
+
96
+ ## Authentication
97
+
98
+ ```python
99
+ with MDLClient() as client:
100
+ client.auth.login("username", "password") # password is MD5-hashed for you
101
+ profile = client.account.get_profile() # bearer token attached automatically
102
+ print(profile.username)
103
+ ```
104
+
105
+ Tokens are held in an in-memory store by default. Persist them with `FileTokenStore`:
106
+
107
+ ```python
108
+ from mdl import MDLClient, FileTokenStore
109
+
110
+ client = MDLClient(token_store=FileTokenStore("~/.mydramalist/token.json"))
111
+ ```
112
+
113
+ The client clears stored tokens on 401 and raises `MDLAuthError`. Automatic
114
+ token refresh via `refresh_token` is not yet implemented — callers must
115
+ re-authenticate when the access token expires.
116
+
117
+ ## Transport
118
+
119
+ Production MyDramaList sits behind **Cloudflare bot protection** that fingerprints the
120
+ client's TLS/HTTP2 handshake (JA3/JA4). This is the real access gate — not the `mdl-api-key`.
121
+
122
+ A plain `httpx`/`requests` client (a stock Python TLS stack) is served Cloudflare's
123
+ `403 "Just a moment..."` challenge on every request, regardless of headers or User-Agent.
124
+ Only a client that impersonates a real mobile/browser TLS fingerprint is allowed through — in
125
+ testing, [`curl_cffi`](https://github.com/lexiforest/curl_cffi) with `impersonate="safari_ios"`
126
+ (or `chrome`) reaches the API and returns real JSON.
127
+
128
+ If you get `403` responses with a `"Just a moment..."` HTML body, this is why: the request
129
+ never reached the API, it was stopped at Cloudflare's edge.
130
+
131
+ ## Resource groups
132
+
133
+ The client exposes one attribute per API area, e.g.:
134
+
135
+ | Attribute | Area |
136
+ | ------------------------------------------------------------------------------- | ----------------------------------------------------------------- |
137
+ | `client.auth` | login, social auth, register, reset password |
138
+ | `client.account` | own profile/settings, verification, devices, payments |
139
+ | `client.users` | other users & people pages, credits, likes |
140
+ | `client.titles` | title detail, progress, reviews, recommendations, credits, genres |
141
+ | `client.search` | title & people search |
142
+ | `client.explore` | trending / top_airing / upcoming / recommended feeds |
143
+ | `client.reviews` | review CRUD, voting |
144
+ | `client.comments` | comment listing & CRUD |
145
+ | `client.watchlist` | watchlist sync (add/remove/status lists) |
146
+ | `client.custom_lists` | custom lists, items, votes |
147
+ | `client.feeds` | activity feed, posts, embeds, uploads |
148
+ | `client.articles` | articles |
149
+ | `client.friends` / `client.messages` / `client.notifications` / `client.groups` | social |
150
+ | `client.subscription` / `client.flowers` / `client.awards` / `client.reports` | misc |
151
+ | `client.calendar` / `client.leaderboard` | calendar & leaderboard |
152
+
153
+ ## Development
154
+
155
+ The async implementation under `src/mdl/_async` is the **source of truth**; the sync
156
+ package `src/mdl/_sync` is generated from it with
157
+ [`unasync`](https://pypi.org/project/unasync/):
158
+
159
+ ```bash
160
+ pip install -e ".[dev]"
161
+ python scripts/build_sync.py # regenerate _sync after editing _async
162
+ python scripts/check_sync.py # verify _sync is up to date (CI does this)
163
+ ruff check src tests
164
+ mypy
165
+ pytest
166
+ ```
167
+
168
+ ## License
169
+
170
+ MIT
@@ -0,0 +1,137 @@
1
+ # mdl
2
+
3
+ An **unofficial**, fully-typed, sync **and** async Python client for the private
4
+ MyDramaList app API (`https://app-api.mydramalist.com/v1`), reconstructed from the app's
5
+ reverse-engineered spec. Built on [curl_cffi](https://github.com/lexiforest/curl_cffi) and
6
+ [Pydantic v2](https://docs.pydantic.dev/).
7
+
8
+ Install as `mydramalist-client`; import as `mdl`.
9
+
10
+ > **Interoperability / research tool.** This library targets an **undocumented, private**
11
+ > API discovered by decompiling the MyDramaList Flutter app. Endpoint paths and field
12
+ > names are inferred from machine code and may change without notice; models tolerate
13
+ > unknown/renamed fields but nothing is guaranteed. Use responsibly and in accordance with
14
+ > MyDramaList's Terms of Service. Not affiliated with or endorsed by MyDramaList.
15
+
16
+ ## Install
17
+
18
+ ```bash
19
+ pip install mydramalist-client
20
+ ```
21
+
22
+ ## The `mdl-api-key`
23
+
24
+ Despite its name, `mdl-api-key` is **not a secret** — the app fills it with a random 20-char
25
+ string generated on each launch, and the server does not validate it. This library generates
26
+ a valid one for you, so you do not need to extract or supply anything. You may still pin a
27
+ value via `api_key=` or `MDL_API_KEY` for reproducible requests, but it is optional. See
28
+ [docs/api-key-extraction.md](docs/api-key-extraction.md).
29
+
30
+ > **Heads up — Cloudflare:** production sits behind Cloudflare bot protection that fingerprints
31
+ > the client's TLS handshake. A plain `httpx`/`requests` client is served a `403 "Just a
32
+ moment..."` challenge; only a client impersonating a real mobile/browser TLS fingerprint
33
+ > gets through. See [Transport](#transport).
34
+
35
+ ## Quick start (sync)
36
+
37
+ ```python
38
+ from mdl import MDLClient
39
+
40
+ with MDLClient() as client: # api_key generated automatically
41
+ title = client.titles.get_title(686)
42
+ print(title.title, title.rating)
43
+
44
+ results = client.search.titles("signal")
45
+ for movie in results:
46
+ print(movie.id, movie.title)
47
+ ```
48
+
49
+ ## Quick start (async)
50
+
51
+ ```python
52
+ import asyncio
53
+ from mdl import AsyncMDLClient
54
+
55
+ async def main():
56
+ async with AsyncMDLClient() as client:
57
+ title = await client.titles.get_title(686)
58
+ print(title.title)
59
+
60
+ asyncio.run(main())
61
+ ```
62
+
63
+ ## Authentication
64
+
65
+ ```python
66
+ with MDLClient() as client:
67
+ client.auth.login("username", "password") # password is MD5-hashed for you
68
+ profile = client.account.get_profile() # bearer token attached automatically
69
+ print(profile.username)
70
+ ```
71
+
72
+ Tokens are held in an in-memory store by default. Persist them with `FileTokenStore`:
73
+
74
+ ```python
75
+ from mdl import MDLClient, FileTokenStore
76
+
77
+ client = MDLClient(token_store=FileTokenStore("~/.mydramalist/token.json"))
78
+ ```
79
+
80
+ The client clears stored tokens on 401 and raises `MDLAuthError`. Automatic
81
+ token refresh via `refresh_token` is not yet implemented — callers must
82
+ re-authenticate when the access token expires.
83
+
84
+ ## Transport
85
+
86
+ Production MyDramaList sits behind **Cloudflare bot protection** that fingerprints the
87
+ client's TLS/HTTP2 handshake (JA3/JA4). This is the real access gate — not the `mdl-api-key`.
88
+
89
+ A plain `httpx`/`requests` client (a stock Python TLS stack) is served Cloudflare's
90
+ `403 "Just a moment..."` challenge on every request, regardless of headers or User-Agent.
91
+ Only a client that impersonates a real mobile/browser TLS fingerprint is allowed through — in
92
+ testing, [`curl_cffi`](https://github.com/lexiforest/curl_cffi) with `impersonate="safari_ios"`
93
+ (or `chrome`) reaches the API and returns real JSON.
94
+
95
+ If you get `403` responses with a `"Just a moment..."` HTML body, this is why: the request
96
+ never reached the API, it was stopped at Cloudflare's edge.
97
+
98
+ ## Resource groups
99
+
100
+ The client exposes one attribute per API area, e.g.:
101
+
102
+ | Attribute | Area |
103
+ | ------------------------------------------------------------------------------- | ----------------------------------------------------------------- |
104
+ | `client.auth` | login, social auth, register, reset password |
105
+ | `client.account` | own profile/settings, verification, devices, payments |
106
+ | `client.users` | other users & people pages, credits, likes |
107
+ | `client.titles` | title detail, progress, reviews, recommendations, credits, genres |
108
+ | `client.search` | title & people search |
109
+ | `client.explore` | trending / top_airing / upcoming / recommended feeds |
110
+ | `client.reviews` | review CRUD, voting |
111
+ | `client.comments` | comment listing & CRUD |
112
+ | `client.watchlist` | watchlist sync (add/remove/status lists) |
113
+ | `client.custom_lists` | custom lists, items, votes |
114
+ | `client.feeds` | activity feed, posts, embeds, uploads |
115
+ | `client.articles` | articles |
116
+ | `client.friends` / `client.messages` / `client.notifications` / `client.groups` | social |
117
+ | `client.subscription` / `client.flowers` / `client.awards` / `client.reports` | misc |
118
+ | `client.calendar` / `client.leaderboard` | calendar & leaderboard |
119
+
120
+ ## Development
121
+
122
+ The async implementation under `src/mdl/_async` is the **source of truth**; the sync
123
+ package `src/mdl/_sync` is generated from it with
124
+ [`unasync`](https://pypi.org/project/unasync/):
125
+
126
+ ```bash
127
+ pip install -e ".[dev]"
128
+ python scripts/build_sync.py # regenerate _sync after editing _async
129
+ python scripts/check_sync.py # verify _sync is up to date (CI does this)
130
+ ruff check src tests
131
+ mypy
132
+ pytest
133
+ ```
134
+
135
+ ## License
136
+
137
+ MIT
@@ -0,0 +1,48 @@
1
+ # The `mdl-api-key` header
2
+
3
+ **You do not need to extract anything.** Despite its name, `mdl-api-key` is not a secret and
4
+ is not issued by the server — it is a **client-generated nonce**. This library generates a
5
+ valid one for you automatically.
6
+
7
+ ## What it actually is
8
+
9
+ Reverse engineering the app (see the Blutter disassembly under `resources/dart_out/`) shows
10
+ the following chain:
11
+
12
+ - The HTTP layer sends `mdl-api-key: Constants.apiKey` on every request
13
+ (`services/custom_http_client.dart`).
14
+ - `Constants.apiKey` is a `late` field whose initializer returns `AppManager.shared`'s key
15
+ field (`config/constants.dart`).
16
+ - That field is written exactly once, at startup in `main()`, to the result of
17
+ `Utils.getRandomString()` (`main.dart`).
18
+ - `Utils.getRandomString()` builds a **20-character string from `[a-zA-Z0-9]`** using
19
+ `Random._secureRandom` (`utils/utils.dart`).
20
+
21
+ So the value is random, regenerated on every app launch, and never compared against anything
22
+ server-side.
23
+
24
+ ## Verified against production
25
+
26
+ Live requests to `https://app-api.mydramalist.com/v1` confirm it:
27
+
28
+ - A freshly generated random key → `200 OK` with real data.
29
+ - **No `mdl-api-key` header at all → also `200 OK`.**
30
+ - The value has no effect on the response.
31
+
32
+ The real edge gate is **Cloudflare bot protection**, which fingerprints the client's TLS/HTTP2
33
+ handshake (JA3/JA4) — not the `mdl-api-key`. A plain Python HTTP client is challenged with a
34
+ `403 "Just a moment..."` page regardless of headers; a client that impersonates a real
35
+ mobile/browser TLS fingerprint passes. See the transport notes in the README.
36
+
37
+ ## Using it
38
+
39
+ Nothing to do — just construct the client:
40
+
41
+ ```python
42
+ from mdl import MDLClient
43
+
44
+ client = MDLClient() # a valid mdl-api-key is generated automatically
45
+ ```
46
+
47
+ You may still pin a specific value if you want reproducible requests, via `api_key=` or the
48
+ `MDL_API_KEY` environment variable, but it is optional and carries no security meaning.
@@ -0,0 +1,63 @@
1
+ # mdl
2
+
3
+ An **unofficial**, fully-typed, sync **and** async Python client for the private
4
+ MyDramaList app API, built on curl_cffi and Pydantic v2. Install as `mydramalist-client`; import
5
+ as `mdl`.
6
+
7
+ !!! warning "Interoperability / research tool"
8
+ This library targets an **undocumented, private** API discovered by decompiling the
9
+ MyDramaList Flutter app. Paths and field names may change without notice. Use
10
+ responsibly and in accordance with MyDramaList's Terms of Service. Not affiliated with
11
+ or endorsed by MyDramaList.
12
+
13
+ ## Install
14
+
15
+ ```bash
16
+ pip install mydramalist-client
17
+ ```
18
+
19
+ The `mdl-api-key` is a client-generated nonce, not a secret — one is generated for you. Note
20
+ that production is behind Cloudflare TLS fingerprinting; see
21
+ [the `mdl-api-key` notes](api-key-extraction.md).
22
+
23
+ ## Sync
24
+
25
+ ```python
26
+ from mdl import MDLClient
27
+
28
+ with MDLClient() as client:
29
+ title = client.titles.get_title(686)
30
+ print(title.title, title.rating)
31
+ ```
32
+
33
+ ## Async
34
+
35
+ ```python
36
+ import asyncio
37
+ from mdl import AsyncMDLClient
38
+
39
+ async def main():
40
+ async with AsyncMDLClient() as client:
41
+ print((await client.titles.get_title(686)).title)
42
+
43
+ asyncio.run(main())
44
+ ```
45
+
46
+ ## Authentication
47
+
48
+ ```python
49
+ with MDLClient() as client:
50
+ client.auth.login("username", "password") # MD5-hashed automatically
51
+ print(client.account.get_profile().username)
52
+ ```
53
+
54
+ ## Errors
55
+
56
+ All errors derive from `mdl.MDLError`. HTTP failures raise `MDLNetworkError`
57
+ subclasses (`MDLAuthError` for 401, `MDLNotFoundError` for 404, `MDLRateLimitedError` for
58
+ 429, `MDLServerError` for 5xx), each carrying `.status_code`, `.message`, and `.body`.
59
+
60
+ ## Pagination
61
+
62
+ List endpoints accept `page` / `limit`. Where the API reports a grand total via the
63
+ `x-pagination-total` header (e.g. comment counts), it is surfaced through the `Page` helper.
@@ -0,0 +1,19 @@
1
+ site_name: mydramalist-client
2
+ site_description: Unofficial sync + async Python client for the MyDramaList app API
3
+ theme:
4
+ name: material
5
+ palette:
6
+ - scheme: default
7
+ toggle:
8
+ icon: material/weather-night
9
+ name: Dark mode
10
+ - scheme: slate
11
+ toggle:
12
+ icon: material/weather-sunny
13
+ name: Light mode
14
+ nav:
15
+ - Home: index.md
16
+ - API key extraction: api-key-extraction.md
17
+ markdown_extensions:
18
+ - admonition
19
+ - pymdownx.superfences
@@ -0,0 +1,80 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "mydramalist-client"
7
+ version = "0.1.0"
8
+ description = "Unofficial fully-typed sync + async Python client for the MyDramaList app API"
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "pymdl contributors" }]
13
+ keywords = ["mydramalist", "mdl", "drama", "kdrama", "api", "client", "async", "curl_cffi"]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Intended Audience :: Developers",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Programming Language :: Python :: 3",
19
+ "Programming Language :: Python :: 3 :: Only",
20
+ "Typing :: Typed",
21
+ ]
22
+ dependencies = [
23
+ "curl_cffi>=0.7",
24
+ "pydantic>=2.13.4",
25
+ ]
26
+
27
+ [project.optional-dependencies]
28
+ dev = [
29
+ "unasync>=0.6.0",
30
+ # httpx + respx are test-only: the transport is client-agnostic and the suite injects
31
+ # an httpx client so respx can mock the network (curl_cffi is not respx-interceptable).
32
+ "httpx>=0.24",
33
+ "respx>=0.23.1",
34
+ "pytest>=7",
35
+ "pytest-asyncio>=0.21",
36
+ "pytest-cov>=4",
37
+ "ruff>=0.1",
38
+ "mypy>=1.5",
39
+ ]
40
+ docs = [
41
+ "mkdocs-material>=9",
42
+ ]
43
+
44
+ [project.urls]
45
+ Homepage = "https://github.com/danieyal/pymdl"
46
+ Documentation = "https://github.com/danieyal/pymdl#readme"
47
+ Source = "https://github.com/danieyal/pymdl"
48
+
49
+ [tool.hatch.build.targets.wheel]
50
+ packages = ["src/mdl"]
51
+
52
+ [tool.ruff]
53
+ line-length = 100
54
+ target-version = "py39"
55
+ src = ["src", "tests"]
56
+
57
+ [tool.ruff.lint]
58
+ # UP is intentionally omitted: the library targets Python 3.9 and keeps typing.Optional /
59
+ # typing.Union in annotations rather than PEP 604 syntax.
60
+ select = ["E", "F", "I", "B", "W"]
61
+ ignore = ["B008"]
62
+
63
+ [tool.ruff.lint.per-file-ignores]
64
+ # Generated by unasync from _async — not hand-edited.
65
+ "src/mdl/_sync/**" = ["E501"]
66
+
67
+ [tool.mypy]
68
+ # mypy's analysis target (>=3.10); the package itself still supports 3.9 at runtime.
69
+ python_version = "3.10"
70
+ strict = true
71
+ files = ["src/mdl"]
72
+ plugins = ["pydantic.mypy"]
73
+
74
+ [tool.pytest.ini_options]
75
+ testpaths = ["tests"]
76
+ asyncio_mode = "auto"
77
+ pythonpath = ["src", "."]
78
+
79
+ [tool.pyright]
80
+ include = ["src"]