tapline 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 (74) hide show
  1. tapline-0.1.0/.gitignore +15 -0
  2. tapline-0.1.0/LICENSE +21 -0
  3. tapline-0.1.0/PKG-INFO +513 -0
  4. tapline-0.1.0/README.md +483 -0
  5. tapline-0.1.0/examples/__init__.py +0 -0
  6. tapline-0.1.0/examples/youtube/__init__.py +0 -0
  7. tapline-0.1.0/examples/youtube/download_channel_transcripts.py +74 -0
  8. tapline-0.1.0/examples/youtube/download_video_comments.py +69 -0
  9. tapline-0.1.0/pyproject.toml +143 -0
  10. tapline-0.1.0/scripts/capture_fixtures.py +337 -0
  11. tapline-0.1.0/scripts/validate_live.py +245 -0
  12. tapline-0.1.0/src/tapline/__init__.py +95 -0
  13. tapline-0.1.0/src/tapline/_base_client.py +534 -0
  14. tapline-0.1.0/src/tapline/_client.py +163 -0
  15. tapline-0.1.0/src/tapline/_constants.py +33 -0
  16. tapline-0.1.0/src/tapline/_exceptions.py +356 -0
  17. tapline-0.1.0/src/tapline/_models.py +32 -0
  18. tapline-0.1.0/src/tapline/_pagination.py +34 -0
  19. tapline-0.1.0/src/tapline/_resource.py +153 -0
  20. tapline-0.1.0/src/tapline/_types.py +47 -0
  21. tapline-0.1.0/src/tapline/_version.py +2 -0
  22. tapline-0.1.0/src/tapline/py.typed +0 -0
  23. tapline-0.1.0/src/tapline/resources/__init__.py +7 -0
  24. tapline-0.1.0/src/tapline/resources/youtube.py +1027 -0
  25. tapline-0.1.0/src/tapline/youtube/__init__.py +147 -0
  26. tapline-0.1.0/src/tapline/youtube/channel.py +51 -0
  27. tapline-0.1.0/src/tapline/youtube/comment.py +86 -0
  28. tapline-0.1.0/src/tapline/youtube/format.py +108 -0
  29. tapline-0.1.0/src/tapline/youtube/heatmap.py +27 -0
  30. tapline-0.1.0/src/tapline/youtube/json3.py +79 -0
  31. tapline-0.1.0/src/tapline/youtube/playlist.py +49 -0
  32. tapline-0.1.0/src/tapline/youtube/request_enums.py +185 -0
  33. tapline-0.1.0/src/tapline/youtube/response_enums.py +166 -0
  34. tapline-0.1.0/src/tapline/youtube/search.py +58 -0
  35. tapline-0.1.0/src/tapline/youtube/subtitle.py +76 -0
  36. tapline-0.1.0/src/tapline/youtube/thumbnail.py +22 -0
  37. tapline-0.1.0/src/tapline/youtube/video.py +161 -0
  38. tapline-0.1.0/tests/conftest.py +186 -0
  39. tapline-0.1.0/tests/fixtures/openapi/youtube.json +779 -0
  40. tapline-0.1.0/tests/fixtures/youtube/channel_by_handle.json +64 -0
  41. tapline-0.1.0/tests/fixtures/youtube/channel_by_id.json +74 -0
  42. tapline-0.1.0/tests/fixtures/youtube/channel_videos.json +185 -0
  43. tapline-0.1.0/tests/fixtures/youtube/channel_videos_shorts.json +130 -0
  44. tapline-0.1.0/tests/fixtures/youtube/channel_videos_streams.json +185 -0
  45. tapline-0.1.0/tests/fixtures/youtube/comment_replies.json +164 -0
  46. tapline-0.1.0/tests/fixtures/youtube/comments.json +210 -0
  47. tapline-0.1.0/tests/fixtures/youtube/formats.json +1126 -0
  48. tapline-0.1.0/tests/fixtures/youtube/formats_live.json +198 -0
  49. tapline-0.1.0/tests/fixtures/youtube/heatmap.json +505 -0
  50. tapline-0.1.0/tests/fixtures/youtube/manifest.json +156 -0
  51. tapline-0.1.0/tests/fixtures/youtube/metadata.json +331 -0
  52. tapline-0.1.0/tests/fixtures/youtube/metadata_fields.json +57 -0
  53. tapline-0.1.0/tests/fixtures/youtube/metadata_heatmap.json +783 -0
  54. tapline-0.1.0/tests/fixtures/youtube/metadata_live.json +301 -0
  55. tapline-0.1.0/tests/fixtures/youtube/playlist.json +215 -0
  56. tapline-0.1.0/tests/fixtures/youtube/search.json +217 -0
  57. tapline-0.1.0/tests/fixtures/youtube/search_filtered.json +141 -0
  58. tapline-0.1.0/tests/fixtures/youtube/search_movies.json +141 -0
  59. tapline-0.1.0/tests/fixtures/youtube/subtitle_tracks.json +1747 -0
  60. tapline-0.1.0/tests/fixtures/youtube/subtitles_json3.json +78 -0
  61. tapline-0.1.0/tests/fixtures/youtube/subtitles_json3_auto.json +1235 -0
  62. tapline-0.1.0/tests/fixtures/youtube/subtitles_srt.json +11 -0
  63. tapline-0.1.0/tests/spec.py +135 -0
  64. tapline-0.1.0/tests/test_channel_transcripts_example.py +116 -0
  65. tapline-0.1.0/tests/test_client.py +462 -0
  66. tapline-0.1.0/tests/test_errors.py +358 -0
  67. tapline-0.1.0/tests/test_example_logging.py +27 -0
  68. tapline-0.1.0/tests/test_fixture_payloads.py +99 -0
  69. tapline-0.1.0/tests/test_live.py +352 -0
  70. tapline-0.1.0/tests/test_models.py +491 -0
  71. tapline-0.1.0/tests/test_pagination.py +156 -0
  72. tapline-0.1.0/tests/test_retries.py +368 -0
  73. tapline-0.1.0/tests/test_video_comments_example.py +109 -0
  74. tapline-0.1.0/tests/test_youtube_params.py +656 -0
@@ -0,0 +1,15 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.py[cod]
4
+
5
+ build/
6
+ dist/
7
+ *.egg-info/
8
+
9
+ .pytest_cache/
10
+ .mypy_cache/
11
+ .ruff_cache/
12
+ .coverage
13
+ htmlcov/
14
+
15
+ .env
tapline-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Tapline
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.
tapline-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,513 @@
1
+ Metadata-Version: 2.4
2
+ Name: tapline
3
+ Version: 0.1.0
4
+ Summary: The official Python client for the Tapline API
5
+ Project-URL: Homepage, https://tapline.sh
6
+ Project-URL: Documentation, https://tapline.sh/docs
7
+ Project-URL: Repository, https://github.com/TaplineData/tapline-python
8
+ Project-URL: Issues, https://github.com/TaplineData/tapline-python/issues
9
+ Author-email: Tapline <support@tapline.sh>
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: api,client,scraping,sdk,tapline,youtube
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Programming Language :: Python :: 3.14
22
+ Classifier: Topic :: Internet :: WWW/HTTP
23
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
24
+ Classifier: Typing :: Typed
25
+ Requires-Python: >=3.10
26
+ Requires-Dist: httpx<1,>=0.25
27
+ Requires-Dist: pydantic<3,>=2.7
28
+ Requires-Dist: typing-extensions<5,>=4.10
29
+ Description-Content-Type: text/markdown
30
+
31
+ [![Tapline — Official Python Client](./assets/tapline-github-banner.png)](https://tapline.sh)
32
+
33
+ # tapline
34
+
35
+ [![PyPI](https://img.shields.io/pypi/v/tapline.svg)](https://pypi.org/project/tapline/)
36
+ [![Python](https://img.shields.io/pypi/pyversions/tapline.svg)](https://pypi.org/project/tapline/)
37
+
38
+ Tapline is a Python package for working with public YouTube data. It downloads
39
+ transcripts and provides data about videos, channels, searches, comments, and
40
+ playlists.
41
+
42
+ You will need Python 3.10 or newer and a Tapline API key. You can get a key at
43
+ [tapline.sh](https://tapline.sh).
44
+
45
+ ## Install
46
+
47
+ ```sh
48
+ pip install tapline
49
+ ```
50
+
51
+ Set your API key as an environment variable:
52
+
53
+ ```sh
54
+ export TAPLINE_API_KEY="your-api-key"
55
+ ```
56
+
57
+ The examples below read the key from that variable automatically.
58
+
59
+ You can also pass the key when you create the client:
60
+
61
+ ```python
62
+ from tapline import SyncTaplineClient
63
+
64
+ tapline = SyncTaplineClient(api_key="your-api-key")
65
+ ```
66
+
67
+ `SyncTaplineClient` blocks. `TaplineClient` is its awaitable twin, for an async
68
+ application:
69
+
70
+ ```python
71
+ from tapline import TaplineClient
72
+
73
+ tapline = TaplineClient(api_key="your-api-key")
74
+ ```
75
+
76
+ ## Client methods
77
+
78
+ | Method | What you get | Credits |
79
+ | --- | --- | --- |
80
+ | `search(query=...)` | Search results | 2 |
81
+ | `metadata(video)` | Title, counts, dates, chapters, thumbnails | 2 |
82
+ | `subtitles(video)` | A transcript document | 2 |
83
+ | `subtitle_tracks(video)` | The languages a video has captions in | 2 |
84
+ | `comments(video)` | One page of comment threads | 2 |
85
+ | `comment_replies(video, comment_id, cursor=...)` | One page of replies | 2 |
86
+ | `channel(channel)` | A channel's profile and subscriber count | 3 |
87
+ | `channel_videos(channel)` | One page of a channel's uploads | 2 |
88
+ | `playlist(playlist_id)` | A playlist and its videos | 2 |
89
+ | `heatmap(video)` | Which parts of a video get replayed | 2 |
90
+ | `formats(video)` | The streams YouTube offers for a video | 2 |
91
+
92
+ Every method is on both the sync and async clients, and takes an optional
93
+ `timeout` that overrides the client's own for that one call.
94
+
95
+ ## Get a transcript
96
+
97
+ Use `SyncTaplineClient` for a regular Python script:
98
+
99
+ ```python
100
+ from tapline import SyncTaplineClient
101
+
102
+ with SyncTaplineClient() as tapline:
103
+ subtitles = tapline.youtube.subtitles(
104
+ "https://www.youtube.com/watch?v=jNQXAC9IVRw",
105
+ subtitle_format="txt",
106
+ )
107
+
108
+ print(subtitles.transcript)
109
+ ```
110
+
111
+ The default language is English. Tapline accepts BCP 47 language tags such as
112
+ `en`, `pt-BR`, `es-419`, and `zh-Hans`:
113
+
114
+ ```python
115
+ subtitles = tapline.youtube.subtitles(
116
+ "jNQXAC9IVRw",
117
+ language="pt-BR",
118
+ subtitle_format="txt",
119
+ )
120
+ ```
121
+
122
+ Tapline can also return `srt`, `vtt`, `ttml`, or `json3` transcripts.
123
+
124
+ The default `source="any"` prefers a human-authored track and falls back to a
125
+ machine-generated one. Pass `source="manual"` or `source="auto"` to insist on
126
+ one or the other.
127
+
128
+ ## List subtitle languages
129
+
130
+ If you do not know which languages a video has, check its subtitle tracks:
131
+
132
+ ```python
133
+ from tapline import SyncTaplineClient
134
+
135
+ with SyncTaplineClient() as tapline:
136
+ tracks = tapline.youtube.subtitle_tracks("jNQXAC9IVRw")
137
+
138
+ for track in tracks.manual + tracks.auto:
139
+ print(track.language, track.language_name)
140
+ ```
141
+
142
+ Each track's `language` and `formats` feed straight back into `subtitles`.
143
+
144
+ ## Get every transcript from a channel
145
+
146
+ `pages` walks a channel's uploads for you, so a whole channel is one loop over
147
+ another — see [walking pages](#walk-every-page):
148
+
149
+ ```python
150
+ from tapline import NotFoundError, SyncTaplineClient
151
+
152
+ with SyncTaplineClient() as tapline:
153
+ pages = tapline.youtube.pages(
154
+ lambda cursor: tapline.youtube.channel_videos("@Computerphile2", cursor=cursor)
155
+ )
156
+
157
+ for page in pages:
158
+ for video in page.videos:
159
+ try:
160
+ subtitles = tapline.youtube.subtitles(video.video_id, subtitle_format="txt")
161
+ except NotFoundError:
162
+ continue
163
+
164
+ print(video.title)
165
+ print(subtitles.transcript)
166
+ ```
167
+
168
+ A video with no English track raises `NotFoundError`, which is why the loop
169
+ skips it rather than stopping. Pass `content_type="shorts"` or
170
+ `content_type="streams"` to walk those tabs instead.
171
+
172
+ ## Look up a video
173
+
174
+ ```python
175
+ from tapline import SyncTaplineClient
176
+
177
+ with SyncTaplineClient() as tapline:
178
+ video = tapline.youtube.metadata("https://youtu.be/dQw4w9WgXcQ")
179
+
180
+ print(video.title)
181
+ print(video.view_count)
182
+ print(video.duration)
183
+ ```
184
+
185
+ The full record also carries the description, tags, chapters, thumbnails,
186
+ upload date, channel details, and live status. Ask for a subset with `fields`
187
+ when you only need part of it:
188
+
189
+ ```python
190
+ video = tapline.youtube.metadata("dQw4w9WgXcQ", fields=["title", "view_count"])
191
+ ```
192
+
193
+ Under a projection the keys you did not ask for are absent rather than null, so
194
+ use `video.model_fields_set` to tell them apart from fields the server sent as
195
+ `None`. `video_id` always comes back.
196
+
197
+ ## Search YouTube
198
+
199
+ ```python
200
+ from tapline import SyncTaplineClient
201
+
202
+ with SyncTaplineClient() as tapline:
203
+ results = tapline.youtube.search(
204
+ query="learn python",
205
+ limit=5,
206
+ )
207
+
208
+ for result in results.results:
209
+ print(result.title)
210
+ ```
211
+
212
+ Searches can be narrowed by upload date, duration, result type, country, and
213
+ features such as subtitles or 4K video:
214
+
215
+ ```python
216
+ results = tapline.youtube.search(
217
+ query="learn python",
218
+ sort="view_count",
219
+ upload_date="this_year",
220
+ search_type="video",
221
+ duration="over_20_min",
222
+ features=["hd", "subtitles"],
223
+ country="BR",
224
+ )
225
+ ```
226
+
227
+ ## Get comments
228
+
229
+ ```python
230
+ from tapline import SyncTaplineClient
231
+
232
+ with SyncTaplineClient() as tapline:
233
+ comments = tapline.youtube.comments("dQw4w9WgXcQ", sort="new")
234
+
235
+ for thread in comments.threads:
236
+ print(thread.comment.author, thread.comment.text, thread.comment.like_count)
237
+ ```
238
+
239
+ Comments come back one page at a time, twenty threads at most. Sort by `top`
240
+ (the default) or `new`; pinned threads surface first either way. To read every
241
+ page, see [walking pages](#walk-every-page).
242
+
243
+ ## Get replies to a comment
244
+
245
+ Each thread carries a `replies_cursor`, which is where its replies start. It is
246
+ `None` when the comment has no replies:
247
+
248
+ ```python
249
+ from tapline import SyncTaplineClient
250
+
251
+ with SyncTaplineClient() as tapline:
252
+ comments = tapline.youtube.comments("dQw4w9WgXcQ")
253
+ thread = comments.threads[0]
254
+
255
+ if thread.replies_cursor:
256
+ replies = tapline.youtube.comment_replies(
257
+ "dQw4w9WgXcQ",
258
+ thread.comment.comment_id,
259
+ cursor=thread.replies_cursor,
260
+ )
261
+
262
+ for reply in replies.replies:
263
+ print(reply.author, reply.text)
264
+ ```
265
+
266
+ ## List a channel's videos
267
+
268
+ ```python
269
+ from tapline import SyncTaplineClient
270
+
271
+ with SyncTaplineClient() as tapline:
272
+ uploads = tapline.youtube.channel_videos("@Computerphile2")
273
+
274
+ for video in uploads.videos:
275
+ print(video.title, video.view_count, video.url)
276
+ ```
277
+
278
+ Pass `content_type="shorts"` or `content_type="streams"` for the other two
279
+ tabs. Like comments, uploads arrive one page at a time.
280
+
281
+ ## Look up a channel
282
+
283
+ ```python
284
+ from tapline import SyncTaplineClient
285
+
286
+ with SyncTaplineClient() as tapline:
287
+ channel = tapline.youtube.channel("@Computerphile2")
288
+
289
+ print(channel.channel)
290
+ print(channel.handle)
291
+ print(channel.channel_follower_count)
292
+ print(channel.description)
293
+ ```
294
+
295
+ ## Get a playlist
296
+
297
+ Playlists are looked up by ID — the `list=` parameter of a playlist URL, not the
298
+ URL itself:
299
+
300
+ ```python
301
+ from tapline import SyncTaplineClient
302
+
303
+ with SyncTaplineClient() as tapline:
304
+ playlist = tapline.youtube.playlist("PLbpi6ZahtOH6Blw3RGYpWkSByi_T7Rygb", limit=100)
305
+
306
+ print(playlist.title, playlist.total_count)
307
+
308
+ for entry in playlist.entries:
309
+ print(entry.title, entry.url)
310
+ ```
311
+
312
+ `limit` caps how many entries come back, up to 100. `total_count` reports how
313
+ many the playlist actually holds.
314
+
315
+ ## Find the most replayed moments
316
+
317
+ YouTube's heatmap scores a hundred equal slices of the timeline by replay
318
+ intensity, from `0.0` to `1.0`:
319
+
320
+ ```python
321
+ from tapline import SyncTaplineClient
322
+
323
+ with SyncTaplineClient() as tapline:
324
+ heatmap = tapline.youtube.heatmap("dQw4w9WgXcQ")
325
+
326
+ if heatmap.heatmap:
327
+ peak = max(heatmap.heatmap, key=lambda point: point.value)
328
+ print(f"Most replayed at {peak.start_time:.0f}s")
329
+
330
+ for point in heatmap.heatmap:
331
+ print(point.start_time, point.end_time, point.value)
332
+ ```
333
+
334
+ `heatmap` is `None` for the many videos YouTube publishes no heatmap for, which
335
+ is not an error. The same points also arrive on `metadata` under `heatmap`.
336
+
337
+ ## List stream formats
338
+
339
+ ```python
340
+ from tapline import SyncTaplineClient
341
+
342
+ with SyncTaplineClient() as tapline:
343
+ formats = tapline.youtube.formats("dQw4w9WgXcQ")
344
+
345
+ for stream in formats.formats:
346
+ print(stream.format_id, stream.resolution, stream.ext, stream.filesize)
347
+ ```
348
+
349
+ Each `url` is signed by YouTube and expires within hours, so fetch it when you
350
+ are ready to use it. Storyboard formats are left out.
351
+
352
+ ## Walk every page
353
+
354
+ `comments`, `comment_replies`, and `channel_videos` return one page per call.
355
+ `pages` walks one of them to its end, requesting the next page only as you ask
356
+ for it:
357
+
358
+ ```python
359
+ from tapline import SyncTaplineClient
360
+
361
+ with SyncTaplineClient() as tapline:
362
+ pages = tapline.youtube.pages(
363
+ lambda cursor: tapline.youtube.comments("dQw4w9WgXcQ", cursor=cursor)
364
+ )
365
+
366
+ for page in pages:
367
+ for thread in page.threads:
368
+ print(thread.comment.text)
369
+ ```
370
+
371
+ Each page is its own request, so a walk is billed per page rather than once.
372
+ Replies have no first page without a cursor, so start that walk from a thread's
373
+ `replies_cursor`:
374
+
375
+ ```python
376
+ reply_pages = tapline.youtube.pages(
377
+ lambda cursor: tapline.youtube.comment_replies(video_id, comment_id, cursor=cursor),
378
+ cursor=thread.replies_cursor,
379
+ )
380
+ ```
381
+
382
+ A walk ends when a page's `pagination.next_cursor` is `None`. That page's
383
+ `pagination.completion` says why: `exhausted` when YouTube ran out, or
384
+ `depth_limit` when Tapline hit its paging cap — sorting comments by `top` stops
385
+ at roughly 1,200 of them.
386
+
387
+ ## Use the async client
388
+
389
+ For an async application, use `TaplineClient` and `await`:
390
+
391
+ ```python
392
+ import asyncio
393
+
394
+ from tapline import TaplineClient
395
+
396
+
397
+ async def main() -> None:
398
+ async with TaplineClient() as tapline:
399
+ video = await tapline.youtube.metadata("dQw4w9WgXcQ")
400
+ print(video.title)
401
+
402
+
403
+ asyncio.run(main())
404
+ ```
405
+
406
+ The sync and async clients have the same YouTube methods. `pages` becomes an
407
+ async generator, driven with `async for`, and calls can be in flight together:
408
+
409
+ ```python
410
+ async with TaplineClient() as tapline:
411
+ video, comments = await asyncio.gather(
412
+ tapline.youtube.metadata("dQw4w9WgXcQ"),
413
+ tapline.youtube.comments("dQw4w9WgXcQ"),
414
+ )
415
+ ```
416
+
417
+ ## IDs, handles, and URLs
418
+
419
+ You can pass a video ID or a common YouTube video URL wherever a video is
420
+ needed:
421
+
422
+ ```python
423
+ "dQw4w9WgXcQ"
424
+
425
+ "https://youtu.be/dQw4w9WgXcQ"
426
+ "https://www.youtube.com/watch?v=dQw4w9WgXcQ"
427
+ ```
428
+
429
+ For channels, you can use a channel ID, handle, or URL:
430
+
431
+ ```python
432
+ "UCXuqSBlHAE6Xw-yeJA0Tunw"
433
+
434
+ "@LinusTechTips"
435
+ "https://www.youtube.com/@LinusTechTips"
436
+ ```
437
+
438
+ ## Enums and responses
439
+
440
+ Anywhere a method takes an enum it also takes that enum's value as a plain
441
+ string, so these are the same call:
442
+
443
+ ```python
444
+ from tapline.youtube import SearchSort
445
+
446
+ tapline.youtube.search(query="learn python", sort=SearchSort.VIEW_COUNT)
447
+ tapline.youtube.search(query="learn python", sort="view_count")
448
+ ```
449
+
450
+ Every response is a pydantic model, so `model_dump()` gives you a dict and
451
+ `model_dump_json()` gives you JSON. Fields a newer Tapline release adds are
452
+ kept rather than dropped — read them from `model_extra`.
453
+
454
+ ## Handle errors
455
+
456
+ ```python
457
+ from tapline import NotFoundError, PermissionDeniedError, SyncTaplineClient
458
+
459
+ with SyncTaplineClient() as tapline:
460
+ try:
461
+ video = tapline.youtube.metadata("dQw4w9WgXcQ")
462
+ except NotFoundError:
463
+ print("no such video")
464
+ except PermissionDeniedError as error:
465
+ print("not public:", error.message, error.request_id)
466
+ ```
467
+
468
+ Everything the library raises is a `TaplineError`. A failed request is an
469
+ `APIError`; a non-2xx answer is an `APIStatusError` carrying `status_code`,
470
+ `code`, `message`, `request_id`, and `details`. The ones you are most likely to
471
+ catch by name:
472
+
473
+ | Exception | When |
474
+ | --- | --- |
475
+ | `NotFoundError` | No such video, channel, playlist, or subtitle track |
476
+ | `PermissionDeniedError` | Private, age-gated, members-only, or region-blocked |
477
+ | `InvalidCursorError` | A cursor is malformed, expired, or from another walk |
478
+ | `UnprocessableEntityError` | A parameter failed server-side validation |
479
+ | `AuthenticationError` | The API key is missing or invalid |
480
+ | `InsufficientCreditsError` | The account cannot pay for the request |
481
+ | `RateLimitError` | Too many requests, after retries were exhausted |
482
+ | `APITimeoutError` | The request outlived its timeout, including retries |
483
+
484
+ ## Client settings
485
+
486
+ ```python
487
+ from tapline import SyncTaplineClient
488
+
489
+ tapline = SyncTaplineClient(
490
+ api_key="your-api-key",
491
+ timeout=120.0,
492
+ max_retries=5,
493
+ default_headers={"X-Trace-Id": "trace-1"},
494
+ )
495
+ ```
496
+
497
+ The default timeout is 60 seconds, 10 of them for connecting, because every
498
+ endpoint proxies YouTube live. Rate limits, 5xx answers, and connection
499
+ failures are retried twice by default, honoring `Retry-After`; pass
500
+ `max_retries=0` to disable that.
501
+
502
+ The client owns a connection pool, so build one and keep it. Close it with
503
+ `tapline.close()`, or scope it to a `with` block as the examples do. To send
504
+ through your own proxies, transports, or connection limits, pass an
505
+ `http_client` — an `httpx.Client` for the sync client, an `httpx.AsyncClient`
506
+ for the async one.
507
+
508
+ `base_url` points the client at a proxy or a local server, and can also be set
509
+ with the `TAPLINE_BASE_URL` environment variable.
510
+
511
+ ## License
512
+
513
+ MIT. See [LICENSE](./LICENSE).