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.
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/PKG-INFO +13 -11
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/README.md +12 -10
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/src/utmax/__init__.py +40 -10
- u_transcript_max-0.1.0a4/src/utmax/_version.py +1 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/src/utmax/adapters/downloader.py +44 -8
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/src/utmax/adapters/innertube.py +87 -9
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/src/utmax/client.py +11 -2
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/src/utmax/core/clients.py +38 -6
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/src/utmax/core/downloads.py +3 -1
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/src/utmax/core/formats.py +68 -2
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/src/utmax/core/player.py +6 -1
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/src/utmax/core/selection.py +11 -3
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/src/utmax/core/streams.py +105 -47
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/src/utmax/errors.py +2 -1
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/src/utmax/mcp/server.py +274 -19
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/src/utmax/models.py +80 -4
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/src/utmax/services/bulk.py +3 -1
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/src/utmax/services/download.py +28 -11
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/src/utmax/services/transcripts.py +4 -1
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/helpers/bulk.py +3 -1
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/helpers/downloads.py +5 -2
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/helpers/fake_media.py +10 -2
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/helpers/youtube.py +39 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/live/test_download_live.py +22 -4
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/mcp/conftest.py +1 -1
- u_transcript_max-0.1.0a4/tests/mcp/test_flow.py +314 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/mcp/test_server.py +50 -4
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/mcp/test_stdio.py +8 -1
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/unit/adapters/test_downloader_recovery.py +36 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/unit/adapters/test_innertube.py +24 -8
- u_transcript_max-0.1.0a4/tests/unit/adapters/test_innertube_visitor.py +230 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/unit/core/test_clients.py +20 -3
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/unit/core/test_player.py +47 -1
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/unit/core/test_selection.py +25 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/unit/core/test_streams.py +163 -38
- u_transcript_max-0.1.0a4/tests/unit/core/test_subtitle_parsing.py +93 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/unit/services/test_bulk_downloads.py +18 -5
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/unit/services/test_download_audio.py +6 -3
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/unit/services/test_download_video.py +8 -7
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/unit/services/test_transcripts.py +14 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/unit/test_collections_api.py +1 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/unit/test_download_api.py +25 -2
- u_transcript_max-0.1.0a2/src/utmax/_version.py +0 -1
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/.gitignore +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/LICENSE +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/pyproject.toml +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/src/utmax/adapters/__init__.py +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/src/utmax/adapters/ffmpeg.py +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/src/utmax/adapters/files.py +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/src/utmax/adapters/http.py +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/src/utmax/adapters/providers/__init__.py +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/src/utmax/adapters/providers/base.py +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/src/utmax/adapters/providers/claude.py +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/src/utmax/adapters/providers/gemini.py +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/src/utmax/adapters/providers/openai.py +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/src/utmax/adapters/providers/openrouter.py +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/src/utmax/adapters/watch_page.py +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/src/utmax/compat/__init__.py +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/src/utmax/compat/_api.py +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/src/utmax/compat/_bridge.py +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/src/utmax/compat/_errors.py +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/src/utmax/compat/_settings.py +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/src/utmax/compat/_transcripts.py +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/src/utmax/compat/formatters.py +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/src/utmax/compat/proxies.py +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/src/utmax/core/__init__.py +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/src/utmax/core/bilingual.py +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/src/utmax/core/browse.py +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/src/utmax/core/captions.py +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/src/utmax/core/filenames.py +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/src/utmax/core/ids.py +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/src/utmax/core/languages.py +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/src/utmax/core/media/__init__.py +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/src/utmax/core/media/boxes.py +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/src/utmax/core/media/fmp4.py +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/src/utmax/core/media/moov.py +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/src/utmax/core/media/mux.py +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/src/utmax/core/media/progressive.py +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/src/utmax/core/media/tables.py +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/src/utmax/core/media/tx3g.py +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/src/utmax/core/playability.py +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/src/utmax/core/retry.py +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/src/utmax/core/segmentation.py +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/src/utmax/core/translate/__init__.py +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/src/utmax/core/translate/batching.py +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/src/utmax/core/translate/data/protocol.json +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/src/utmax/core/translate/data/request.schema.json +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/src/utmax/core/translate/data/response.schema.json +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/src/utmax/core/translate/data/system_prompt.txt +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/src/utmax/core/translate/protocol.py +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/src/utmax/core/translate/spec.py +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/src/utmax/core/ytdata.py +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/src/utmax/mcp/__init__.py +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/src/utmax/mcp/__main__.py +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/src/utmax/mcp/config.py +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/src/utmax/providers.py +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/src/utmax/py.typed +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/src/utmax/services/__init__.py +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/src/utmax/services/collections.py +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/src/utmax/services/translation.py +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/src/utmax/transport.py +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/__init__.py +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/compat/__init__.py +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/compat/test_api.py +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/compat/test_bridge.py +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/compat/test_errors.py +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/compat/test_formatters.py +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/compat/test_install.py +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/compat/test_legacy.py +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/compat/test_manifest.py +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/compat/test_manifest_script.py +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/compat/test_proxies.py +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/compat/test_transcripts.py +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/fixtures/compat/youtube_transcript_api-1.2.4.json +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/fixtures/media/README.md +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/fixtures/media/dQw4w9WgXcQ_137.hollow.bin +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/fixtures/media/dQw4w9WgXcQ_140.hollow.bin +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/fixtures/media/dQw4w9WgXcQ_399.hollow.bin +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/fixtures/media/manifest.json +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/fixtures/youtube/README.md +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/fixtures/youtube/browse_android_vr_1.json +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/fixtures/youtube/browse_android_vr_2.json +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/fixtures/youtube/browse_web_1.json +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/fixtures/youtube/browse_web_2.json +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/fixtures/youtube/browse_web_shorts.json +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/fixtures/youtube/json3_en_asr.json +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/fixtures/youtube/json3_en_manual.json +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/fixtures/youtube/legacy_en_manual.xml +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/fixtures/youtube/player_android.json +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/fixtures/youtube/player_android_vr.json +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/fixtures/youtube/player_ios.json +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/fixtures/youtube/resolve_handle.json +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/fixtures/youtube/resolve_unknown.json +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/fixtures/youtube/srv3_en_asr.xml +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/fixtures/youtube/streams_android_vr.json +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/helpers/__init__.py +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/helpers/browse.py +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/helpers/builders.py +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/helpers/compat.py +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/helpers/fake_translator.py +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/helpers/fake_transport.py +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/helpers/files.py +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/helpers/fmp4_factory.py +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/helpers/hollow_source.py +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/helpers/http_server.py +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/helpers/json_schema.py +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/helpers/sdk.py +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/live/__init__.py +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/live/conftest.py +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/live/test_collections_live.py +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/live/test_compat_live.py +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/live/test_transcripts_live.py +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/live/test_translation_live.py +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/mcp/__init__.py +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/mcp/test_config.py +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/mcp/test_download.py +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/mcp/test_live.py +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/mcp/test_main.py +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/mcp/test_package.py +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/test_architecture.py +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/test_extras.py +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/test_package.py +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/unit/__init__.py +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/unit/adapters/__init__.py +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/unit/adapters/providers/__init__.py +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/unit/adapters/providers/test_base.py +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/unit/adapters/providers/test_claude.py +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/unit/adapters/providers/test_factory.py +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/unit/adapters/providers/test_gemini.py +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/unit/adapters/providers/test_openai.py +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/unit/adapters/providers/test_openrouter.py +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/unit/adapters/test_downloader.py +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/unit/adapters/test_ffmpeg_adapter.py +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/unit/adapters/test_ffmpeg_mp3.py +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/unit/adapters/test_files.py +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/unit/adapters/test_http.py +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/unit/adapters/test_http_stream.py +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/unit/adapters/test_innertube_browse.py +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/unit/adapters/test_mux_executor.py +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/unit/adapters/test_watch_page.py +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/unit/core/__init__.py +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/unit/core/test_batching.py +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/unit/core/test_bilingual.py +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/unit/core/test_browse.py +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/unit/core/test_captions.py +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/unit/core/test_downloads.py +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/unit/core/test_filenames.py +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/unit/core/test_formats.py +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/unit/core/test_ids.py +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/unit/core/test_languages.py +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/unit/core/test_model_spec.py +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/unit/core/test_name_templates.py +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/unit/core/test_pager.py +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/unit/core/test_playability.py +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/unit/core/test_retry.py +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/unit/core/test_segmentation.py +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/unit/core/test_sources.py +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/unit/core/test_translate_protocol.py +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/unit/core/test_ytdata.py +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/unit/media/__init__.py +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/unit/media/invariants.py +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/unit/media/test_boxes.py +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/unit/media/test_ffmpeg.py +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/unit/media/test_fmp4.py +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/unit/media/test_fmp4_factory.py +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/unit/media/test_hollow.py +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/unit/media/test_moov.py +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/unit/media/test_mux.py +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/unit/media/test_mux_large.py +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/unit/media/test_mux_subtitles.py +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/unit/media/test_progressive.py +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/unit/media/test_tables.py +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/unit/media/test_tx3g.py +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/unit/services/__init__.py +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/unit/services/test_bulk_runner.py +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/unit/services/test_bulk_transcripts.py +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/unit/services/test_collections.py +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/unit/services/test_translation.py +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/unit/test_client.py +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/unit/test_collection_types.py +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/unit/test_download_types.py +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/unit/test_errors.py +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/unit/test_facade.py +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/unit/test_models.py +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/unit/test_recorded_fixtures.py +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/unit/test_track_translate.py +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/unit/test_transcript_output.py +0 -0
- {u_transcript_max-0.1.0a2 → u_transcript_max-0.1.0a4}/tests/unit/test_translation_api.py +0 -0
- {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.
|
|
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
|
|
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
|
|
88
|
-
and `
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
|
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
|
|
49
|
-
and `
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
|
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 = "
|
|
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
|
|
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/"
|
|
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: ``"
|
|
366
|
-
|
|
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 = "
|
|
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`,
|
|
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
|
|
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
|
-
|
|
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
|
|
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(
|
|
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.
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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 = "
|
|
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 = "
|
|
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
|
-
|
|
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": (
|
|
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
|
)
|