u-transcript-max 0.1.0a2__tar.gz → 0.1.0a3__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 (226) hide show
  1. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/PKG-INFO +7 -7
  2. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/README.md +6 -6
  3. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/src/utmax/__init__.py +2 -0
  4. u_transcript_max-0.1.0a3/src/utmax/_version.py +1 -0
  5. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/src/utmax/adapters/downloader.py +44 -8
  6. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/src/utmax/core/player.py +23 -1
  7. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/src/utmax/core/selection.py +11 -3
  8. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/src/utmax/errors.py +2 -1
  9. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/src/utmax/models.py +7 -1
  10. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/src/utmax/services/transcripts.py +4 -1
  11. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/tests/helpers/fake_media.py +10 -2
  12. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/tests/helpers/youtube.py +33 -0
  13. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/tests/unit/adapters/test_downloader_recovery.py +36 -0
  14. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/tests/unit/core/test_player.py +47 -1
  15. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/tests/unit/core/test_selection.py +25 -0
  16. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/tests/unit/services/test_transcripts.py +14 -0
  17. u_transcript_max-0.1.0a2/src/utmax/_version.py +0 -1
  18. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/.gitignore +0 -0
  19. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/LICENSE +0 -0
  20. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/pyproject.toml +0 -0
  21. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/src/utmax/adapters/__init__.py +0 -0
  22. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/src/utmax/adapters/ffmpeg.py +0 -0
  23. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/src/utmax/adapters/files.py +0 -0
  24. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/src/utmax/adapters/http.py +0 -0
  25. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/src/utmax/adapters/innertube.py +0 -0
  26. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/src/utmax/adapters/providers/__init__.py +0 -0
  27. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/src/utmax/adapters/providers/base.py +0 -0
  28. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/src/utmax/adapters/providers/claude.py +0 -0
  29. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/src/utmax/adapters/providers/gemini.py +0 -0
  30. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/src/utmax/adapters/providers/openai.py +0 -0
  31. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/src/utmax/adapters/providers/openrouter.py +0 -0
  32. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/src/utmax/adapters/watch_page.py +0 -0
  33. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/src/utmax/client.py +0 -0
  34. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/src/utmax/compat/__init__.py +0 -0
  35. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/src/utmax/compat/_api.py +0 -0
  36. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/src/utmax/compat/_bridge.py +0 -0
  37. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/src/utmax/compat/_errors.py +0 -0
  38. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/src/utmax/compat/_settings.py +0 -0
  39. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/src/utmax/compat/_transcripts.py +0 -0
  40. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/src/utmax/compat/formatters.py +0 -0
  41. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/src/utmax/compat/proxies.py +0 -0
  42. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/src/utmax/core/__init__.py +0 -0
  43. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/src/utmax/core/bilingual.py +0 -0
  44. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/src/utmax/core/browse.py +0 -0
  45. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/src/utmax/core/captions.py +0 -0
  46. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/src/utmax/core/clients.py +0 -0
  47. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/src/utmax/core/downloads.py +0 -0
  48. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/src/utmax/core/filenames.py +0 -0
  49. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/src/utmax/core/formats.py +0 -0
  50. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/src/utmax/core/ids.py +0 -0
  51. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/src/utmax/core/languages.py +0 -0
  52. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/src/utmax/core/media/__init__.py +0 -0
  53. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/src/utmax/core/media/boxes.py +0 -0
  54. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/src/utmax/core/media/fmp4.py +0 -0
  55. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/src/utmax/core/media/moov.py +0 -0
  56. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/src/utmax/core/media/mux.py +0 -0
  57. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/src/utmax/core/media/progressive.py +0 -0
  58. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/src/utmax/core/media/tables.py +0 -0
  59. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/src/utmax/core/media/tx3g.py +0 -0
  60. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/src/utmax/core/playability.py +0 -0
  61. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/src/utmax/core/retry.py +0 -0
  62. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/src/utmax/core/segmentation.py +0 -0
  63. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/src/utmax/core/streams.py +0 -0
  64. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/src/utmax/core/translate/__init__.py +0 -0
  65. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/src/utmax/core/translate/batching.py +0 -0
  66. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/src/utmax/core/translate/data/protocol.json +0 -0
  67. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/src/utmax/core/translate/data/request.schema.json +0 -0
  68. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/src/utmax/core/translate/data/response.schema.json +0 -0
  69. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/src/utmax/core/translate/data/system_prompt.txt +0 -0
  70. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/src/utmax/core/translate/protocol.py +0 -0
  71. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/src/utmax/core/translate/spec.py +0 -0
  72. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/src/utmax/core/ytdata.py +0 -0
  73. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/src/utmax/mcp/__init__.py +0 -0
  74. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/src/utmax/mcp/__main__.py +0 -0
  75. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/src/utmax/mcp/config.py +0 -0
  76. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/src/utmax/mcp/server.py +0 -0
  77. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/src/utmax/providers.py +0 -0
  78. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/src/utmax/py.typed +0 -0
  79. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/src/utmax/services/__init__.py +0 -0
  80. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/src/utmax/services/bulk.py +0 -0
  81. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/src/utmax/services/collections.py +0 -0
  82. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/src/utmax/services/download.py +0 -0
  83. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/src/utmax/services/translation.py +0 -0
  84. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/src/utmax/transport.py +0 -0
  85. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/tests/__init__.py +0 -0
  86. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/tests/compat/__init__.py +0 -0
  87. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/tests/compat/test_api.py +0 -0
  88. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/tests/compat/test_bridge.py +0 -0
  89. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/tests/compat/test_errors.py +0 -0
  90. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/tests/compat/test_formatters.py +0 -0
  91. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/tests/compat/test_install.py +0 -0
  92. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/tests/compat/test_legacy.py +0 -0
  93. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/tests/compat/test_manifest.py +0 -0
  94. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/tests/compat/test_manifest_script.py +0 -0
  95. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/tests/compat/test_proxies.py +0 -0
  96. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/tests/compat/test_transcripts.py +0 -0
  97. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/tests/fixtures/compat/youtube_transcript_api-1.2.4.json +0 -0
  98. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/tests/fixtures/media/README.md +0 -0
  99. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/tests/fixtures/media/dQw4w9WgXcQ_137.hollow.bin +0 -0
  100. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/tests/fixtures/media/dQw4w9WgXcQ_140.hollow.bin +0 -0
  101. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/tests/fixtures/media/dQw4w9WgXcQ_399.hollow.bin +0 -0
  102. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/tests/fixtures/media/manifest.json +0 -0
  103. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/tests/fixtures/youtube/README.md +0 -0
  104. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/tests/fixtures/youtube/browse_android_vr_1.json +0 -0
  105. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/tests/fixtures/youtube/browse_android_vr_2.json +0 -0
  106. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/tests/fixtures/youtube/browse_web_1.json +0 -0
  107. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/tests/fixtures/youtube/browse_web_2.json +0 -0
  108. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/tests/fixtures/youtube/browse_web_shorts.json +0 -0
  109. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/tests/fixtures/youtube/json3_en_asr.json +0 -0
  110. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/tests/fixtures/youtube/json3_en_manual.json +0 -0
  111. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/tests/fixtures/youtube/legacy_en_manual.xml +0 -0
  112. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/tests/fixtures/youtube/player_android.json +0 -0
  113. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/tests/fixtures/youtube/player_android_vr.json +0 -0
  114. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/tests/fixtures/youtube/player_ios.json +0 -0
  115. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/tests/fixtures/youtube/resolve_handle.json +0 -0
  116. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/tests/fixtures/youtube/resolve_unknown.json +0 -0
  117. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/tests/fixtures/youtube/srv3_en_asr.xml +0 -0
  118. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/tests/fixtures/youtube/streams_android_vr.json +0 -0
  119. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/tests/helpers/__init__.py +0 -0
  120. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/tests/helpers/browse.py +0 -0
  121. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/tests/helpers/builders.py +0 -0
  122. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/tests/helpers/bulk.py +0 -0
  123. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/tests/helpers/compat.py +0 -0
  124. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/tests/helpers/downloads.py +0 -0
  125. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/tests/helpers/fake_translator.py +0 -0
  126. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/tests/helpers/fake_transport.py +0 -0
  127. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/tests/helpers/files.py +0 -0
  128. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/tests/helpers/fmp4_factory.py +0 -0
  129. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/tests/helpers/hollow_source.py +0 -0
  130. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/tests/helpers/http_server.py +0 -0
  131. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/tests/helpers/json_schema.py +0 -0
  132. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/tests/helpers/sdk.py +0 -0
  133. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/tests/live/__init__.py +0 -0
  134. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/tests/live/conftest.py +0 -0
  135. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/tests/live/test_collections_live.py +0 -0
  136. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/tests/live/test_compat_live.py +0 -0
  137. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/tests/live/test_download_live.py +0 -0
  138. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/tests/live/test_transcripts_live.py +0 -0
  139. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/tests/live/test_translation_live.py +0 -0
  140. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/tests/mcp/__init__.py +0 -0
  141. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/tests/mcp/conftest.py +0 -0
  142. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/tests/mcp/test_config.py +0 -0
  143. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/tests/mcp/test_download.py +0 -0
  144. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/tests/mcp/test_live.py +0 -0
  145. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/tests/mcp/test_main.py +0 -0
  146. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/tests/mcp/test_package.py +0 -0
  147. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/tests/mcp/test_server.py +0 -0
  148. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/tests/mcp/test_stdio.py +0 -0
  149. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/tests/test_architecture.py +0 -0
  150. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/tests/test_extras.py +0 -0
  151. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/tests/test_package.py +0 -0
  152. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/tests/unit/__init__.py +0 -0
  153. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/tests/unit/adapters/__init__.py +0 -0
  154. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/tests/unit/adapters/providers/__init__.py +0 -0
  155. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/tests/unit/adapters/providers/test_base.py +0 -0
  156. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/tests/unit/adapters/providers/test_claude.py +0 -0
  157. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/tests/unit/adapters/providers/test_factory.py +0 -0
  158. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/tests/unit/adapters/providers/test_gemini.py +0 -0
  159. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/tests/unit/adapters/providers/test_openai.py +0 -0
  160. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/tests/unit/adapters/providers/test_openrouter.py +0 -0
  161. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/tests/unit/adapters/test_downloader.py +0 -0
  162. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/tests/unit/adapters/test_ffmpeg_adapter.py +0 -0
  163. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/tests/unit/adapters/test_ffmpeg_mp3.py +0 -0
  164. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/tests/unit/adapters/test_files.py +0 -0
  165. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/tests/unit/adapters/test_http.py +0 -0
  166. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/tests/unit/adapters/test_http_stream.py +0 -0
  167. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/tests/unit/adapters/test_innertube.py +0 -0
  168. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/tests/unit/adapters/test_innertube_browse.py +0 -0
  169. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/tests/unit/adapters/test_mux_executor.py +0 -0
  170. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/tests/unit/adapters/test_watch_page.py +0 -0
  171. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/tests/unit/core/__init__.py +0 -0
  172. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/tests/unit/core/test_batching.py +0 -0
  173. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/tests/unit/core/test_bilingual.py +0 -0
  174. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/tests/unit/core/test_browse.py +0 -0
  175. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/tests/unit/core/test_captions.py +0 -0
  176. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/tests/unit/core/test_clients.py +0 -0
  177. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/tests/unit/core/test_downloads.py +0 -0
  178. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/tests/unit/core/test_filenames.py +0 -0
  179. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/tests/unit/core/test_formats.py +0 -0
  180. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/tests/unit/core/test_ids.py +0 -0
  181. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/tests/unit/core/test_languages.py +0 -0
  182. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/tests/unit/core/test_model_spec.py +0 -0
  183. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/tests/unit/core/test_name_templates.py +0 -0
  184. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/tests/unit/core/test_pager.py +0 -0
  185. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/tests/unit/core/test_playability.py +0 -0
  186. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/tests/unit/core/test_retry.py +0 -0
  187. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/tests/unit/core/test_segmentation.py +0 -0
  188. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/tests/unit/core/test_sources.py +0 -0
  189. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/tests/unit/core/test_streams.py +0 -0
  190. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/tests/unit/core/test_translate_protocol.py +0 -0
  191. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/tests/unit/core/test_ytdata.py +0 -0
  192. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/tests/unit/media/__init__.py +0 -0
  193. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/tests/unit/media/invariants.py +0 -0
  194. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/tests/unit/media/test_boxes.py +0 -0
  195. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/tests/unit/media/test_ffmpeg.py +0 -0
  196. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/tests/unit/media/test_fmp4.py +0 -0
  197. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/tests/unit/media/test_fmp4_factory.py +0 -0
  198. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/tests/unit/media/test_hollow.py +0 -0
  199. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/tests/unit/media/test_moov.py +0 -0
  200. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/tests/unit/media/test_mux.py +0 -0
  201. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/tests/unit/media/test_mux_large.py +0 -0
  202. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/tests/unit/media/test_mux_subtitles.py +0 -0
  203. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/tests/unit/media/test_progressive.py +0 -0
  204. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/tests/unit/media/test_tables.py +0 -0
  205. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/tests/unit/media/test_tx3g.py +0 -0
  206. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/tests/unit/services/__init__.py +0 -0
  207. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/tests/unit/services/test_bulk_downloads.py +0 -0
  208. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/tests/unit/services/test_bulk_runner.py +0 -0
  209. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/tests/unit/services/test_bulk_transcripts.py +0 -0
  210. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/tests/unit/services/test_collections.py +0 -0
  211. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/tests/unit/services/test_download_audio.py +0 -0
  212. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/tests/unit/services/test_download_video.py +0 -0
  213. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/tests/unit/services/test_translation.py +0 -0
  214. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/tests/unit/test_client.py +0 -0
  215. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/tests/unit/test_collection_types.py +0 -0
  216. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/tests/unit/test_collections_api.py +0 -0
  217. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/tests/unit/test_download_api.py +0 -0
  218. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/tests/unit/test_download_types.py +0 -0
  219. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/tests/unit/test_errors.py +0 -0
  220. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/tests/unit/test_facade.py +0 -0
  221. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/tests/unit/test_models.py +0 -0
  222. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/tests/unit/test_recorded_fixtures.py +0 -0
  223. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/tests/unit/test_track_translate.py +0 -0
  224. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/tests/unit/test_transcript_output.py +0 -0
  225. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/tests/unit/test_translation_api.py +0 -0
  226. {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a3}/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.0a3
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
@@ -87,13 +87,13 @@ from utmax.compat import YouTubeTranscriptApi
87
87
  `utmax-mcp` gives an AI assistant four tools: `list_tracks`, `get_transcript`, `list_videos`
88
88
  and `download`. It needs no API key: ask for a translation and the assistant translates the
89
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.
90
+ [uv](https://docs.astral.sh/uv/getting-started/installation/), which installs it from PyPI;
91
+ `@latest` makes it move to each new release the next time the client starts the server.
92
92
 
93
93
  ### Claude Code
94
94
 
95
95
  ```bash
96
- claude mcp add --scope user utmax -- uvx --from "u-transcript-max[mcp]" utmax-mcp
96
+ claude mcp add --scope user utmax -- uvx --from "u-transcript-max[mcp]@latest" utmax-mcp
97
97
  ```
98
98
 
99
99
  `claude mcp get utmax` should say `Connected`; inside Claude Code, `/mcp` lists the server.
@@ -101,7 +101,7 @@ claude mcp add --scope user utmax -- uvx --from "u-transcript-max[mcp]" utmax-mc
101
101
  ### Codex
102
102
 
103
103
  ```bash
104
- codex mcp add utmax -- uvx --from "u-transcript-max[mcp]" utmax-mcp
104
+ codex mcp add utmax -- uvx --from "u-transcript-max[mcp]@latest" utmax-mcp
105
105
  ```
106
106
 
107
107
  A long download can outlast Codex's default tool timeout, and the first start installs the
@@ -111,7 +111,7 @@ file):
111
111
  ```toml
112
112
  [mcp_servers.utmax]
113
113
  command = "uvx"
114
- args = ["--from", "u-transcript-max[mcp]", "utmax-mcp"]
114
+ args = ["--from", "u-transcript-max[mcp]@latest", "utmax-mcp"]
115
115
  startup_timeout_sec = 60
116
116
  tool_timeout_sec = 1800
117
117
  ```
@@ -126,7 +126,7 @@ restart Claude Desktop:
126
126
  "mcpServers": {
127
127
  "utmax": {
128
128
  "command": "uvx",
129
- "args": ["--from", "u-transcript-max[mcp]", "utmax-mcp"]
129
+ "args": ["--from", "u-transcript-max[mcp]@latest", "utmax-mcp"]
130
130
  }
131
131
  }
132
132
  }
@@ -48,13 +48,13 @@ from utmax.compat import YouTubeTranscriptApi
48
48
  `utmax-mcp` gives an AI assistant four tools: `list_tracks`, `get_transcript`, `list_videos`
49
49
  and `download`. It needs no API key: ask for a translation and the assistant translates the
50
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.
51
+ [uv](https://docs.astral.sh/uv/getting-started/installation/), which installs it from PyPI;
52
+ `@latest` makes it move to each new release the next time the client starts the server.
53
53
 
54
54
  ### Claude Code
55
55
 
56
56
  ```bash
57
- claude mcp add --scope user utmax -- uvx --from "u-transcript-max[mcp]" utmax-mcp
57
+ claude mcp add --scope user utmax -- uvx --from "u-transcript-max[mcp]@latest" utmax-mcp
58
58
  ```
59
59
 
60
60
  `claude mcp get utmax` should say `Connected`; inside Claude Code, `/mcp` lists the server.
@@ -62,7 +62,7 @@ claude mcp add --scope user utmax -- uvx --from "u-transcript-max[mcp]" utmax-mc
62
62
  ### Codex
63
63
 
64
64
  ```bash
65
- codex mcp add utmax -- uvx --from "u-transcript-max[mcp]" utmax-mcp
65
+ codex mcp add utmax -- uvx --from "u-transcript-max[mcp]@latest" utmax-mcp
66
66
  ```
67
67
 
68
68
  A long download can outlast Codex's default tool timeout, and the first start installs the
@@ -72,7 +72,7 @@ file):
72
72
  ```toml
73
73
  [mcp_servers.utmax]
74
74
  command = "uvx"
75
- args = ["--from", "u-transcript-max[mcp]", "utmax-mcp"]
75
+ args = ["--from", "u-transcript-max[mcp]@latest", "utmax-mcp"]
76
76
  startup_timeout_sec = 60
77
77
  tool_timeout_sec = 1800
78
78
  ```
@@ -87,7 +87,7 @@ restart Claude Desktop:
87
87
  "mcpServers": {
88
88
  "utmax": {
89
89
  "command": "uvx",
90
- "args": ["--from", "u-transcript-max[mcp]", "utmax-mcp"]
90
+ "args": ["--from", "u-transcript-max[mcp]@latest", "utmax-mcp"]
91
91
  }
92
92
  }
93
93
  }
@@ -396,6 +396,8 @@ def download(
396
396
  FormatNotAvailable: no stream fits the type and quality (live streams, for example).
397
397
  StreamForbidden, DownloadIncomplete, NetworkError: the download failed; call again to
398
398
  resume.
399
+ PoTokenRequired: YouTube serves only the start of this video's streams without a
400
+ proof-of-origin token, which utmax cannot create.
399
401
  DownloadCancelled: ``cancel`` was set.
400
402
  MuxError, FFmpegFailed: the file could not be assembled.
401
403
  VideoUnavailable, VideoUnplayable, AgeRestricted, RequestBlocked: YouTube refused.
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0a3"
@@ -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
@@ -35,6 +35,7 @@ class PlayerData:
35
35
  caption_tracks: tuple[CaptionTrackInfo, ...] | None
36
36
  translation_languages: tuple[Language, ...]
37
37
  streams: tuple[Stream, ...] = ()
38
+ spoken_language: str | None = None
38
39
 
39
40
 
40
41
  def parse_player_response(data: Mapping[str, Any], *, video_id: str) -> PlayerData:
@@ -62,15 +63,36 @@ def parse_player_response(data: Mapping[str, Any], *, video_id: str) -> PlayerDa
62
63
  for raw in map(mapping, items(renderer.get("translationLanguages")))
63
64
  if raw.get("languageCode")
64
65
  )
66
+ streaming = mapping(data.get("streamingData"))
65
67
  return PlayerData(
66
68
  video=video,
67
69
  playability=parse_playability(data),
68
70
  caption_tracks=tracks or None,
69
71
  translation_languages=languages if tracks else (),
70
- streams=parse_streams(mapping(data.get("streamingData"))),
72
+ streams=parse_streams(streaming),
73
+ spoken_language=_original_audio_language(streaming),
71
74
  )
72
75
 
73
76
 
77
+ def _original_audio_language(streaming: Mapping[str, Any]) -> str | None:
78
+ """The language of the original audio of a video with dubbed audio tracks, like ``en-US``.
79
+
80
+ YouTube names that track "<language> original" (utmax always asks in English) and tags its
81
+ stream URLs ``acont=original``; videos with a single audio track mark nothing.
82
+ """
83
+ for raw in map(mapping, items(streaming.get("adaptiveFormats"))):
84
+ track = mapping(raw.get("audioTrack"))
85
+ name = str(track.get("displayName") or "").lower()
86
+ url = str(raw.get("url") or "").lower()
87
+ original = name.endswith(" original") or any(
88
+ mark in url for mark in ("acont%3doriginal", "acont=original")
89
+ )
90
+ code = str(track.get("id") or "").partition(".")[0]
91
+ if original and code:
92
+ return code
93
+ return None
94
+
95
+
74
96
  def _track(raw: Mapping[str, Any]) -> CaptionTrackInfo:
75
97
  code = str(raw.get("languageCode") or "")
76
98
  return CaptionTrackInfo(
@@ -16,13 +16,17 @@ def select_track(
16
16
  *,
17
17
  include_manual: bool = True,
18
18
  include_generated: bool = True,
19
+ spoken_language: str | None = None,
19
20
  ) -> Track:
20
21
  """Pick one track from ``tracks``.
21
22
 
22
23
  With ``languages``, each code is tried in order: a manual track in exactly that code, then a
23
24
  manual track in the same base language (``de`` finds ``de-DE``), then the same two steps for
24
- auto-generated tracks. Without ``languages`` the spoken language (that of the auto-generated
25
- track) wins, manual first. YouTube's own translation is never used implicitly.
25
+ auto-generated tracks. Without ``languages`` the spoken language wins, manual first: the
26
+ language of the original audio (``spoken_language``) when tracks exist in it, else that of
27
+ the first auto-generated track. Videos with dubbed audio list an auto-generated track per
28
+ dub, so their first one need not be the original's. YouTube's own translation is never
29
+ used implicitly.
26
30
 
27
31
  Raises:
28
32
  NoTranscriptFound: nothing matches; the error lists every available track.
@@ -44,7 +48,11 @@ def select_track(
44
48
  if pool:
45
49
  return pool[0]
46
50
  raise _not_found(tracks, requested)
47
- spoken = next((track.language_code for track in tracks if track.is_generated), None)
51
+ spoken = (
52
+ spoken_language
53
+ if spoken_language and _in_language(tracks, spoken_language)
54
+ else next((track.language_code for track in tracks if track.is_generated), None)
55
+ )
48
56
  if spoken is not None:
49
57
  manual = _in_language([track for track in candidates if not track.is_generated], spoken)
50
58
  if manual:
@@ -197,7 +197,8 @@ class IpBlocked(RequestBlocked):
197
197
 
198
198
 
199
199
  class PoTokenRequired(YouTubeError):
200
- """YouTube requires a proof-of-origin token that utmax cannot produce."""
200
+ """YouTube requires a proof-of-origin token that utmax cannot produce (for captions, or
201
+ for the streams of some videos)."""
201
202
 
202
203
  suggestion = (
203
204
  "YouTube changed how captions are served; please report it at "
@@ -172,11 +172,16 @@ class Track:
172
172
 
173
173
  @dataclass(frozen=True, slots=True)
174
174
  class TrackList(Sequence[Track]):
175
- """All subtitle tracks of a video, in YouTube's order."""
175
+ """All subtitle tracks of a video, in YouTube's order.
176
+
177
+ ``spoken_language`` is the language of the video's original audio when YouTube marks it,
178
+ which it does for videos with dubbed audio tracks (``None`` otherwise).
179
+ """
176
180
 
177
181
  video: VideoInfo
178
182
  tracks: tuple[Track, ...]
179
183
  translation_languages: tuple[Language, ...] = ()
184
+ spoken_language: str | None = None
180
185
 
181
186
  @overload
182
187
  def __getitem__(self, index: int) -> Track: ...
@@ -216,6 +221,7 @@ class TrackList(Sequence[Track]):
216
221
  languages,
217
222
  include_manual=include_manual,
218
223
  include_generated=include_generated,
224
+ spoken_language=self.spoken_language,
219
225
  )
220
226
 
221
227
 
@@ -44,7 +44,10 @@ class TranscriptService:
44
44
  for info in player.caption_tracks or ()
45
45
  )
46
46
  return TrackList(
47
- video=player.video, tracks=tracks, translation_languages=player.translation_languages
47
+ video=player.video,
48
+ tracks=tracks,
49
+ translation_languages=player.translation_languages,
50
+ spoken_language=player.spoken_language,
48
51
  )
49
52
 
50
53
  def fetch(
@@ -100,6 +100,8 @@ class FakeMedia:
100
100
  self.requests: list[HttpRequest] = []
101
101
  self.bodies: list[FakeBody] = []
102
102
  self.expired: set[str] = set()
103
+ # name -> bytes served without a proof-of-origin token; requests past them answer 403
104
+ self.start_only: dict[str, int] = {}
103
105
  self._streams: dict[str, bytes] = {}
104
106
  self._faults: dict[str, deque[Fault]] = {}
105
107
  self._lock = threading.Lock()
@@ -129,7 +131,7 @@ class FakeMedia:
129
131
  fault = faults.popleft() if faults else Fault()
130
132
  if fault.error is not None:
131
133
  raise fault.error
132
- body = self._answer(request, self._streams.get(name), fault)
134
+ body = self._answer(request, self._streams.get(name), fault, self.start_only.get(name))
133
135
  with self._lock:
134
136
  self.bodies.append(body)
135
137
  return body
@@ -141,7 +143,9 @@ class FakeMedia:
141
143
  body.close()
142
144
  return HttpResponse(status=body.status, url=request.url, headers=body.headers, body=data)
143
145
 
144
- def _answer(self, request: HttpRequest, data: bytes | None, fault: Fault) -> FakeBody:
146
+ def _answer(
147
+ self, request: HttpRequest, data: bytes | None, fault: Fault, limit: int | None
148
+ ) -> FakeBody:
145
149
  if data is None:
146
150
  return FakeBody(404, {}, b"")
147
151
  if request.url in self.expired:
@@ -150,10 +154,14 @@ class FakeMedia:
150
154
  return FakeBody(fault.status, {}, b"")
151
155
  header = request.headers.get("Range")
152
156
  if header is None or fault.ignore_range:
157
+ if limit is not None and len(data) > limit:
158
+ return FakeBody(403, {}, b"")
153
159
  return FakeBody(200, {"Content-Length": str(len(data))}, data)
154
160
  first, _, last = header.removeprefix("bytes=").partition("-")
155
161
  start = int(first)
156
162
  end = min(int(last) + 1 if last else len(data), len(data))
163
+ if limit is not None and end > limit:
164
+ return FakeBody(403, {}, b"")
157
165
  if start >= len(data):
158
166
  return FakeBody(416, {"Content-Range": f"bytes */{len(data)}"}, b"")
159
167
  body = data[start:end]
@@ -97,6 +97,39 @@ DEFAULT_TRACKS: tuple[tuple[str, str, bool], ...] = (
97
97
  )
98
98
 
99
99
 
100
+ def audio_track_format(
101
+ track_id: str, name: str, *, xtags: str, default: bool = False
102
+ ) -> dict[str, Any]:
103
+ """An AAC format of one audio track of a video with dubbed audio (URL is a placeholder)."""
104
+ return {
105
+ "itag": 140,
106
+ "mimeType": 'audio/mp4; codecs="mp4a.40.2"',
107
+ "url": f"https://media.test/140?xtags={xtags}",
108
+ "audioTrack": {"id": track_id, "displayName": name, "audioIsDefault": default},
109
+ }
110
+
111
+
112
+ # Shaped like ZcDFZzsp3_Y on 2026-10-08: English original audio, 20 automatic dubs, and an
113
+ # auto-generated track per dub listed before the original's (Arabic first).
114
+ DUBBED_AUDIO: dict[str, Any] = {
115
+ "adaptiveFormats": [
116
+ audio_track_format("ar.10", "Arabic", xtags="acont%3Ddubbed-auto%3Alang%3Dar"),
117
+ audio_track_format(
118
+ "en-US.4",
119
+ "English (US) original",
120
+ xtags="acont%3Doriginal%3Adrc%3D1%3Alang%3Den-US",
121
+ default=True,
122
+ ),
123
+ ]
124
+ }
125
+ DUBBED_TRACKS: tuple[tuple[str, str, bool], ...] = (
126
+ ("ar", "Arabic (auto-generated)", True),
127
+ ("en", "English", False),
128
+ ("en", "English (auto-generated)", True),
129
+ ("de", "German (auto-generated)", True),
130
+ )
131
+
132
+
100
133
  def caption_track(
101
134
  code: str, name: str, generated: bool, *, video_id: str = VIDEO_ID
102
135
  ) -> dict[str, Any]:
@@ -19,6 +19,7 @@ from utmax.errors import (
19
19
  DownloadIncomplete,
20
20
  IpBlocked,
21
21
  NetworkError,
22
+ PoTokenRequired,
22
23
  StreamForbidden,
23
24
  )
24
25
  from utmax.models import Progress
@@ -138,6 +139,41 @@ def test_forbidden_streams_give_up_after_three_refreshes(tmp_path: Path) -> None
138
139
  downloader(media, refresh=refresh).run([job], video_id="v")
139
140
  assert caught.value.itag == 137
140
141
  assert len(calls) == 3
142
+
143
+
144
+ def test_streams_served_only_at_the_start_need_a_po_token(tmp_path: Path) -> None:
145
+ """Without a proof-of-origin token YouTube serves only the first megabyte of some videos'
146
+ streams and answers 403 after it (ZcDFZzsp3_Y over ANDROID and IOS, 2026-10-08)."""
147
+ media, _, job = served(tmp_path, size=8000)
148
+ media.start_only["video"] = 1500
149
+ calls: list[int] = []
150
+
151
+ def refresh() -> list[Stream]:
152
+ calls.append(1)
153
+ return [media_stream(137, f"{job.stream.url}?v={len(calls)}", 8000)]
154
+
155
+ with pytest.raises(PoTokenRequired, match=r"proof-of-origin \(PO\) token") as caught:
156
+ downloader(media, refresh=refresh, connections=1).run([job], video_id="v")
157
+ error = caught.value
158
+ assert "stream 137 of video v" in str(error)
159
+ assert error.video_id == "v"
160
+ assert "subtitles can still be fetched" in error.suggestion
161
+ assert len(calls) == 3
162
+ assert media.ranges("video")[-1] == "bytes=0-0"
163
+ assert all(body.closed for body in media.bodies)
164
+
165
+
166
+ def test_a_failed_first_byte_check_keeps_the_403_error(tmp_path: Path) -> None:
167
+ media, _, job = served(tmp_path, size=8000)
168
+ media.expired.add(job.stream.url)
169
+
170
+ def opener(request: HttpRequest) -> FakeBody:
171
+ if request.headers.get("Range") == "bytes=0-0":
172
+ raise NetworkError("Could not complete GET https://media.test: connection reset")
173
+ return media.stream(request)
174
+
175
+ with pytest.raises(StreamForbidden, match="after 0 fresh URLs"):
176
+ Downloader(opener, chunk_size=CHUNK, sleep=lambda _: None).run([job], video_id="v")
141
177
  assert completed(job) == []
142
178
 
143
179
 
@@ -6,7 +6,13 @@ from typing import Any
6
6
 
7
7
  import pytest
8
8
 
9
- from tests.helpers.youtube import VIDEO_ID, player_payload, streaming_data
9
+ from tests.helpers.youtube import (
10
+ DUBBED_AUDIO,
11
+ VIDEO_ID,
12
+ audio_track_format,
13
+ player_payload,
14
+ streaming_data,
15
+ )
10
16
  from utmax.core.player import CaptionTrackInfo, parse_player_response
11
17
  from utmax.models import Language, VideoInfo
12
18
 
@@ -92,3 +98,43 @@ def test_streams_are_parsed_from_streaming_data() -> None:
92
98
 
93
99
  def test_players_without_streaming_data_have_no_streams() -> None:
94
100
  assert parse_player_response(player_payload(), video_id=VIDEO_ID).streams == ()
101
+
102
+
103
+ def test_the_original_audio_track_names_the_spoken_language() -> None:
104
+ payload = player_payload(streaming_data=DUBBED_AUDIO)
105
+ assert parse_player_response(payload, video_id=VIDEO_ID).spoken_language == "en-US"
106
+
107
+
108
+ @pytest.mark.parametrize(
109
+ ("name", "xtags"),
110
+ [
111
+ ("English (US) original", ""),
112
+ ("English (US)", "acont%3Doriginal%3Alang%3Den-US"),
113
+ ("English (US)", "acont=original:lang=en-US"),
114
+ ],
115
+ )
116
+ def test_either_mark_of_the_original_audio_is_enough(name: str, xtags: str) -> None:
117
+ dub = audio_track_format("ar.10", "Arabic", xtags="acont%3Ddubbed-auto")
118
+ original = audio_track_format("en-US.4", name, xtags=xtags)
119
+ payload = player_payload(streaming_data={"adaptiveFormats": [dub, original]})
120
+ assert parse_player_response(payload, video_id=VIDEO_ID).spoken_language == "en-US"
121
+
122
+
123
+ @pytest.mark.parametrize(
124
+ "streaming",
125
+ [
126
+ None,
127
+ {"adaptiveFormats": [audio_track_format("ar.10", "Arabic", xtags="acont%3Ddubbed-auto")]},
128
+ {"adaptiveFormats": [{"audioTrack": {"displayName": "English original"}}]},
129
+ ],
130
+ )
131
+ def test_without_a_marked_original_audio_there_is_no_spoken_language(
132
+ streaming: dict[str, Any] | None,
133
+ ) -> None:
134
+ payload = player_payload(streaming_data=streaming)
135
+ assert parse_player_response(payload, video_id=VIDEO_ID).spoken_language is None
136
+
137
+
138
+ def test_a_single_audio_track_carries_no_mark() -> None:
139
+ payload = player_payload(streaming_data=streaming_data())
140
+ assert parse_player_response(payload, video_id=VIDEO_ID).spoken_language is None
@@ -89,3 +89,28 @@ def test_track_list_find_uses_the_same_rules() -> None:
89
89
  assert tracks.find(["de"]) is DE
90
90
  assert tracks.find() is EN
91
91
  assert tracks.find(["en"], include_manual=False) is EN_AUTO
92
+
93
+
94
+ AR_AUTO = make_track("ar", generated=True, name="Arabic (auto-generated)")
95
+ DUBBED = (AR_AUTO, EN, EN_AUTO, make_track("de", generated=True))
96
+
97
+
98
+ def test_the_original_audio_language_beats_tracks_of_dubbed_audio() -> None:
99
+ """Videos with dubbed audio list an auto-generated track per dub, often before the
100
+ original's; without the original audio's language the first one would win."""
101
+ assert pick(DUBBED) is AR_AUTO
102
+ assert select_track(DUBBED, spoken_language="en-US") is EN
103
+ assert select_track(DUBBED, spoken_language="en-US", include_manual=False) is EN_AUTO
104
+ assert select_track(DUBBED, ["ar"], spoken_language="en-US") is AR_AUTO
105
+
106
+
107
+ def test_a_spoken_language_without_tracks_falls_back_to_the_first_auto_track() -> None:
108
+ assert select_track((AR_AUTO, DE), spoken_language="fr") is AR_AUTO
109
+ assert select_track((JA, DE), spoken_language="fr") is JA
110
+
111
+
112
+ def test_track_list_find_uses_the_original_audio_language() -> None:
113
+ tracks = TrackList(video=VIDEO, tracks=DUBBED, spoken_language="en-US")
114
+ assert tracks.find() is EN
115
+ assert tracks.find(include_manual=False) is EN_AUTO
116
+ assert TrackList(video=VIDEO, tracks=DUBBED).find() is AR_AUTO
@@ -7,6 +7,9 @@ import pytest
7
7
  from tests.helpers.fake_transport import FakeTransport, json_response
8
8
  from tests.helpers.youtube import (
9
9
  ASR_JSON3,
10
+ DUBBED_AUDIO,
11
+ DUBBED_TRACKS,
12
+ MANUAL_JSON3,
10
13
  VIDEO_ID,
11
14
  json3_payload,
12
15
  player_payload,
@@ -140,6 +143,17 @@ def test_track_list_reuses_a_player_response_without_another_request() -> None:
140
143
  assert tracks[0].fetch().language_code == "en"
141
144
 
142
145
 
146
+ def test_videos_with_dubbed_audio_default_to_their_original_language() -> None:
147
+ transport = FakeTransport()
148
+ payload = player_payload(tracks=DUBBED_TRACKS, streaming_data=DUBBED_AUDIO)
149
+ transport.add("POST", "/youtubei/v1/player", json_response(payload))
150
+ transport.add("GET", "lang=en&fmt=json3", json_response(MANUAL_JSON3))
151
+ tracks = service(transport).list_tracks(VIDEO_ID)
152
+ assert tracks.spoken_language == "en-US"
153
+ transcript = tracks.find().fetch()
154
+ assert (transcript.language_code, transcript.is_generated) == ("en", False)
155
+
156
+
143
157
  def test_track_list_is_empty_without_captions() -> None:
144
158
  transport = FakeTransport()
145
159
  transport.add("POST", "/youtubei/v1/player", json_response(player_payload(captions=False)))
@@ -1 +0,0 @@
1
- __version__ = "0.1.0a2"