u-transcript-max 0.1.0a2__tar.gz → 0.1.0a4__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 (229) hide show
  1. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/PKG-INFO +13 -11
  2. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/README.md +12 -10
  3. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/src/utmax/__init__.py +40 -10
  4. u_transcript_max-0.1.0a4/src/utmax/_version.py +1 -0
  5. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/src/utmax/adapters/downloader.py +44 -8
  6. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/src/utmax/adapters/innertube.py +87 -9
  7. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/src/utmax/client.py +11 -2
  8. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/src/utmax/core/clients.py +38 -6
  9. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/src/utmax/core/downloads.py +3 -1
  10. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/src/utmax/core/formats.py +68 -2
  11. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/src/utmax/core/player.py +6 -1
  12. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/src/utmax/core/selection.py +11 -3
  13. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/src/utmax/core/streams.py +105 -47
  14. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/src/utmax/errors.py +2 -1
  15. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/src/utmax/mcp/server.py +274 -19
  16. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/src/utmax/models.py +80 -4
  17. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/src/utmax/services/bulk.py +3 -1
  18. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/src/utmax/services/download.py +28 -11
  19. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/src/utmax/services/transcripts.py +4 -1
  20. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/helpers/bulk.py +3 -1
  21. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/helpers/downloads.py +5 -2
  22. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/helpers/fake_media.py +10 -2
  23. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/helpers/youtube.py +39 -0
  24. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/live/test_download_live.py +22 -4
  25. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/mcp/conftest.py +1 -1
  26. u_transcript_max-0.1.0a4/tests/mcp/test_flow.py +314 -0
  27. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/mcp/test_server.py +50 -4
  28. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/mcp/test_stdio.py +8 -1
  29. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/unit/adapters/test_downloader_recovery.py +36 -0
  30. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/unit/adapters/test_innertube.py +24 -8
  31. u_transcript_max-0.1.0a4/tests/unit/adapters/test_innertube_visitor.py +230 -0
  32. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/unit/core/test_clients.py +20 -3
  33. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/unit/core/test_player.py +47 -1
  34. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/unit/core/test_selection.py +25 -0
  35. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/unit/core/test_streams.py +163 -38
  36. u_transcript_max-0.1.0a4/tests/unit/core/test_subtitle_parsing.py +93 -0
  37. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/unit/services/test_bulk_downloads.py +18 -5
  38. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/unit/services/test_download_audio.py +6 -3
  39. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/unit/services/test_download_video.py +8 -7
  40. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/unit/services/test_transcripts.py +14 -0
  41. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/unit/test_collections_api.py +1 -0
  42. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/unit/test_download_api.py +25 -2
  43. u_transcript_max-0.1.0a2/src/utmax/_version.py +0 -1
  44. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/.gitignore +0 -0
  45. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/LICENSE +0 -0
  46. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/pyproject.toml +0 -0
  47. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/src/utmax/adapters/__init__.py +0 -0
  48. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/src/utmax/adapters/ffmpeg.py +0 -0
  49. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/src/utmax/adapters/files.py +0 -0
  50. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/src/utmax/adapters/http.py +0 -0
  51. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/src/utmax/adapters/providers/__init__.py +0 -0
  52. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/src/utmax/adapters/providers/base.py +0 -0
  53. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/src/utmax/adapters/providers/claude.py +0 -0
  54. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/src/utmax/adapters/providers/gemini.py +0 -0
  55. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/src/utmax/adapters/providers/openai.py +0 -0
  56. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/src/utmax/adapters/providers/openrouter.py +0 -0
  57. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/src/utmax/adapters/watch_page.py +0 -0
  58. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/src/utmax/compat/__init__.py +0 -0
  59. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/src/utmax/compat/_api.py +0 -0
  60. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/src/utmax/compat/_bridge.py +0 -0
  61. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/src/utmax/compat/_errors.py +0 -0
  62. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/src/utmax/compat/_settings.py +0 -0
  63. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/src/utmax/compat/_transcripts.py +0 -0
  64. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/src/utmax/compat/formatters.py +0 -0
  65. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/src/utmax/compat/proxies.py +0 -0
  66. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/src/utmax/core/__init__.py +0 -0
  67. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/src/utmax/core/bilingual.py +0 -0
  68. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/src/utmax/core/browse.py +0 -0
  69. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/src/utmax/core/captions.py +0 -0
  70. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/src/utmax/core/filenames.py +0 -0
  71. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/src/utmax/core/ids.py +0 -0
  72. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/src/utmax/core/languages.py +0 -0
  73. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/src/utmax/core/media/__init__.py +0 -0
  74. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/src/utmax/core/media/boxes.py +0 -0
  75. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/src/utmax/core/media/fmp4.py +0 -0
  76. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/src/utmax/core/media/moov.py +0 -0
  77. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/src/utmax/core/media/mux.py +0 -0
  78. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/src/utmax/core/media/progressive.py +0 -0
  79. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/src/utmax/core/media/tables.py +0 -0
  80. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/src/utmax/core/media/tx3g.py +0 -0
  81. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/src/utmax/core/playability.py +0 -0
  82. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/src/utmax/core/retry.py +0 -0
  83. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/src/utmax/core/segmentation.py +0 -0
  84. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/src/utmax/core/translate/__init__.py +0 -0
  85. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/src/utmax/core/translate/batching.py +0 -0
  86. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/src/utmax/core/translate/data/protocol.json +0 -0
  87. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/src/utmax/core/translate/data/request.schema.json +0 -0
  88. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/src/utmax/core/translate/data/response.schema.json +0 -0
  89. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/src/utmax/core/translate/data/system_prompt.txt +0 -0
  90. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/src/utmax/core/translate/protocol.py +0 -0
  91. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/src/utmax/core/translate/spec.py +0 -0
  92. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/src/utmax/core/ytdata.py +0 -0
  93. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/src/utmax/mcp/__init__.py +0 -0
  94. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/src/utmax/mcp/__main__.py +0 -0
  95. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/src/utmax/mcp/config.py +0 -0
  96. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/src/utmax/providers.py +0 -0
  97. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/src/utmax/py.typed +0 -0
  98. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/src/utmax/services/__init__.py +0 -0
  99. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/src/utmax/services/collections.py +0 -0
  100. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/src/utmax/services/translation.py +0 -0
  101. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/src/utmax/transport.py +0 -0
  102. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/__init__.py +0 -0
  103. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/compat/__init__.py +0 -0
  104. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/compat/test_api.py +0 -0
  105. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/compat/test_bridge.py +0 -0
  106. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/compat/test_errors.py +0 -0
  107. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/compat/test_formatters.py +0 -0
  108. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/compat/test_install.py +0 -0
  109. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/compat/test_legacy.py +0 -0
  110. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/compat/test_manifest.py +0 -0
  111. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/compat/test_manifest_script.py +0 -0
  112. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/compat/test_proxies.py +0 -0
  113. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/compat/test_transcripts.py +0 -0
  114. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/fixtures/compat/youtube_transcript_api-1.2.4.json +0 -0
  115. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/fixtures/media/README.md +0 -0
  116. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/fixtures/media/dQw4w9WgXcQ_137.hollow.bin +0 -0
  117. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/fixtures/media/dQw4w9WgXcQ_140.hollow.bin +0 -0
  118. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/fixtures/media/dQw4w9WgXcQ_399.hollow.bin +0 -0
  119. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/fixtures/media/manifest.json +0 -0
  120. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/fixtures/youtube/README.md +0 -0
  121. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/fixtures/youtube/browse_android_vr_1.json +0 -0
  122. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/fixtures/youtube/browse_android_vr_2.json +0 -0
  123. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/fixtures/youtube/browse_web_1.json +0 -0
  124. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/fixtures/youtube/browse_web_2.json +0 -0
  125. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/fixtures/youtube/browse_web_shorts.json +0 -0
  126. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/fixtures/youtube/json3_en_asr.json +0 -0
  127. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/fixtures/youtube/json3_en_manual.json +0 -0
  128. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/fixtures/youtube/legacy_en_manual.xml +0 -0
  129. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/fixtures/youtube/player_android.json +0 -0
  130. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/fixtures/youtube/player_android_vr.json +0 -0
  131. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/fixtures/youtube/player_ios.json +0 -0
  132. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/fixtures/youtube/resolve_handle.json +0 -0
  133. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/fixtures/youtube/resolve_unknown.json +0 -0
  134. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/fixtures/youtube/srv3_en_asr.xml +0 -0
  135. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/fixtures/youtube/streams_android_vr.json +0 -0
  136. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/helpers/__init__.py +0 -0
  137. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/helpers/browse.py +0 -0
  138. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/helpers/builders.py +0 -0
  139. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/helpers/compat.py +0 -0
  140. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/helpers/fake_translator.py +0 -0
  141. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/helpers/fake_transport.py +0 -0
  142. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/helpers/files.py +0 -0
  143. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/helpers/fmp4_factory.py +0 -0
  144. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/helpers/hollow_source.py +0 -0
  145. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/helpers/http_server.py +0 -0
  146. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/helpers/json_schema.py +0 -0
  147. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/helpers/sdk.py +0 -0
  148. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/live/__init__.py +0 -0
  149. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/live/conftest.py +0 -0
  150. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/live/test_collections_live.py +0 -0
  151. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/live/test_compat_live.py +0 -0
  152. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/live/test_transcripts_live.py +0 -0
  153. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/live/test_translation_live.py +0 -0
  154. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/mcp/__init__.py +0 -0
  155. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/mcp/test_config.py +0 -0
  156. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/mcp/test_download.py +0 -0
  157. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/mcp/test_live.py +0 -0
  158. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/mcp/test_main.py +0 -0
  159. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/mcp/test_package.py +0 -0
  160. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/test_architecture.py +0 -0
  161. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/test_extras.py +0 -0
  162. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/test_package.py +0 -0
  163. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/unit/__init__.py +0 -0
  164. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/unit/adapters/__init__.py +0 -0
  165. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/unit/adapters/providers/__init__.py +0 -0
  166. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/unit/adapters/providers/test_base.py +0 -0
  167. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/unit/adapters/providers/test_claude.py +0 -0
  168. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/unit/adapters/providers/test_factory.py +0 -0
  169. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/unit/adapters/providers/test_gemini.py +0 -0
  170. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/unit/adapters/providers/test_openai.py +0 -0
  171. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/unit/adapters/providers/test_openrouter.py +0 -0
  172. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/unit/adapters/test_downloader.py +0 -0
  173. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/unit/adapters/test_ffmpeg_adapter.py +0 -0
  174. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/unit/adapters/test_ffmpeg_mp3.py +0 -0
  175. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/unit/adapters/test_files.py +0 -0
  176. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/unit/adapters/test_http.py +0 -0
  177. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/unit/adapters/test_http_stream.py +0 -0
  178. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/unit/adapters/test_innertube_browse.py +0 -0
  179. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/unit/adapters/test_mux_executor.py +0 -0
  180. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/unit/adapters/test_watch_page.py +0 -0
  181. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/unit/core/__init__.py +0 -0
  182. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/unit/core/test_batching.py +0 -0
  183. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/unit/core/test_bilingual.py +0 -0
  184. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/unit/core/test_browse.py +0 -0
  185. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/unit/core/test_captions.py +0 -0
  186. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/unit/core/test_downloads.py +0 -0
  187. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/unit/core/test_filenames.py +0 -0
  188. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/unit/core/test_formats.py +0 -0
  189. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/unit/core/test_ids.py +0 -0
  190. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/unit/core/test_languages.py +0 -0
  191. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/unit/core/test_model_spec.py +0 -0
  192. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/unit/core/test_name_templates.py +0 -0
  193. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/unit/core/test_pager.py +0 -0
  194. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/unit/core/test_playability.py +0 -0
  195. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/unit/core/test_retry.py +0 -0
  196. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/unit/core/test_segmentation.py +0 -0
  197. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/unit/core/test_sources.py +0 -0
  198. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/unit/core/test_translate_protocol.py +0 -0
  199. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/unit/core/test_ytdata.py +0 -0
  200. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/unit/media/__init__.py +0 -0
  201. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/unit/media/invariants.py +0 -0
  202. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/unit/media/test_boxes.py +0 -0
  203. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/unit/media/test_ffmpeg.py +0 -0
  204. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/unit/media/test_fmp4.py +0 -0
  205. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/unit/media/test_fmp4_factory.py +0 -0
  206. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/unit/media/test_hollow.py +0 -0
  207. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/unit/media/test_moov.py +0 -0
  208. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/unit/media/test_mux.py +0 -0
  209. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/unit/media/test_mux_large.py +0 -0
  210. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/unit/media/test_mux_subtitles.py +0 -0
  211. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/unit/media/test_progressive.py +0 -0
  212. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/unit/media/test_tables.py +0 -0
  213. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/unit/media/test_tx3g.py +0 -0
  214. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/unit/services/__init__.py +0 -0
  215. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/unit/services/test_bulk_runner.py +0 -0
  216. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/unit/services/test_bulk_transcripts.py +0 -0
  217. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/unit/services/test_collections.py +0 -0
  218. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/unit/services/test_translation.py +0 -0
  219. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/unit/test_client.py +0 -0
  220. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/unit/test_collection_types.py +0 -0
  221. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/unit/test_download_types.py +0 -0
  222. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/unit/test_errors.py +0 -0
  223. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/unit/test_facade.py +0 -0
  224. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/unit/test_models.py +0 -0
  225. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/unit/test_recorded_fixtures.py +0 -0
  226. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/unit/test_track_translate.py +0 -0
  227. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/unit/test_transcript_output.py +0 -0
  228. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/unit/test_translation_api.py +0 -0
  229. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/unit/test_transport.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: u-transcript-max
3
- Version: 0.1.0a2
3
+ Version: 0.1.0a4
4
4
  Summary: YouTube transcripts, AI translation and downloads for Python, with zero dependencies.
5
5
  Project-URL: Homepage, https://github.com/U-C4N/U-transkript
6
6
  Project-URL: Issues, https://github.com/U-C4N/U-transkript/issues
@@ -72,7 +72,7 @@ transcript.save("rick.srt") # also .vtt, .json and .txt
72
72
  turkish = utmax.translate(transcript, "tr", model="claude=claude-opus-5-5") # needs [claude]
73
73
  utmax.bilingual(transcript, turkish).save("rick.en+tr.srt")
74
74
 
75
- utmax.download("dQw4w9WgXcQ", "rick.mp4") # H.264 up to 1080p with English subtitles
75
+ utmax.download("dQw4w9WgXcQ", "rick.mp4") # the best MP4 (AV1 or H.264) with English subtitles
76
76
  videos = utmax.list_videos("@RickAstleyYT", kind="videos", limit=20)
77
77
  ```
78
78
 
@@ -84,16 +84,18 @@ from utmax.compat import YouTubeTranscriptApi
84
84
 
85
85
  ## MCP server
86
86
 
87
- `utmax-mcp` gives an AI assistant four tools: `list_tracks`, `get_transcript`, `list_videos`
88
- and `download`. It needs no API key: ask for a translation and the assistant translates the
89
- transcript itself. The commands below start it with `uvx` from
90
- [uv](https://docs.astral.sh/uv/getting-started/installation/), which installs it from PyPI on
91
- first use.
87
+ `utmax-mcp` gives an AI assistant six tools: `list_tracks`, `get_transcript`, `list_formats`,
88
+ `list_videos`, `download` and `save_subtitles`. It needs no API key. Ask it to download a video
89
+ and the assistant asks which file type, which of the video's resolutions and which subtitles
90
+ (original or translated) you want; ask for a translation and it translates the subtitles
91
+ itself. The commands below start it with `uvx` from
92
+ [uv](https://docs.astral.sh/uv/getting-started/installation/), which installs it from PyPI;
93
+ `@latest` makes it move to each new release the next time the client starts the server.
92
94
 
93
95
  ### Claude Code
94
96
 
95
97
  ```bash
96
- claude mcp add --scope user utmax -- uvx --from "u-transcript-max[mcp]" utmax-mcp
98
+ claude mcp add --scope user utmax -- uvx --from "u-transcript-max[mcp]@latest" utmax-mcp
97
99
  ```
98
100
 
99
101
  `claude mcp get utmax` should say `Connected`; inside Claude Code, `/mcp` lists the server.
@@ -101,7 +103,7 @@ claude mcp add --scope user utmax -- uvx --from "u-transcript-max[mcp]" utmax-mc
101
103
  ### Codex
102
104
 
103
105
  ```bash
104
- codex mcp add utmax -- uvx --from "u-transcript-max[mcp]" utmax-mcp
106
+ codex mcp add utmax -- uvx --from "u-transcript-max[mcp]@latest" utmax-mcp
105
107
  ```
106
108
 
107
109
  A long download can outlast Codex's default tool timeout, and the first start installs the
@@ -111,7 +113,7 @@ file):
111
113
  ```toml
112
114
  [mcp_servers.utmax]
113
115
  command = "uvx"
114
- args = ["--from", "u-transcript-max[mcp]", "utmax-mcp"]
116
+ args = ["--from", "u-transcript-max[mcp]@latest", "utmax-mcp"]
115
117
  startup_timeout_sec = 60
116
118
  tool_timeout_sec = 1800
117
119
  ```
@@ -126,7 +128,7 @@ restart Claude Desktop:
126
128
  "mcpServers": {
127
129
  "utmax": {
128
130
  "command": "uvx",
129
- "args": ["--from", "u-transcript-max[mcp]", "utmax-mcp"]
131
+ "args": ["--from", "u-transcript-max[mcp]@latest", "utmax-mcp"]
130
132
  }
131
133
  }
132
134
  }
@@ -33,7 +33,7 @@ transcript.save("rick.srt") # also .vtt, .json and .txt
33
33
  turkish = utmax.translate(transcript, "tr", model="claude=claude-opus-5-5") # needs [claude]
34
34
  utmax.bilingual(transcript, turkish).save("rick.en+tr.srt")
35
35
 
36
- utmax.download("dQw4w9WgXcQ", "rick.mp4") # H.264 up to 1080p with English subtitles
36
+ utmax.download("dQw4w9WgXcQ", "rick.mp4") # the best MP4 (AV1 or H.264) with English subtitles
37
37
  videos = utmax.list_videos("@RickAstleyYT", kind="videos", limit=20)
38
38
  ```
39
39
 
@@ -45,16 +45,18 @@ from utmax.compat import YouTubeTranscriptApi
45
45
 
46
46
  ## MCP server
47
47
 
48
- `utmax-mcp` gives an AI assistant four tools: `list_tracks`, `get_transcript`, `list_videos`
49
- and `download`. It needs no API key: ask for a translation and the assistant translates the
50
- transcript itself. The commands below start it with `uvx` from
51
- [uv](https://docs.astral.sh/uv/getting-started/installation/), which installs it from PyPI on
52
- first use.
48
+ `utmax-mcp` gives an AI assistant six tools: `list_tracks`, `get_transcript`, `list_formats`,
49
+ `list_videos`, `download` and `save_subtitles`. It needs no API key. Ask it to download a video
50
+ and the assistant asks which file type, which of the video's resolutions and which subtitles
51
+ (original or translated) you want; ask for a translation and it translates the subtitles
52
+ itself. The commands below start it with `uvx` from
53
+ [uv](https://docs.astral.sh/uv/getting-started/installation/), which installs it from PyPI;
54
+ `@latest` makes it move to each new release the next time the client starts the server.
53
55
 
54
56
  ### Claude Code
55
57
 
56
58
  ```bash
57
- claude mcp add --scope user utmax -- uvx --from "u-transcript-max[mcp]" utmax-mcp
59
+ claude mcp add --scope user utmax -- uvx --from "u-transcript-max[mcp]@latest" utmax-mcp
58
60
  ```
59
61
 
60
62
  `claude mcp get utmax` should say `Connected`; inside Claude Code, `/mcp` lists the server.
@@ -62,7 +64,7 @@ claude mcp add --scope user utmax -- uvx --from "u-transcript-max[mcp]" utmax-mc
62
64
  ### Codex
63
65
 
64
66
  ```bash
65
- codex mcp add utmax -- uvx --from "u-transcript-max[mcp]" utmax-mcp
67
+ codex mcp add utmax -- uvx --from "u-transcript-max[mcp]@latest" utmax-mcp
66
68
  ```
67
69
 
68
70
  A long download can outlast Codex's default tool timeout, and the first start installs the
@@ -72,7 +74,7 @@ file):
72
74
  ```toml
73
75
  [mcp_servers.utmax]
74
76
  command = "uvx"
75
- args = ["--from", "u-transcript-max[mcp]", "utmax-mcp"]
77
+ args = ["--from", "u-transcript-max[mcp]@latest", "utmax-mcp"]
76
78
  startup_timeout_sec = 60
77
79
  tool_timeout_sec = 1800
78
80
  ```
@@ -87,7 +89,7 @@ restart Claude Desktop:
87
89
  "mcpServers": {
88
90
  "utmax": {
89
91
  "command": "uvx",
90
- "args": ["--from", "u-transcript-max[mcp]", "utmax-mcp"]
92
+ "args": ["--from", "u-transcript-max[mcp]@latest", "utmax-mcp"]
91
93
  }
92
94
  }
93
95
  }
@@ -10,7 +10,7 @@ Quick start::
10
10
  turkish = utmax.translate(transcript, "tr", model="claude=claude-opus-5")
11
11
  utmax.bilingual(transcript, turkish).save("rick.en+tr.srt")
12
12
 
13
- utmax.download("dQw4w9WgXcQ", "rick.mp4") # H.264 + AAC + English subtitles
13
+ utmax.download("dQw4w9WgXcQ", "rick.mp4") # the best MP4 (AV1 or H.264), English subtitles
14
14
 
15
15
  videos = utmax.list_videos("@RickAstleyYT", kind="videos", limit=20)
16
16
  utmax.fetch_many(videos, out_dir="subs") # one .srt per video
@@ -77,6 +77,7 @@ from utmax.models import (
77
77
  Container,
78
78
  DownloadResult,
79
79
  Format,
80
+ FormatList,
80
81
  FormatName,
81
82
  Language,
82
83
  Progress,
@@ -112,6 +113,7 @@ __all__ = [
112
113
  "FFmpegNotFound",
113
114
  "FailedToCreateConsentCookie",
114
115
  "Format",
116
+ "FormatList",
115
117
  "FormatName",
116
118
  "FormatNotAvailable",
117
119
  "InvalidModelSpec",
@@ -161,6 +163,7 @@ __all__ = [
161
163
  "download_many",
162
164
  "fetch",
163
165
  "fetch_many",
166
+ "list_formats",
164
167
  "list_tracks",
165
168
  "list_videos",
166
169
  "translate",
@@ -234,6 +237,23 @@ def list_tracks(video: str) -> TrackList:
234
237
  return _client().list_tracks(video)
235
238
 
236
239
 
240
+ def list_formats(video: str) -> FormatList:
241
+ """The streams of a video utmax can download, in YouTube's order; :func:`download` picks
242
+ among those its file type holds (see :func:`utmax.core.streams.file_types`).
243
+
244
+ Example::
245
+
246
+ formats = utmax.list_formats("dQw4w9WgXcQ")
247
+ print([f.label for f in formats if f.kind == "video"]) # "401 mp4 av1 2160p25", ...
248
+ utmax.download("dQw4w9WgXcQ", "rick.mp4", resolution=720) # lines of the short side
249
+
250
+ Raises:
251
+ InvalidVideoId: ``video`` is not a YouTube video URL or ID.
252
+ VideoUnavailable, VideoUnplayable, AgeRestricted, RequestBlocked: YouTube refused.
253
+ """
254
+ return _client().list_formats(video)
255
+
256
+
237
257
  def video_info(video: str) -> VideoInfo:
238
258
  """Title, channel and duration of a video."""
239
259
  return _client().video_info(video)
@@ -334,7 +354,8 @@ def download(
334
354
  path: str | os.PathLike[str],
335
355
  *,
336
356
  format: Container | None = None,
337
- quality: Quality = "compat",
357
+ quality: Quality = "best",
358
+ resolution: int | None = None,
338
359
  subtitles: Sequence[str | Transcript] | None = None,
339
360
  subtitle_mode: SubtitleMode = "embed",
340
361
  default_subtitle: str | None = None,
@@ -350,9 +371,9 @@ def download(
350
371
 
351
372
  Examples::
352
373
 
353
- utmax.download("dQw4w9WgXcQ", "rick.mp4") # H.264 up to 1080p, AAC, English subtitles
374
+ utmax.download("dQw4w9WgXcQ", "rick.mp4") # the best AV1 or H.264, AAC, English subtitles
354
375
  utmax.download("dQw4w9WgXcQ", "rick.m4a") # audio only; no ffmpeg needed
355
- utmax.download("dQw4w9WgXcQ", "videos/", quality="max") # AV1 up to 4K, named by title
376
+ utmax.download("dQw4w9WgXcQ", "videos/") # the best MP4 (AV1 up to 8K), named by title
356
377
  utmax.download("dQw4w9WgXcQ", "rick.mp4", subtitles=[english, turkish])
357
378
 
358
379
  Args:
@@ -362,8 +383,11 @@ def download(
362
383
  ``"{title} [{video_id}].{ext}"``.
363
384
  format: the file type when ``path`` is a folder or has no extension; it must match the
364
385
  extension otherwise.
365
- quality: ``"compat"`` (H.264 up to 1080p, plays everywhere) or ``"max"`` (AV1 or
366
- H.264 up to 2160p; ``.mp4`` only).
386
+ quality: ``"best"`` (the largest picture the file type holds: AV1 or H.264 in
387
+ ``.mp4``, HDR where it is the only way to a larger picture; H.264 in ``.mov``) or
388
+ ``"compat"`` (H.264 up to 1080p, plays everywhere).
389
+ resolution: the largest picture to take, in lines of its short side (``1080`` means at
390
+ most 1080p); ``None`` takes the largest. :func:`list_formats` shows what exists.
367
391
  subtitles: language codes and/or transcripts (translations and bilingual ones too).
368
392
  ``None`` embeds the spoken-language track in videos and adds nothing to audio;
369
393
  ``[]`` adds none. Codes are chosen like :func:`fetch`, never with YouTube's own
@@ -373,7 +397,8 @@ def download(
373
397
  default_subtitle: the language code of the embedded track shown by default (else the
374
398
  first one).
375
399
  connections: parallel connections, 1 to 16.
376
- chunk_size: bytes per range request, at least 256 KiB.
400
+ chunk_size: bytes per range request, at least 256 KiB (YouTube slows down larger
401
+ requests of 8 MiB, so the default is 2 MiB).
377
402
  resume: continue an interrupted download from its ``.part`` files.
378
403
  overwrite: replace existing files instead of raising :class:`OutputExists`.
379
404
  ffmpeg: the ffmpeg executable for ``.mp3`` (default: ``$UTMAX_FFMPEG``, then ``PATH``).
@@ -396,6 +421,8 @@ def download(
396
421
  FormatNotAvailable: no stream fits the type and quality (live streams, for example).
397
422
  StreamForbidden, DownloadIncomplete, NetworkError: the download failed; call again to
398
423
  resume.
424
+ PoTokenRequired: YouTube serves only the start of this video's streams without a
425
+ proof-of-origin token, which utmax cannot create.
399
426
  DownloadCancelled: ``cancel`` was set.
400
427
  MuxError, FFmpegFailed: the file could not be assembled.
401
428
  VideoUnavailable, VideoUnplayable, AgeRestricted, RequestBlocked: YouTube refused.
@@ -405,6 +432,7 @@ def download(
405
432
  path,
406
433
  format=format,
407
434
  quality=quality,
435
+ resolution=resolution,
408
436
  subtitles=subtitles,
409
437
  subtitle_mode=subtitle_mode,
410
438
  default_subtitle=default_subtitle,
@@ -597,7 +625,8 @@ def download_many(
597
625
  out_dir: str | os.PathLike[str],
598
626
  *,
599
627
  format: Container = "mp4",
600
- quality: Quality = "compat",
628
+ quality: Quality = "best",
629
+ resolution: int | None = None,
601
630
  subtitles: Sequence[str] | None = None,
602
631
  subtitle_mode: SubtitleMode = "embed",
603
632
  concurrency: int = 2,
@@ -616,8 +645,8 @@ def download_many(
616
645
  Args:
617
646
  videos: video IDs, URLs and/or entries of a :func:`list_videos` result.
618
647
  out_dir: the folder for the files (created when missing).
619
- format, quality, subtitles, subtitle_mode, ffmpeg: as in :func:`download`, for every
620
- video; ``subtitles`` takes language codes only.
648
+ format, quality, resolution, subtitles, subtitle_mode, ffmpeg: as in :func:`download`,
649
+ for every video; ``subtitles`` takes language codes only.
621
650
  concurrency: how many videos are downloaded at the same time, 1 to 16 (each with four
622
651
  connections).
623
652
  skip_existing: skip a video, without any request, when ``out_dir`` already holds its
@@ -641,6 +670,7 @@ def download_many(
641
670
  out_dir,
642
671
  format=format,
643
672
  quality=quality,
673
+ resolution=resolution,
644
674
  subtitles=subtitles,
645
675
  subtitle_mode=subtitle_mode,
646
676
  concurrency=concurrency,
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0a4"
@@ -34,7 +34,13 @@ from utmax.core.downloads import (
34
34
  )
35
35
  from utmax.core.retry import backoff_delay, is_transient_status
36
36
  from utmax.core.streams import Stream
37
- from utmax.errors import DownloadCancelled, DownloadIncomplete, NetworkError, StreamForbidden
37
+ from utmax.errors import (
38
+ DownloadCancelled,
39
+ DownloadIncomplete,
40
+ NetworkError,
41
+ PoTokenRequired,
42
+ StreamForbidden,
43
+ )
38
44
  from utmax.models import Progress, ProgressPhase
39
45
  from utmax.transport import HttpRequest, HttpStream
40
46
 
@@ -173,6 +179,8 @@ class Downloader:
173
179
  Raises:
174
180
  DownloadCancelled: ``cancel`` was set; the parts stay for a resume.
175
181
  StreamForbidden: YouTube kept answering 403 after ``max_refreshes`` fresh URLs.
182
+ PoTokenRequired: ... while still serving the stream's first byte: it wants a
183
+ proof-of-origin token for this video.
176
184
  DownloadIncomplete: a stream kept failing, changed on YouTube's side or answered
177
185
  an unexpected status.
178
186
  NetworkError: the connection kept failing.
@@ -350,13 +358,7 @@ class Downloader:
350
358
  if self._fresh_streams is None or self._refreshes >= self._max_refreshes:
351
359
  if not required:
352
360
  return
353
- itag = target.stream.format.itag
354
- raise StreamForbidden(
355
- f"YouTube refused stream {itag} of video {self._video_id} (HTTP 403) "
356
- f"after {self._refreshes} fresh URLs.",
357
- itag=itag,
358
- video_id=self._video_id,
359
- )
361
+ raise self._forbidden(target)
360
362
  self._refreshes += 1
361
363
  log.info(
362
364
  "getting fresh stream URLs for %s (%d/%d)",
@@ -370,6 +372,40 @@ class Downloader:
370
372
  each.stream = self._match(each, fresh)
371
373
  self._generation += 1
372
374
 
375
+ def _forbidden(self, target: _Target) -> PoTokenRequired | StreamForbidden:
376
+ """Why YouTube keeps refusing a stream: without a proof-of-origin token it serves only
377
+ the first megabyte of some videos' streams, so a stream whose first byte still comes
378
+ needs that token; otherwise its URLs do not work from here."""
379
+ stream = target.stream
380
+ itag = stream.format.itag
381
+ if self._serves_first_byte(stream):
382
+ return PoTokenRequired(
383
+ f"YouTube serves only the start of stream {itag} of video {self._video_id} and "
384
+ "refuses the rest (HTTP 403): it wants a proof-of-origin (PO) token for this "
385
+ "video, which utmax cannot create.",
386
+ suggestion=(
387
+ "YouTube asks for this token for some videos only, and not always: try "
388
+ "again later. The video's subtitles can still be fetched."
389
+ ),
390
+ video_id=self._video_id,
391
+ )
392
+ return StreamForbidden(
393
+ f"YouTube refused stream {itag} of video {self._video_id} (HTTP 403) "
394
+ f"after {self._refreshes} fresh URLs.",
395
+ itag=itag,
396
+ video_id=self._video_id,
397
+ )
398
+
399
+ def _serves_first_byte(self, stream: Stream) -> bool:
400
+ try:
401
+ body = self._opener(self._request(stream, "bytes=0-0"))
402
+ except NetworkError:
403
+ return False
404
+ try:
405
+ return body.status == 206
406
+ finally:
407
+ body.close()
408
+
373
409
  def _match(self, target: _Target, fresh: Sequence[Stream]) -> Stream:
374
410
  """The fresh stream with the same itag, size and version as ``target``'s."""
375
411
  old = target.stream.format
@@ -5,15 +5,24 @@ from __future__ import annotations
5
5
 
6
6
  import json
7
7
  import logging
8
+ import threading
8
9
  from collections.abc import Callable
9
10
  from functools import partial
10
11
  from typing import Any, TypeVar
11
12
 
12
13
  from utmax.adapters.watch_page import fetch_api_key
13
14
  from utmax.core.captions import caption_url, check_caption_url
14
- from utmax.core.clients import ANDROID, DESKTOP_USER_AGENT, ORDER, ClientProfile, Purpose
15
+ from utmax.core.clients import (
16
+ ANDROID,
17
+ DESKTOP_USER_AGENT,
18
+ ORDER,
19
+ VISIONOS,
20
+ ClientProfile,
21
+ Purpose,
22
+ )
15
23
  from utmax.core.playability import check_playability
16
24
  from utmax.core.player import PlayerData, parse_player_response
25
+ from utmax.core.ytdata import mapping
17
26
  from utmax.errors import (
18
27
  IpBlocked,
19
28
  RequestBlocked,
@@ -34,24 +43,36 @@ _HTTP_LEVEL_FAILURES = (YouTubeRequestFailed, YouTubeDataUnparsable)
34
43
 
35
44
 
36
45
  class InnerTubeClient:
37
- """Talks to InnerTube with a chain of client profiles (see ``utmax.core.clients``)."""
46
+ """Talks to InnerTube with a chain of client profiles (see ``utmax.core.clients``).
47
+
48
+ Profiles that need a ``visitorData`` send the one this client asked YouTube for on first
49
+ use, shared by every thread; a bot check renews it once per player request.
50
+ """
38
51
 
39
52
  def __init__(self, transport: Transport, *, block_retries: int = 0) -> None:
40
53
  self._transport = transport
41
54
  self._block_retries = block_retries
55
+ self._visitor: str | None = None
56
+ self._visitor_lock = threading.Lock()
42
57
 
43
- def player(self, video_id: str, *, purpose: Purpose = "captions") -> PlayerData:
58
+ def player(
59
+ self, video_id: str, *, purpose: Purpose = "captions", renew_visitor: bool = False
60
+ ) -> PlayerData:
44
61
  """A playable player response, trying each profile for ``purpose`` in order.
45
62
 
46
63
  For ``"streams"``, a response without a direct MP4 stream URL counts as a failed profile.
64
+ ``renew_visitor`` first asks YouTube for a new ``visitorData``: it restricts the streams
65
+ of some visitors, so a download that keeps meeting 403s refreshes as a new one.
47
66
  """
67
+ if renew_visitor:
68
+ self._renew_visitor_data(self._visitor)
48
69
  profiles = ORDER[purpose]
49
70
  failures: list[YouTubeError] = []
50
71
  http_failures = 0
51
72
  for profile in profiles:
52
73
  try:
53
74
  return self._with_block_retries(
54
- partial(self._playable, profile, video_id, None, purpose=purpose)
75
+ partial(self._playable_as_visitor, profile, video_id, purpose=purpose)
55
76
  )
56
77
  except _HTTP_LEVEL_FAILURES as error:
57
78
  http_failures += 1
@@ -70,22 +91,38 @@ class InnerTubeClient:
70
91
  raise failures[0]
71
92
 
72
93
  def player_json(
73
- self, profile: ClientProfile, video_id: str, *, api_key: str | None = None
94
+ self,
95
+ profile: ClientProfile,
96
+ video_id: str,
97
+ *,
98
+ api_key: str | None = None,
99
+ visitor_data: str | None = None,
74
100
  ) -> dict[str, Any]:
75
101
  """The raw player response of one profile (low level; used by the fixture recorder)."""
76
102
  url = f"{API_BASE}/player?prettyPrint=false"
77
103
  if api_key is not None:
78
104
  url += f"&key={api_key}"
79
105
  payload = {
80
- "context": profile.context_payload(),
106
+ "context": profile.context_payload(visitor_data),
81
107
  "videoId": video_id,
82
108
  "contentCheckOk": True,
83
109
  "racyCheckOk": True,
84
110
  }
85
111
  body = json.dumps(payload).encode("utf-8")
86
- response = self._transport.send(HttpRequest("POST", url, profile.request_headers(), body))
112
+ headers = profile.request_headers(visitor_data)
113
+ response = self._transport.send(HttpRequest("POST", url, headers, body))
87
114
  return _json_object(response, video_id=video_id)
88
115
 
116
+ def visitor_data(self) -> str | None:
117
+ """The ``visitorData`` YouTube issued to this client, asked for on first use.
118
+
119
+ ``None`` when YouTube issued none; the next call asks again.
120
+ """
121
+ with self._visitor_lock:
122
+ if self._visitor is None:
123
+ self._visitor = self._new_visitor_data()
124
+ return self._visitor
125
+
89
126
  def browse(
90
127
  self,
91
128
  profile: ClientProfile,
@@ -126,10 +163,51 @@ class InnerTubeClient:
126
163
  response = self._transport.send(HttpRequest("POST", url, profile.request_headers(), body))
127
164
  return _json_object(response)
128
165
 
166
+ def _new_visitor_data(self) -> str | None:
167
+ try:
168
+ data = self._post(VISIONOS, "visitor_id", {"context": VISIONOS.context_payload()})
169
+ except (YouTubeRequestFailed, YouTubeDataUnparsable) as error:
170
+ log.info("YouTube issued no visitorData: %s", error)
171
+ return None
172
+ value = mapping(data.get("responseContext")).get("visitorData")
173
+ return value if isinstance(value, str) and value else None
174
+
175
+ def _renew_visitor_data(self, seen: str | None) -> str | None:
176
+ """A new ``visitorData``, unless another thread already replaced ``seen``."""
177
+ with self._visitor_lock:
178
+ if self._visitor == seen:
179
+ self._visitor = self._new_visitor_data()
180
+ return self._visitor
181
+
182
+ def _playable_as_visitor(
183
+ self, profile: ClientProfile, video_id: str, *, purpose: Purpose
184
+ ) -> PlayerData:
185
+ """``_playable`` for ``profile``; one that needs a ``visitorData`` and meets a bot check
186
+ tries once more with a new one."""
187
+ if not profile.needs_visitor:
188
+ return self._playable(profile, video_id, None, purpose=purpose)
189
+ visitor = self.visitor_data()
190
+ try:
191
+ return self._playable(profile, video_id, None, purpose=purpose, visitor_data=visitor)
192
+ except RequestBlocked as error:
193
+ if isinstance(error, IpBlocked):
194
+ raise
195
+ log.info(
196
+ "%s met a bot check for %s; asking for a new visitorData", profile.name, video_id
197
+ )
198
+ visitor = self._renew_visitor_data(visitor)
199
+ return self._playable(profile, video_id, None, purpose=purpose, visitor_data=visitor)
200
+
129
201
  def _playable(
130
- self, profile: ClientProfile, video_id: str, api_key: str | None, *, purpose: Purpose
202
+ self,
203
+ profile: ClientProfile,
204
+ video_id: str,
205
+ api_key: str | None,
206
+ *,
207
+ purpose: Purpose,
208
+ visitor_data: str | None = None,
131
209
  ) -> PlayerData:
132
- data = self.player_json(profile, video_id, api_key=api_key)
210
+ data = self.player_json(profile, video_id, api_key=api_key, visitor_data=visitor_data)
133
211
  player = parse_player_response(data, video_id=video_id)
134
212
  check_playability(player.playability, video_id=video_id)
135
213
  if purpose == "streams" and not any(
@@ -20,6 +20,7 @@ from utmax.models import (
20
20
  CollectionKind,
21
21
  Container,
22
22
  DownloadResult,
23
+ FormatList,
23
24
  FormatName,
24
25
  Progress,
25
26
  Quality,
@@ -121,6 +122,10 @@ class Client:
121
122
  """Every subtitle track of ``video``, in YouTube's order."""
122
123
  return self._transcripts.list_tracks(video)
123
124
 
125
+ def list_formats(self, video: str) -> FormatList:
126
+ """The streams of ``video`` a download can choose from; see :func:`utmax.list_formats`."""
127
+ return self._downloads.list_formats(video)
128
+
124
129
  def video_info(self, video: str) -> VideoInfo:
125
130
  """Title, channel and duration of ``video``."""
126
131
  return self._transcripts.video_info(video)
@@ -161,7 +166,8 @@ class Client:
161
166
  path: str | os.PathLike[str],
162
167
  *,
163
168
  format: Container | None = None,
164
- quality: Quality = "compat",
169
+ quality: Quality = "best",
170
+ resolution: int | None = None,
165
171
  subtitles: Sequence[str | Transcript] | None = None,
166
172
  subtitle_mode: SubtitleMode = "embed",
167
173
  default_subtitle: str | None = None,
@@ -177,6 +183,7 @@ class Client:
177
183
  options = DownloadOptions(
178
184
  format=format,
179
185
  quality=quality,
186
+ resolution=resolution,
180
187
  subtitles=subtitles,
181
188
  subtitle_mode=subtitle_mode,
182
189
  default_subtitle=default_subtitle,
@@ -266,7 +273,8 @@ class Client:
266
273
  out_dir: str | os.PathLike[str],
267
274
  *,
268
275
  format: Container = "mp4",
269
- quality: Quality = "compat",
276
+ quality: Quality = "best",
277
+ resolution: int | None = None,
270
278
  subtitles: Sequence[str] | None = None,
271
279
  subtitle_mode: SubtitleMode = "embed",
272
280
  concurrency: int = 2,
@@ -281,6 +289,7 @@ class Client:
281
289
  out_dir,
282
290
  format=format,
283
291
  quality=quality,
292
+ resolution=resolution,
284
293
  subtitles=subtitles,
285
294
  subtitle_mode=subtitle_mode,
286
295
  concurrency=concurrency,
@@ -2,6 +2,11 @@
2
2
 
3
3
  Verified on 2026-09-27: ANDROID, IOS and ANDROID_VR return captions and direct stream URLs
4
4
  without an API key or proof-of-origin token; ANDROID_VR returns no ``translationLanguages``.
5
+
6
+ Verified on 2026-10-09: the stream URLs of ANDROID and IOS serve only the start of a stream
7
+ without a proof-of-origin token, and ANDROID_VR mostly answers with a bot check. VISIONOS, sent
8
+ with a ``visitorData`` that YouTube issued, serves whole streams of every quality (H.264, AV1
9
+ and VP9 up to 2160p and HDR, AAC and Opus) without JavaScript or a token.
5
10
  """
6
11
 
7
12
  from __future__ import annotations
@@ -18,6 +23,7 @@ __all__ = [
18
23
  "IOS",
19
24
  "ORDER",
20
25
  "PROFILES",
26
+ "VISIONOS",
21
27
  "WEB",
22
28
  "ClientProfile",
23
29
  "Purpose",
@@ -33,24 +39,31 @@ DESKTOP_USER_AGENT = (
33
39
 
34
40
  @dataclass(frozen=True, slots=True)
35
41
  class ClientProfile:
36
- """How utmax introduces itself to InnerTube as one of YouTube's apps."""
42
+ """How utmax introduces itself to InnerTube as one of YouTube's apps.
43
+
44
+ ``needs_visitor``: YouTube answers this app with a bot check unless the request carries a
45
+ ``visitorData`` that it issued (``POST /youtubei/v1/visitor_id``).
46
+ """
37
47
 
38
48
  name: str
39
49
  client_id: int
40
50
  version: str
41
51
  user_agent: str
42
52
  extra: tuple[tuple[str, str | int], ...] = ()
53
+ needs_visitor: bool = False
43
54
 
44
- def context_payload(self) -> dict[str, Any]:
55
+ def context_payload(self, visitor_data: str | None = None) -> dict[str, Any]:
45
56
  """The ``context`` object of an InnerTube request body."""
46
57
  client: dict[str, Any] = {"clientName": self.name, "clientVersion": self.version}
47
58
  client.update(self.extra)
48
59
  client.update({"hl": "en", "gl": "US"})
60
+ if visitor_data is not None:
61
+ client["visitorData"] = visitor_data
49
62
  return {"client": client}
50
63
 
51
- def request_headers(self) -> dict[str, str]:
64
+ def request_headers(self, visitor_data: str | None = None) -> dict[str, str]:
52
65
  """HTTP headers for an InnerTube request made as this app."""
53
- return {
66
+ headers = {
54
67
  "Content-Type": "application/json",
55
68
  "User-Agent": self.user_agent,
56
69
  "X-YouTube-Client-Name": str(self.client_id),
@@ -58,6 +71,9 @@ class ClientProfile:
58
71
  "Accept-Language": "en-US,en;q=0.9",
59
72
  "Accept-Encoding": "gzip",
60
73
  }
74
+ if visitor_data is not None:
75
+ headers["X-Goog-Visitor-Id"] = visitor_data
76
+ return headers
61
77
 
62
78
 
63
79
  ANDROID = ClientProfile(
@@ -95,6 +111,22 @@ ANDROID_VR = ClientProfile(
95
111
  ("osVersion", "12L"),
96
112
  ),
97
113
  )
114
+ VISIONOS = ClientProfile(
115
+ name="VISIONOS",
116
+ client_id=101,
117
+ version="1.02",
118
+ user_agent=(
119
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 15_7_3) AppleWebKit/605.1.15 "
120
+ "(KHTML, like Gecko) Version/26.0 Safari/605.1.15"
121
+ ),
122
+ extra=(
123
+ ("deviceMake", "Apple"),
124
+ ("deviceModel", "RealityDevice17,1"),
125
+ ("osName", "visionOS"),
126
+ ("osVersion", "26.5.23O471"),
127
+ ),
128
+ needs_visitor=True,
129
+ )
98
130
  WEB = ClientProfile(
99
131
  name="WEB", client_id=1, version="2.20260925.01.00", user_agent=DESKTOP_USER_AGENT
100
132
  )
@@ -102,12 +134,12 @@ WEB = ClientProfile(
102
134
  ORDER: Mapping[Purpose, tuple[ClientProfile, ...]] = MappingProxyType(
103
135
  {
104
136
  "captions": (ANDROID, IOS, ANDROID_VR),
105
- "streams": (ANDROID_VR, ANDROID, IOS),
137
+ "streams": (VISIONOS, ANDROID_VR),
106
138
  "browse": (ANDROID_VR, WEB),
107
139
  "resolve": (ANDROID_VR, WEB),
108
140
  }
109
141
  )
110
142
 
111
143
  PROFILES: Mapping[str, ClientProfile] = MappingProxyType(
112
- {profile.name: profile for profile in (ANDROID, IOS, ANDROID_VR, WEB)}
144
+ {profile.name: profile for profile in (ANDROID, IOS, ANDROID_VR, VISIONOS, WEB)}
113
145
  )