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