From cfc6bbabc954c6695d8078490a45dad67e7be21e Mon Sep 17 00:00:00 2001 From: UrloMythus Date: Thu, 19 Feb 2026 20:15:03 +0100 Subject: [PATCH] update --- .../__pycache__/__init__.cpython-313.pyc | Bin 0 -> 162 bytes .../__pycache__/configs.cpython-313.pyc | Bin 0 -> 5823 bytes .../__pycache__/const.cpython-313.pyc | Bin 0 -> 406 bytes .../__pycache__/handlers.cpython-313.pyc | Bin 0 -> 44613 bytes .../__pycache__/main.cpython-313.pyc | Bin 0 -> 13874 bytes .../__pycache__/middleware.cpython-313.pyc | Bin 0 -> 1637 bytes .../__pycache__/mpd_processor.cpython-313.pyc | Bin 0 -> 23146 bytes .../__pycache__/schemas.cpython-313.pyc | Bin 0 -> 15442 bytes mediaflow_proxy/configs.py | 109 +- .../drm/__pycache__/__init__.cpython-313.pyc | Bin 0 -> 979 bytes .../drm/__pycache__/decrypter.cpython-313.pyc | Bin 0 -> 63239 bytes mediaflow_proxy/drm/decrypter.py | 919 +++- mediaflow_proxy/extractors/F16Px.py | 8 +- .../__pycache__/F16Px.cpython-313.pyc | Bin 0 -> 5489 bytes .../__pycache__/__init__.cpython-313.pyc | Bin 0 -> 173 bytes .../__pycache__/base.cpython-313.pyc | Bin 0 -> 7600 bytes .../__pycache__/dlhd.cpython-313.pyc | Bin 0 -> 32191 bytes .../__pycache__/doodstream.cpython-313.pyc | Bin 0 -> 2906 bytes .../__pycache__/factory.cpython-313.pyc | Bin 0 -> 3729 bytes .../__pycache__/fastream.cpython-313.pyc | Bin 0 -> 2248 bytes .../__pycache__/filelions.cpython-313.pyc | Bin 0 -> 1630 bytes .../__pycache__/filemoon.cpython-313.pyc | Bin 0 -> 2794 bytes .../__pycache__/gupload.cpython-313.pyc | Bin 0 -> 3134 bytes .../__pycache__/livetv.cpython-313.pyc | Bin 0 -> 12554 bytes .../__pycache__/lulustream.cpython-313.pyc | Bin 0 -> 1805 bytes .../__pycache__/maxstream.cpython-313.pyc | Bin 0 -> 3617 bytes .../__pycache__/mixdrop.cpython-313.pyc | Bin 0 -> 1457 bytes .../__pycache__/okru.cpython-313.pyc | Bin 0 -> 2266 bytes .../__pycache__/sportsonline.cpython-313.pyc | Bin 0 -> 8095 bytes .../__pycache__/streamtape.cpython-313.pyc | Bin 0 -> 1695 bytes .../__pycache__/streamwish.cpython-313.pyc | Bin 0 -> 3695 bytes .../__pycache__/supervideo.cpython-313.pyc | Bin 0 -> 3712 bytes .../__pycache__/turbovidplay.cpython-313.pyc | Bin 0 -> 2330 bytes .../__pycache__/uqload.cpython-313.pyc | Bin 0 -> 1525 bytes .../__pycache__/vavoo.cpython-313.pyc | Bin 0 -> 11295 bytes .../__pycache__/vidmoly.cpython-313.pyc | Bin 0 -> 2805 bytes .../__pycache__/vidoza.cpython-313.pyc | Bin 0 -> 2930 bytes .../__pycache__/vixcloud.cpython-313.pyc | Bin 0 -> 4596 bytes .../__pycache__/voe.cpython-313.pyc | Bin 0 -> 4680 bytes mediaflow_proxy/extractors/base.py | 117 +- mediaflow_proxy/extractors/dlhd.py | 804 ++- mediaflow_proxy/extractors/doodstream.py | 53 +- mediaflow_proxy/extractors/factory.py | 2 + mediaflow_proxy/extractors/fastream.py | 20 +- mediaflow_proxy/extractors/filelions.py | 12 +- mediaflow_proxy/extractors/filemoon.py | 2 +- mediaflow_proxy/extractors/gupload.py | 65 + mediaflow_proxy/extractors/livetv.py | 29 +- mediaflow_proxy/extractors/lulustream.py | 2 +- mediaflow_proxy/extractors/mixdrop.py | 2 +- mediaflow_proxy/extractors/okru.py | 4 +- mediaflow_proxy/extractors/sportsonline.py | 55 +- mediaflow_proxy/extractors/streamtape.py | 4 +- mediaflow_proxy/extractors/streamwish.py | 29 +- mediaflow_proxy/extractors/supervideo.py | 63 +- mediaflow_proxy/extractors/turbovidplay.py | 16 +- mediaflow_proxy/extractors/vavoo.py | 124 +- mediaflow_proxy/extractors/vidmoly.py | 18 +- mediaflow_proxy/extractors/vidoza.py | 34 +- mediaflow_proxy/extractors/vixcloud.py | 9 +- mediaflow_proxy/extractors/voe.py | 17 +- mediaflow_proxy/handlers.py | 788 ++- mediaflow_proxy/main.py | 108 +- mediaflow_proxy/mpd_processor.py | 467 +- mediaflow_proxy/remuxer/__init__.py | 18 + .../__pycache__/__init__.cpython-313.pyc | Bin 0 -> 955 bytes .../audio_transcoder.cpython-313.pyc | Bin 0 -> 13556 bytes .../__pycache__/codec_utils.cpython-313.pyc | Bin 0 -> 14817 bytes .../container_probe.cpython-313.pyc | Bin 0 -> 21810 bytes .../__pycache__/ebml_parser.cpython-313.pyc | Bin 0 -> 39016 bytes .../__pycache__/hls_manifest.cpython-313.pyc | Bin 0 -> 5474 bytes .../__pycache__/media_source.cpython-313.pyc | Bin 0 -> 12034 bytes .../__pycache__/mkv_demuxer.cpython-313.pyc | Bin 0 -> 20403 bytes .../__pycache__/mp4_muxer.cpython-313.pyc | Bin 0 -> 57356 bytes .../__pycache__/mp4_parser.cpython-313.pyc | Bin 0 -> 26375 bytes .../__pycache__/pyav_demuxer.cpython-313.pyc | Bin 0 -> 26222 bytes .../transcode_handler.cpython-313.pyc | Bin 0 -> 37682 bytes .../transcode_pipeline.cpython-313.pyc | Bin 0 -> 41184 bytes .../__pycache__/ts_muxer.cpython-313.pyc | Bin 0 -> 53600 bytes .../video_transcoder.cpython-313.pyc | Bin 0 -> 16885 bytes mediaflow_proxy/remuxer/audio_transcoder.py | 351 ++ mediaflow_proxy/remuxer/codec_utils.py | 515 ++ mediaflow_proxy/remuxer/container_probe.py | 614 +++ mediaflow_proxy/remuxer/ebml_parser.py | 1228 +++++ mediaflow_proxy/remuxer/hls_manifest.py | 151 + mediaflow_proxy/remuxer/media_source.py | 234 + mediaflow_proxy/remuxer/mkv_demuxer.py | 469 ++ mediaflow_proxy/remuxer/mp4_muxer.py | 1376 +++++ mediaflow_proxy/remuxer/mp4_parser.py | 834 +++ mediaflow_proxy/remuxer/pyav_demuxer.py | 608 +++ mediaflow_proxy/remuxer/transcode_handler.py | 1121 ++++ mediaflow_proxy/remuxer/transcode_pipeline.py | 1268 +++++ mediaflow_proxy/remuxer/ts_muxer.py | 1728 +++++++ mediaflow_proxy/remuxer/video_transcoder.py | 403 ++ mediaflow_proxy/routes/__init__.py | 13 +- .../__pycache__/__init__.cpython-313.pyc | Bin 0 -> 592 bytes .../__pycache__/acestream.cpython-313.pyc | Bin 0 -> 22426 bytes .../__pycache__/extractor.cpython-313.pyc | Bin 0 -> 11686 bytes .../playlist_builder.cpython-313.pyc | Bin 0 -> 16020 bytes .../routes/__pycache__/proxy.cpython-313.pyc | Bin 0 -> 35170 bytes .../__pycache__/speedtest.cpython-313.pyc | Bin 0 -> 2054 bytes .../__pycache__/telegram.cpython-313.pyc | Bin 0 -> 36104 bytes .../routes/__pycache__/xtream.cpython-313.pyc | Bin 0 -> 44682 bytes mediaflow_proxy/routes/acestream.py | 540 ++ mediaflow_proxy/routes/extractor.py | 198 +- mediaflow_proxy/routes/playlist_builder.py | 342 +- mediaflow_proxy/routes/proxy.py | 777 +-- mediaflow_proxy/routes/telegram.py | 975 ++++ mediaflow_proxy/routes/xtream.py | 1146 ++++ mediaflow_proxy/schemas.py | 210 +- .../__pycache__/__init__.cpython-313.pyc | Bin 0 -> 172 bytes .../__pycache__/models.cpython-313.pyc | Bin 0 -> 2300 bytes .../__pycache__/service.cpython-313.pyc | Bin 0 -> 1862 bytes .../__pycache__/all_debrid.cpython-313.pyc | Bin 0 -> 3410 bytes .../__pycache__/base.cpython-313.pyc | Bin 0 -> 1615 bytes .../__pycache__/real_debrid.cpython-313.pyc | Bin 0 -> 2539 bytes .../speedtest/providers/all_debrid.py | 4 +- mediaflow_proxy/static/playlist_builder.html | 304 +- mediaflow_proxy/static/speedtest.html | 715 +-- mediaflow_proxy/static/url_generator.html | 3051 +++++++++++ .../__pycache__/__init__.cpython-313.pyc | Bin 0 -> 168 bytes .../__pycache__/acestream.cpython-313.pyc | Bin 0 -> 36813 bytes .../utils/__pycache__/aes.cpython-313.pyc | Bin 0 -> 1736 bytes .../utils/__pycache__/aesgcm.cpython-313.pyc | Bin 0 -> 7016 bytes .../__pycache__/base64_utils.cpython-313.pyc | Bin 0 -> 4500 bytes .../base_prebuffer.cpython-313.pyc | Bin 0 -> 18246 bytes .../__pycache__/cache_utils.cpython-313.pyc | Bin 0 -> 9426 bytes .../utils/__pycache__/codec.cpython-313.pyc | Bin 0 -> 14197 bytes .../utils/__pycache__/compat.cpython-313.pyc | Bin 0 -> 4727 bytes .../__pycache__/constanttime.cpython-313.pyc | Bin 0 -> 6973 bytes .../__pycache__/crypto_utils.cpython-313.pyc | Bin 0 -> 8308 bytes .../__pycache__/cryptomath.cpython-313.pyc | Bin 0 -> 11866 bytes .../dash_prebuffer.cpython-313.pyc | Bin 0 -> 17029 bytes .../__pycache__/deprecations.cpython-313.pyc | Bin 0 -> 10680 bytes .../extractor_helpers.cpython-313.pyc | Bin 0 -> 7752 bytes .../__pycache__/hls_prebuffer.cpython-313.pyc | Bin 0 -> 23113 bytes .../__pycache__/hls_utils.cpython-313.pyc | Bin 0 -> 4642 bytes .../__pycache__/http_client.cpython-313.pyc | Bin 0 -> 12690 bytes .../__pycache__/http_utils.cpython-313.pyc | Bin 0 -> 42417 bytes .../m3u8_processor.cpython-313.pyc | Bin 0 -> 30760 bytes .../__pycache__/mpd_utils.cpython-313.pyc | Bin 0 -> 29258 bytes .../utils/__pycache__/packed.cpython-313.pyc | Bin 0 -> 7812 bytes .../__pycache__/python_aes.cpython-313.pyc | Bin 0 -> 5982 bytes .../__pycache__/python_aesgcm.cpython-313.pyc | Bin 0 -> 636 bytes .../rate_limit_handlers.cpython-313.pyc | Bin 0 -> 8092 bytes .../__pycache__/redis_utils.cpython-313.pyc | Bin 0 -> 33861 bytes .../__pycache__/rijndael.cpython-313.pyc | Bin 0 -> 36300 bytes .../stream_transformers.cpython-313.pyc | Bin 0 -> 9379 bytes .../__pycache__/telegram.cpython-313.pyc | Bin 0 -> 53186 bytes .../__pycache__/tlshashlib.cpython-313.pyc | Bin 0 -> 1485 bytes .../utils/__pycache__/tlshmac.cpython-313.pyc | Bin 0 -> 4666 bytes mediaflow_proxy/utils/acestream.py | 684 +++ mediaflow_proxy/utils/aes.py | 19 +- mediaflow_proxy/utils/aesgcm.py | 50 +- mediaflow_proxy/utils/base64_utils.py | 60 +- mediaflow_proxy/utils/base_prebuffer.py | 367 ++ mediaflow_proxy/utils/cache_utils.py | 497 +- mediaflow_proxy/utils/codec.py | 249 +- mediaflow_proxy/utils/compat.py | 251 +- mediaflow_proxy/utils/constanttime.py | 42 +- mediaflow_proxy/utils/crypto_utils.py | 2 +- mediaflow_proxy/utils/cryptomath.py | 216 +- mediaflow_proxy/utils/dash_prebuffer.py | 775 +-- mediaflow_proxy/utils/deprecations.py | 73 +- mediaflow_proxy/utils/extractor_helpers.py | 151 + mediaflow_proxy/utils/hls_prebuffer.py | 968 ++-- mediaflow_proxy/utils/hls_utils.py | 72 +- mediaflow_proxy/utils/http_client.py | 362 ++ mediaflow_proxy/utils/http_utils.py | 601 ++- mediaflow_proxy/utils/m3u8_processor.py | 695 ++- mediaflow_proxy/utils/mpd_utils.py | 280 +- mediaflow_proxy/utils/packed.py | 28 +- mediaflow_proxy/utils/python_aes.py | 42 +- mediaflow_proxy/utils/rate_limit_handlers.py | 204 + mediaflow_proxy/utils/redis_utils.py | 861 +++ mediaflow_proxy/utils/rijndael.py | 4604 +++++++++++++---- mediaflow_proxy/utils/stream_transformers.py | 241 + mediaflow_proxy/utils/telegram.py | 1263 +++++ mediaflow_proxy/utils/tlshashlib.py | 21 +- mediaflow_proxy/utils/tlshmac.py | 16 +- requirements.txt | 3 + 181 files changed, 32141 insertions(+), 4629 deletions(-) create mode 100644 mediaflow_proxy/__pycache__/__init__.cpython-313.pyc create mode 100644 mediaflow_proxy/__pycache__/configs.cpython-313.pyc create mode 100644 mediaflow_proxy/__pycache__/const.cpython-313.pyc create mode 100644 mediaflow_proxy/__pycache__/handlers.cpython-313.pyc create mode 100644 mediaflow_proxy/__pycache__/main.cpython-313.pyc create mode 100644 mediaflow_proxy/__pycache__/middleware.cpython-313.pyc create mode 100644 mediaflow_proxy/__pycache__/mpd_processor.cpython-313.pyc create mode 100644 mediaflow_proxy/__pycache__/schemas.cpython-313.pyc create mode 100644 mediaflow_proxy/drm/__pycache__/__init__.cpython-313.pyc create mode 100644 mediaflow_proxy/drm/__pycache__/decrypter.cpython-313.pyc create mode 100644 mediaflow_proxy/extractors/__pycache__/F16Px.cpython-313.pyc create mode 100644 mediaflow_proxy/extractors/__pycache__/__init__.cpython-313.pyc create mode 100644 mediaflow_proxy/extractors/__pycache__/base.cpython-313.pyc create mode 100644 mediaflow_proxy/extractors/__pycache__/dlhd.cpython-313.pyc create mode 100644 mediaflow_proxy/extractors/__pycache__/doodstream.cpython-313.pyc create mode 100644 mediaflow_proxy/extractors/__pycache__/factory.cpython-313.pyc create mode 100644 mediaflow_proxy/extractors/__pycache__/fastream.cpython-313.pyc create mode 100644 mediaflow_proxy/extractors/__pycache__/filelions.cpython-313.pyc create mode 100644 mediaflow_proxy/extractors/__pycache__/filemoon.cpython-313.pyc create mode 100644 mediaflow_proxy/extractors/__pycache__/gupload.cpython-313.pyc create mode 100644 mediaflow_proxy/extractors/__pycache__/livetv.cpython-313.pyc create mode 100644 mediaflow_proxy/extractors/__pycache__/lulustream.cpython-313.pyc create mode 100644 mediaflow_proxy/extractors/__pycache__/maxstream.cpython-313.pyc create mode 100644 mediaflow_proxy/extractors/__pycache__/mixdrop.cpython-313.pyc create mode 100644 mediaflow_proxy/extractors/__pycache__/okru.cpython-313.pyc create mode 100644 mediaflow_proxy/extractors/__pycache__/sportsonline.cpython-313.pyc create mode 100644 mediaflow_proxy/extractors/__pycache__/streamtape.cpython-313.pyc create mode 100644 mediaflow_proxy/extractors/__pycache__/streamwish.cpython-313.pyc create mode 100644 mediaflow_proxy/extractors/__pycache__/supervideo.cpython-313.pyc create mode 100644 mediaflow_proxy/extractors/__pycache__/turbovidplay.cpython-313.pyc create mode 100644 mediaflow_proxy/extractors/__pycache__/uqload.cpython-313.pyc create mode 100644 mediaflow_proxy/extractors/__pycache__/vavoo.cpython-313.pyc create mode 100644 mediaflow_proxy/extractors/__pycache__/vidmoly.cpython-313.pyc create mode 100644 mediaflow_proxy/extractors/__pycache__/vidoza.cpython-313.pyc create mode 100644 mediaflow_proxy/extractors/__pycache__/vixcloud.cpython-313.pyc create mode 100644 mediaflow_proxy/extractors/__pycache__/voe.cpython-313.pyc create mode 100644 mediaflow_proxy/extractors/gupload.py create mode 100644 mediaflow_proxy/remuxer/__init__.py create mode 100644 mediaflow_proxy/remuxer/__pycache__/__init__.cpython-313.pyc create mode 100644 mediaflow_proxy/remuxer/__pycache__/audio_transcoder.cpython-313.pyc create mode 100644 mediaflow_proxy/remuxer/__pycache__/codec_utils.cpython-313.pyc create mode 100644 mediaflow_proxy/remuxer/__pycache__/container_probe.cpython-313.pyc create mode 100644 mediaflow_proxy/remuxer/__pycache__/ebml_parser.cpython-313.pyc create mode 100644 mediaflow_proxy/remuxer/__pycache__/hls_manifest.cpython-313.pyc create mode 100644 mediaflow_proxy/remuxer/__pycache__/media_source.cpython-313.pyc create mode 100644 mediaflow_proxy/remuxer/__pycache__/mkv_demuxer.cpython-313.pyc create mode 100644 mediaflow_proxy/remuxer/__pycache__/mp4_muxer.cpython-313.pyc create mode 100644 mediaflow_proxy/remuxer/__pycache__/mp4_parser.cpython-313.pyc create mode 100644 mediaflow_proxy/remuxer/__pycache__/pyav_demuxer.cpython-313.pyc create mode 100644 mediaflow_proxy/remuxer/__pycache__/transcode_handler.cpython-313.pyc create mode 100644 mediaflow_proxy/remuxer/__pycache__/transcode_pipeline.cpython-313.pyc create mode 100644 mediaflow_proxy/remuxer/__pycache__/ts_muxer.cpython-313.pyc create mode 100644 mediaflow_proxy/remuxer/__pycache__/video_transcoder.cpython-313.pyc create mode 100644 mediaflow_proxy/remuxer/audio_transcoder.py create mode 100644 mediaflow_proxy/remuxer/codec_utils.py create mode 100644 mediaflow_proxy/remuxer/container_probe.py create mode 100644 mediaflow_proxy/remuxer/ebml_parser.py create mode 100644 mediaflow_proxy/remuxer/hls_manifest.py create mode 100644 mediaflow_proxy/remuxer/media_source.py create mode 100644 mediaflow_proxy/remuxer/mkv_demuxer.py create mode 100644 mediaflow_proxy/remuxer/mp4_muxer.py create mode 100644 mediaflow_proxy/remuxer/mp4_parser.py create mode 100644 mediaflow_proxy/remuxer/pyav_demuxer.py create mode 100644 mediaflow_proxy/remuxer/transcode_handler.py create mode 100644 mediaflow_proxy/remuxer/transcode_pipeline.py create mode 100644 mediaflow_proxy/remuxer/ts_muxer.py create mode 100644 mediaflow_proxy/remuxer/video_transcoder.py create mode 100644 mediaflow_proxy/routes/__pycache__/__init__.cpython-313.pyc create mode 100644 mediaflow_proxy/routes/__pycache__/acestream.cpython-313.pyc create mode 100644 mediaflow_proxy/routes/__pycache__/extractor.cpython-313.pyc create mode 100644 mediaflow_proxy/routes/__pycache__/playlist_builder.cpython-313.pyc create mode 100644 mediaflow_proxy/routes/__pycache__/proxy.cpython-313.pyc create mode 100644 mediaflow_proxy/routes/__pycache__/speedtest.cpython-313.pyc create mode 100644 mediaflow_proxy/routes/__pycache__/telegram.cpython-313.pyc create mode 100644 mediaflow_proxy/routes/__pycache__/xtream.cpython-313.pyc create mode 100644 mediaflow_proxy/routes/acestream.py create mode 100644 mediaflow_proxy/routes/telegram.py create mode 100644 mediaflow_proxy/routes/xtream.py create mode 100644 mediaflow_proxy/speedtest/__pycache__/__init__.cpython-313.pyc create mode 100644 mediaflow_proxy/speedtest/__pycache__/models.cpython-313.pyc create mode 100644 mediaflow_proxy/speedtest/__pycache__/service.cpython-313.pyc create mode 100644 mediaflow_proxy/speedtest/providers/__pycache__/all_debrid.cpython-313.pyc create mode 100644 mediaflow_proxy/speedtest/providers/__pycache__/base.cpython-313.pyc create mode 100644 mediaflow_proxy/speedtest/providers/__pycache__/real_debrid.cpython-313.pyc create mode 100644 mediaflow_proxy/static/url_generator.html create mode 100644 mediaflow_proxy/utils/__pycache__/__init__.cpython-313.pyc create mode 100644 mediaflow_proxy/utils/__pycache__/acestream.cpython-313.pyc create mode 100644 mediaflow_proxy/utils/__pycache__/aes.cpython-313.pyc create mode 100644 mediaflow_proxy/utils/__pycache__/aesgcm.cpython-313.pyc create mode 100644 mediaflow_proxy/utils/__pycache__/base64_utils.cpython-313.pyc create mode 100644 mediaflow_proxy/utils/__pycache__/base_prebuffer.cpython-313.pyc create mode 100644 mediaflow_proxy/utils/__pycache__/cache_utils.cpython-313.pyc create mode 100644 mediaflow_proxy/utils/__pycache__/codec.cpython-313.pyc create mode 100644 mediaflow_proxy/utils/__pycache__/compat.cpython-313.pyc create mode 100644 mediaflow_proxy/utils/__pycache__/constanttime.cpython-313.pyc create mode 100644 mediaflow_proxy/utils/__pycache__/crypto_utils.cpython-313.pyc create mode 100644 mediaflow_proxy/utils/__pycache__/cryptomath.cpython-313.pyc create mode 100644 mediaflow_proxy/utils/__pycache__/dash_prebuffer.cpython-313.pyc create mode 100644 mediaflow_proxy/utils/__pycache__/deprecations.cpython-313.pyc create mode 100644 mediaflow_proxy/utils/__pycache__/extractor_helpers.cpython-313.pyc create mode 100644 mediaflow_proxy/utils/__pycache__/hls_prebuffer.cpython-313.pyc create mode 100644 mediaflow_proxy/utils/__pycache__/hls_utils.cpython-313.pyc create mode 100644 mediaflow_proxy/utils/__pycache__/http_client.cpython-313.pyc create mode 100644 mediaflow_proxy/utils/__pycache__/http_utils.cpython-313.pyc create mode 100644 mediaflow_proxy/utils/__pycache__/m3u8_processor.cpython-313.pyc create mode 100644 mediaflow_proxy/utils/__pycache__/mpd_utils.cpython-313.pyc create mode 100644 mediaflow_proxy/utils/__pycache__/packed.cpython-313.pyc create mode 100644 mediaflow_proxy/utils/__pycache__/python_aes.cpython-313.pyc create mode 100644 mediaflow_proxy/utils/__pycache__/python_aesgcm.cpython-313.pyc create mode 100644 mediaflow_proxy/utils/__pycache__/rate_limit_handlers.cpython-313.pyc create mode 100644 mediaflow_proxy/utils/__pycache__/redis_utils.cpython-313.pyc create mode 100644 mediaflow_proxy/utils/__pycache__/rijndael.cpython-313.pyc create mode 100644 mediaflow_proxy/utils/__pycache__/stream_transformers.cpython-313.pyc create mode 100644 mediaflow_proxy/utils/__pycache__/telegram.cpython-313.pyc create mode 100644 mediaflow_proxy/utils/__pycache__/tlshashlib.cpython-313.pyc create mode 100644 mediaflow_proxy/utils/__pycache__/tlshmac.cpython-313.pyc create mode 100644 mediaflow_proxy/utils/acestream.py create mode 100644 mediaflow_proxy/utils/base_prebuffer.py create mode 100644 mediaflow_proxy/utils/extractor_helpers.py create mode 100644 mediaflow_proxy/utils/http_client.py create mode 100644 mediaflow_proxy/utils/rate_limit_handlers.py create mode 100644 mediaflow_proxy/utils/redis_utils.py create mode 100644 mediaflow_proxy/utils/stream_transformers.py create mode 100644 mediaflow_proxy/utils/telegram.py diff --git a/mediaflow_proxy/__pycache__/__init__.cpython-313.pyc b/mediaflow_proxy/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c0093f8f2cff7835a5f1dab06516873344250d8e GIT binary patch literal 162 zcmey&%ge<81k)3yXM*U*AOZ#$p^VQgK*m&tbOudEzm*I{OhDdekklKECAd|D5U@Z literal 0 HcmV?d00001 diff --git a/mediaflow_proxy/__pycache__/configs.cpython-313.pyc b/mediaflow_proxy/__pycache__/configs.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1da72df326833f837a0ac638e03fc94f39cd9cf6 GIT binary patch literal 5823 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 literal 0 HcmV?d00001 diff --git a/mediaflow_proxy/__pycache__/const.cpython-313.pyc b/mediaflow_proxy/__pycache__/const.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..173fd13b79b8946f837641c4d28d6f4d362f38d3 GIT binary patch literal 406 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 literal 0 HcmV?d00001 diff --git a/mediaflow_proxy/__pycache__/main.cpython-313.pyc b/mediaflow_proxy/__pycache__/main.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..8e81be04aa99853d0e3d38590f919308ef9deaa8 GIT binary patch literal 13874 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 Xk{KCSGIKZN%&x)wkpU}^mHqz#zbXIa literal 0 HcmV?d00001 diff --git a/mediaflow_proxy/__pycache__/schemas.cpython-313.pyc b/mediaflow_proxy/__pycache__/schemas.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e15268db5a6609f9e2fb598f922dbeffbecafd8e GIT binary patch literal 15442 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 I;K8T(zy9@Lr~m)} literal 0 HcmV?d00001 diff --git a/mediaflow_proxy/configs.py b/mediaflow_proxy/configs.py index bae1ddf..fbae17f 100644 --- a/mediaflow_proxy/configs.py +++ b/mediaflow_proxy/configs.py @@ -1,7 +1,6 @@ -from typing import Dict, Literal, Optional, Union +from typing import Dict, Literal, Optional -import httpx -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, SecretStr from pydantic_settings import BaseSettings @@ -28,48 +27,6 @@ class TransportConfig(BaseSettings): ) timeout: int = Field(60, description="Timeout for HTTP requests in seconds") - def get_mounts( - self, async_http: bool = True - ) -> Dict[str, Optional[Union[httpx.HTTPTransport, httpx.AsyncHTTPTransport]]]: - """ - Get a dictionary of httpx mount points to transport instances. - """ - mounts = {} - transport_cls = httpx.AsyncHTTPTransport if async_http else httpx.HTTPTransport - global_verify = not self.disable_ssl_verification_globally - - # Configure specific routes - for pattern, route in self.transport_routes.items(): - mounts[pattern] = transport_cls( - verify=route.verify_ssl if global_verify else False, - proxy=route.proxy_url or self.proxy_url if route.proxy else None, - ) - - # Hardcoded configuration for jxoplay.xyz domain - SSL verification disabled - mounts["all://jxoplay.xyz"] = transport_cls( - verify=False, proxy=self.proxy_url if self.all_proxy else None - ) - - mounts["all://dlhd.dad"] = transport_cls( - verify=False, proxy=self.proxy_url if self.all_proxy else None - ) - - mounts["all://*.newkso.ru"] = transport_cls( - verify=False, proxy=self.proxy_url if self.all_proxy else None - ) - - # Apply global settings for proxy and SSL - default_proxy_url = self.proxy_url if self.all_proxy else None - if default_proxy_url or not global_verify: - mounts["all://"] = transport_cls(proxy=default_proxy_url, verify=global_verify) - - # Set default proxy for all routes if enabled - # This part is now handled above to combine proxy and SSL settings - # if self.all_proxy: - # mounts["all://"] = transport_cls(proxy=self.proxy_url) - - return mounts - class Config: env_file = ".env" extra = "ignore" @@ -78,30 +35,80 @@ class TransportConfig(BaseSettings): class Settings(BaseSettings): api_password: str | None = None # The password for protecting the API endpoints. log_level: str = "INFO" # The logging level to use. - transport_config: TransportConfig = Field(default_factory=TransportConfig) # Configuration for httpx transport. + transport_config: TransportConfig = Field(default_factory=TransportConfig) # Configuration for HTTP transport. enable_streaming_progress: bool = False # Whether to enable streaming progress tracking. disable_home_page: bool = False # Whether to disable the home page UI. disable_docs: bool = False # Whether to disable the API documentation (Swagger UI). disable_speedtest: bool = False # Whether to disable the speedtest UI. + clear_cache_on_startup: bool = ( + False # Whether to clear all caches (extractor, MPD, etc.) on startup. Useful for development. + ) stremio_proxy_url: str | None = None # The Stremio server URL for alternative content proxying. m3u8_content_routing: Literal["mediaflow", "stremio", "direct"] = ( "mediaflow" # Routing strategy for M3U8 content URLs: "mediaflow", "stremio", or "direct" ) - enable_hls_prebuffer: bool = False # Whether to enable HLS pre-buffering for improved streaming performance. + enable_hls_prebuffer: bool = True # Whether to enable HLS pre-buffering for improved streaming performance. + livestream_start_offset: ( + float | None + ) = -18 # Default start offset for live streams (e.g., -18 to start 18 seconds behind live edge). Applies to HLS and MPD live playlists. Set to None to disable. hls_prebuffer_segments: int = 5 # Number of segments to pre-buffer ahead. hls_prebuffer_cache_size: int = 50 # Maximum number of segments to cache in memory. hls_prebuffer_max_memory_percent: int = 80 # Maximum percentage of system memory to use for HLS pre-buffer cache. hls_prebuffer_emergency_threshold: int = 90 # Emergency threshold percentage to trigger aggressive cache cleanup. - enable_dash_prebuffer: bool = False # Whether to enable DASH pre-buffering for improved streaming performance. + hls_prebuffer_inactivity_timeout: int = 60 # Seconds of inactivity before stopping playlist refresh loop. + hls_segment_cache_ttl: int = 300 # TTL (seconds) for cached HLS segments; 300s (5min) for VOD, lower for live. + enable_dash_prebuffer: bool = True # Whether to enable DASH pre-buffering for improved streaming performance. dash_prebuffer_segments: int = 5 # Number of segments to pre-buffer ahead. dash_prebuffer_cache_size: int = 50 # Maximum number of segments to cache in memory. dash_prebuffer_max_memory_percent: int = 80 # Maximum percentage of system memory to use for DASH pre-buffer cache. dash_prebuffer_emergency_threshold: int = 90 # Emergency threshold percentage to trigger aggressive cache cleanup. - mpd_live_init_cache_ttl: int = 0 # TTL (seconds) for live init segment cache; 0 disables caching. + dash_prebuffer_inactivity_timeout: int = 60 # Seconds of inactivity before cleaning up stream state. + dash_segment_cache_ttl: int = 60 # TTL (seconds) for cached media segments; longer = better for slow playback. + mpd_live_init_cache_ttl: int = 60 # TTL (seconds) for live init segment cache; 0 disables caching. mpd_live_playlist_depth: int = 8 # Number of recent segments to expose per live playlist variant. + remux_to_ts: bool = False # Remux fMP4 segments to MPEG-TS for ExoPlayer/VLC compatibility. + processed_segment_cache_ttl: int = 60 # TTL (seconds) for caching processed (decrypted/remuxed) segments. - user_agent: str = ( - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36" # The user agent to use for HTTP requests. + # FlareSolverr settings (for Cloudflare bypass) + flaresolverr_url: str | None = None # FlareSolverr service URL. Example: http://localhost:8191 + flaresolverr_timeout: int = 60 # Timeout (seconds) for FlareSolverr requests. + + # Acestream settings + enable_acestream: bool = False # Whether to enable Acestream proxy support. + acestream_host: str = "localhost" # Acestream engine host. + acestream_port: int = 6878 # Acestream engine port. + acestream_buffer_size: int = 4 * 1024 * 1024 # Buffer size for MPEG-TS streaming (4MB default, like acexy). + acestream_empty_timeout: int = 30 # Timeout (seconds) when no data is received from upstream. + acestream_session_timeout: int = 60 # Session timeout (seconds) for cleanup of inactive sessions. + acestream_keepalive_interval: int = 15 # Interval (seconds) for session keepalive polling. + + # Telegram MTProto settings + enable_telegram: bool = False # Whether to enable Telegram MTProto proxy support. + telegram_api_id: int | None = None # Telegram API ID from https://my.telegram.org/apps + telegram_api_hash: SecretStr | None = None # Telegram API hash from https://my.telegram.org/apps + telegram_session_string: SecretStr | None = None # Persistent session string (avoids re-authentication). + telegram_max_connections: int = 8 # Max parallel DC connections for downloads (max 20, careful of floods). + telegram_request_timeout: int = 30 # Request timeout in seconds. + + # Transcode settings + enable_transcode: bool = True # Whether to enable on-the-fly transcoding endpoints (MKV→fMP4, HLS VOD). + transcode_prefer_gpu: bool = True # Prefer GPU acceleration (NVENC/VideoToolbox/VAAPI) when available. + transcode_video_bitrate: str = "4M" # Target video bitrate for re-encoding (e.g. "4M", "2000k"). + transcode_audio_bitrate: int = 192000 # AAC audio bitrate in bits/s for the Python transcode pipeline. + transcode_video_preset: str = "medium" # Encoding speed/quality tradeoff (libx264: ultrafast..veryslow). + + user_agent: str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36" # The user agent to use for HTTP requests. + + # Upstream error resilience settings + upstream_retry_on_disconnect: bool = True # Enable/disable retry when upstream disconnects mid-stream. + upstream_retry_attempts: int = 2 # Number of retry attempts when upstream disconnects during streaming. + upstream_retry_delay: float = 1.0 # Delay (seconds) between retry attempts. + graceful_stream_end: bool = True # Return valid empty playlist instead of error when upstream fails. + + # Redis settings + redis_url: str | None = None # Redis URL for distributed locking and caching. None = disabled. + cache_namespace: str | None = ( + None # Optional namespace for instance-specific caches (e.g. pod name or hostname). When set, extractor results and other IP-bound data are stored under this namespace so multiple pods sharing one Redis don't serve each other's IP-specific URLs. ) class Config: diff --git a/mediaflow_proxy/drm/__pycache__/__init__.cpython-313.pyc b/mediaflow_proxy/drm/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ab17842d77f80c08edef7302906aaa88521745f6 GIT binary patch literal 979 zcmah|&rcIU6rS1HZWjqq5LDDdMGkIA*aHw_0#ytW8RD>eY4x_>$?QFZN1oX z-#`FKqX|z91|s&S36s~IISAWb+9I{L6TU7 zntCdha!jGsa@pNvNjnT897Ig15G(=;!BLe2HtHS$6wY7s(cI>48QlhHUe0sQTb7qP ztczp3*k_U<-9tVqqG@O#UW8ge-bHQ^-f5>>w*V-he6qW{!-{Z*iy|y7GCG2^Lb0D| zbFn@S?xHf(?T&KEmpurr! zR0=6ms)6SQub3QIKGq=XiLhTW%dUs1=%vmc=`TYc>s8`~`a)<|eJ(%M4VzdMH(1q+ z!DH9Kj_zZ}waQ+&VOB`ES<@Zj>!#@jZe*I1l^RoLJxH)E z(F`Z89HA{8;_kc-K0W-L{h0mYehqel#`s)QbfhJiDz=L<*-(yG?iBbmBo?VHidb(; z&bSYj*dT6%S;F>0iZ!{Vv5+Sgn)Go-#WbBxKOp^_u++aLlB+x{o`v4$?)((g!Aa`c zVQS)AYGQY0@5({yX+3*#bFQ9k$fMtr{YUEHyMea@ACe6<-R2E-^r&}uUmpHV;}3Mc zcfKELQZA<{MEq1l^ve*92urNgB20;R@%Z8$Kn4YmYg1?a(5ZSjLvHYxcq2N+-5G)q n9)q4^Fz{1R;K=sW8Q`!x62l&iVSj1)aB2NuY5kX2ix2$+1=H2% literal 0 HcmV?d00001 diff --git a/mediaflow_proxy/drm/__pycache__/decrypter.cpython-313.pyc b/mediaflow_proxy/drm/__pycache__/decrypter.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f69ffd6c5373db20445e07923310a1df54ef25a5 GIT binary patch literal 63239 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 {crypt, skip, iv}) + self.track_encryption_settings: dict[int, dict] = {} + # Extracted KIDs from tenc boxes (track_id -> kid) + self.extracted_kids: dict[int, bytes] = {} + # Current track ID being processed + self.current_track_id: int = 0 - def decrypt_segment(self, combined_segment: bytes) -> bytes: + def decrypt_segment(self, combined_segment: bytes, include_init: bool = True) -> bytes: """ Decrypts a combined MP4 segment. Args: combined_segment (bytes): Combined initialization and media segment. + include_init (bool): If True, include processed init atoms (ftyp, moov) in output. + If False, only return media atoms (moof, sidx, mdat) for use with EXT-X-MAP. Returns: bytes: Decrypted segment content. @@ -210,6 +238,44 @@ class MP4Decrypter: if atom := next((a for a in atoms if a.atom_type == atom_type), None): processed_atoms[atom_type] = self._process_atom(atom_type, atom) + result = bytearray() + # Init atoms to skip when include_init is False + # Note: styp is a segment type atom that should be kept in segments + init_atoms = {b"ftyp", b"moov"} + + for atom in atoms: + # Skip init atoms if not including init + if not include_init and atom.atom_type in init_atoms: + continue + + if atom.atom_type in processed_atoms: + processed_atom = processed_atoms[atom.atom_type] + result.extend(processed_atom.pack()) + else: + result.extend(atom.pack()) + + return bytes(result) + + def process_init_only(self, init_segment: bytes) -> bytes: + """ + Processes only the initialization segment, removing encryption-related boxes. + Used for EXT-X-MAP where init is served separately. + + Args: + init_segment (bytes): Initialization segment data. + + Returns: + bytes: Processed init segment with encryption boxes removed. + """ + data = memoryview(init_segment) + parser = MP4Parser(data) + atoms = parser.list_atoms() + + processed_atoms = {} + # Only process moov for init segments + if moov_atom := next((a for a in atoms if a.atom_type == b"moov"), None): + processed_atoms[b"moov"] = self._process_moov(moov_atom) + result = bytearray() for atom in atoms: if atom.atom_type in processed_atoms: @@ -268,19 +334,33 @@ class MP4Decrypter: def _process_moof(self, moof: MP4Atom) -> MP4Atom: """ - Processes the 'moov' (Movie) atom, which contains metadata about the entire presentation. - This includes information about tracks, media data, and other movie-level metadata. + Processes the 'moof' (Movie Fragment) atom, which contains metadata about a fragment. + This includes information about track fragments, sample information, and encryption data. Args: - moov (MP4Atom): The 'moov' atom to process. + moof (MP4Atom): The 'moof' atom to process. Returns: - MP4Atom: Processed 'moov' atom with updated track information. + MP4Atom: Processed 'moof' atom with updated track information. """ parser = MP4Parser(moof.data) - new_moof_data = bytearray() + atoms = parser.list_atoms() - for atom in iter(parser.read_atom, None): + # Reset track infos for this moof + self.track_infos = [] + + # First pass: calculate total encryption overhead from all trafs + self.total_encryption_overhead = 0 + for atom in atoms: + if atom.atom_type == b"traf": + traf_parser = MP4Parser(atom.data) + traf_atoms = traf_parser.list_atoms() + traf_overhead = sum(a.size for a in traf_atoms if a.atom_type in {b"senc", b"saiz", b"saio"}) + self.total_encryption_overhead += traf_overhead + + # Second pass: process atoms + new_moof_data = bytearray() + for atom in atoms: if atom.atom_type == b"traf": new_traf = self._process_traf(atom) new_moof_data.extend(new_traf.pack()) @@ -304,19 +384,21 @@ class MP4Decrypter: new_traf_data = bytearray() tfhd = None sample_count = 0 + trun_data_offset = 0 sample_info = [] + track_default_sample_size = 0 atoms = parser.list_atoms() - # calculate encryption_overhead earlier to avoid dependency on trun - self.encryption_overhead = sum(a.size for a in atoms if a.atom_type in {b"senc", b"saiz", b"saio"}) - for atom in atoms: if atom.atom_type == b"tfhd": tfhd = atom new_traf_data.extend(atom.pack()) + # Extract default_sample_size from tfhd if present + self._parse_tfhd(atom) + track_default_sample_size = self.default_sample_size elif atom.atom_type == b"trun": - sample_count = self._process_trun(atom) + sample_count, trun_data_offset = self._process_trun(atom) new_trun = self._modify_trun(atom) new_traf_data.extend(new_trun.pack()) elif atom.atom_type == b"senc": @@ -327,15 +409,60 @@ class MP4Decrypter: if tfhd: tfhd_track_id = struct.unpack_from(">I", tfhd.data, 4)[0] - self.current_key = self._get_key_for_track(tfhd_track_id) + track_key = self._get_key_for_track(tfhd_track_id) + # Get per-track encryption settings if available + track_enc_settings = self.track_encryption_settings.get(tfhd_track_id, {}) + # Store track info for multi-track mdat decryption + # Copy the sample sizes array since it gets overwritten for each track + track_sample_sizes = array.array("I", self.trun_sample_sizes) + self.track_infos.append( + { + "data_offset": trun_data_offset, + "sample_sizes": track_sample_sizes, + "sample_info": sample_info, + "key": track_key, + "default_sample_size": track_default_sample_size, + "track_id": tfhd_track_id, + "crypt_byte_block": track_enc_settings.get("crypt_byte_block", self.crypt_byte_block), + "skip_byte_block": track_enc_settings.get("skip_byte_block", self.skip_byte_block), + "constant_iv": track_enc_settings.get("constant_iv", self.constant_iv), + } + ) + # Keep backward compatibility for single-track case + self.current_key = track_key self.current_sample_info = sample_info return MP4Atom(b"traf", len(new_traf_data) + 8, new_traf_data) + def _parse_tfhd(self, tfhd: MP4Atom) -> None: + """ + Parses the 'tfhd' (Track Fragment Header) atom to extract default sample size. + + Args: + tfhd (MP4Atom): The 'tfhd' atom to parse. + """ + data = tfhd.data + flags = struct.unpack_from(">I", data, 0)[0] & 0xFFFFFF + offset = 8 # Skip version_flags (4) + track_id (4) + + # Skip optional fields based on flags + if flags & 0x000001: # base-data-offset-present + offset += 8 + if flags & 0x000002: # sample-description-index-present + offset += 4 + if flags & 0x000008: # default-sample-duration-present + offset += 4 + if flags & 0x000010: # default-sample-size-present + if offset + 4 <= len(data): + self.default_sample_size = struct.unpack_from(">I", data, offset)[0] + offset += 4 + # We don't need default-sample-flags (0x000020) + def _decrypt_mdat(self, mdat: MP4Atom) -> MP4Atom: """ Decrypts the 'mdat' (Media Data) atom, which contains the actual media data (audio, video, etc.). The decryption is performed using the current decryption key and sample information. + Supports multiple tracks by using track_infos collected during moof processing. Args: mdat (MP4Atom): The 'mdat' atom to decrypt. @@ -343,30 +470,130 @@ class MP4Decrypter: Returns: MP4Atom: Decrypted 'mdat' atom with decrypted media data. """ + mdat_data = mdat.data + + # Use multi-track decryption if we have track_infos + if self.track_infos: + return self._decrypt_mdat_multi_track(mdat) + + # Fallback to single-track decryption for backward compatibility if not self.current_key or not self.current_sample_info: return mdat # Return original mdat if we don't have decryption info decrypted_samples = bytearray() - mdat_data = mdat.data position = 0 for i, info in enumerate(self.current_sample_info): if position >= len(mdat_data): break # No more data to process - sample_size = self.trun_sample_sizes[i] if i < len(self.trun_sample_sizes) else len(mdat_data) - position + # Get sample size from trun, or use default_sample_size from tfhd, or remaining data + sample_size = 0 + if i < len(self.trun_sample_sizes): + sample_size = self.trun_sample_sizes[i] + + # If sample size is 0 (not specified in trun), use default from tfhd + if sample_size == 0: + sample_size = self.default_sample_size if self.default_sample_size > 0 else len(mdat_data) - position + sample = mdat_data[position : position + sample_size] position += sample_size - decrypted_sample = self._process_sample(sample, info, self.current_key) + decrypted_sample = self._decrypt_sample(sample, info, self.current_key) decrypted_samples.extend(decrypted_sample) return MP4Atom(b"mdat", len(decrypted_samples) + 8, decrypted_samples) + def _decrypt_mdat_multi_track(self, mdat: MP4Atom) -> MP4Atom: + """ + Decrypts the 'mdat' atom with support for multiple tracks. + Each track's samples are located at their respective data_offset positions. + + The data_offset in trun is the byte offset from the start of the moof box + to the first byte of sample data. Since mdat immediately follows moof, + we can calculate the position within mdat as: + position_in_mdat = data_offset - moof_size + + But we don't have moof_size here directly. However, we know that the first + track's data_offset minus 8 (mdat header) gives us the moof size. + + For simplicity, we sort tracks by data_offset and process them in order, + using the data_offset difference to determine where each track's samples start. + + Args: + mdat (MP4Atom): The 'mdat' atom to decrypt. + + Returns: + MP4Atom: Decrypted 'mdat' atom with decrypted media data from all tracks. + """ + mdat_data = mdat.data + + if not self.track_infos: + return mdat + + # Sort tracks by data_offset to process in order + sorted_tracks = sorted(self.track_infos, key=lambda x: x["data_offset"]) + + # The first track's data_offset tells us where mdat data starts relative to moof + # data_offset = moof_size + 8 (mdat header) for the first sample + # So mdat_data_start_in_file = moof_start + first_data_offset + # And position_in_mdat = data_offset - first_data_offset + first_data_offset = sorted_tracks[0]["data_offset"] + + # Pre-allocate output buffer with original data (in case some parts aren't encrypted) + decrypted_data = bytearray(mdat_data) + + # Process each track's samples at their respective offsets + for track_info in sorted_tracks: + data_offset = track_info["data_offset"] + sample_sizes = track_info["sample_sizes"] + sample_info = track_info["sample_info"] + key = track_info["key"] + default_sample_size = track_info["default_sample_size"] + # Get per-track encryption settings + track_crypt = track_info.get("crypt_byte_block", self.crypt_byte_block) + track_skip = track_info.get("skip_byte_block", self.skip_byte_block) + track_constant_iv = track_info.get("constant_iv", self.constant_iv) + + if not key or not sample_info: + continue + + # Calculate start position in mdat + # position = data_offset - first_data_offset (relative to first track's start) + mdat_position = data_offset - first_data_offset + + for i, info in enumerate(sample_info): + sample_size = 0 + if i < len(sample_sizes): + sample_size = sample_sizes[i] + + if sample_size == 0: + sample_size = default_sample_size if default_sample_size > 0 else 0 + + if sample_size == 0: + continue + + if mdat_position + sample_size > len(mdat_data): + break + + sample = mdat_data[mdat_position : mdat_position + sample_size] + decrypted_sample = self._decrypt_sample_with_track_settings( + sample, info, key, track_crypt, track_skip, track_constant_iv + ) + + # Write decrypted sample to output at the same position + decrypted_data[mdat_position : mdat_position + len(decrypted_sample)] = decrypted_sample + mdat_position += sample_size + + return MP4Atom(b"mdat", len(decrypted_data) + 8, bytes(decrypted_data)) + def _parse_senc(self, senc: MP4Atom, sample_count: int) -> list[CENCSampleAuxiliaryDataFormat]: """ Parses the 'senc' (Sample Encryption) atom, which contains encryption information for samples. This includes initialization vectors (IVs) and sub-sample encryption data. + For CBCS with constant IV (default_iv_size == 0 in tenc), the senc box only contains + subsample info, not per-sample IVs. The constant IV from tenc is used instead. + Args: senc (MP4Atom): The 'senc' atom to parse. sample_count (int): The number of samples. @@ -383,13 +610,23 @@ class MP4Decrypter: sample_count = struct.unpack_from(">I", data, position)[0] position += 4 + # Use the IV size from tenc box (8 or 16 bytes, or 0 for constant IV) + iv_size = self.default_iv_size + + # For CBCS with constant IV, use the IV from tenc instead of per-sample IVs + use_constant_iv = self.encryption_scheme == b"cbcs" and self.constant_iv is not None + sample_info = [] for _ in range(sample_count): - if position + 8 > len(data): - break - - iv = data[position : position + 8].tobytes() - position += 8 + if use_constant_iv: + # Use constant IV from tenc box + iv = self.constant_iv + else: + # Read per-sample IV from senc + if position + iv_size > len(data): + break + iv = data[position : position + iv_size].tobytes() + position += iv_size sub_samples = [] if flags & 0x000002 and position + 2 <= len(data): # Check if subsample information is present @@ -411,6 +648,8 @@ class MP4Decrypter: def _get_key_for_track(self, track_id: int) -> bytes: """ Retrieves the decryption key for a given track ID from the key map. + Uses the KID extracted from the tenc box if available, otherwise falls back to + using the first key if only one key is provided. Args: track_id (int): The track ID. @@ -418,9 +657,32 @@ class MP4Decrypter: Returns: bytes: The decryption key for the specified track ID. """ + # If we have an extracted KID for this track, use it to look up the key + if track_id in self.extracted_kids: + extracted_kid = self.extracted_kids[track_id] + # If KID is all zeros, it's a placeholder - use the provided key_id directly + # Check if all bytes are zero + is_all_zeros = all(b == 0 for b in extracted_kid) and len(extracted_kid) == 16 + if is_all_zeros: + # All zeros KID means use the provided key_id (first key in map) + if len(self.key_map) == 1: + return next(iter(self.key_map.values())) + else: + # Use the extracted KID to look up the key + key = self.key_map.get(extracted_kid) + if key: + return key + # If KID doesn't match, try fallback + # Note: This is expected when KID in file doesn't match provided key_id + # The provided key_id should still work if it's the correct decryption key + + # Fallback: if only one key provided, use it (backward compatibility) if len(self.key_map) == 1: return next(iter(self.key_map.values())) - key = self.key_map.get(track_id.pack(4, "big")) + + # Try using track_id as KID (for multi-key scenarios) + track_id_bytes = track_id.to_bytes(4, "big") + key = self.key_map.get(track_id_bytes) if not key: raise ValueError(f"No key found for track ID {track_id}") return key @@ -466,7 +728,399 @@ class MP4Decrypter: return result - def _process_trun(self, trun: MP4Atom) -> int: + def _process_sample_cbcs( + self, sample: memoryview, sample_info: CENCSampleAuxiliaryDataFormat, key: bytes + ) -> Union[memoryview, bytearray, bytes]: + """ + Processes and decrypts a sample using CBCS (AES-CBC with pattern encryption). + + CBCS uses AES-CBC mode with a constant IV (no counter increment between blocks). + Pattern encryption encrypts 'crypt_byte_block' 16-byte blocks, then leaves + 'skip_byte_block' 16-byte blocks in the clear, repeating this pattern. + + Args: + sample (memoryview): The sample data. + sample_info (CENCSampleAuxiliaryDataFormat): The sample auxiliary data format with encryption information. + key (bytes): The decryption key. + + Returns: + Union[memoryview, bytearray, bytes]: The decrypted sample. + """ + if not sample_info.is_encrypted: + return sample + + # CBCS uses constant IV - pad to 16 bytes + iv = sample_info.iv + b"\x00" * (16 - len(sample_info.iv)) + + if not sample_info.sub_samples: + # Full sample encryption with pattern + return self._decrypt_cbcs_pattern(bytes(sample), key, iv) + + # Subsample encryption + result = bytearray() + offset = 0 + for clear_bytes, encrypted_bytes in sample_info.sub_samples: + # Copy clear bytes as-is + result.extend(sample[offset : offset + clear_bytes]) + offset += clear_bytes + + # Decrypt encrypted portion using pattern encryption + encrypted_part = bytes(sample[offset : offset + encrypted_bytes]) + decrypted = self._decrypt_cbcs_pattern(encrypted_part, key, iv) + result.extend(decrypted) + offset += encrypted_bytes + + # If there's any remaining data after subsamples, copy as-is (shouldn't happen) + if offset < len(sample): + result.extend(sample[offset:]) + + return result + + def _decrypt_cbcs_pattern(self, data: bytes, key: bytes, iv: bytes) -> bytes: + """ + Decrypts data using CBCS pattern encryption (AES-CBC with crypt/skip pattern). + + Pattern encryption decrypts 'crypt_byte_block' 16-byte blocks, then skips + 'skip_byte_block' 16-byte blocks (leaving them in clear), repeating. + + Important: In CBCS, the CBC cipher state (previous ciphertext) carries over + between encrypted blocks, even though clear blocks are skipped. This means + we need to collect all encrypted blocks, decrypt them as a continuous CBC + stream, then interleave the results with the clear blocks. + + Args: + data (bytes): The encrypted data. + key (bytes): The decryption key. + iv (bytes): The initialization vector. + + Returns: + bytes: The decrypted data. + """ + if not data: + return data + + block_size = 16 + crypt_blocks = self.crypt_byte_block + skip_blocks = self.skip_byte_block + + # If no pattern (crypt=0), no encryption + if crypt_blocks == 0: + return data + + # If skip=0, it's full encryption (all blocks encrypted) + if skip_blocks == 0: + # Decrypt complete blocks only + complete_blocks_size = (len(data) // block_size) * block_size + if complete_blocks_size > 0: + cipher = AES.new(key, AES.MODE_CBC, iv) + decrypted = cipher.decrypt(data[:complete_blocks_size]) + if complete_blocks_size < len(data): + return decrypted + data[complete_blocks_size:] + return decrypted + return data + + crypt_bytes = crypt_blocks * block_size + skip_bytes = skip_blocks * block_size + + # Step 1: Collect all encrypted blocks + encrypted_blocks = bytearray() + block_positions = [] # Track where each encrypted block came from + pos = 0 + + while pos < len(data): + # Encrypted portion + if pos + crypt_bytes <= len(data): + encrypted_blocks.extend(data[pos : pos + crypt_bytes]) + block_positions.append((pos, crypt_bytes)) + pos += crypt_bytes + else: + # Remaining data - encrypt complete blocks only + remaining = len(data) - pos + complete = (remaining // block_size) * block_size + if complete > 0: + encrypted_blocks.extend(data[pos : pos + complete]) + block_positions.append((pos, complete)) + pos += complete + break + + # Skip clear portion + if pos + skip_bytes <= len(data): + pos += skip_bytes + else: + break + + # Step 2: Decrypt all encrypted blocks as a continuous CBC stream + if encrypted_blocks: + cipher = AES.new(key, AES.MODE_CBC, iv) + decrypted_blocks = cipher.decrypt(bytes(encrypted_blocks)) + else: + decrypted_blocks = b"" + + # Step 3: Reconstruct the output with decrypted blocks and clear blocks + result = bytearray(data) # Start with original data + decrypted_pos = 0 + + for orig_pos, length in block_positions: + result[orig_pos : orig_pos + length] = decrypted_blocks[decrypted_pos : decrypted_pos + length] + decrypted_pos += length + + return bytes(result) + + def _process_sample_cbc1( + self, sample: memoryview, sample_info: CENCSampleAuxiliaryDataFormat, key: bytes + ) -> Union[memoryview, bytearray, bytes]: + """ + Processes and decrypts a sample using CBC1 (full sample AES-CBC encryption). + + Unlike CBCS, CBC1 encrypts the entire sample without pattern encryption. + + Args: + sample (memoryview): The sample data. + sample_info (CENCSampleAuxiliaryDataFormat): The sample auxiliary data format with encryption information. + key (bytes): The decryption key. + + Returns: + Union[memoryview, bytearray, bytes]: The decrypted sample. + """ + if not sample_info.is_encrypted: + return sample + + # Pad IV to 16 bytes + iv = sample_info.iv + b"\x00" * (16 - len(sample_info.iv)) + cipher = AES.new(key, AES.MODE_CBC, iv) + + if not sample_info.sub_samples: + # Full sample encryption - decrypt complete blocks only + block_size = 16 + complete_blocks_size = (len(sample) // block_size) * block_size + if complete_blocks_size > 0: + decrypted = cipher.decrypt(bytes(sample[:complete_blocks_size])) + if complete_blocks_size < len(sample): + # Append remaining partial block as-is + return decrypted + bytes(sample[complete_blocks_size:]) + return decrypted + return sample + + # Subsample encryption + result = bytearray() + offset = 0 + for clear_bytes, encrypted_bytes in sample_info.sub_samples: + result.extend(sample[offset : offset + clear_bytes]) + offset += clear_bytes + + encrypted_part = bytes(sample[offset : offset + encrypted_bytes]) + # Only decrypt complete blocks + block_size = 16 + complete_blocks_size = (len(encrypted_part) // block_size) * block_size + if complete_blocks_size > 0: + decrypted = cipher.decrypt(encrypted_part[:complete_blocks_size]) + result.extend(decrypted) + if complete_blocks_size < len(encrypted_part): + result.extend(encrypted_part[complete_blocks_size:]) + else: + result.extend(encrypted_part) + offset += encrypted_bytes + + if offset < len(sample): + result.extend(sample[offset:]) + + return result + + def _decrypt_sample( + self, sample: memoryview, sample_info: CENCSampleAuxiliaryDataFormat, key: bytes + ) -> Union[memoryview, bytearray, bytes]: + """ + Decrypts a sample using the appropriate scheme based on encryption_scheme attribute. + + Args: + sample (memoryview): The sample data. + sample_info (CENCSampleAuxiliaryDataFormat): The sample auxiliary data format. + key (bytes): The decryption key. + + Returns: + Union[memoryview, bytearray, bytes]: The decrypted sample. + """ + if self.encryption_scheme == b"cbcs": + return self._process_sample_cbcs(sample, sample_info, key) + elif self.encryption_scheme == b"cbc1": + return self._process_sample_cbc1(sample, sample_info, key) + else: + # cenc and cens use AES-CTR + return self._process_sample(sample, sample_info, key) + + def _decrypt_sample_with_track_settings( + self, + sample: memoryview, + sample_info: CENCSampleAuxiliaryDataFormat, + key: bytes, + crypt_byte_block: int, + skip_byte_block: int, + constant_iv: Optional[bytes], + ) -> Union[memoryview, bytearray, bytes]: + """ + Decrypts a sample using per-track encryption settings. + + Args: + sample (memoryview): The sample data. + sample_info (CENCSampleAuxiliaryDataFormat): The sample auxiliary data format. + key (bytes): The decryption key. + crypt_byte_block (int): Number of encrypted blocks in pattern. + skip_byte_block (int): Number of clear blocks in pattern. + constant_iv (Optional[bytes]): Constant IV for CBCS, or None. + + Returns: + Union[memoryview, bytearray, bytes]: The decrypted sample. + """ + if self.encryption_scheme == b"cbcs": + return self._process_sample_cbcs_with_settings( + sample, sample_info, key, crypt_byte_block, skip_byte_block, constant_iv + ) + elif self.encryption_scheme == b"cbc1": + return self._process_sample_cbc1(sample, sample_info, key) + else: + # cenc and cens use AES-CTR + return self._process_sample(sample, sample_info, key) + + def _process_sample_cbcs_with_settings( + self, + sample: memoryview, + sample_info: CENCSampleAuxiliaryDataFormat, + key: bytes, + crypt_byte_block: int, + skip_byte_block: int, + constant_iv: Optional[bytes], + ) -> Union[memoryview, bytearray, bytes]: + """ + Processes and decrypts a sample using CBCS with per-track settings. + + Args: + sample (memoryview): The sample data. + sample_info (CENCSampleAuxiliaryDataFormat): The sample auxiliary data format. + key (bytes): The decryption key. + crypt_byte_block (int): Number of encrypted blocks in pattern. + skip_byte_block (int): Number of clear blocks in pattern. + constant_iv (Optional[bytes]): Constant IV for CBCS, or None. + + Returns: + Union[memoryview, bytearray, bytes]: The decrypted sample. + """ + if not sample_info.is_encrypted: + return sample + + # Use constant IV if provided, otherwise use the IV from sample_info + if constant_iv: + iv = constant_iv + b"\x00" * (16 - len(constant_iv)) + else: + iv = sample_info.iv + b"\x00" * (16 - len(sample_info.iv)) + + if not sample_info.sub_samples: + # Full sample encryption with pattern + return self._decrypt_cbcs_pattern_with_settings(bytes(sample), key, iv, crypt_byte_block, skip_byte_block) + + # Subsample encryption + result = bytearray() + offset = 0 + for clear_bytes, encrypted_bytes in sample_info.sub_samples: + # Copy clear bytes as-is + result.extend(sample[offset : offset + clear_bytes]) + offset += clear_bytes + + # Decrypt encrypted portion using pattern encryption + encrypted_part = bytes(sample[offset : offset + encrypted_bytes]) + decrypted = self._decrypt_cbcs_pattern_with_settings( + encrypted_part, key, iv, crypt_byte_block, skip_byte_block + ) + result.extend(decrypted) + offset += encrypted_bytes + + # If there's any remaining data after subsamples, copy as-is + if offset < len(sample): + result.extend(sample[offset:]) + + return result + + def _decrypt_cbcs_pattern_with_settings( + self, data: bytes, key: bytes, iv: bytes, crypt_blocks: int, skip_blocks: int + ) -> bytes: + """ + Decrypts data using CBCS pattern encryption with explicit pattern settings. + + Args: + data (bytes): The encrypted data. + key (bytes): The decryption key. + iv (bytes): The initialization vector. + crypt_blocks (int): Number of encrypted blocks in pattern. + skip_blocks (int): Number of clear blocks in pattern. + + Returns: + bytes: The decrypted data. + """ + if not data: + return data + + block_size = 16 + + # If both crypt=0 and skip=0, it means full sample CBC encryption (no pattern) + # This is common for audio tracks in CBCS + if crypt_blocks == 0 and skip_blocks == 0: + # Decrypt complete blocks only + complete_blocks_size = (len(data) // block_size) * block_size + if complete_blocks_size > 0: + cipher = AES.new(key, AES.MODE_CBC, iv) + decrypted = cipher.decrypt(data[:complete_blocks_size]) + if complete_blocks_size < len(data): + return decrypted + data[complete_blocks_size:] + return decrypted + return data + + crypt_bytes = crypt_blocks * block_size + skip_bytes = skip_blocks * block_size + + # Step 1: Collect all encrypted blocks + encrypted_blocks = bytearray() + block_positions = [] # Track where each encrypted block came from + pos = 0 + + while pos < len(data): + # Encrypted portion + if pos + crypt_bytes <= len(data): + encrypted_blocks.extend(data[pos : pos + crypt_bytes]) + block_positions.append((pos, crypt_bytes)) + pos += crypt_bytes + else: + # Remaining data - encrypt complete blocks only + remaining = len(data) - pos + complete = (remaining // block_size) * block_size + if complete > 0: + encrypted_blocks.extend(data[pos : pos + complete]) + block_positions.append((pos, complete)) + pos += complete + break + + # Skip clear portion + if pos + skip_bytes <= len(data): + pos += skip_bytes + else: + break + + # Step 2: Decrypt all encrypted blocks as a continuous CBC stream + if encrypted_blocks: + cipher = AES.new(key, AES.MODE_CBC, iv) + decrypted_blocks = cipher.decrypt(bytes(encrypted_blocks)) + else: + decrypted_blocks = b"" + + # Step 3: Reconstruct the output with decrypted blocks and clear blocks + result = bytearray(data) # Start with original data + decrypted_pos = 0 + + for orig_pos, length in block_positions: + result[orig_pos : orig_pos + length] = decrypted_blocks[decrypted_pos : decrypted_pos + length] + decrypted_pos += length + + return bytes(result) + + def _process_trun(self, trun: MP4Atom) -> tuple[int, int]: """ Processes the 'trun' (Track Fragment Run) atom, which contains information about the samples in a track fragment. This includes sample sizes, durations, flags, and composition time offsets. @@ -475,38 +1129,43 @@ class MP4Decrypter: trun (MP4Atom): The 'trun' atom to process. Returns: - int: The number of samples in the 'trun' atom. + tuple[int, int]: (sample_count, data_offset_value) where data_offset_value is the offset + into mdat where this track's samples start (0 if not present in trun). """ trun_flags, sample_count = struct.unpack_from(">II", trun.data, 0) - data_offset = 8 + parse_offset = 8 + # Extract data_offset if present (flag 0x000001) + trun_data_offset = 0 if trun_flags & 0x000001: - data_offset += 4 - if trun_flags & 0x000004: - data_offset += 4 + trun_data_offset = struct.unpack_from(">i", trun.data, parse_offset)[0] # signed int + parse_offset += 4 + if trun_flags & 0x000004: # first-sample-flags-present + parse_offset += 4 self.trun_sample_sizes = array.array("I") for _ in range(sample_count): if trun_flags & 0x000100: # sample-duration-present flag - data_offset += 4 + parse_offset += 4 if trun_flags & 0x000200: # sample-size-present flag - sample_size = struct.unpack_from(">I", trun.data, data_offset)[0] + sample_size = struct.unpack_from(">I", trun.data, parse_offset)[0] self.trun_sample_sizes.append(sample_size) - data_offset += 4 + parse_offset += 4 else: self.trun_sample_sizes.append(0) # Using 0 instead of None for uniformity in the array if trun_flags & 0x000400: # sample-flags-present flag - data_offset += 4 + parse_offset += 4 if trun_flags & 0x000800: # sample-composition-time-offsets-present flag - data_offset += 4 + parse_offset += 4 - return sample_count + return sample_count, trun_data_offset def _modify_trun(self, trun: MP4Atom) -> MP4Atom: """ Modifies the 'trun' (Track Fragment Run) atom to update the data offset. - This is necessary to account for the encryption overhead. + This is necessary to account for the total encryption overhead from all trafs, + since mdat comes after all trafs in the moof. Args: trun (MP4Atom): The 'trun' atom to modify. @@ -518,9 +1177,10 @@ class MP4Decrypter: current_flags = struct.unpack_from(">I", trun_data, 0)[0] & 0xFFFFFF # If the data-offset-present flag is set, update the data offset to account for encryption overhead + # All trun data_offsets need to be reduced by the total encryption overhead from all trafs if current_flags & 0x000001: current_data_offset = struct.unpack_from(">i", trun_data, 8)[0] - struct.pack_into(">i", trun_data, 8, current_data_offset - self.encryption_overhead) + struct.pack_into(">i", trun_data, 8, current_data_offset - self.total_encryption_overhead) return MP4Atom(b"trun", len(trun_data) + 8, trun_data) @@ -541,8 +1201,8 @@ class MP4Decrypter: reference_type = current_size >> 31 current_referenced_size = current_size & 0x7FFFFFFF - # Remove encryption overhead from referenced size - new_referenced_size = current_referenced_size - self.encryption_overhead + # Remove total encryption overhead from referenced size + new_referenced_size = current_referenced_size - self.total_encryption_overhead new_size = (reference_type << 31) | new_referenced_size struct.pack_into(">I", sidx_data, 32, new_size) @@ -562,6 +1222,19 @@ class MP4Decrypter: parser = MP4Parser(trak.data) new_trak_data = bytearray() + # First pass: find track ID from tkhd + for atom in parser.list_atoms(): + if atom.atom_type == b"tkhd": + # tkhd: version(1) + flags(3) + ... + track_id at offset 12 (v0) or 20 (v1) + version = atom.data[0] + if version == 0: + self.current_track_id = struct.unpack_from(">I", atom.data, 12)[0] + else: + self.current_track_id = struct.unpack_from(">I", atom.data, 20)[0] + break + + # Second pass: process atoms + parser.position = 0 for atom in iter(parser.read_atom, None): if atom.atom_type == b"mdia": new_mdia = self._process_mdia(atom) @@ -705,6 +1378,7 @@ class MP4Decrypter: """ Extracts the codec format from the 'sinf' (Protection Scheme Information) atom. This includes information about the original format of the protected content. + Also extracts IV size from the 'tenc' box and encryption scheme from 'schm' box. Args: sinf (MP4Atom): The 'sinf' atom to extract from. @@ -713,28 +1387,177 @@ class MP4Decrypter: Optional[bytes]: The codec format or None if not found. """ parser = MP4Parser(sinf.data) + codec_format = None for atom in iter(parser.read_atom, None): if atom.atom_type == b"frma": - return atom.data - return None + codec_format = atom.data + elif atom.atom_type == b"schm": + self._parse_schm(atom) + elif atom.atom_type == b"schi": + # Parse schi to find tenc + schi_parser = MP4Parser(atom.data) + for schi_atom in iter(schi_parser.read_atom, None): + if schi_atom.atom_type == b"tenc": + self._parse_tenc(schi_atom) + return codec_format + + def _parse_schm(self, schm: MP4Atom) -> None: + """ + Parses the 'schm' (Scheme Type) atom to detect the encryption scheme. + + Args: + schm (MP4Atom): The 'schm' atom to parse. + """ + # schm structure: + # - version (1 byte) + flags (3 bytes) = 4 bytes + # - scheme_type (4 bytes): "cenc", "cens", "cbc1", or "cbcs" + # - scheme_version (4 bytes) + data = schm.data + if len(data) >= 8: + scheme_type = bytes(data[4:8]) + if scheme_type in (b"cenc", b"cens", b"cbc1", b"cbcs"): + self.encryption_scheme = scheme_type + + def _parse_tenc(self, tenc: MP4Atom) -> None: + """ + Parses the 'tenc' (Track Encryption) atom to extract encryption parameters. + Stores per-track encryption settings for multi-track support. + + Args: + tenc (MP4Atom): The 'tenc' atom to parse. + """ + # tenc structure: + # - version (1 byte) + flags (3 bytes) = 4 bytes + # - reserved (1 byte) + reserved (1 byte) if version == 0, or reserved (1 byte) + default_crypt_byte_block (4 bits) + default_skip_byte_block (4 bits) if version > 0 + # - default_isProtected (1 byte) + # - default_Per_Sample_IV_Size (1 byte) + # - default_KID (16 bytes) + # For version 1 with IV size 0: + # - default_constant_IV_size (1 byte) + # - default_constant_IV (default_constant_IV_size bytes) + data = tenc.data + if len(data) >= 8: + version = data[0] + + # Initialize per-track settings + track_settings = { + "crypt_byte_block": 1, # Default + "skip_byte_block": 9, # Default + "constant_iv": None, + "iv_size": 8, + "kid": None, # KID from tenc box + } + + # Extract pattern encryption parameters for version > 0 (used in cbcs) + if version > 0 and len(data) >= 6: + # Byte 5 contains crypt_byte_block (upper 4 bits) and skip_byte_block (lower 4 bits) + pattern_byte = data[5] + track_settings["crypt_byte_block"] = (pattern_byte >> 4) & 0x0F + track_settings["skip_byte_block"] = pattern_byte & 0x0F + # Also update global defaults (for backward compatibility) + self.crypt_byte_block = track_settings["crypt_byte_block"] + self.skip_byte_block = track_settings["skip_byte_block"] + + # Extract KID (default_KID is at offset 8, 16 bytes) + kid_offset = 8 + if len(data) >= kid_offset + 16: + kid = bytes(data[kid_offset : kid_offset + 16]) + track_settings["kid"] = kid + # Also store globally for backward compatibility + if not hasattr(self, "extracted_kids"): + self.extracted_kids = {} + if self.current_track_id > 0: + self.extracted_kids[self.current_track_id] = kid + + # IV size is at offset 7 for both versions + iv_size_offset = 7 + if len(data) > iv_size_offset: + iv_size = data[iv_size_offset] + if iv_size in (0, 8, 16): + # IV size of 0 means constant IV (used in cbcs) + track_settings["iv_size"] = iv_size if iv_size > 0 else 16 + self.default_iv_size = track_settings["iv_size"] + + # If IV size is 0, extract constant IV from tenc (for CBCS) + if iv_size == 0: + # After KID (16 bytes at offset 8), there's constant_IV_size (1 byte) and constant_IV + constant_iv_size_offset = 8 + 16 # offset 24 + if len(data) > constant_iv_size_offset: + constant_iv_size = data[constant_iv_size_offset] + constant_iv_offset = constant_iv_size_offset + 1 + if constant_iv_size > 0 and len(data) >= constant_iv_offset + constant_iv_size: + track_settings["constant_iv"] = bytes( + data[constant_iv_offset : constant_iv_offset + constant_iv_size] + ) + self.constant_iv = track_settings["constant_iv"] + + # Store per-track settings + if self.current_track_id > 0: + self.track_encryption_settings[self.current_track_id] = track_settings -def decrypt_segment(init_segment: bytes, segment_content: bytes, key_id: str, key: str) -> bytes: +def _build_key_map(key_id: str, key: str) -> dict: + """ + Build a key_map dict from (possibly comma-separated) key_id and key strings. + + Both arguments may be comma-separated lists of equal length to support + multi-key DRM streams where different tracks use different keys. + + Args: + key_id: Hex key ID(s), comma-separated for multi-key. + key: Hex key(s), comma-separated for multi-key. + + Returns: + dict mapping key-ID bytes to key bytes. + """ + key_ids = [k.strip() for k in key_id.split(",") if k.strip()] + keys = [k.strip() for k in key.split(",") if k.strip()] + return {bytes.fromhex(kid): bytes.fromhex(k) for kid, k in zip(key_ids, keys)} + + +def decrypt_segment( + init_segment: bytes, segment_content: bytes, key_id: str, key: str, include_init: bool = True +) -> bytes: """ Decrypts a CENC encrypted MP4 segment. Args: init_segment (bytes): Initialization segment data. segment_content (bytes): Encrypted segment content. - key_id (str): Key ID in hexadecimal format. - key (str): Key in hexadecimal format. + key_id (str): Key ID(s) in hexadecimal format, comma-separated for multi-key DRM. + key (str): Key(s) in hexadecimal format, comma-separated for multi-key DRM. + include_init (bool): If True, include processed init segment in output. + If False, only return decrypted media segment (for use with EXT-X-MAP). + + Returns: + bytes: Decrypted segment with processed init (moov/ftyp) + decrypted media (moof/mdat), + or just decrypted media if include_init is False. """ - key_map = {bytes.fromhex(key_id): bytes.fromhex(key)} + key_map = _build_key_map(key_id, key) decrypter = MP4Decrypter(key_map) - decrypted_content = decrypter.decrypt_segment(init_segment + segment_content) + decrypted_content = decrypter.decrypt_segment(init_segment + segment_content, include_init=include_init) return decrypted_content +def process_drm_init_segment(init_segment: bytes, key_id: str, key: str) -> bytes: + """ + Processes a DRM-protected init segment for use with EXT-X-MAP. + Removes encryption-related boxes but keeps the moov structure. + + Args: + init_segment (bytes): Initialization segment data. + key_id (str): Key ID(s) in hexadecimal format, comma-separated for multi-key DRM. + key (str): Key(s) in hexadecimal format, comma-separated for multi-key DRM. + + Returns: + bytes: Processed init segment with encryption boxes removed. + """ + key_map = _build_key_map(key_id, key) + decrypter = MP4Decrypter(key_map) + processed_init = decrypter.process_init_only(init_segment) + return processed_init + + def cli(): """ Command line interface for decrypting a CENC encrypted MP4 segment. diff --git a/mediaflow_proxy/extractors/F16Px.py b/mediaflow_proxy/extractors/F16Px.py index fbb7708..ea244eb 100644 --- a/mediaflow_proxy/extractors/F16Px.py +++ b/mediaflow_proxy/extractors/F16Px.py @@ -65,9 +65,9 @@ class F16PxExtractor(BaseExtractor): raise ExtractorError("F16PX: No playback data") try: - iv = self._b64url_decode(pb["iv"]) # nonce - key = self._join_key_parts(pb["key_parts"]) # AES key - payload = self._b64url_decode(pb["payload"]) # ciphertext + tag + iv = self._b64url_decode(pb["iv"]) # nonce + key = self._join_key_parts(pb["key_parts"]) # AES key + payload = self._b64url_decode(pb["payload"]) # ciphertext + tag cipher = python_aesgcm.new(key) decrypted = cipher.open(iv, payload) # AAD = '' like ResolveURL @@ -95,7 +95,7 @@ class F16PxExtractor(BaseExtractor): self.base_headers["origin"] = origin self.base_headers["Accept-Language"] = "en-US,en;q=0.5" self.base_headers["Accept"] = "*/*" - self.base_headers['user-agent'] = 'Mozilla/5.0 (X11; Linux x86_64; rv:138.0) Gecko/20100101 Firefox/138.0' + self.base_headers["user-agent"] = "Mozilla/5.0 (X11; Linux x86_64; rv:138.0) Gecko/20100101 Firefox/138.0" return { "destination_url": best, diff --git a/mediaflow_proxy/extractors/__pycache__/F16Px.cpython-313.pyc b/mediaflow_proxy/extractors/__pycache__/F16Px.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..77bccc4c61e3bd5b70c95ae0a8914a044b61884a GIT binary patch literal 5489 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 literal 0 HcmV?d00001 diff --git a/mediaflow_proxy/extractors/__pycache__/dlhd.cpython-313.pyc b/mediaflow_proxy/extractors/__pycache__/dlhd.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ec4dd713eaf49315a7446299f971f2ea56b63765 GIT binary patch literal 32191 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 literal 0 HcmV?d00001 diff --git a/mediaflow_proxy/extractors/__pycache__/fastream.cpython-313.pyc b/mediaflow_proxy/extractors/__pycache__/fastream.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..22dd5fbb9c92a4a0c65ec54bfd857df64cc27ade GIT binary patch literal 2248 zcmb^y&r=gu_-%HxSt0>}q7vzl28t{{vIzzhty+azi^zdB6(zb|H!rZV*^O^E2st@D zbm;W#ltVk?wLRfXXXH=tn8`KXHDc!+AlJa7G$M?Qh)-q+EBgqSLPRD_%N}gED zD9Yw{+7HIhV7sk##wh%-b%%c+1-x6h$*s z+g21C96*ssWV!}Sd9W-eBDmX?+MHh^ch(T_st^T0#lz*)0TP8G@lSs1C=ovBy-OA2zsNX>#Aq~azdHynn? zovByk%es3G0rme`I_!Icokq{jo4g2l(xx|@ZT6(lFRzVYFO{bJua^Q>cq!Z(D!0Vg z9weaY?~brpMr@rfo^qgYqy;Bu466COp=+w6Tc)&3IRneDa&yMlA7X9Ml7{1nIQ$ZkvpT_9YgwvFIhQ}JX*ln=!Ewtpu||D--dyDkO{wO5 zL7m6Xg9RItA;3(h*qGG~d@R}*>pvw%i^78A2vn78$)XZ>SoEp#aHr z+{WuLHUF*26FP^;J_=gAGqsCcA+I_PCZ_E+WWl0A&Dr!?m?~%=-S#@c7nS7*%88kh{PAYuQfc;bIdP>FpDXpIcMtWIh3<-Us!o@} z17%^b(mPORta%FkWS1T+3x~FO6cH<3-CJGpa#y?(?cIuwm7`-%0^Hu z1oOAO7efCuJl@89#f-ObU!NHPRAj2})JW&Fspc@u>7b(ItW3e6c%!1+ET~3J(yS<1 zo!E|{o7l8K98i>ur722vp0e;AB6L6zI>U$vz-@;a?m(4j3OpjwuNNE)W8DqaCcK6X zLs7tsR#3L-n0nuPPeQxY0|6=+0PyTH^aJy@uWtX3fwv17C{2_2Je=c`%;M>p(PUey+H#;a@3 x!EaF2oL7dzd)3sPBuC&18jl?Yu+1_I^E2xF4UPSTl7H}>OmHpq6v5`5{ReR{FC_o~ literal 0 HcmV?d00001 diff --git a/mediaflow_proxy/extractors/__pycache__/filelions.cpython-313.pyc b/mediaflow_proxy/extractors/__pycache__/filelions.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f8f0cd9e036d71dd150a287421d062ceb91d4667 GIT binary patch literal 1630 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 literal 0 HcmV?d00001 diff --git a/mediaflow_proxy/extractors/__pycache__/maxstream.cpython-313.pyc b/mediaflow_proxy/extractors/__pycache__/maxstream.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..39fd20678ca18132348190bb23b19875e9baef6e GIT binary patch literal 3617 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 H27mkC*JUHA literal 0 HcmV?d00001 diff --git a/mediaflow_proxy/extractors/__pycache__/sportsonline.cpython-313.pyc b/mediaflow_proxy/extractors/__pycache__/sportsonline.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c9051e7fe4eb5df5e74bdb154782f4b1ff8b0687 GIT binary patch literal 8095 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 s{suMv2O9qk3VdoW%iBF4**y_s>I<< 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 aFVMk%q6_~(qc6BIiW*)T_y&>Q+~dF5lv&^a literal 0 HcmV?d00001 diff --git a/mediaflow_proxy/extractors/__pycache__/supervideo.cpython-313.pyc b/mediaflow_proxy/extractors/__pycache__/supervideo.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0882358ab03721df2b813d1c96c4b40cf00519ab GIT binary patch literal 3712 zcma)9UrZdw8K1q~yW9H<20IYIX2EB}`Olq+A;dU#LTv8fsATEp>$^Q}<74+S zyJs6*AuA7Us=hdCU5Hevq>5zx(ArTS%A?iRq>o2V6=*|ctBD%<4F^Y#l&Gdk3ph)wnvf z1Mf)H;2I9qqgKST4T$%RhPr552V6T&Mg5ORXDI42nbS(zFzH@(5<1=@&cCZEs>uRT zkgzVpVCXGbDjG_*nCG=(!3>g`H?X9rGLBMauvec{)4Z(fil$oZk}mgM1%|YtVKclz z_F=5yC~bO*YC%e0GCdjDkYVqzVD%Ms+~P>vYTC7@(pm2Z;sSVX6k&=-*u_(r=3ST( zP$$LH4d`7KyLo2R!@Eblqn=T=(?wj~$?zVSW3Z3+V!wb+P&g1lQP!;Giv<~9Rx+}- zq2Hq*NK6fG&Tzu;_c+~(b#JnGhoH6|;HimDi*Xx2=~8AZzGwJV~mW(p8dBg&YJF6Xl* zBjIt~^jx}PwLVcy=fMO~)IkBnaolxtD4x?M0@~}R+z-QU}3Xq)2|yIk2wn00EfOCs_(9&RW|f<@8@j&Y^20S z=k|ZjMps&2{eby9+q@18$fQ9p(}QKBh}HCldtioz?-%f`e+4IfFXG5OqvDiG??EFQ zf#?DTQYbV)@vaJaf!70C)8{ez)KrCO+F%cz4eg zgrr7cA;`Hs7XGcQL2=8;`c>gh4^$4C} za>TZkO)k|dcxzGrLxfaTU|+O)dqp+_o;~vt*s#M{?8W@f{?Ez10&=zgpPcFwXwc}F zppk!Mrz0~AJ8v(o==|-jO1#y85O{9KR)mDW@XOm}5aI)lhH7vGVqzB|=!_0GIb)mc z#>xS(quCih-?XuN@Od8?o|Xm_q4Xh4!jX<9Wp&>OBSBKz^1tT=x<==l)L}jxgT&-)!daF z$Z%QNNap}EIg`m>odDR<(^x4OAnLbXS5Z^*laIi7WO}ijl`*`iv{{t_a93=dM1poz zSbnca06!;78PFG#>oL_aq;cdSl`~w5=dxN+&2Sp#RE;D0TD~Yqh9P5>n~*>cIWxs2 zB$&z&L+wX+i=$wX9&T#Srstfek9a0)$@Os2U6{a=Nsr5h8PE;MDC%Nb1Et`uAWQ(y zWDN2Wu!Ll@ikcCnd>#|X!sLN9U04P`$P!NHO!qj3WCD|OL41>wCuB_CNi%p6l8EQb z#bGk;>${qk3nW;kU(pp6G*HuWG>q$D7VjYy0RfY(SgxC1c(X}nVzOA)3!19S@D7qZ z#&5^M(rzD-3E3`@GnPT7x3Yxkw!CMCD(@T2lbBGlPBelCb4$SlVN7p@(`?*yX62k} zI98b?A=E#C&_6=g(OMAgij-<2AFyjot1n5d*6p2{{?+t+-AYUQt;UZV=lO-)Tw|#v zx!jn%!9J{QT&`_fs%@J;TCR<)Hnh)o-K}5l=v(UOD;-Ic5^tA&_@mO>BcUPgu zo4NMkwNEb3XYX9PeQCL^r`*=F+8&#~eCOKjYvuOiv!O5A;Ng8*eRh9ZOut z-RKhc`or$iE8WMJyZe{A`&W8SF87>W>N)$^%e1$yBc`SGX%yA(pPBw}dc7XCA9?IW z&Cw^VsBT|r-}&-O@069nr+eRWHxB{N}=P)uE-TLo3z0Eau*OE%$0mEq&#hL@Ahfw&o?A z|JLt7wBBcuN2s5L6V2!*)px@4YZ|DJb|&2P@2Ldx{Gs<1Apabxhx#w5i`2(@TcEk$ z5Cq2i`>6!&x!=YR`XB`>-9NO0%$}fNWD!w7FVYt6_9U9TivcPT4lV|RgsvxBEjALV z#U_hx_9qY1i_vhRn_lb=0sR>hNw(9UwYv#@mZ%_|pU7SLL# zV&e>bQSY>X%7&<#jeI^ literal 0 HcmV?d00001 diff --git a/mediaflow_proxy/extractors/__pycache__/turbovidplay.cpython-313.pyc b/mediaflow_proxy/extractors/__pycache__/turbovidplay.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0055a02dad71d0269cbcfeb5971560339906c270 GIT binary patch literal 2330 zcmai0O>7fK6rNqL*Xy5+gY)OOr7__rah!N*Xj&2x{t}uW+CZ`x2@$&8*lT;iyK83G zfmlUCJyeDs5JD(9=16ZGIU>|UkG68KHLVa*4^?h~P*uHkX6+3nKs(aTyqWjry?Nif z_hz?w-Uo17{dQIT(gVQnWa2b;lh_`{#6utgkvazg1i^plL1PS*hi!yqwK0t7*kZ+*~P@0(jk)wr7s6bS`g_ zy(okUNt1OoqnMUdfX2-gjmZV0St1}qz z9ENq7dgz*S2Y~KMx)%Pev$;`cE;Ejf1>*3ohA)XqG9%5Mx;yFaZ%U;709^Ju;v(%> zR;Z+V0rPe@(d9_!Y?58zck=-b1xLcMA>$K??j$>eg?08^>;wDQclNabfc*`=Yh$Fj zQbt(v$SO7fJ4ak@P0H9VER;ujAz|Bz=xI7)kGYk@Pzfx-aPy z1OM$Q_JS8%a32&~1zOxAhQzi6djN1?ikjm&Fn9F}SB8qU&y@!HPmJNvBT4XGTDIh& zoNVS|i{`*M{w^=}^^6G>fO$}3UJ zh$@YW5GDDTS51o;g(N@`Yd|%t>ZL%+&@Dx`hOCu>Qu5i!C}@RYtJLCf@`|3dawXoG zLs`!%kRw{WmMNJQRAep2qF@^5rK-!8YUmOM2L+*Wy-0Gk=>Ur0q%Fu<*-|8^m<2;O zl^tFeR5WAxJ&ceIS0-=VkQ6;#FjU<_p@vZj*E`+J7*NANafrv&;Tq9kTSbh8-A6Q3 z5Nj$jOywX+U*yh0qgX&b(~_ZO-cYR^@?{lEY5)@RBgRzn8A#FtIWA&y zmX=49hP?K%Y(Kpu0&I^E=OB$}Z5VkmGI@#IkV&#U`lg}tLe9_>0mEbnlSZnj;X5z| zF$LvA5c#Cm9j|uBA5Cm@AFj0>E_3x@*S%YJZk3tWKEMa>&D@=-bWS|Iuo0LoQ_q8~ zzlEZeeWy1<6P3Wki}sGysryrFryk6fy<02@bkzJkRe#SqQ}Z9J`VZE_(e=@4c<|0l zo$svi`>OoD_5C&eP?bM~S+#Jy8jf#-haP*Y;nDKUmJfW?Q;Q5%BZH4FJ?;KwqH^+l zB{Eov%-7oI%ien1$E(NhA1`zGTrI8lF5SIU=^B1Ky}^%{>F2>c_PORZLgy-hb1&LE zSEuh!ugyO=Yo99`t4I4E_16Z*s{`X311BmyC+qRiC&wxS)AjzNPlQU}Y`ypMdhgIn z-qY&a0v@mL)dU6FKYcj>Iu@wE&HWg)?>i=A)G9T3fLS|z0F%#-QImblv++~?c=B@} zh5MiIW9&toB3UJlH#tE`%Bc7u%&WSh8<_1$8=9=@ChQ=A z=fVLFV}$CEaE9TWN=dS1L3OEUDW)XBIOc8R4?t2HA$gAMju7Tv-2DJv(VifCdul5L zB8dvqxy1~+ed}$10X)DXj(p4wyD^P=c14;KE;!N>OZeXclK&1nff}}m{e~7|A92ST lejbP9)gT4U?84oai=wDsf%gwE^O`+M`PRPv6X3z#_z%vlRObKy literal 0 HcmV?d00001 diff --git a/mediaflow_proxy/extractors/__pycache__/uqload.cpython-313.pyc b/mediaflow_proxy/extractors/__pycache__/uqload.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..4d802f68751c13262f6371a3509896b117c2f503 GIT binary patch literal 1525 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 pY0~~5nGMP+WVt z%gioqONEi31q%7(w1Ht4DNr4qTY4%`2pIvmWJ> z_A&rcnON5x)21>kN(ON;Y_?7sE}pvO5hLq4gzno}P7&geY+Y)76dS;H3Kmzv@C8IT z6%n7x5ne+hoa*aB*JL87{DNN<7Q_Yrf;8e|ryUWPq1OZ=t0D<#=rxW6`%qk>ZCA`( z(Xp0y)IODgJj*pyv5E1Ap>L1C@H%%7Wniwi%b@lh0f8ShZU||<6gS3!pEwO!z9!8Q ziu>kiq+q#v(Xh=tc0IjBoLkFrp8DN#2@@*ox@nu9uG2^n=S(ASIX87|=Stu#FD_8Q z#a5mQ23c~cc>QK=4(NK;GF(^JT`&MCit;|49V|FSJP6)(22)P9T*S8L4ysvVluUbR zP_r+XIh-5Z)lsu!5I4Q=4&JDHBT-r=?8r2>*FA}*sR2N{-7q|VA8pC4ccd@n&If%J zIlgx6OF6#TJGLtPMLxU@1c>rr5fusc%EZp@I0i7xwLgcxy#~8Skpg#`*>SzbjW*V{ zPxGb!mk(H2>#tA9XhY2DFQu6P8Ac02nP$*KkT>OGPUSUDh1f{&He6*I5@m0DJF1Fj zeAAE&1qksQ+&zBYA40aE327E7dz$Uf)Y6V^r$c*?07Ak@nt!x$7F*QB<{5G?+9>bZ zsEKKI7kio-Xj0gcCZ*f=paE2aO^O$EM4OavAKbA+>OueK^zKVfFKcqTt0`x9R$IqE z1#ib*@bt&*jl;{9%;mPV;##-ab#IMuMyyyfU4- z_~zV?$CYXOhGCgGCFc|k(_S$zIxD7S8H1yVA*KH<)6O|JU1k2VGCY(RnowY4Y-B>Y zH8v7glBJS`-@=QtrZ+fx>U84Nn9@Ic;qt|~6N+VC$I1_J_PP^S&KC&F>f!GXDXNh- zh}j^fqM0W~5y$zJpypz7GPwl#PNg)?V}i-bJ{4zAzJa}L;baPmPMo7s0UJ3?+?8S1 zDU&RA7u@6H&=)dn_$dr|)549-%)+a^@APM`vS}c`(!FzF)g#y_Dq4C@u^mszJ7qif zlw~Rv3Z7S@l4lmNQ}$N6c39H*-7=V8D&AN@O?( zjHYdPreo`@j)X~FnBF)Hf#gT=Fk!C)l?#sR+29nyl9RSV$-R#C1_u>9D0S3FFcn>F zkZghamxxm?(V*)Y#B*<&UV*SWK?75_vbe-_Qr`6l^+QF5LM;NFS9a?vgM=j=>16a` z?K#Vl9tOw+yCuhVF>^@KqW+=*mq3F>!}Tz!9Re(IJ<4I1RST%yjYjq}eyLHAY3FyU zZv)c`WbPEW@F{}o+a7yx^uz8|aWlOC?t6FNTZ?}(_WQHHJGc*uaz6z{B&E*gz$g+=wPu<<0QHySMM$UKPF( zQG4`$;8%f4*L>yD<@Gjg)&I9}M@{az^+>W3N`Ae+>we~!nYH#0udW8a4#zgaeNV!D zYgabHC!T~)Y{itd=z45;b*3u%1Ch;GcSGGwP2Gi!=!Ml(HH1QKcPH*lJUF`E(#?b) zJ@;A1pAJ1b^jYhle^fd0=6d8(C3LA8M9T5a!^bvzk8SjxdD464nIyCYt4Ih0pN*o9 zN$yz>>X_!9Z-qOz5f943oxeH1#(nh0Z|8nJ_tE@%`1sa=zRLHK>jx$)ZIhd!_J3C; zAp4Iy4etC|vTbS~_b9@p!os6yasYroo{glU=<{|i)h>J<5!m{`IP5=$ohW)74NkT2 zkGtbjBL9WxhxHdNJYeHoJv>_2v(7>hp7o%v7oA+$V(XBuzgsq}1}3cQd6T%FW!l(w zbiMZMl9ON%VLvLu-ga^t2D%?!dWb$Vs~et2%*8T<5QuAS`~$MsvUDAkv+C%wbUas! zCc+}DHn5Cv*;DZR7wB7`&wsiqpYR9QqE!Ub!;Xj9Pfk57eBARd2L6)`gca2qYrKUp zMk;#C>?g5n8Vx~8TIOP+7XP)}sVCy$J)exk?k6L$2tPi;p4wWH))ah<>dI>IFaMzK rtk=nTAYwLRFFLxqzBAenvvg( zY@@7ew{l2{%7JWBE}T<1dQZE1iqCt>5fw>^nrsrPvXzu?a&T%lxAc=V$fh=jJe6K| zzwUnBuU}8UK5A<6BN#hB-OqQB?(o);qc|Xsk&I+!2@wiCOU$B&cotd0N|a`o zxJ57VR&j2LUlfS2C=yXZou~)N-qT3tlfpP#+mHCxnTYV567h(~;jZZ^+hOO7%?RU& zd6LO##KOR6x}sTl{-I5@lx-5H`52ui#3ZTOX0@5G6GztpxdXl?5n*IRJTgOA*^}hP zJPI0PWcD=rQ2dZ1UYSesvNtIt`J_1Jp$8q~WFEL2@yP=5D`=V_fj$%wozN{kZ5Fg+ z>(3!f$O#dAFOMx--N0HJ6D#GZyFdXwDE%K@glUF(j8*{F#!YbKHrP{`ai+EcL1Pq@ zIo4lC5l`Id$Px45rfS&)YgrW=>71z>c7%1jRz8P`BdV%y=(egl;Ve$;+FHhZ@TwSR z;mjHdfC9$JQ1X*>z)s|Rwqo~eS*Fgo3@0K*!iUUk7DGYXR^rs zN$frX9&%W4#o-CI^Tc>9lmc*n3IC%*2v9GQ;BHsyfqONG2CJ}%;}FsR5DEp$EYhOaJKfS>djJ?PrT%u3kZE79&^4G5K^=@p4n4UUVA*Kgbq z6*1A}t~Q_|xV8+R;`<-|G`ljGQGAIr4Q&^bL#~A3SNw@{?s@~tEv|%XLxZf6TNVE> z2vIG?3vqt4q2Xe3ILh`R5yd}$hh1YLZSjJ?{$&~1uHv{*h*JF2t%YmLKbf177L5B^ zMo&v=Gpp%FLENzIoHZ4TIZUiz-Y^S#CZom1qa#xP2fC3qA6QcSru6nmbmWo*3ln3P zq=yq@5os=$%is_2>XL59#z)_Yj!sDZOYh&jaeYY2=y$R79!}jgBhu9kLMz4F7hwbX zlVoj8BYIt>zz)O)3eDIUR^%aUT)HB?b|L%l{4BZuBi`!PT`E}5R^D< zN=o9oWSF+JX6B7Fp-EZr#9{@$b`4OVLuq&xbVIXs(@?>YY7r*%tBJG_T5KF~!?bKe z%VI}_!frj#?F~X}gA-hZ7teiB94=+%HXS~nOKUcEn$)az7pwJW$Kh-!#*T+zM@X63 z90bMjFT59DPRw7Oljj}Y!Wv0!INo()=5vnUvNdAYELA-TkXA5Qb>HFdKG4Xz}8tFq}6| zJ`Z&Uty0!9)br;Eo`=@1C%wNpSL6>v9orvoe|!)OZ!d2xmxFz!VBc=C9K2WxUi@P1 z_vV*oc_Ln#i2vcE@zXUK%@xIi&>P#2wjPy3J-ePF_m5E9*VO#@et2RpIPsE4Z5=xU zzaFS~n5LV|H^JuZ8(TMy1g_0r6e>Z~+PTyGY4a0%zooC}{cEVbsynkEp4|)19=3Pw zEPcAP>)mg^Q1l%J+RK6NQlNX6D+k^x1>QR7ly)aeokPV1IHMFf=)Q2!bD`WbS?Za5 zA#g4J3gUeJ7sIIi3iBU}hX3bRrsku}SC^UjA?~Z`D$hE}ci;Iq(~5ZX=pxOBlTlT0fmVHsKH~3I(}d6-p*o3_5rgTwU!aP|znuA&lK-L;>2P<5_{jyp9p2ugolL_+CkR=Z(O09@B(Hsk-6#5+ zXrsT0(l3$FwzB$VR=xWS)$CMLTfAHg+6VVFN7-`~*Lzo7H~L<=wZ PNyh)H?7tC!s``HcK~Sv> literal 0 HcmV?d00001 diff --git a/mediaflow_proxy/extractors/__pycache__/vixcloud.cpython-313.pyc b/mediaflow_proxy/extractors/__pycache__/vixcloud.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..99c4449f937a4c78bf48b05674c74e887d6a9af7 GIT binary patch literal 4596 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 Any: + """Parse response content as JSON.""" + return json.loads(self.text) + + def get_origin(self) -> str: + """Get the origin (scheme + host) from the response URL.""" + parsed = urlparse(self.url) + return f"{parsed.scheme}://{parsed.netloc}" + + class BaseExtractor(ABC): """Base class for all URL extractors. @@ -43,74 +75,99 @@ class BaseExtractor(ABC): backoff_factor: float = 0.5, raise_on_status: bool = True, **kwargs, - ) -> httpx.Response: + ) -> HttpResponse: """ - Make HTTP request with retry and timeout support. + Make HTTP request with retry and timeout support using aiohttp. Parameters ---------- + url : str + The URL to request. + method : str + HTTP method (GET, POST, etc.). Defaults to GET. + headers : dict | None + Additional headers to merge with base headers. timeout : float | None - Seconds to wait for the request (applied to httpx.Timeout). Defaults to 15s. + Seconds to wait for the request. Defaults to 15s. retries : int Number of attempts for transient errors. backoff_factor : float Base for exponential backoff between retries. raise_on_status : bool - If True, HTTP non-2xx raises DownloadError (preserves status code). + If True, HTTP non-2xx raises DownloadError. + **kwargs + Additional arguments passed to aiohttp request (e.g., data, json). + + Returns + ------- + HttpResponse + Response object with pre-loaded content. """ attempt = 0 last_exc = None - # build request headers merging base and per-request + # Build request headers merging base and per-request request_headers = self.base_headers.copy() if headers: request_headers.update(headers) - timeout_cfg = httpx.Timeout(timeout or 15.0) + timeout_val = timeout or 15.0 while attempt < retries: try: - async with create_httpx_client(timeout=timeout_cfg) as client: - response = await client.request( + async with create_aiohttp_session(url, timeout=timeout_val) as (session, proxy_url): + async with session.request( method, url, headers=request_headers, + proxy=proxy_url, **kwargs, - ) + ) as response: + # Read content while session is still open + content = await response.read() + text = content.decode("utf-8", errors="replace") + final_url = str(response.url) + status = response.status + resp_headers = dict(response.headers) - if raise_on_status: - try: - response.raise_for_status() - except httpx.HTTPStatusError as e: - # Provide a short body preview for debugging - body_preview = "" - try: - body_preview = e.response.text[:500] - except Exception: - body_preview = "" + if raise_on_status and status >= 400: + body_preview = text[:500] logger.debug( - "HTTPStatusError for %s (status=%s) -- body preview: %s", + "HTTP error for %s (status=%s) -- body preview: %s", url, - e.response.status_code, + status, body_preview, ) - raise DownloadError(e.response.status_code, f"HTTP error {e.response.status_code} while requesting {url}") - return response + raise DownloadError(status, f"HTTP error {status} while requesting {url}") + + return HttpResponse( + status=status, + headers=resp_headers, + text=text, + content=content, + url=final_url, + ) except DownloadError: # Do not retry on explicit HTTP status errors (they are intentional) raise - except (httpx.ReadTimeout, httpx.ConnectTimeout, httpx.NetworkError, httpx.TransportError) as e: - # Transient network error — retry with backoff + except (asyncio.TimeoutError, aiohttp.ClientError) as e: + # Transient network error - retry with backoff last_exc = e attempt += 1 sleep_for = backoff_factor * (2 ** (attempt - 1)) - logger.warning("Transient network error (attempt %s/%s) for %s: %s — retrying in %.1fs", - attempt, retries, url, e, sleep_for) + logger.warning( + "Transient network error (attempt %s/%s) for %s: %s — retrying in %.1fs", + attempt, + retries, + url, + e, + sleep_for, + ) await asyncio.sleep(sleep_for) continue except Exception as e: - # Unexpected exception — wrap as ExtractorError to keep interface consistent + # Unexpected exception - wrap as ExtractorError to keep interface consistent logger.exception("Unhandled exception while requesting %s: %s", url, e) raise ExtractorError(f"Request failed for URL {url}: {str(e)}") diff --git a/mediaflow_proxy/extractors/dlhd.py b/mediaflow_proxy/extractors/dlhd.py index 940e696..07a1d87 100644 --- a/mediaflow_proxy/extractors/dlhd.py +++ b/mediaflow_proxy/extractors/dlhd.py @@ -1,133 +1,345 @@ +import hashlib +import hmac import re -import base64 +import time import logging -from typing import Any, Dict, Optional, List -from urllib.parse import urlparse, quote_plus, urljoin +from typing import Any, Dict, Optional +from urllib.parse import urlparse +import aiohttp -import httpx - - -from mediaflow_proxy.extractors.base import BaseExtractor, ExtractorError +from mediaflow_proxy.extractors.base import BaseExtractor, ExtractorError, HttpResponse +from mediaflow_proxy.utils.http_client import create_aiohttp_session +from mediaflow_proxy.configs import settings logger = logging.getLogger(__name__) # Silenzia l'errore ConnectionResetError su Windows -logging.getLogger('asyncio').setLevel(logging.CRITICAL) +logging.getLogger("asyncio").setLevel(logging.CRITICAL) + +# Default fingerprint parameters +DEFAULT_DLHD_USER_AGENT = "Mozilla/5.0 (X11; Linux x86_64; rv:146.0) Gecko/20100101 Firefox/146.0" +DEFAULT_DLHD_SCREEN_RESOLUTION = "1920x1080" +DEFAULT_DLHD_TIMEZONE = "UTC" +DEFAULT_DLHD_LANGUAGE = "en" + + +def compute_fingerprint( + user_agent: str = DEFAULT_DLHD_USER_AGENT, + screen_resolution: str = DEFAULT_DLHD_SCREEN_RESOLUTION, + timezone: str = DEFAULT_DLHD_TIMEZONE, + language: str = DEFAULT_DLHD_LANGUAGE, +) -> str: + """ + Compute the X-Fingerprint header value. + + Algorithm: + fingerprint = SHA256(useragent + screen_resolution + timezone + language).hex()[:16] + + Args: + user_agent: The user agent string + screen_resolution: The screen resolution (e.g., "1920x1080") + timezone: The timezone (e.g., "UTC") + language: The language code (e.g., "en") + + Returns: + The 16-character fingerprint + """ + combined = f"{user_agent}{screen_resolution}{timezone}{language}" + return hashlib.sha256(combined.encode("utf-8")).hexdigest()[:16] + + +def compute_key_path(resource: str, number: str, timestamp: int, fingerprint: str, secret_key: str) -> str: + """ + Compute the X-Key-Path header value. + + Algorithm: + key_path = HMAC-SHA256("resource|number|timestamp|fingerprint", secret_key).hex()[:16] + + Args: + resource: The resource from the key URL + number: The number from the key URL + timestamp: The Unix timestamp + fingerprint: The fingerprint value + secret_key: The HMAC secret key (channel_salt) + + Returns: + The 16-character key path + """ + combined = f"{resource}|{number}|{timestamp}|{fingerprint}" + hmac_hash = hmac.new(secret_key.encode("utf-8"), combined.encode("utf-8"), hashlib.sha256).hexdigest() + return hmac_hash[:16] + + +def compute_key_headers(key_url: str, secret_key: str) -> tuple[int, int, str, str] | None: + """ + Compute X-Key-Timestamp, X-Key-Nonce, X-Key-Path, and X-Fingerprint for a /key/ URL. + + Algorithm: + 1. Extract resource and number from URL pattern /key/{resource}/{number} + 2. ts = Unix timestamp in seconds + 3. hmac_hash = HMAC-SHA256(resource, secret_key).hex() + 4. nonce = proof-of-work: find i where MD5(hmac+resource+number+ts+i)[:4] < 0x1000 + 5. fingerprint = compute_fingerprint() + 6. key_path = HMAC-SHA256("resource|number|ts|fingerprint", secret_key).hex()[:16] + + Args: + key_url: The key URL containing /key/{resource}/{number} + secret_key: The HMAC secret key (channel_salt) + + Returns: + Tuple of (timestamp, nonce, key_path, fingerprint) or None if URL doesn't match pattern + """ + # Extract resource and number from URL + pattern = r"/key/([^/]+)/(\d+)" + match = re.search(pattern, key_url) + + if not match: + return None + + resource = match.group(1) + number = match.group(2) + + ts = int(time.time()) + + # Compute HMAC-SHA256 + hmac_hash = hmac.new(secret_key.encode("utf-8"), resource.encode("utf-8"), hashlib.sha256).hexdigest() + + # Proof-of-work loop + nonce = 0 + for i in range(100000): + combined = f"{hmac_hash}{resource}{number}{ts}{i}" + md5_hash = hashlib.md5(combined.encode("utf-8")).hexdigest() + prefix_value = int(md5_hash[:4], 16) + + if prefix_value < 0x1000: # < 4096 + nonce = i + break + + fingerprint = compute_fingerprint() + key_path = compute_key_path(resource, number, ts, fingerprint, secret_key) + + return ts, nonce, key_path, fingerprint class DLHDExtractor(BaseExtractor): """DLHD (DaddyLive) URL extractor for M3U8 streams. - - Notes: - - Multi-domain support for daddylive.sx / dlhd.dad - - Robust extraction of auth parameters and server lookup - - Uses retries/timeouts via BaseExtractor where possible - - Multi-iframe fallback for resilience + Supports the new authentication flow with: + - EPlayerAuth extraction (auth_token, channel_key, channel_salt) + - Server lookup for dynamic server selection + - Dynamic key header computation for AES-128 encrypted streams """ - def __init__(self, request_headers: dict): super().__init__(request_headers) - self.mediaflow_endpoint = "hls_manifest_proxy" + self.mediaflow_endpoint = "hls_key_proxy" self._iframe_context: Optional[str] = None + self._flaresolverr_cookies: Optional[str] = None + self._flaresolverr_user_agent: Optional[str] = None + async def _fetch_via_flaresolverr(self, url: str) -> HttpResponse: + """Fetch a URL using FlareSolverr to bypass Cloudflare protection.""" + if not settings.flaresolverr_url: + raise ExtractorError("FlareSolverr URL not configured. Set FLARESOLVERR_URL in environment.") + flaresolverr_endpoint = f"{settings.flaresolverr_url.rstrip('/')}/v1" + payload = { + "cmd": "request.get", + "url": url, + "maxTimeout": settings.flaresolverr_timeout * 1000, + } - async def _make_request(self, url: str, method: str = "GET", headers: Optional[Dict] = None, **kwargs) -> Any: - """Override to disable SSL verification for this extractor and use fetch_with_retry if available.""" - from mediaflow_proxy.utils.http_utils import create_httpx_client, fetch_with_retry + logger.info(f"Using FlareSolverr to fetch: {url}") + async with aiohttp.ClientSession() as session: + async with session.post( + flaresolverr_endpoint, + json=payload, + timeout=aiohttp.ClientTimeout(total=settings.flaresolverr_timeout + 10), + ) as response: + if response.status != 200: + raise ExtractorError(f"FlareSolverr returned status {response.status}") + + data = await response.json() + + if data.get("status") != "ok": + raise ExtractorError(f"FlareSolverr failed: {data.get('message', 'Unknown error')}") + + solution = data.get("solution", {}) + html_content = solution.get("response", "") + final_url = solution.get("url", url) + status = solution.get("status", 200) + + # Store cookies and user-agent for subsequent requests + cookies = solution.get("cookies", []) + if cookies: + cookie_str = "; ".join([f"{c['name']}={c['value']}" for c in cookies]) + self._flaresolverr_cookies = cookie_str + logger.info(f"FlareSolverr provided {len(cookies)} cookies") + + user_agent = solution.get("userAgent") + if user_agent: + self._flaresolverr_user_agent = user_agent + logger.info(f"FlareSolverr user-agent: {user_agent}") + + logger.info(f"FlareSolverr successfully bypassed Cloudflare for: {url}") + + return HttpResponse( + status=status, + headers={}, + text=html_content, + content=html_content.encode("utf-8", errors="replace"), + url=final_url, + ) + + async def _make_request( + self, url: str, method: str = "GET", headers: Optional[Dict] = None, use_flaresolverr: bool = False, **kwargs + ) -> HttpResponse: + """Override to disable SSL verification and optionally use FlareSolverr.""" + # Use FlareSolverr for Cloudflare-protected pages + if use_flaresolverr and settings.flaresolverr_url: + return await self._fetch_via_flaresolverr(url) timeout = kwargs.pop("timeout", 15) - retries = kwargs.pop("retries", 3) - backoff_factor = kwargs.pop("backoff_factor", 0.5) + kwargs.pop("retries", 3) # consumed but not used directly + kwargs.pop("backoff_factor", 0.5) # consumed but not used directly + # Merge headers + request_headers = self.base_headers.copy() + if headers: + request_headers.update(headers) - async with create_httpx_client(verify=False, timeout=httpx.Timeout(timeout)) as client: - try: - return await fetch_with_retry(client, method, url, headers or {}, timeout=timeout) - except Exception: - logger.debug("fetch_with_retry failed or unavailable; falling back to direct request for %s", url) - response = await client.request(method, url, headers=headers or {}, timeout=timeout) - response.raise_for_status() - return response + # Add FlareSolverr cookies if available + if self._flaresolverr_cookies: + existing_cookies = request_headers.get("Cookie", "") + if existing_cookies: + request_headers["Cookie"] = f"{existing_cookies}; {self._flaresolverr_cookies}" + else: + request_headers["Cookie"] = self._flaresolverr_cookies + # Use FlareSolverr user-agent if available + if self._flaresolverr_user_agent: + request_headers["User-Agent"] = self._flaresolverr_user_agent - async def _extract_lovecdn_stream(self, iframe_url: str, iframe_content: str, headers: dict) -> Dict[str, Any]: + # Use create_aiohttp_session with verify=False for SSL bypass + async with create_aiohttp_session(url, timeout=timeout, verify=False) as (session, proxy_url): + async with session.request(method, url, headers=request_headers, proxy=proxy_url, **kwargs) as response: + content = await response.read() + final_url = str(response.url) + status = response.status + resp_headers = dict(response.headers) + + if status >= 400: + raise ExtractorError(f"HTTP error {status} while requesting {url}") + + return HttpResponse( + status=status, + headers=resp_headers, + text=content.decode("utf-8", errors="replace"), + content=content, + url=final_url, + ) + + async def _extract_session_data(self, iframe_url: str, main_url: str) -> dict | None: """ - Estrattore alternativo per iframe lovecdn.ru che usa un formato diverso. + Fetch the iframe URL and extract auth_token, channel_key, and channel_salt. + + Args: + iframe_url: The iframe URL to fetch + main_url: The main site domain for Referer header + + Returns: + Dict with auth_token, channel_key, channel_salt, or None if not found """ + headers = { + "User-Agent": self._flaresolverr_user_agent or DEFAULT_DLHD_USER_AGENT, + "Referer": f"https://{main_url}/", + } + try: - # Cerca pattern di stream URL diretto - m3u8_patterns = [ - r'["\']([^"\']*\.m3u8[^"\']*)["\']', - r'source[:\s]+["\']([^"\']+)["\']', - r'file[:\s]+["\']([^"\']+\.m3u8[^"\']*)["\']', - r'hlsManifestUrl[:\s]*["\']([^"\']+)["\']', - ] - - stream_url = None - for pattern in m3u8_patterns: - matches = re.findall(pattern, iframe_content) - for match in matches: - if '.m3u8' in match and match.startswith('http'): - stream_url = match - logger.info(f"Found direct m3u8 URL: {stream_url}") - break - if stream_url: - break - - # Pattern 2: Cerca costruzione dinamica URL - if not stream_url: - channel_match = re.search(r'(?:stream|channel)["\s:=]+["\']([^"\']+)["\']', iframe_content) - server_match = re.search(r'(?:server|domain|host)["\s:=]+["\']([^"\']+)["\']', iframe_content) - - if channel_match: - channel_name = channel_match.group(1) - server = server_match.group(1) if server_match else 'newkso.ru' - stream_url = f"https://{server}/{channel_name}/mono.m3u8" - logger.info(f"Constructed stream URL: {stream_url}") - - if not stream_url: - # Fallback: cerca qualsiasi URL che sembri uno stream - url_pattern = r'https?://[^\s"\'<>]+\.m3u8[^\s"\'<>]*' - matches = re.findall(url_pattern, iframe_content) - if matches: - stream_url = matches[0] - logger.info(f"Found fallback stream URL: {stream_url}") - - if not stream_url: - raise ExtractorError(f"Could not find stream URL in lovecdn.ru iframe") - - # Usa iframe URL come referer - iframe_origin = f"https://{urlparse(iframe_url).netloc}" - stream_headers = { - 'User-Agent': headers['User-Agent'], - 'Referer': iframe_url, - 'Origin': iframe_origin - } - - # Determina endpoint in base al dominio dello stream - endpoint = "hls_key_proxy" - - logger.info(f"Using lovecdn.ru stream with endpoint: {endpoint}") - - return { - "destination_url": stream_url, - "request_headers": stream_headers, - "mediaflow_endpoint": endpoint, - } - + resp = await self._make_request(iframe_url, headers=headers, timeout=12) + html = resp.text except Exception as e: - raise ExtractorError(f"Failed to extract lovecdn.ru stream: {e}") + logger.warning(f"Error fetching iframe URL: {e}") + return None + + # Pattern to extract EPlayerAuth.init block with authToken, channelKey, channelSalt + # Matches: EPlayerAuth.init({ authToken: '...', channelKey: '...', ..., channelSalt: '...' }); + auth_pattern = r"EPlayerAuth\.init\s*\(\s*\{\s*authToken:\s*'([^']+)'" + channel_key_pattern = r"channelKey:\s*'([^']+)'" + channel_salt_pattern = r"channelSalt:\s*'([^']+)'" + + # Pattern to extract server lookup base URL from fetchWithRetry call + lookup_pattern = r"fetchWithRetry\s*\(\s*'([^']+server_lookup\?channel_id=)" + + auth_match = re.search(auth_pattern, html) + channel_key_match = re.search(channel_key_pattern, html) + channel_salt_match = re.search(channel_salt_pattern, html) + lookup_match = re.search(lookup_pattern, html) + + if auth_match and channel_key_match and channel_salt_match: + result = { + "auth_token": auth_match.group(1), + "channel_key": channel_key_match.group(1), + "channel_salt": channel_salt_match.group(1), + } + if lookup_match: + result["server_lookup_url"] = lookup_match.group(1) + result["channel_key"] + + return result + + return None + + async def _get_server_key(self, server_lookup_url: str, iframe_url: str) -> str | None: + """ + Fetch the server lookup URL and extract the server_key. + + Args: + server_lookup_url: The server lookup URL + iframe_url: The iframe URL for extracting the host for headers + + Returns: + The server_key or None if not found + """ + parsed = urlparse(iframe_url) + iframe_host = parsed.netloc + + headers = { + "User-Agent": self._flaresolverr_user_agent or DEFAULT_DLHD_USER_AGENT, + "Referer": f"https://{iframe_host}/", + "Origin": f"https://{iframe_host}", + } + + try: + resp = await self._make_request(server_lookup_url, headers=headers, timeout=10) + data = resp.json() + return data.get("server_key") + except Exception as e: + logger.warning(f"Error fetching server lookup: {e}") + return None + + def _build_m3u8_url(self, server_key: str, channel_key: str) -> str: + """ + Build the m3u8 URL based on the server_key. + + Args: + server_key: The server key from server lookup + channel_key: The channel key + + Returns: + The m3u8 URL (with .css extension as per the original implementation) + """ + if server_key == "top1/cdn": + return f"https://top1.dvalna.ru/top1/cdn/{channel_key}/mono.css" + else: + return f"https://{server_key}new.dvalna.ru/{server_key}/{channel_key}/mono.css" async def _extract_new_auth_flow(self, iframe_url: str, iframe_content: str, headers: dict) -> Dict[str, Any]: """Handles the new authentication flow found in recent updates.""" - + def _extract_params(js: str) -> Dict[str, Optional[str]]: params = {} patterns = { @@ -143,82 +355,93 @@ class DLHDExtractor(BaseExtractor): return params params = _extract_params(iframe_content) - + missing_params = [k for k, v in params.items() if not v] if missing_params: # This is not an error, just means it's not the new flow raise ExtractorError(f"Not the new auth flow: missing params {missing_params}") logger.info("New auth flow detected. Proceeding with POST auth.") - + # 1. Initial Auth POST - auth_url = 'https://security.newkso.ru/auth2.php' - # Use files parameter to force multipart/form-data which is required by the server - # (None, value) tells httpx to send it as a form field, not a file upload - multipart_data = { - 'channelKey': (None, params["channel_key"]), - 'country': (None, params["auth_country"]), - 'timestamp': (None, params["auth_ts"]), - 'expiry': (None, params["auth_expiry"]), - 'token': (None, params["auth_token"]), - } + auth_url = "https://security.newkso.ru/auth2.php" iframe_origin = f"https://{urlparse(iframe_url).netloc}" auth_headers = headers.copy() - auth_headers.update({ - 'Accept': '*/*', - 'Accept-Language': 'en-US,en;q=0.9', - 'Origin': iframe_origin, - 'Referer': iframe_url, - 'Sec-Fetch-Dest': 'empty', - 'Sec-Fetch-Mode': 'cors', - 'Sec-Fetch-Site': 'cross-site', - 'Priority': 'u=1, i', - }) - - from mediaflow_proxy.utils.http_utils import create_httpx_client + auth_headers.update( + { + "Accept": "*/*", + "Accept-Language": "en-US,en;q=0.9", + "Origin": iframe_origin, + "Referer": iframe_url, + "Sec-Fetch-Dest": "empty", + "Sec-Fetch-Mode": "cors", + "Sec-Fetch-Site": "cross-site", + "Priority": "u=1, i", + } + ) + + # Build form data for multipart/form-data + form_data = aiohttp.FormData() + form_data.add_field("channelKey", params["channel_key"]) + form_data.add_field("country", params["auth_country"]) + form_data.add_field("timestamp", params["auth_ts"]) + form_data.add_field("expiry", params["auth_expiry"]) + form_data.add_field("token", params["auth_token"]) + try: - async with create_httpx_client(verify=False) as client: - # Note: using 'files' instead of 'data' to ensure multipart/form-data Content-Type - auth_resp = await client.post(auth_url, files=multipart_data, headers=auth_headers, timeout=12) - auth_resp.raise_for_status() - auth_data = auth_resp.json() - if not (auth_data.get("valid") or auth_data.get("success")): - raise ExtractorError(f"Initial auth failed with response: {auth_data}") + async with create_aiohttp_session(auth_url, timeout=12, verify=False) as (session, proxy_url): + async with session.post( + auth_url, + headers=auth_headers, + data=form_data, + proxy=proxy_url, + ) as response: + content = await response.read() + response.raise_for_status() + import json + + auth_data = json.loads(content.decode("utf-8")) + if not (auth_data.get("valid") or auth_data.get("success")): + raise ExtractorError(f"Initial auth failed with response: {auth_data}") logger.info("New auth flow: Initial auth successful.") + except ExtractorError: + raise except Exception as e: raise ExtractorError(f"New auth flow failed during initial auth POST: {e}") # 2. Server Lookup server_lookup_url = f"https://{urlparse(iframe_url).netloc}/server_lookup.js?channel_id={params['channel_key']}" try: - # Use _make_request as it handles retries and expects JSON + # Use _make_request as it handles retries lookup_resp = await self._make_request(server_lookup_url, headers=headers, timeout=10) server_data = lookup_resp.json() - server_key = server_data.get('server_key') + server_key = server_data.get("server_key") if not server_key: raise ExtractorError(f"No server_key in lookup response: {server_data}") logger.info(f"New auth flow: Server lookup successful - Server key: {server_key}") + except ExtractorError: + raise except Exception as e: raise ExtractorError(f"New auth flow failed during server lookup: {e}") # 3. Build final stream URL - channel_key = params['channel_key'] - auth_token = params['auth_token'] + channel_key = params["channel_key"] + auth_token = params["auth_token"] # The JS logic uses .css, not .m3u8 - if server_key == 'top1/cdn': - stream_url = f'https://top1.newkso.ru/top1/cdn/{channel_key}/mono.css' + if server_key == "top1/cdn": + stream_url = f"https://top1.newkso.ru/top1/cdn/{channel_key}/mono.css" else: - stream_url = f'https://{server_key}new.newkso.ru/{server_key}/{channel_key}/mono.css' - - logger.info(f'New auth flow: Constructed stream URL: {stream_url}') + stream_url = f"https://{server_key}new.newkso.ru/{server_key}/{channel_key}/mono.css" + + logger.info(f"New auth flow: Constructed stream URL: {stream_url}") stream_headers = { - 'User-Agent': headers['User-Agent'], - 'Referer': iframe_url, - 'Origin': iframe_origin, - 'Authorization': f'Bearer {auth_token}', - 'X-Channel-Key': channel_key + "User-Agent": headers["User-Agent"], + "Referer": iframe_url, + "Origin": iframe_origin, + "Authorization": f"Bearer {auth_token}", + "X-Channel-Key": channel_key, } return { @@ -227,106 +450,255 @@ class DLHDExtractor(BaseExtractor): "mediaflow_endpoint": "hls_manifest_proxy", } - async def extract(self, url: str, **kwargs) -> Dict[str, Any]: - """Main extraction flow: resolve base, fetch players, extract iframe, auth and final m3u8.""" - baseurl = "https://dlhd.dad/" + async def _extract_lovecdn_stream(self, iframe_url: str, iframe_content: str, headers: dict) -> Dict[str, Any]: + """ + Alternative extractor for lovecdn.ru iframe that uses a different format. + """ + try: + # Look for direct stream URL patterns + m3u8_patterns = [ + r'["\']([^"\']*\.m3u8[^"\']*)["\']', + r'source[:\s]+["\']([^"\']+)["\']', + r'file[:\s]+["\']([^"\']+\.m3u8[^"\']*)["\']', + r'hlsManifestUrl[:\s]*["\']([^"\']+)["\']', + ] - def extract_channel_id(u: str) -> Optional[str]: - match_watch_id = re.search(r'watch\.php\?id=(\d+)', u) - if match_watch_id: - return match_watch_id.group(1) - return None + stream_url = None + for pattern in m3u8_patterns: + matches = re.findall(pattern, iframe_content) + for match in matches: + if ".m3u8" in match and match.startswith("http"): + stream_url = match + logger.info(f"Found direct m3u8 URL: {stream_url}") + break + if stream_url: + break + # Pattern 2: Look for dynamic URL construction + if not stream_url: + channel_match = re.search(r'(?:stream|channel)["\s:=]+["\']([^"\']+)["\']', iframe_content) + server_match = re.search(r'(?:server|domain|host)["\s:=]+["\']([^"\']+)["\']', iframe_content) - async def get_stream_data(initial_url: str): - daddy_origin = urlparse(baseurl).scheme + "://" + urlparse(baseurl).netloc - daddylive_headers = { - 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36', - 'Referer': baseurl, - 'Origin': daddy_origin + if channel_match: + channel_name = channel_match.group(1) + server = server_match.group(1) if server_match else "newkso.ru" + stream_url = f"https://{server}/{channel_name}/mono.m3u8" + logger.info(f"Constructed stream URL: {stream_url}") + + if not stream_url: + # Fallback: look for any URL that looks like a stream + url_pattern = r'https?://[^\s"\'<>]+\.m3u8[^\s"\'<>]*' + matches = re.findall(url_pattern, iframe_content) + if matches: + stream_url = matches[0] + logger.info(f"Found fallback stream URL: {stream_url}") + + if not stream_url: + raise ExtractorError("Could not find stream URL in lovecdn.ru iframe") + + # Use iframe URL as referer + iframe_origin = f"https://{urlparse(iframe_url).netloc}" + stream_headers = {"User-Agent": headers["User-Agent"], "Referer": iframe_url, "Origin": iframe_origin} + + # Determine endpoint based on the stream domain + endpoint = "hls_key_proxy" + + logger.info(f"Using lovecdn.ru stream with endpoint: {endpoint}") + + return { + "destination_url": stream_url, + "request_headers": stream_headers, + "mediaflow_endpoint": endpoint, } + except Exception as e: + raise ExtractorError(f"Failed to extract lovecdn.ru stream: {e}") - # 1. Request initial page - resp1 = await self._make_request(initial_url, headers=daddylive_headers, timeout=15) - player_links = re.findall(r']*data-url="([^"]+)"[^>]*>Player\s*\d+', resp1.text) - if not player_links: - raise ExtractorError("No player links found on the page.") + async def _extract_direct_stream(self, channel_id: str) -> Dict[str, Any]: + """ + Direct stream extraction using server lookup API with the new auth flow. + This extracts auth_token, channel_key, channel_salt and computes key headers. + """ + # Common iframe domains for DLHD + iframe_domains = ["lefttoplay.xyz"] + for iframe_domain in iframe_domains: + try: + iframe_url = f"https://{iframe_domain}/premiumtv/daddyhd.php?id={channel_id}" + logger.info(f"Attempting extraction via {iframe_domain}") - # Prova tutti i player e raccogli tutti gli iframe validi - last_player_error = None - iframe_candidates = [] + session_data = await self._extract_session_data(iframe_url, "dlhd.link") - for player_url in player_links: - try: - if not player_url.startswith('http'): - player_url = baseurl + player_url.lstrip('/') - - - daddylive_headers['Referer'] = player_url - daddylive_headers['Origin'] = player_url - resp2 = await self._make_request(player_url, headers=daddylive_headers, timeout=12) - iframes2 = re.findall(r' Dict[str, Any]: + """Main extraction flow - uses direct server lookup with new auth flow.""" + + def extract_channel_id(u: str) -> Optional[str]: + match_watch_id = re.search(r"watch\.php\?id=(\d+)", u) + if match_watch_id: + return match_watch_id.group(1) + # Also try stream-XXX pattern + match_stream = re.search(r"stream-(\d+)", u) + if match_stream: + return match_stream.group(1) + return None try: channel_id = extract_channel_id(url) if not channel_id: raise ExtractorError(f"Unable to extract channel ID from {url}") - logger.info(f"Using base domain: {baseurl}") - return await get_stream_data(url) + logger.info(f"Extracting DLHD stream for channel ID: {channel_id}") + # Try direct stream extraction with new auth flow + try: + return await self._extract_direct_stream(channel_id) + except ExtractorError as e: + logger.warning(f"Direct stream extraction failed: {e}") + + # Fallback to legacy iframe-based extraction if direct fails + logger.info("Falling back to iframe-based extraction...") + return await self._extract_via_iframe(url, channel_id) except Exception as e: raise ExtractorError(f"Extraction failed: {str(e)}") + + async def _extract_via_iframe(self, url: str, channel_id: str) -> Dict[str, Any]: + """Legacy iframe-based extraction flow - used as fallback.""" + baseurl = "https://dlhd.dad/" + + daddy_origin = urlparse(baseurl).scheme + "://" + urlparse(baseurl).netloc + daddylive_headers = { + "User-Agent": self._flaresolverr_user_agent + or "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36", + "Referer": baseurl, + "Origin": daddy_origin, + } + + # 1. Request initial page - use FlareSolverr if available to bypass Cloudflare + use_flaresolverr = settings.flaresolverr_url is not None + resp1 = await self._make_request(url, headers=daddylive_headers, timeout=15, use_flaresolverr=use_flaresolverr) + resp1_text = resp1.text + + # Update headers with FlareSolverr user-agent after initial request + if self._flaresolverr_user_agent: + daddylive_headers["User-Agent"] = self._flaresolverr_user_agent + + player_links = re.findall(r']*data-url="([^"]+)"[^>]*>Player\s*\d+', resp1_text) + if not player_links: + raise ExtractorError("No player links found on the page.") + + # Try all players and collect all valid iframes + last_player_error = None + iframe_candidates = [] + + for player_url in player_links: + try: + if not player_url.startswith("http"): + player_url = baseurl + player_url.lstrip("/") + + daddylive_headers["Referer"] = player_url + daddylive_headers["Origin"] = player_url + resp2 = await self._make_request(player_url, headers=daddylive_headers, timeout=12) + resp2_text = resp2.text + iframes2 = re.findall(r' Dict[str, str]: - """Extract DoodStream URL.""" - response = await self._make_request(url) + async def extract(self, url: str, **kwargs): + parsed = urlparse(url) + video_id = parsed.path.rstrip("/").split("/")[-1] + if not video_id: + raise ExtractorError("Invalid Dood URL") - # Extract URL pattern - pattern = r"(\/pass_md5\/.*?)'.*(\?token=.*?expiry=)" - match = re.search(pattern, response.text, re.DOTALL) + headers = { + "User-Agent": self.base_headers.get("User-Agent") or "Mozilla/5.0", + "Referer": f"{self.base_url}/", + } + + embed_url = f"{self.base_url}/e/{video_id}" + html = (await self._make_request(embed_url, headers=headers)).text + + match = re.search(r"(\/pass_md5\/[^']+)", html) if not match: - raise ExtractorError("Failed to extract URL pattern") + raise ExtractorError("Dood: pass_md5 not found") - # Build final URL - pass_url = f"{self.base_url}{match[1]}" - referer = f"{self.base_url}/" - headers = {"range": "bytes=0-", "referer": referer} + pass_url = urljoin(self.base_url, match.group(1)) - response = await self._make_request(pass_url, headers=headers) - timestamp = str(int(time.time())) - final_url = f"{response.text}123456789{match[2]}{timestamp}" + base_stream = (await self._make_request(pass_url, headers=headers)).text.strip() + + token_match = re.search(r"token=([^&]+)", html) + if not token_match: + raise ExtractorError("Dood: token missing") + + token = token_match.group(1) + + final_url = f"{base_stream}123456789?token={token}&expiry={int(time.time())}" - self.base_headers["referer"] = referer return { "destination_url": final_url, - "request_headers": self.base_headers, - "mediaflow_endpoint": self.mediaflow_endpoint, + "request_headers": headers, + "mediaflow_endpoint": "proxy_stream_endpoint", } diff --git a/mediaflow_proxy/extractors/factory.py b/mediaflow_proxy/extractors/factory.py index 042f3ac..e3158bc 100644 --- a/mediaflow_proxy/extractors/factory.py +++ b/mediaflow_proxy/extractors/factory.py @@ -7,6 +7,7 @@ from mediaflow_proxy.extractors.sportsonline import SportsonlineExtractor from mediaflow_proxy.extractors.filelions import FileLionsExtractor from mediaflow_proxy.extractors.filemoon import FileMoonExtractor from mediaflow_proxy.extractors.F16Px import F16PxExtractor +from mediaflow_proxy.extractors.gupload import GuploadExtractor from mediaflow_proxy.extractors.livetv import LiveTVExtractor from mediaflow_proxy.extractors.lulustream import LuluStreamExtractor from mediaflow_proxy.extractors.maxstream import MaxstreamExtractor @@ -33,6 +34,7 @@ class ExtractorFactory: "FileLions": FileLionsExtractor, "FileMoon": FileMoonExtractor, "F16Px": F16PxExtractor, + "Gupload": GuploadExtractor, "Uqload": UqloadExtractor, "Mixdrop": MixdropExtractor, "Streamtape": StreamtapeExtractor, diff --git a/mediaflow_proxy/extractors/fastream.py b/mediaflow_proxy/extractors/fastream.py index dcfe8c4..c656faf 100644 --- a/mediaflow_proxy/extractors/fastream.py +++ b/mediaflow_proxy/extractors/fastream.py @@ -4,25 +4,29 @@ from mediaflow_proxy.extractors.base import BaseExtractor from mediaflow_proxy.utils.packed import eval_solver - - class FastreamExtractor(BaseExtractor): """Fastream URL extractor.""" + def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.mediaflow_endpoint = "hls_manifest_proxy" async def extract(self, url: str, **kwargs) -> Dict[str, Any]: - headers = {'Accept': '*/*', 'Connection': 'keep-alive','Accept-Language': 'en-US,en;q=0.5','Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', 'user-agent': 'Mozilla/5.0 (X11; Linux x86_64; rv:138.0) Gecko/20100101 Firefox/138.0'} + headers = { + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Connection": "keep-alive", + "Accept-Language": "en-US,en;q=0.5", + "user-agent": "Mozilla/5.0 (X11; Linux x86_64; rv:138.0) Gecko/20100101 Firefox/138.0", + } patterns = [r'file:"(.*?)"'] final_url = await eval_solver(self, url, headers, patterns) - self.base_headers["referer"] = f'https://{url.replace("https://","").split("/")[0]}/' - self.base_headers["origin"] = f'https://{url.replace("https://","").split("/")[0]}' - self.base_headers['Accept-Language'] = 'en-US,en;q=0.5' - self.base_headers['Accept'] = '*/*' - self.base_headers['user-agent'] = 'Mozilla/5.0 (X11; Linux x86_64; rv:138.0) Gecko/20100101 Firefox/138.0' + self.base_headers["referer"] = f"https://{url.replace('https://', '').split('/')[0]}/" + self.base_headers["origin"] = f"https://{url.replace('https://', '').split('/')[0]}" + self.base_headers["Accept-Language"] = "en-US,en;q=0.5" + self.base_headers["Accept"] = "*/*" + self.base_headers["user-agent"] = "Mozilla/5.0 (X11; Linux x86_64; rv:138.0) Gecko/20100101 Firefox/138.0" return { "destination_url": final_url, diff --git a/mediaflow_proxy/extractors/filelions.py b/mediaflow_proxy/extractors/filelions.py index 25271ed..1eb0dc9 100644 --- a/mediaflow_proxy/extractors/filelions.py +++ b/mediaflow_proxy/extractors/filelions.py @@ -3,17 +3,18 @@ from typing import Dict, Any from mediaflow_proxy.extractors.base import BaseExtractor from mediaflow_proxy.utils.packed import eval_solver + class FileLionsExtractor(BaseExtractor): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.mediaflow_endpoint = "hls_manifest_proxy" async def extract(self, url: str, **kwargs) -> Dict[str, Any]: - headers = {} - patterns = [ # See https://github.com/Gujal00/ResolveURL/blob/master/script.module.resolveurl/lib/resolveurl/plugins/filelions.py - r'''sources:\s*\[{file:\s*["'](?P[^"']+)''', - r'''["']hls4["']:\s*["'](?P[^"']+)''', - r'''["']hls2["']:\s*["'](?P[^"']+)''' + headers = {} + patterns = [ # See https://github.com/Gujal00/ResolveURL/blob/master/script.module.resolveurl/lib/resolveurl/plugins/filelions.py + r"""sources:\s*\[{file:\s*["'](?P[^"']+)""", + r"""["']hls4["']:\s*["'](?P[^"']+)""", + r"""["']hls2["']:\s*["'](?P[^"']+)""", ] final_url = await eval_solver(self, url, headers, patterns) @@ -23,4 +24,5 @@ class FileLionsExtractor(BaseExtractor): "destination_url": final_url, "request_headers": self.base_headers, "mediaflow_endpoint": self.mediaflow_endpoint, + "stream_transformer": "ts_stream", } diff --git a/mediaflow_proxy/extractors/filemoon.py b/mediaflow_proxy/extractors/filemoon.py index 808042a..e67d3f1 100644 --- a/mediaflow_proxy/extractors/filemoon.py +++ b/mediaflow_proxy/extractors/filemoon.py @@ -40,7 +40,7 @@ class FileMoonExtractor(BaseExtractor): ) test_resp = await self._make_request(final_url, headers=headers) - if test_resp.status_code == 404: + if test_resp.status == 404: raise ExtractorError("Stream not found (404)") self.base_headers["referer"] = url diff --git a/mediaflow_proxy/extractors/gupload.py b/mediaflow_proxy/extractors/gupload.py new file mode 100644 index 0000000..f509877 --- /dev/null +++ b/mediaflow_proxy/extractors/gupload.py @@ -0,0 +1,65 @@ +import re +import base64 +import json +from typing import Dict, Any +from urllib.parse import urlparse + +from mediaflow_proxy.extractors.base import BaseExtractor, ExtractorError + + +class GuploadExtractor(BaseExtractor): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.mediaflow_endpoint = "hls_manifest_proxy" + + async def extract(self, url: str) -> Dict[str, Any]: + parsed = urlparse(url) + if not parsed.hostname or "gupload.xyz" not in parsed.hostname: + raise ExtractorError("GUPLOAD: Invalid domain") + + headers = { + "User-Agent": ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/144 Safari/537.36" + ), + "Referer": "https://gupload.xyz/", + "Origin": "https://gupload.xyz", + } + + # --- Fetch embed page --- + response = await self._make_request(url, headers=headers) + html = response.text + + # --- Extract base64 payload --- + match = re.search(r"decodePayload\('([^']+)'\)", html) + if not match: + raise ExtractorError("GUPLOAD: Payload not found") + + encoded = match.group(1).strip() + + # --- Decode payload --- + try: + decoded = base64.b64decode(encoded).decode("utf-8", "ignore") + # payload format: |{json} + json_part = decoded.split("|", 1)[1] + payload = json.loads(json_part) + except Exception: + raise ExtractorError("GUPLOAD: Payload decode failed") + + # --- Extract HLS URL --- + hls_url = payload.get("videoUrl") + if not hls_url: + raise ExtractorError("GUPLOAD: videoUrl missing") + + # --- Validate stream (prevents client timeout) --- + test = await self._make_request(hls_url, headers=headers, raise_on_status=False) + if test.status >= 400: + raise ExtractorError(f"GUPLOAD: Stream unavailable ({test.status})") + + # Return MASTER playlist + return { + "destination_url": hls_url, + "request_headers": headers, + "mediaflow_endpoint": self.mediaflow_endpoint, + } diff --git a/mediaflow_proxy/extractors/livetv.py b/mediaflow_proxy/extractors/livetv.py index fbe0c93..c1ab473 100644 --- a/mediaflow_proxy/extractors/livetv.py +++ b/mediaflow_proxy/extractors/livetv.py @@ -2,9 +2,9 @@ import re from typing import Dict, Tuple, Optional from urllib.parse import urljoin, urlparse, unquote -from httpx import Response +import aiohttp -from mediaflow_proxy.extractors.base import BaseExtractor, ExtractorError +from mediaflow_proxy.extractors.base import BaseExtractor, ExtractorError, HttpResponse class LiveTVExtractor(BaseExtractor): @@ -33,20 +33,21 @@ class LiveTVExtractor(BaseExtractor): stream_title: Optional stream title to filter specific stream Returns: - Tuple[str, Dict[str, str]]: Stream URL and required headers + Dict containing destination_url, request_headers, and mediaflow_endpoint """ try: # Get the channel page response = await self._make_request(url) + response_text = response.text self.base_headers["referer"] = urljoin(url, "/") # Extract player API details - player_api_base, method = await self._extract_player_api_base(response.text) + player_api_base, method = await self._extract_player_api_base(response_text) if not player_api_base: raise ExtractorError("Failed to extract player API URL") # Get player options - options_data = await self._get_player_options(response.text) + options_data = await self._get_player_options(response_text) if not options_data: raise ExtractorError("No player options found") @@ -66,7 +67,7 @@ class LiveTVExtractor(BaseExtractor): if not stream_url: continue - response = { + result = { "destination_url": stream_url, "request_headers": self.base_headers, "mediaflow_endpoint": self.mediaflow_endpoint, @@ -75,7 +76,7 @@ class LiveTVExtractor(BaseExtractor): # Set endpoint based on stream type if stream_data.get("type") == "mpd": if stream_data.get("drm_key_id") and stream_data.get("drm_key"): - response.update( + result.update( { "query_params": { "key_id": stream_data["drm_key_id"], @@ -85,7 +86,7 @@ class LiveTVExtractor(BaseExtractor): } ) - return response + return result raise ExtractorError("No valid stream found") @@ -120,7 +121,12 @@ class LiveTVExtractor(BaseExtractor): api_url = f"{api_base}{post}/{type_}/{nume}" response = await self._make_request(api_url) else: - form_data = {"action": "doo_player_ajax", "post": post, "nume": nume, "type": type_} + # Use aiohttp FormData for POST requests + form_data = aiohttp.FormData() + form_data.add_field("action", "doo_player_ajax") + form_data.add_field("post", post) + form_data.add_field("nume", nume) + form_data.add_field("type", type_) response = await self._make_request(api_base, method="POST", data=form_data) # Get iframe URL from API response @@ -136,7 +142,7 @@ class LiveTVExtractor(BaseExtractor): except Exception as e: raise ExtractorError(f"Failed to process player option: {str(e)}") - async def _extract_stream_url(self, iframe_response: Response, iframe_url: str) -> Dict: + async def _extract_stream_url(self, iframe_response: HttpResponse, iframe_url: str) -> Dict: """ Extract final stream URL from iframe content. """ @@ -147,8 +153,9 @@ class LiveTVExtractor(BaseExtractor): # Check if content is already a direct M3U8 stream content_types = ["application/x-mpegurl", "application/vnd.apple.mpegurl"] + content_type = iframe_response.headers.get("content-type", "") - if any(ext in iframe_response.headers["content-type"] for ext in content_types): + if any(ext in content_type for ext in content_types): return {"url": iframe_url, "type": "m3u8"} stream_data = {} diff --git a/mediaflow_proxy/extractors/lulustream.py b/mediaflow_proxy/extractors/lulustream.py index 4c1d4c9..63aaf7d 100644 --- a/mediaflow_proxy/extractors/lulustream.py +++ b/mediaflow_proxy/extractors/lulustream.py @@ -13,7 +13,7 @@ class LuluStreamExtractor(BaseExtractor): response = await self._make_request(url) # See https://github.com/Gujal00/ResolveURL/blob/master/script.module.resolveurl/lib/resolveurl/plugins/lulustream.py - pattern = r'''sources:\s*\[{file:\s*["'](?P[^"']+)''' + pattern = r"""sources:\s*\[{file:\s*["'](?P[^"']+)""" match = re.search(pattern, response.text, re.DOTALL) if not match: raise ExtractorError("Failed to extract source URL") diff --git a/mediaflow_proxy/extractors/mixdrop.py b/mediaflow_proxy/extractors/mixdrop.py index bd77f34..dff9870 100644 --- a/mediaflow_proxy/extractors/mixdrop.py +++ b/mediaflow_proxy/extractors/mixdrop.py @@ -1,6 +1,6 @@ from typing import Dict, Any -from mediaflow_proxy.extractors.base import BaseExtractor, ExtractorError +from mediaflow_proxy.extractors.base import BaseExtractor from mediaflow_proxy.utils.packed import eval_solver diff --git a/mediaflow_proxy/extractors/okru.py b/mediaflow_proxy/extractors/okru.py index bf6d307..6aa6ec8 100644 --- a/mediaflow_proxy/extractors/okru.py +++ b/mediaflow_proxy/extractors/okru.py @@ -22,7 +22,9 @@ class OkruExtractor(BaseExtractor): data_options = div.get("data-options") data = json.loads(data_options) metadata = json.loads(data["flashvars"]["metadata"]) - final_url = metadata.get("hlsMasterPlaylistUrl") or metadata.get("hlsManifestUrl") + final_url = ( + metadata.get("hlsMasterPlaylistUrl") or metadata.get("hlsManifestUrl") or metadata.get("ondemandHls") + ) self.base_headers["referer"] = url return { "destination_url": final_url, diff --git a/mediaflow_proxy/extractors/sportsonline.py b/mediaflow_proxy/extractors/sportsonline.py index f1c3b90..a72a62b 100644 --- a/mediaflow_proxy/extractors/sportsonline.py +++ b/mediaflow_proxy/extractors/sportsonline.py @@ -1,10 +1,10 @@ import re import logging -from typing import Any, Dict, Optional +from typing import Any, Dict from urllib.parse import urlparse from mediaflow_proxy.extractors.base import BaseExtractor, ExtractorError -from mediaflow_proxy.utils.packed import detect, unpack +from mediaflow_proxy.utils.packed import unpack logger = logging.getLogger(__name__) @@ -32,18 +32,17 @@ class SportsonlineExtractor(BaseExtractor): def _detect_packed_blocks(self, html: str) -> list[str]: """ Detect and extract packed eval blocks from HTML. - Replicates the TypeScript logic: /eval\(function(.+?.+)/g """ # Find all eval(function...) blocks - more greedy to capture full packed code pattern = re.compile(r"eval\(function\(p,a,c,k,e,.*?\)\)(?:\s*;|\s*<)", re.DOTALL) raw_matches = pattern.findall(html) - + # If no matches with the strict pattern, try a more relaxed one if not raw_matches: # Try to find eval(function and capture until we find the closing )) pattern = re.compile(r"eval\(function\(p,a,c,k,e,[dr]\).*?\}\(.*?\)\)", re.DOTALL) raw_matches = pattern.findall(html) - + return raw_matches async def extract(self, url: str, **kwargs) -> Dict[str, Any]: @@ -60,25 +59,25 @@ class SportsonlineExtractor(BaseExtractor): raise ExtractorError("No iframe found on the page") iframe_url = iframe_match.group(1) - + # Normalize iframe URL - if iframe_url.startswith('//'): - iframe_url = 'https:' + iframe_url - elif iframe_url.startswith('/'): + if iframe_url.startswith("//"): + iframe_url = "https:" + iframe_url + elif iframe_url.startswith("/"): parsed_main = urlparse(url) iframe_url = f"{parsed_main.scheme}://{parsed_main.netloc}{iframe_url}" - + logger.info(f"Found iframe URL: {iframe_url}") # Step 2: Fetch iframe with Referer iframe_headers = { - 'Referer': 'https://sportzonline.st/', - 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36', - 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', - 'Accept-Language': 'en-US,en;q=0.9,it;q=0.8', - 'Cache-Control': 'no-cache' + "Referer": "https://sportzonline.st/", + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-US,en;q=0.9,it;q=0.8", + "Cache-Control": "no-cache", } - + iframe_response = await self._make_request(iframe_url, headers=iframe_headers, timeout=15) iframe_html = iframe_response.text @@ -86,9 +85,9 @@ class SportsonlineExtractor(BaseExtractor): # Step 3: Detect packed blocks packed_blocks = self._detect_packed_blocks(iframe_html) - + logger.info(f"Found {len(packed_blocks)} packed blocks") - + if not packed_blocks: logger.warning("No packed blocks found, trying direct m3u8 search") # Fallback: try direct m3u8 search @@ -96,13 +95,10 @@ class SportsonlineExtractor(BaseExtractor): if direct_match: m3u8_url = direct_match.group(1) logger.info(f"Found direct m3u8 URL: {m3u8_url}") - + return { "destination_url": m3u8_url, - "request_headers": { - 'Referer': iframe_url, - 'User-Agent': iframe_headers['User-Agent'] - }, + "request_headers": {"Referer": iframe_url, "User-Agent": iframe_headers["User-Agent"]}, "mediaflow_endpoint": self.mediaflow_endpoint, } else: @@ -134,13 +130,13 @@ class SportsonlineExtractor(BaseExtractor): r'file\s*:\s*["\']([^"\']+\.m3u8[^"\']*)["\']', # file: "...m3u8" r'["\']([^"\']*https?://[^"\']+\.m3u8[^"\']*)["\']', # any m3u8 URL ] - + for pattern in patterns: src_match = re.search(pattern, unpacked_code) if src_match: m3u8_url = src_match.group(1) # Verify it looks like a valid m3u8 URL - if '.m3u8' in m3u8_url or 'http' in m3u8_url: + if ".m3u8" in m3u8_url or "http" in m3u8_url: break m3u8_url = None @@ -162,11 +158,11 @@ class SportsonlineExtractor(BaseExtractor): src_match = re.search(pattern, unpacked_code) if src_match: test_url = src_match.group(1) - if '.m3u8' in test_url or 'http' in test_url: + if ".m3u8" in test_url or "http" in test_url: m3u8_url = test_url logger.info(f"Found m3u8 in block {i}") break - + if m3u8_url: break except Exception as e: @@ -181,10 +177,7 @@ class SportsonlineExtractor(BaseExtractor): # Return stream configuration return { "destination_url": m3u8_url, - "request_headers": { - 'Referer': iframe_url, - 'User-Agent': iframe_headers['User-Agent'] - }, + "request_headers": {"Referer": iframe_url, "User-Agent": iframe_headers["User-Agent"]}, "mediaflow_endpoint": self.mediaflow_endpoint, } diff --git a/mediaflow_proxy/extractors/streamtape.py b/mediaflow_proxy/extractors/streamtape.py index c180b7c..a5a6966 100644 --- a/mediaflow_proxy/extractors/streamtape.py +++ b/mediaflow_proxy/extractors/streamtape.py @@ -15,8 +15,8 @@ class StreamtapeExtractor(BaseExtractor): if not matches: raise ExtractorError("Failed to extract URL components") i = 0 - for i in range(len(matches)): - if matches[i-1] == matches[i] and "ip=" in matches[i]: + for i in range(len(matches)): + if matches[i - 1] == matches[i] and "ip=" in matches[i]: final_url = f"https://streamtape.com/get_video?{matches[i]}" self.base_headers["referer"] = url diff --git a/mediaflow_proxy/extractors/streamwish.py b/mediaflow_proxy/extractors/streamwish.py index 51c665a..d09fd94 100644 --- a/mediaflow_proxy/extractors/streamwish.py +++ b/mediaflow_proxy/extractors/streamwish.py @@ -19,18 +19,11 @@ class StreamWishExtractor(BaseExtractor): headers = {"Referer": referer} response = await self._make_request(url, headers=headers) - - iframe_match = re.search( - r']+src=["\']([^"\']+)["\']', - response.text, - re.DOTALL - ) + + iframe_match = re.search(r']+src=["\']([^"\']+)["\']', response.text, re.DOTALL) iframe_url = urljoin(url, iframe_match.group(1)) if iframe_match else url - iframe_response = await self._make_request( - iframe_url, - headers=headers - ) + iframe_response = await self._make_request(iframe_url, headers=headers) html = iframe_response.text final_url = self._extract_m3u8(html) @@ -58,15 +51,18 @@ class StreamWishExtractor(BaseExtractor): final_url = urljoin(iframe_url, final_url) origin = f"{urlparse(referer).scheme}://{urlparse(referer).netloc}" - self.base_headers.update({ - "Referer": referer, - "Origin": origin, - }) + self.base_headers.update( + { + "Referer": referer, + "Origin": origin, + } + ) return { "destination_url": final_url, "request_headers": self.base_headers, "mediaflow_endpoint": self.mediaflow_endpoint, + "stream_transformer": "ts_stream", } @staticmethod @@ -74,8 +70,5 @@ class StreamWishExtractor(BaseExtractor): """ Extract first absolute m3u8 URL from text """ - match = re.search( - r'https?://[^"\']+\.m3u8[^"\']*', - text - ) + match = re.search(r'https?://[^"\']+\.m3u8[^"\']*', text) return match.group(0) if match else None diff --git a/mediaflow_proxy/extractors/supervideo.py b/mediaflow_proxy/extractors/supervideo.py index f2be69a..d62ab3f 100644 --- a/mediaflow_proxy/extractors/supervideo.py +++ b/mediaflow_proxy/extractors/supervideo.py @@ -1,27 +1,64 @@ import re from typing import Dict, Any +from urllib.parse import urljoin, urlparse -from mediaflow_proxy.extractors.base import BaseExtractor -from mediaflow_proxy.utils.packed import eval_solver - +from bs4 import BeautifulSoup, SoupStrainer +from curl_cffi.requests import AsyncSession +from mediaflow_proxy.extractors.base import BaseExtractor, ExtractorError +from mediaflow_proxy.utils.packed import unpack, detect, UnpackingError class SupervideoExtractor(BaseExtractor): - """Supervideo URL extractor.""" + """Supervideo URL extractor. + + Uses curl_cffi to bypass Cloudflare protection. + """ + def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.mediaflow_endpoint = "hls_manifest_proxy" - + async def extract(self, url: str, **kwargs) -> Dict[str, Any]: - headers = {'Accept': '*/*', 'Connection': 'keep-alive', 'User-Agent': 'Mozilla/5.0 (Linux; Android 12) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.5060.71 Mobile Safari/537.36', 'user-agent': 'Mozilla/5.0 (Linux; Android 12) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.5060.71 Mobile Safari/537.36'} + """Extract video URL from Supervideo. + + Uses curl_cffi with Chrome impersonation to bypass Cloudflare. + """ + patterns = [r'file:"(.*?)"'] - final_url = await eval_solver(self, url, headers, patterns) + try: + async with AsyncSession() as session: + response = await session.get(url, impersonate="chrome") - self.base_headers["referer"] = url - return { - "destination_url": final_url, - "request_headers": self.base_headers, - "mediaflow_endpoint": self.mediaflow_endpoint, - } + if response.status_code != 200: + raise ExtractorError(f"HTTP {response.status_code} while fetching {url}") + + soup = BeautifulSoup(response.text, "lxml", parse_only=SoupStrainer("script")) + script_all = soup.find_all("script") + + for script in script_all: + if script.text and detect(script.text): + unpacked_code = unpack(script.text) + for pattern in patterns: + match = re.search(pattern, unpacked_code) + if match: + extracted_url = match.group(1) + if not urlparse(extracted_url).scheme: + extracted_url = urljoin(url, extracted_url) + + self.base_headers["referer"] = url + return { + "destination_url": extracted_url, + "request_headers": self.base_headers, + "mediaflow_endpoint": self.mediaflow_endpoint, + } + + raise ExtractorError("No packed JS found or no file URL pattern matched") + + except UnpackingError as e: + raise ExtractorError(f"Failed to unpack Supervideo JS: {e}") + except Exception as e: + if isinstance(e, ExtractorError): + raise + raise ExtractorError(f"Supervideo extraction failed: {e}") diff --git a/mediaflow_proxy/extractors/turbovidplay.py b/mediaflow_proxy/extractors/turbovidplay.py index d0b7a47..611bf92 100644 --- a/mediaflow_proxy/extractors/turbovidplay.py +++ b/mediaflow_proxy/extractors/turbovidplay.py @@ -1,5 +1,4 @@ import re -from typing import Dict, Any from mediaflow_proxy.extractors.base import BaseExtractor, ExtractorError @@ -36,7 +35,7 @@ class TurboVidPlayExtractor(BaseExtractor): if media_url.startswith("//"): media_url = "https:" + media_url elif media_url.startswith("/"): - media_url = response.url.origin + media_url + media_url = response.get_origin() + media_url # # 3. Fetch the intermediate playlist @@ -53,16 +52,11 @@ class TurboVidPlayExtractor(BaseExtractor): real_m3u8 = m2.group(0) - # - # 5. Final headers - # - self.base_headers["referer"] = url - - # - # 6. Always return master proxy (your MediaFlow only supports this) - # return { "destination_url": real_m3u8, - "request_headers": self.base_headers, + "request_headers": {"origin": response.get_origin()}, + "propagate_response_headers": {"content-type": "video/mp2t"}, + "remove_response_headers": ["content-length", "content-range"], "mediaflow_endpoint": "hls_manifest_proxy", + "stream_transformer": "ts_stream", # Use TS transformer for PNG/padding stripping } diff --git a/mediaflow_proxy/extractors/vavoo.py b/mediaflow_proxy/extractors/vavoo.py index 34c31eb..b096512 100644 --- a/mediaflow_proxy/extractors/vavoo.py +++ b/mediaflow_proxy/extractors/vavoo.py @@ -1,5 +1,6 @@ import logging from typing import Any, Dict, Optional + from mediaflow_proxy.extractors.base import BaseExtractor, ExtractorError logger = logging.getLogger(__name__) @@ -8,6 +9,11 @@ logger = logging.getLogger(__name__) class VavooExtractor(BaseExtractor): """Vavoo URL extractor for resolving vavoo.to links. + Supports two URL formats: + 1. Web-VOD API links: https://vavoo.to/web-vod/api/get?link=... + These redirect (302) to external video hosts (Doodstream, etc.) + 2. Legacy mediahubmx format (currently broken on Vavoo's end) + Features: - Uses BaseExtractor's retry/timeouts - Improved headers to mimic Android okhttp client @@ -18,6 +24,40 @@ class VavooExtractor(BaseExtractor): super().__init__(request_headers) self.mediaflow_endpoint = "proxy_stream_endpoint" + async def _resolve_web_vod_link(self, url: str) -> str: + """Resolve a web-vod API link by getting the redirect Location header.""" + import aiohttp + + try: + # Use aiohttp directly with allow_redirects=False to get the Location header + timeout = aiohttp.ClientTimeout(total=10) + async with aiohttp.ClientSession(timeout=timeout) as session: + async with session.get( + url, + headers={"Accept": "application/json"}, + allow_redirects=False, + ) as resp: + # Check for redirect + if resp.status in (301, 302, 303, 307, 308): + location = resp.headers.get("Location") or resp.headers.get("location") + if location: + logger.info(f"Vavoo web-vod redirected to: {location}") + return location + + # If we got a 200, the response might contain the URL + if resp.status == 200: + text = await resp.text() + if text and text.startswith("http"): + logger.info(f"Vavoo web-vod resolved to: {text.strip()}") + return text.strip() + + raise ExtractorError(f"Vavoo web-vod API returned unexpected status {resp.status}") + + except ExtractorError: + raise + except Exception as e: + raise ExtractorError(f"Failed to resolve Vavoo web-vod link: {e}") + async def get_auth_signature(self) -> Optional[str]: """Get authentication signature for Vavoo API (async).""" headers = { @@ -27,10 +67,11 @@ class VavooExtractor(BaseExtractor): "accept-encoding": "gzip", } import time + current_time = int(time.time() * 1000) data = { - "token": "tosFwQCJMS8qrW_AjLoHPQ41646J5dRNha6ZWHnijoYQQQoADQoXYSo7ki7O5-CsgN4CH0uRk6EEoJ0728ar9scCRQW3ZkbfrPfeCXW2VgopSW2FWDqPOoVYIuVPAOnXCZ5g", + "token": "", "reason": "app-blur", "locale": "de", "theme": "dark", @@ -40,21 +81,11 @@ class VavooExtractor(BaseExtractor): "brand": "google", "model": "Pixel", "name": "sdk_gphone64_arm64", - "uniqueId": "d10e5d99ab665233" - }, - "os": { - "name": "android", - "version": "13" - }, - "app": { - "platform": "android", - "version": "3.1.21" - }, - "version": { - "package": "tv.vavoo.app", - "binary": "3.1.21", - "js": "3.1.21" + "uniqueId": "d10e5d99ab665233", }, + "os": {"name": "android", "version": "13"}, + "app": {"platform": "android", "version": "3.1.21"}, + "version": {"package": "tv.vavoo.app", "binary": "3.1.21", "js": "3.1.21"}, }, "appFocusTime": 0, "playerActive": False, @@ -75,11 +106,9 @@ class VavooExtractor(BaseExtractor): "ssVersion": 1, "enabled": True, "autoServer": True, - "id": "de-fra" + "id": "de-fra", }, - "iap": { - "supported": False - } + "iap": {"supported": False}, } try: @@ -94,7 +123,7 @@ class VavooExtractor(BaseExtractor): try: result = resp.json() except Exception: - logger.warning("Vavoo ping returned non-json response (status=%s).", resp.status_code) + logger.warning("Vavoo ping returned non-json response (status=%s).", resp.status) return None addon_sig = result.get("addonSig") if isinstance(result, dict) else None @@ -109,10 +138,48 @@ class VavooExtractor(BaseExtractor): return None async def extract(self, url: str, **kwargs) -> Dict[str, Any]: - """Extract Vavoo stream URL (async).""" + """Extract Vavoo stream URL (async). + + Supports: + - Direct play URLs: https://vavoo.to/play/{id}/index.m3u8 (Live TV) + - Web-VOD API links: https://vavoo.to/web-vod/api/get?link=... + - Legacy mediahubmx links (may not work due to Vavoo API changes) + """ if "vavoo.to" not in url: raise ExtractorError("Not a valid Vavoo URL") + # Check if this is a direct play URL (Live TV) + # These URLs are already m3u8 streams but need auth signature + if "/play/" in url and url.endswith(".m3u8"): + signature = await self.get_auth_signature() + if not signature: + raise ExtractorError("Failed to get Vavoo authentication signature for Live TV") + + stream_headers = { + "user-agent": "okhttp/4.11.0", + "referer": "https://vavoo.to/", + "mediahubmx-signature": signature, + } + return { + "destination_url": url, + "request_headers": stream_headers, + "mediaflow_endpoint": "hls_manifest_proxy", + } + + # Check if this is a web-vod API link (new format) + if "/web-vod/api/get" in url: + resolved_url = await self._resolve_web_vod_link(url) + stream_headers = { + "user-agent": self.base_headers.get("user-agent", "Mozilla/5.0"), + "referer": "https://vavoo.to/", + } + return { + "destination_url": resolved_url, + "request_headers": stream_headers, + "mediaflow_endpoint": self.mediaflow_endpoint, + } + + # Legacy mediahubmx flow signature = await self.get_auth_signature() if not signature: raise ExtractorError("Failed to get Vavoo authentication signature") @@ -139,14 +206,9 @@ class VavooExtractor(BaseExtractor): "accept": "application/json", "content-type": "application/json; charset=utf-8", "accept-encoding": "gzip", - "mediahubmx-signature": signature - } - data = { - "language": "de", - "region": "AT", - "url": link, - "clientVersion": "3.1.21" + "mediahubmx-signature": signature, } + data = {"language": "de", "region": "AT", "url": link, "clientVersion": "3.1.21"} try: logger.info(f"Attempting to resolve Vavoo URL: {link}") resp = await self._make_request( @@ -161,7 +223,11 @@ class VavooExtractor(BaseExtractor): try: result = resp.json() except Exception: - logger.warning("Vavoo resolve returned non-json response (status=%s). Body preview: %s", resp.status_code, getattr(resp, "text", "")[:500]) + logger.warning( + "Vavoo resolve returned non-json response (status=%s). Body preview: %s", + resp.status, + getattr(resp, "text", "")[:500], + ) return None logger.debug("Vavoo API response: %s", result) diff --git a/mediaflow_proxy/extractors/vidmoly.py b/mediaflow_proxy/extractors/vidmoly.py index c46d189..161f813 100644 --- a/mediaflow_proxy/extractors/vidmoly.py +++ b/mediaflow_proxy/extractors/vidmoly.py @@ -16,10 +16,9 @@ class VidmolyExtractor(BaseExtractor): raise ExtractorError("VIDMOLY: Invalid domain") headers = { - "User-Agent": - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " - "AppleWebKit/537.36 (KHTML, like Gecko) " - "Chrome/120 Safari/537.36", + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/120 Safari/537.36", "Referer": url, "Sec-Fetch-Dest": "iframe", } @@ -29,10 +28,7 @@ class VidmolyExtractor(BaseExtractor): html = response.text # --- Extract master m3u8 --- - match = re.search( - r'sources:\s*\[\{file:"([^"]+)', - html - ) + match = re.search(r'sources\s*:\s*\[\s*\{\s*file\s*:\s*[\'"]([^\'"]+)', html) if not match: raise ExtractorError("VIDMOLY: Stream URL not found") @@ -49,10 +45,8 @@ class VidmolyExtractor(BaseExtractor): raise ExtractorError("VIDMOLY: Request timed out") raise - if test.status_code >= 400: - raise ExtractorError( - f"VIDMOLY: Stream unavailable ({test.status_code})" - ) + if test.status >= 400: + raise ExtractorError(f"VIDMOLY: Stream unavailable ({test.status})") # Return MASTER playlist, not variant # Let MediaFlow Proxy handle variants diff --git a/mediaflow_proxy/extractors/vidoza.py b/mediaflow_proxy/extractors/vidoza.py index ddfffa2..6ad0fcc 100644 --- a/mediaflow_proxy/extractors/vidoza.py +++ b/mediaflow_proxy/extractors/vidoza.py @@ -8,23 +8,23 @@ from mediaflow_proxy.extractors.base import BaseExtractor, ExtractorError class VidozaExtractor(BaseExtractor): def __init__(self, request_headers: dict): super().__init__(request_headers) - # if your base doesn’t set this, keep it; otherwise you can remove: self.mediaflow_endpoint = "proxy_stream_endpoint" async def extract(self, url: str, **kwargs) -> Dict[str, Any]: parsed = urlparse(url) - # Accept vidoza + videzz if not parsed.hostname or not ( - parsed.hostname.endswith("vidoza.net") - or parsed.hostname.endswith("videzz.net") + parsed.hostname.endswith("vidoza.net") or parsed.hostname.endswith("videzz.net") ): raise ExtractorError("VIDOZA: Invalid domain") + # Use the correct referer for clones + referer = f"https://{parsed.hostname}/" + headers = self.base_headers.copy() headers.update( { - "referer": "https://vidoza.net/", + "referer": referer, "user-agent": ( "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " "AppleWebKit/537.36 (KHTML, like Gecko) " @@ -35,16 +35,14 @@ class VidozaExtractor(BaseExtractor): } ) - # 1) Fetch the embed page (or whatever URL you pass in) + # 1) Fetch embed page response = await self._make_request(url, headers=headers) html = response.text or "" if not html: - raise ExtractorError("VIDOZA: Empty HTML from Vidoza") + raise ExtractorError("VIDOZA: Empty HTML") - cookies = response.cookies or {} - - # 2) Extract final link with REGEX + # 2) Extract video URL pattern = re.compile( r"""["']?\s*(?:file|src)\s*["']?\s*[:=,]?\s*["'](?P[^"']+)""" r"""(?:[^}>\]]+)["']?\s*res\s*["']?\s*[:=]\s*["']?(?P

J10Rxl9u`V;!eA?TyA~AlTuzy1--x zlBNl*mI=;%?x|_i%W1yxg-L6 zL=od)Rpvah$6wb2Y$|+H@AS$7bnY;{=0E}!2xLw|CbP4(bL|8kA(UT4X2^V)z$04? z9b-#A*mv4fP!vV$QLQ~SIPPsStK7}F9~wV2*WsEr7+eXI>DI$ zNY*-~;+}tnsEkz+#3W9#cMJ0|Q28ZF(vVBN-yvuE1!14ldUjL1)cM?wXLls=rkcIt zMs@x9*z>VeweKg@zO%d2&Z_60diJRclMBw)OD(BYTa&A{URyPgUhYXPUz1$E1~P;0 z-c$bB61!>(ce&D;+O&e6Y6n!l_rDe);J)%|EhVv8Q%E zl-&7Hx~c60--Zj`SGr#AdZX`yu1yy3&W%OVp7xYyUDC5I<=L9_Y|Zg*PkOeeM;=a%1o8XE=%o#>?|5~`8;4Wd z4kYngePE$=cy99X54H}z?M*%KD1Kj&F78dWY)rOnTxi@h7n-mKV7^tb^8-4erjuU~$I{abArK5<|8uOxr8pzw&FnbV zGG!ej8nTiN>rtHasCVuyIDpO~$Nsp;6l03U6}yj|RnS+D29Zz&0&xX~N-iE#Oika9 zivYy$U~SgWD&#MMz$+yeiTTWuHIxM<7eV{SJ<4bli7R%is9>xKAT#4PLK$l#kxWN4 z-pj*K`Bd+0gXq!IU91BjF>r}OL+^@_98loZcwu4=qMutH_)eSEBM!VEf}zah+-00s zc`QrB2Xo>z0wO2>HZ|UB3jPtA?oZ$rF_-+~b~4m0WC=_HyhKJ^4ybIf_u25U8lHuo zI>l~O<8>OD-Ymymqfg_O#v zxOjrzKq58{<(x5v>DdZ6uu@|YaIaKm`fVZ1)Hu2X!F zJtZ8+BQ$tgcZx1cDsMvbH|SVWx{pmw9o|^2Py;ic69>cyM>8ZGETs z0Ju4Kwg~Ti(;H~S62k{s!ZN)PPV+}?Lw~z-vgVcFPWkXmP5c;Y=g$N&jT}^nY(1Ug z!QuUnh==wJ4)boX%C)&*PthrgBA7A$Bo(W01Ez*9jiM2!uyCf)9>#?uZJ*r(czcMw zSi%MXucYy*Cfxv3IxPPOEOVw$8O5q!E&!Tuf`@ z5V@dJuE+`)`f~=}ObM8S^rSHpRmgw9f}m1DLCRx3nUaYxe?eM55zc$hNB&QgJpU;l zg@d*lJ14#eCjfX}BI9{2PnP1>&R*(iNZK1x_Lii*Wx?JCZG^J}3-$)^kg~inFC?oD zyr8M^t$azB@3qK+YZC+ll&mpHzg4Wbrlh@z7k9}&N5!s9d)lbXru;Hl;>M)CF=bzo zw6D0dYmUBa(#@@uxRE91C{mkR{dwo}&XlV)>1thYtwe&R6=w(j^dMx?wQJ7~T`#Y_ z;J##>FZZ48Pdm&1FNMgL0c*oW8b0_kAa@)tmXSo+? zZ+q?xatpOh;I)<4z3Z+8hpoKsmvI8Yf73LuLimBPeqg=ugRRwr2K`$rYy)cyZ*}R& zUT+%|3~%kw!TzDaHs~__(5ZtB9_fDMYLn&W8js0)@zA1x&zo-c znHxF$Pvl+1vZVJ^btcb+=}X~c<2sDA$)mio3KypNuJJd`R+INq%c6kK8!i7tpPSWp z1TEHAn;I|q-Y_LsZC@1NezW^FeQvLDqlz_QgK67E-(^$Mvtbe32-CJV5KZ63{Uwd2 zOS@#=Ip^|dIiW literal 0 HcmV?d00001 diff --git a/mediaflow_proxy/remuxer/__pycache__/transcode_handler.cpython-313.pyc b/mediaflow_proxy/remuxer/__pycache__/transcode_handler.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..935501797c7f7eaa987bd7aba17c7d75700c0778 GIT binary patch literal 37682 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 z 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 H40rz@UTMQT literal 0 HcmV?d00001 diff --git a/mediaflow_proxy/remuxer/__pycache__/ts_muxer.cpython-313.pyc b/mediaflow_proxy/remuxer/__pycache__/ts_muxer.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ac6a5243397c98551245333ff0a3f0763d82912c GIT binary patch literal 53600 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 cYrz&Q!DjF^cS%x@G=65}$jIP+ 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 literal 0 HcmV?d00001 diff --git a/mediaflow_proxy/remuxer/audio_transcoder.py b/mediaflow_proxy/remuxer/audio_transcoder.py new file mode 100644 index 0000000..970888b --- /dev/null +++ b/mediaflow_proxy/remuxer/audio_transcoder.py @@ -0,0 +1,351 @@ +""" +PyAV-based audio transcoder for frame-level codec conversion. + +Transcodes audio frames between codecs using PyAV's CodecContext API +(Python bindings for FFmpeg's libavcodec). This provides in-process +audio transcoding without subprocess management or pipe overhead. + +Supported input codecs: EAC3, AC3, AAC, Opus, Vorbis, FLAC, MP3 +Output codec: AAC-LC (stereo, configurable bitrate) + +Architecture: + raw_frame_bytes -> parse() -> decode() -> resample() -> encode() -> raw_aac_bytes + +Usage: + transcoder = AudioTranscoder("eac3", sample_rate=48000, channels=6) + for raw_eac3_frame in frames: + aac_frames = transcoder.transcode(raw_eac3_frame) + for aac_data in aac_frames: + write(aac_data) + # Flush remaining frames + for aac_data in transcoder.flush(): + write(aac_data) +""" + +import logging + +import av +from av.audio.resampler import AudioResampler + +from mediaflow_proxy.remuxer.ebml_parser import ( + CODEC_ID_AAC, + CODEC_ID_AC3, + CODEC_ID_EAC3, + CODEC_ID_FLAC, + CODEC_ID_OPUS, + CODEC_ID_VORBIS, +) + +logger = logging.getLogger(__name__) + + +def _generate_silence_aac_frame() -> bytes | None: + """Pre-encode a single silent AAC frame (48 kHz stereo, 1024 samples). + + PyAV's AAC encoder has an intermittent ``avcodec_send_frame`` bug when + rapidly creating/destroying codec contexts, so we retry a few times. + This function is called once at module load; the result is cached in + ``_SILENCE_AAC_FRAME``. + """ + for _attempt in range(10): + try: + enc = av.CodecContext.create("aac", "w") + enc.sample_rate = 48000 + enc.layout = "stereo" + enc.format = av.AudioFormat("fltp") + enc.bit_rate = 192000 + enc.open() + + frame = av.AudioFrame( + format=enc.format.name, + layout=enc.layout.name, + samples=enc.frame_size or 1024, + ) + frame.sample_rate = enc.sample_rate + frame.pts = 0 + + for pkt in enc.encode(frame): + return bytes(pkt) + # AAC priming delay: first encode buffered; flush to retrieve + for pkt in enc.encode(None): + return bytes(pkt) + except Exception: + continue + return None + + +# Module-level silence frame -- generated once, reused by every transcoder. +_SILENCE_AAC_FRAME: bytes | None = _generate_silence_aac_frame() + +# Map MKV codec IDs to PyAV/FFmpeg codec names +_MKV_TO_FFMPEG_CODEC = { + CODEC_ID_EAC3: "eac3", + CODEC_ID_AC3: "ac3", + CODEC_ID_AAC: "aac", + CODEC_ID_OPUS: "opus", + CODEC_ID_VORBIS: "vorbis", + CODEC_ID_FLAC: "flac", + "A_DTS": "dts", + "A_MP3": "mp3", + "A_MPEG/L3": "mp3", +} + +# Codecs that need transcoding to AAC for browser playback +NEEDS_TRANSCODE = frozenset( + { + CODEC_ID_EAC3, + CODEC_ID_AC3, + CODEC_ID_OPUS, + CODEC_ID_VORBIS, + CODEC_ID_FLAC, + "A_DTS", + "A_MP3", + "A_MPEG/L3", + } +) + +# Output AAC settings +_OUTPUT_CODEC = "aac" +_OUTPUT_SAMPLE_FORMAT = "fltp" # AAC requires float planar +_OUTPUT_LAYOUT = "stereo" + +# Map channel count -> FFmpeg layout name +_CHANNEL_LAYOUT_MAP = { + 1: "mono", + 2: "stereo", + 3: "2.1", + 4: "quad", + 6: "5.1", + 8: "7.1", +} + + +def needs_transcode(codec_id: str) -> bool: + """Check if an MKV audio codec needs transcoding for browser playback.""" + return codec_id in NEEDS_TRANSCODE + + +def get_ffmpeg_codec_name(mkv_codec_id: str) -> str | None: + """Map an MKV CodecID to an FFmpeg codec name.""" + return _MKV_TO_FFMPEG_CODEC.get(mkv_codec_id) + + +class AudioTranscoder: + """ + In-process audio transcoder using PyAV's CodecContext API. + + Decodes raw audio frames from one codec and encodes them to AAC-LC + stereo, suitable for MP4 container and browser playback. No container + I/O or subprocess involved -- operates directly on raw frame bytes. + + The transcoder handles sample format conversion and resampling + automatically via AudioResampler. + """ + + def __init__( + self, + input_codec: str, + input_sample_rate: int = 48000, + input_channels: int = 6, + output_sample_rate: int = 48000, + output_channels: int = 2, + output_bitrate: int = 192000, + ) -> None: + """ + Initialize the transcoder. + + Args: + input_codec: FFmpeg codec name (e.g., "eac3", "ac3", "aac"). + input_sample_rate: Input sample rate in Hz. + input_channels: Input channel count. + output_sample_rate: Output sample rate in Hz (default 48000). + output_channels: Output channel count (default 2 = stereo). + output_bitrate: Output bitrate in bits/s (default 192000). + """ + # Set up decoder -- use layout to configure channel count + # (PyAV's channels property is read-only; layout drives it) + self._decoder = av.CodecContext.create(input_codec, "r") + self._decoder.sample_rate = input_sample_rate + input_layout = _CHANNEL_LAYOUT_MAP.get(input_channels, "stereo") + self._decoder.layout = input_layout + + # Set up encoder + self._encoder = av.CodecContext.create(_OUTPUT_CODEC, "w") + self._encoder.sample_rate = output_sample_rate + self._encoder.layout = _OUTPUT_LAYOUT + self._encoder.format = av.AudioFormat(_OUTPUT_SAMPLE_FORMAT) + self._encoder.bit_rate = output_bitrate + self._encoder.open() + + # Set up resampler for format/rate/channel conversion + self._resampler = AudioResampler( + format=_OUTPUT_SAMPLE_FORMAT, + layout=_OUTPUT_LAYOUT, + rate=output_sample_rate, + ) + + self._input_codec = input_codec + self._frames_decoded = 0 + self._frames_encoded = 0 + self._audio_specific_config: bytes | None = None + + logger.info( + "[audio_transcoder] Initialized: %s %dHz %dch -> aac %dHz %dch @%dk", + input_codec, + input_sample_rate, + input_channels, + output_sample_rate, + output_channels, + output_bitrate // 1000, + ) + + @property + def audio_specific_config(self) -> bytes | None: + """ + AAC AudioSpecificConfig from the encoder (available after first encode). + + This is needed for the MP4 esds box. + """ + if self._audio_specific_config is not None: + return self._audio_specific_config + + # PyAV exposes extradata after the encoder is opened + if self._encoder.extradata: + self._audio_specific_config = bytes(self._encoder.extradata) + return self._audio_specific_config + return None + + @property + def output_sample_rate(self) -> int: + return self._encoder.sample_rate + + @property + def output_channels(self) -> int: + return self._encoder.channels + + @property + def frame_size(self) -> int: + """AAC frame size (samples per frame), typically 1024.""" + return self._encoder.frame_size or 1024 + + def transcode(self, raw_frame_data: bytes) -> list[bytes]: + """ + Transcode a raw audio frame from the input codec to AAC. + + Args: + raw_frame_data: Raw audio frame bytes (one codec frame, e.g., + one EAC3 sync frame). + + Returns: + List of raw AAC frame bytes. May return 0, 1, or more frames + depending on codec frame sizes and buffering. + """ + output = [] + + # Parse raw bytes into packets + packets = self._decoder.parse(raw_frame_data) + + for packet in packets: + # Decode to PCM frames + try: + decoded_frames = self._decoder.decode(packet) + except av.error.InvalidDataError as e: + logger.debug("[audio_transcoder] Decode error (skipping frame): %s", e) + continue + + for frame in decoded_frames: + self._frames_decoded += 1 + + # Resample to match encoder format + resampled = self._resampler.resample(frame) + if resampled is None: + continue + + # resampled can be a single frame or list of frames + if not isinstance(resampled, list): + resampled = [resampled] + + for rs_frame in resampled: + # Encode to AAC + try: + encoded_packets = self._encoder.encode(rs_frame) + except av.error.InvalidDataError as e: + logger.debug("[audio_transcoder] Encode error: %s", e) + continue + + for enc_packet in encoded_packets: + self._frames_encoded += 1 + output.append(bytes(enc_packet)) + + return output + + def flush(self) -> list[bytes]: + """ + Flush the decoder and encoder buffers. + + Call this when the input stream ends to get remaining frames. + + Returns: + List of remaining raw AAC frame bytes. + """ + output = [] + + # Flush decoder + try: + for frame in self._decoder.decode(None): + self._frames_decoded += 1 + resampled = self._resampler.resample(frame) + if resampled is None: + continue + if not isinstance(resampled, list): + resampled = [resampled] + for rs_frame in resampled: + for enc_packet in self._encoder.encode(rs_frame): + self._frames_encoded += 1 + output.append(bytes(enc_packet)) + except Exception as e: + logger.debug("[audio_transcoder] Decoder flush error: %s", e) + + # Flush resampler + try: + resampled = self._resampler.resample(None) + if resampled is not None: + if not isinstance(resampled, list): + resampled = [resampled] + for rs_frame in resampled: + for enc_packet in self._encoder.encode(rs_frame): + self._frames_encoded += 1 + output.append(bytes(enc_packet)) + except Exception as e: + logger.debug("[audio_transcoder] Resampler flush error: %s", e) + + # Flush encoder + try: + for enc_packet in self._encoder.encode(None): + self._frames_encoded += 1 + output.append(bytes(enc_packet)) + except Exception as e: + logger.debug("[audio_transcoder] Encoder flush error: %s", e) + + logger.info( + "[audio_transcoder] Flushed: %d decoded, %d encoded total", + self._frames_decoded, + self._frames_encoded, + ) + return output + + def generate_silence_frame(self) -> bytes | None: + """Return a pre-encoded silent AAC frame (module-level singleton).""" + return _SILENCE_AAC_FRAME + + def close(self) -> None: + """Release codec contexts (best-effort; PyAV AudioCodecContext may not have close()).""" + for ctx in (self._decoder, self._encoder): + try: + if hasattr(ctx, "close"): + ctx.close() + except Exception: + pass + + def __del__(self) -> None: + self.close() diff --git a/mediaflow_proxy/remuxer/codec_utils.py b/mediaflow_proxy/remuxer/codec_utils.py new file mode 100644 index 0000000..c539480 --- /dev/null +++ b/mediaflow_proxy/remuxer/codec_utils.py @@ -0,0 +1,515 @@ +""" +Codec decision engine for browser compatibility detection. + +Determines whether video/audio streams need transcoding for browser +playback and selects appropriate output codecs. +""" + +import logging +import struct + +logger = logging.getLogger(__name__) + +# ──────────────────────────────────────────────────────────────────── +# Browser-compatible codecs (work natively in HTML5

3;Qi3ZA@cX%u~jy1p0vBA;g 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 str: + """ + Override to route acestream segments through the acestream segment endpoint. + + This ensures segments use /proxy/acestream/segment.ts instead of /proxy/hls/segment.ts + """ + full_url = urljoin(base_url, url) + + # If no_proxy is enabled, return the direct URL + if self.no_proxy: + return full_url + + # Check if this is a playlist URL (use standard proxy for playlists) + parsed = urlparse(full_url) + is_playlist = parsed.path.endswith((".m3u", ".m3u8", ".m3u_plus")) + + if is_playlist: + # Use standard playlist proxy + return await super().proxy_content_url(url, base_url) + + # For segments, route through acestream segment endpoint + query_params = { + "d": full_url, + } + + # Preserve the original id/infohash parameter from the request + if "id" in self.request.query_params: + query_params["id"] = self.request.query_params["id"] + else: + query_params["infohash"] = self.session.infohash + + # Include api_password and headers from the original request + for key, value in self.request.query_params.items(): + if key == "api_password" or key.startswith("h_"): + query_params[key] = value + + # Determine the segment extension + path = parsed.path.lower() + if path.endswith(".ts"): + ext = "ts" + elif path.endswith(".m4s"): + ext = "m4s" + elif path.endswith(".mp4"): + ext = "mp4" + else: + ext = "ts" + + # Build acestream segment proxy URL + base_proxy_url = str( + self.request.url_for("acestream_segment_proxy", ext=ext).replace(scheme=get_original_scheme(self.request)) + ) + return f"{base_proxy_url}?{urlencode(query_params)}" + + +@acestream_router.head("/acestream/manifest.m3u8") +@acestream_router.get("/acestream/manifest.m3u8") +async def acestream_hls_manifest( + request: Request, + proxy_headers: Annotated[ProxyRequestHeaders, Depends(get_proxy_headers)], + infohash: str = Query(None, description="Acestream infohash"), + id: str = Query(None, description="Acestream content ID (alternative to infohash)"), +): + """ + Proxy Acestream HLS manifest. + + Creates or reuses an acestream session and proxies the HLS manifest, + rewriting segment URLs to go through mediaflow. + + Args: + request: The incoming HTTP request. + proxy_headers: Headers for proxy requests. + infohash: The acestream infohash. + id: Alternative content ID. + + Returns: + Processed HLS manifest with proxied segment URLs. + """ + if not settings.enable_acestream: + raise HTTPException(status_code=503, detail="Acestream support is disabled") + + if not infohash and not id: + raise HTTPException(status_code=400, detail="Either 'infohash' or 'id' parameter is required") + + content_id = id + if not infohash: + infohash = content_id # Use content_id as the key if no infohash + + max_retries = 2 + last_error = None + + for attempt in range(max_retries): + try: + # Get or create acestream session (don't increment client count for manifest requests) + session = await acestream_manager.get_or_create_session(infohash, content_id, increment_client=False) + + if not session.playback_url: + raise HTTPException(status_code=502, detail="Failed to get playback URL from acestream") + + logger.info(f"[acestream_hls_manifest] Using playback URL: {session.playback_url}") + + # Fetch the manifest from acestream with extended timeout for buffering + async with create_aiohttp_session(session.playback_url, timeout=120) as (http_session, proxy_url): + response = await http_session.get( + session.playback_url, + headers=proxy_headers.request, + proxy=proxy_url, + ) + response.raise_for_status() + manifest_content = await response.text() + break # Success, exit retry loop + + except asyncio.TimeoutError: + last_error = "Timeout fetching manifest" + if attempt < max_retries - 1: + logger.warning(f"[acestream_hls_manifest] Timeout fetching manifest, retrying: {infohash[:16]}...") + await asyncio.sleep(1) # Brief delay before retry + continue + logger.error(f"[acestream_hls_manifest] Timeout after {max_retries} attempts") + raise HTTPException(status_code=504, detail="Timeout fetching manifest from acestream") + + except aiohttp.ClientResponseError as e: + last_error = e + # If we get 403, the session is stale - invalidate and retry + if e.status == 403 and attempt < max_retries - 1: + logger.warning( + f"[acestream_hls_manifest] Session stale (403), invalidating and retrying: {infohash[:16]}..." + ) + await acestream_manager.invalidate_session(infohash) + continue # Retry with fresh session + logger.error(f"[acestream_hls_manifest] HTTP error fetching manifest: {e}") + raise HTTPException(status_code=e.status, detail=f"Failed to fetch manifest: {e}") + + except aiohttp.ClientError as e: + last_error = e + logger.error(f"[acestream_hls_manifest] Client error fetching manifest: {e}") + raise HTTPException(status_code=502, detail=f"Failed to fetch manifest: {e}") + + else: + # Exhausted retries + logger.error(f"[acestream_hls_manifest] Failed after {max_retries} attempts: {last_error}") + raise HTTPException(status_code=502, detail=f"Failed to fetch manifest after retries: {last_error}") + + try: + # Process the manifest to rewrite URLs + processor = AcestreamM3U8Processor( + request=request, + session=session, + force_playlist_proxy=True, + ) + + processed_manifest = await processor.process_m3u8(manifest_content, base_url=session.playback_url) + + # Register with HLS prebuffer for segment caching + if settings.enable_hls_prebuffer: + segment_urls = processor._extract_segment_urls_from_content(manifest_content, session.playback_url) + if segment_urls: + await hls_prebuffer.register_playlist( + playlist_url=session.playback_url, + segment_urls=segment_urls, + headers=proxy_headers.request, + ) + + base_headers = { + "content-type": "application/vnd.apple.mpegurl", + "cache-control": "no-cache, no-store, must-revalidate", + "access-control-allow-origin": "*", + } + response_headers = apply_header_manipulation(base_headers, proxy_headers, include_propagate=False) + + return Response( + content=processed_manifest, media_type="application/vnd.apple.mpegurl", headers=response_headers + ) + + except HTTPException: + raise + except Exception as e: + logger.exception(f"[acestream_hls_manifest] Error: {e}") + raise HTTPException(status_code=500, detail=f"Internal error: {e}") + + +# Map file extensions to MIME types for segments +SEGMENT_MIME_TYPES = { + "ts": "video/mp2t", + "m4s": "video/mp4", + "mp4": "video/mp4", + "m4a": "audio/mp4", + "aac": "audio/aac", +} + + +@acestream_router.get("/acestream/segment.{ext}") +async def acestream_segment_proxy( + request: Request, + proxy_headers: Annotated[ProxyRequestHeaders, Depends(get_proxy_headers)], + ext: str, + d: str = Query(..., description="Segment URL"), + infohash: str = Query(None, description="Acestream session infohash"), + id: str = Query(None, description="Acestream content ID (alternative to infohash)"), +): + """ + Proxy Acestream HLS segments. + + Uses the HLS prebuffer for segment caching if enabled. + + Args: + request: The incoming HTTP request. + proxy_headers: Headers for proxy requests. + ext: Segment file extension. + d: The segment URL to proxy. + infohash: The acestream session infohash (for tracking). + id: Alternative content ID. + + Returns: + Proxied segment content. + """ + if not settings.enable_acestream: + raise HTTPException(status_code=503, detail="Acestream support is disabled") + + # Use id or infohash for session lookup + session_key = id or infohash + if not session_key: + raise HTTPException(status_code=400, detail="Either 'infohash' or 'id' parameter is required") + + segment_url = d + mime_type = SEGMENT_MIME_TYPES.get(ext.lower(), "application/octet-stream") + + logger.debug(f"[acestream_segment_proxy] Request for: {segment_url}") + + # Touch the session to keep it alive - use touch_segment() to indicate active playback + session = acestream_manager.get_session(session_key) + if session: + session.touch_segment() + logger.debug(f"[acestream_segment_proxy] Touched session: {session_key[:16]}...") + + # Use HLS prebuffer if enabled + if settings.enable_hls_prebuffer: + await hls_prebuffer.request_segment(segment_url) + segment_data = await hls_prebuffer.get_or_download(segment_url, proxy_headers.request) + + if segment_data: + logger.info(f"[acestream_segment_proxy] Serving from prebuffer ({len(segment_data)} bytes)") + base_headers = { + "content-type": mime_type, + "cache-control": "public, max-age=3600", + "access-control-allow-origin": "*", + } + response_headers = apply_header_manipulation(base_headers, proxy_headers) + return Response(content=segment_data, media_type=mime_type, headers=response_headers) + + logger.warning("[acestream_segment_proxy] Prebuffer miss, using direct streaming") + + # Fallback to direct streaming + streamer = await create_streamer(segment_url) + try: + await streamer.create_streaming_response(segment_url, proxy_headers.request) + + base_headers = { + "content-type": mime_type, + "cache-control": "public, max-age=3600", + "access-control-allow-origin": "*", + } + response_headers = apply_header_manipulation(base_headers, proxy_headers) + + return EnhancedStreamingResponse( + streamer.stream_content(), + status_code=streamer.response.status if streamer.response else 200, + headers=response_headers, + background=BackgroundTask(streamer.close), + ) + except Exception as e: + await streamer.close() + logger.error(f"[acestream_segment_proxy] Error streaming segment: {e}") + raise HTTPException(status_code=502, detail=f"Failed to stream segment: {e}") + + +@acestream_router.head("/acestream/stream") +@acestream_router.get("/acestream/stream") +async def acestream_ts_stream( + request: Request, + proxy_headers: Annotated[ProxyRequestHeaders, Depends(get_proxy_headers)], + infohash: str = Query(None, description="Acestream infohash"), + id: str = Query(None, description="Acestream content ID (alternative to infohash)"), + transcode: bool = Query(False, description="Transcode to browser-compatible fMP4"), + start: float | None = Query(None, description="Seek start time in seconds (transcode mode)"), +): + """ + Proxy Acestream MPEG-TS stream with fan-out to multiple clients. + + Creates or reuses an acestream session and streams MPEG-TS content. + Multiple clients can share the same upstream connection. + + When transcode=true, the MPEG-TS stream is transcoded on-the-fly to + browser-compatible fMP4 (H.264 + AAC). + + Args: + request: The incoming HTTP request. + proxy_headers: Headers for proxy requests. + infohash: The acestream infohash. + id: Alternative content ID. + transcode: Transcode to browser-compatible format. + start: Seek start time in seconds (transcode mode). + + Returns: + MPEG-TS stream (or fMP4 if transcode=true). + """ + if not settings.enable_acestream: + raise HTTPException(status_code=503, detail="Acestream support is disabled") + + if not infohash and not id: + raise HTTPException(status_code=400, detail="Either 'infohash' or 'id' parameter is required") + + content_id = id + if not infohash: + infohash = content_id + + try: + # Get or create acestream session + # For MPEG-TS, we need to use getstream endpoint + base_url = f"http://{settings.acestream_host}:{settings.acestream_port}" + session = await acestream_manager.get_or_create_session(infohash, content_id) + + if not session.playback_url: + raise HTTPException(status_code=502, detail="Failed to get playback URL from acestream") + + # For MPEG-TS streaming, we need to convert HLS playback URL to getstream + # Acestream uses different parameter names: + # - 'id' for content IDs + # - 'infohash' for magnet link hashes (40-char hex) + if content_id: + ts_url = f"{base_url}/ace/getstream?id={content_id}&pid={session.pid}" + else: + ts_url = f"{base_url}/ace/getstream?infohash={infohash}&pid={session.pid}" + + logger.info(f"[acestream_ts_stream] Streaming from: {ts_url}") + + if transcode: + if not settings.enable_transcode: + await acestream_manager.release_session(infohash) + raise HTTPException(status_code=503, detail="Transcoding support is disabled") + # Acestream provides a live MPEG-TS stream that does NOT support + # HTTP Range requests and is not seekable. Use an ffmpeg subprocess + # to remux video (passthrough) and transcode audio (AC3→AAC) to + # fragmented MP4. The subprocess approach isolates native FFmpeg + # crashes from the Python server process. + + if request.method == "HEAD": + await acestream_manager.release_session(infohash) + return Response( + status_code=200, + headers={ + "access-control-allow-origin": "*", + "cache-control": "no-cache, no-store", + "content-type": "video/mp4", + "content-disposition": "inline", + }, + ) + + async def _acestream_ts_source(): + """Single-connection async byte generator for the live TS stream.""" + try: + async with create_aiohttp_session(ts_url) as (session, proxy_url): + async with session.get( + ts_url, + proxy=proxy_url, + allow_redirects=True, + ) as resp: + resp.raise_for_status() + async for chunk in resp.content.iter_any(): + yield chunk + except asyncio.CancelledError: + logger.debug("[acestream_ts_stream] Transcode source cancelled") + except GeneratorExit: + logger.debug("[acestream_ts_stream] Transcode source closed") + + # Use our custom PyAV pipeline with forced video re-encoding + # (live MPEG-TS sources often have corrupt H.264 bitstreams + # that browsers reject; re-encoding produces a clean stream). + content = stream_transcode_universal( + _acestream_ts_source(), + force_video_reencode=True, + ) + + async def release_transcode_session(): + await acestream_manager.release_session(infohash) + + return EnhancedStreamingResponse( + content=content, + media_type="video/mp4", + headers={ + "access-control-allow-origin": "*", + "cache-control": "no-cache, no-store", + "content-disposition": "inline", + }, + background=BackgroundTask(release_transcode_session), + ) + + streamer = await create_streamer(ts_url) + try: + await streamer.create_streaming_response(ts_url, proxy_headers.request) + + base_headers = { + "content-type": "video/mp2t", + "transfer-encoding": "chunked", + "cache-control": "no-cache, no-store, must-revalidate", + "access-control-allow-origin": "*", + } + response_headers = apply_header_manipulation(base_headers, proxy_headers) + + async def release_on_complete(): + """Release session when streaming completes.""" + await streamer.close() + await acestream_manager.release_session(infohash) + + return EnhancedStreamingResponse( + streamer.stream_content(), + status_code=streamer.response.status if streamer.response else 200, + headers=response_headers, + background=BackgroundTask(release_on_complete), + ) + + except Exception: + await streamer.close() + await acestream_manager.release_session(infohash) + raise + + except HTTPException: + raise + except Exception as e: + logger.exception(f"[acestream_ts_stream] Error: {e}") + await acestream_manager.release_session(infohash) + raise HTTPException(status_code=500, detail=f"Internal error: {e}") + + +@acestream_router.get("/acestream/status") +async def acestream_status( + infohash: str = Query(None, description="Acestream infohash to check"), +): + """ + Get acestream session status. + + Args: + infohash: Optional infohash to check specific session. + + Returns: + Session status information. + """ + if not settings.enable_acestream: + raise HTTPException(status_code=503, detail="Acestream support is disabled") + + if infohash: + session = acestream_manager.get_session(infohash) + if session: + return { + "status": "active", + "infohash": session.infohash, + "client_count": session.client_count, + "is_live": session.is_live, + "created_at": session.created_at, + "last_access": session.last_access, + } + else: + return {"status": "not_found", "infohash": infohash} + + # Return all active sessions + sessions = acestream_manager.get_active_sessions() + return { + "enabled": settings.enable_acestream, + "active_sessions": len(sessions), + "sessions": [ + { + "infohash": s.infohash, + "client_count": s.client_count, + "is_live": s.is_live, + } + for s in sessions.values() + ], + } diff --git a/mediaflow_proxy/routes/extractor.py b/mediaflow_proxy/routes/extractor.py index 1aeab84..7e167b8 100644 --- a/mediaflow_proxy/routes/extractor.py +++ b/mediaflow_proxy/routes/extractor.py @@ -1,3 +1,4 @@ +import copy import logging from typing import Annotated @@ -7,7 +8,10 @@ from fastapi.responses import RedirectResponse from mediaflow_proxy.extractors.base import ExtractorError from mediaflow_proxy.extractors.factory import ExtractorFactory from mediaflow_proxy.schemas import ExtractorURLParams -from mediaflow_proxy.utils.cache_utils import get_cached_extractor_result, set_cache_extractor_result +from mediaflow_proxy.utils.cache_utils import ( + get_cached_extractor_result, + set_cache_extractor_result, +) from mediaflow_proxy.utils.http_utils import ( DownloadError, encode_mediaflow_proxy_url, @@ -16,11 +20,28 @@ from mediaflow_proxy.utils.http_utils import ( get_proxy_headers, ) from mediaflow_proxy.utils.base64_utils import process_potential_base64_url +from mediaflow_proxy.utils import redis_utils extractor_router = APIRouter() logger = logging.getLogger(__name__) -async def refresh_extractor_cache(cache_key: str, extractor_params: ExtractorURLParams, proxy_headers: ProxyRequestHeaders): +# Cooldown duration for background refresh (2 minutes) +_REFRESH_COOLDOWN = 120 + +# Hosts where background refresh should be DISABLED +# These hosts generate unique CDN URLs per extraction - refreshing invalidates existing streams! +# When a new URL is extracted, the old URL becomes invalid and causes 509 errors. +_NO_BACKGROUND_REFRESH_HOSTS = frozenset( + { + "Vidoza", + # Add other hosts here that generate unique per-extraction URLs + } +) + + +async def refresh_extractor_cache( + cache_key: str, extractor_params: ExtractorURLParams, proxy_headers: ProxyRequestHeaders +): """Asynchronously refreshes the extractor cache in the background.""" try: logger.info(f"Background cache refresh started for key: {cache_key}") @@ -32,32 +53,114 @@ async def refresh_extractor_cache(cache_key: str, extractor_params: ExtractorURL logger.error(f"Background cache refresh failed for key {cache_key}: {e}") -@extractor_router.head("/video") -@extractor_router.get("/video") -async def extract_url( - extractor_params: Annotated[ExtractorURLParams, Query()], +# Extension to content-type mapping for player compatibility +# When a player requests /extractor/video.m3u8, it can detect HLS from the URL +EXTRACTOR_EXT_CONTENT_TYPES = { + "m3u8": "application/vnd.apple.mpegurl", + "m3u": "application/vnd.apple.mpegurl", + "mp4": "video/mp4", + "mkv": "video/x-matroska", + "ts": "video/mp2t", + "avi": "video/x-msvideo", + "webm": "video/webm", +} + + +async def _extract_url_impl( + extractor_params: ExtractorURLParams, request: Request, background_tasks: BackgroundTasks, - proxy_headers: Annotated[ProxyRequestHeaders, Depends(get_proxy_headers)], + proxy_headers: ProxyRequestHeaders, + ext: str | None = None, ): - """Extract clean links from various video hosting services.""" + """ + Core extraction logic shared by all extractor endpoints. + + Args: + extractor_params: Extraction parameters from query string + request: FastAPI request object + background_tasks: Background task manager + proxy_headers: Proxy headers from request + ext: Optional file extension hint for player compatibility (e.g., "m3u8", "mp4") + """ try: # Process potential base64 encoded destination URL processed_destination = process_potential_base64_url(extractor_params.destination) extractor_params.destination = processed_destination - + cache_key = f"{extractor_params.host}_{extractor_params.model_dump_json()}" - response = await get_cached_extractor_result(cache_key) - + + # Extractor results are resolved via the pod's outgoing IP and may not + # be valid when served from a different pod. Namespace the cache and + # all associated coordination keys so each pod operates on its own + # partition of the shared Redis. On single-instance deployments (no + # CACHE_NAMESPACE env var) make_instance_key() is a no-op. + instance_cache_key = redis_utils.make_instance_key(cache_key) + + response = await get_cached_extractor_result(instance_cache_key) + if response: - logger.info(f"Serving from cache for key: {cache_key}") - # Schedule a background task to refresh the cache without blocking the user - background_tasks.add_task(refresh_extractor_cache, cache_key, extractor_params, proxy_headers) + logger.info(f"Serving from cache for key: {instance_cache_key}") + # Schedule a background refresh, but only if: + # 1. The host is NOT in the no-refresh list (hosts with unique per-extraction URLs) + # 2. The cooldown has elapsed (prevents flooding upstream) + # + # WARNING: For hosts like Vidoza, background refresh is DANGEROUS! + # Each extraction generates a unique CDN URL. Refreshing invalidates the + # old URL, causing 509 errors for clients still using it. + if extractor_params.host not in _NO_BACKGROUND_REFRESH_HOSTS: + cooldown_key = f"extractor_refresh:{instance_cache_key}" + if await redis_utils.check_and_set_cooldown(cooldown_key, _REFRESH_COOLDOWN): + background_tasks.add_task( + refresh_extractor_cache, instance_cache_key, extractor_params, proxy_headers + ) + else: + logger.debug(f"Skipping background refresh for {extractor_params.host} (unique CDN URLs)") else: - logger.info(f"Cache miss for key: {cache_key}. Fetching fresh data.") - extractor = ExtractorFactory.get_extractor(extractor_params.host, proxy_headers.request) - response = await extractor.extract(extractor_params.destination, **extractor_params.extra_params) - await set_cache_extractor_result(cache_key, response) + # Use Redis-based in-flight tracking for cross-worker deduplication. + # If another worker is already extracting, wait for them to finish. + inflight_key = f"extractor:{instance_cache_key}" + + if not await redis_utils.mark_inflight(inflight_key, ttl=60): + # Another worker is extracting - wait for them to finish and check cache + logger.info(f"Waiting for in-flight extraction (cross-worker) for key: {instance_cache_key}") + if await redis_utils.wait_for_completion(inflight_key, timeout=30.0): + # Extraction completed, check cache + response = await get_cached_extractor_result(instance_cache_key) + if response: + logger.info(f"Serving from cache (after wait) for key: {instance_cache_key}") + + if response is None: + # We either marked it as in-flight (first) or waited and still no cache hit. + # Use Redis lock to ensure only one worker extracts at a time. + if await redis_utils.acquire_lock(f"extractor_lock:{instance_cache_key}", ttl=30, timeout=30.0): + try: + # Re-check cache after acquiring lock - another worker may have populated it + response = await get_cached_extractor_result(instance_cache_key) + if response: + logger.info(f"Serving from cache (after lock) for key: {instance_cache_key}") + else: + logger.info(f"Cache miss for key: {instance_cache_key}. Fetching fresh data.") + try: + extractor = ExtractorFactory.get_extractor(extractor_params.host, proxy_headers.request) + response = await extractor.extract( + extractor_params.destination, **extractor_params.extra_params + ) + await set_cache_extractor_result(instance_cache_key, response) + except Exception: + raise + finally: + await redis_utils.release_lock(f"extractor_lock:{instance_cache_key}") + await redis_utils.clear_inflight(inflight_key) + else: + # Lock timeout - try to serve from cache anyway + response = await get_cached_extractor_result(instance_cache_key) + if not response: + raise HTTPException(status_code=503, detail="Extraction in progress, please retry") + + # Deep copy so each concurrent request gets its own dict to mutate + # (pop mediaflow_endpoint, update request_headers, etc.) + response = copy.deepcopy(response) # Ensure the latest request headers are used, even with cached data if "request_headers" not in response: @@ -94,3 +197,62 @@ async def extract_url( except Exception as e: logger.exception(f"Extraction failed: {str(e)}") raise HTTPException(status_code=500, detail=f"Extraction failed: {str(e)}") + + +@extractor_router.head("/video") +@extractor_router.get("/video") +async def extract_url( + extractor_params: Annotated[ExtractorURLParams, Query()], + request: Request, + background_tasks: BackgroundTasks, + proxy_headers: Annotated[ProxyRequestHeaders, Depends(get_proxy_headers)], +): + """ + Extract clean links from various video hosting services. + + This is the base endpoint without extension. For better player compatibility + (especially ExoPlayer), use the extension variants: + - /extractor/video.m3u8 for HLS streams + - /extractor/video.mp4 for MP4 streams + """ + return await _extract_url_impl(extractor_params, request, background_tasks, proxy_headers) + + +@extractor_router.head("/video.{ext}") +@extractor_router.get("/video.{ext}") +async def extract_url_with_extension( + ext: str, + extractor_params: Annotated[ExtractorURLParams, Query()], + request: Request, + background_tasks: BackgroundTasks, + proxy_headers: Annotated[ProxyRequestHeaders, Depends(get_proxy_headers)], +): + """ + Extract clean links with file extension hint for player compatibility. + + The extension in the URL helps players like ExoPlayer detect the content type + without needing to follow redirects or inspect headers. This is especially + important for HLS streams where ExoPlayer needs .m3u8 in the URL to use + HlsMediaSource instead of ProgressiveMediaSource. + + Supported extensions: + - .m3u8, .m3u - HLS playlists (application/vnd.apple.mpegurl) + - .mp4 - MP4 video (video/mp4) + - .mkv - Matroska video (video/x-matroska) + - .ts - MPEG-TS (video/mp2t) + - .avi - AVI video (video/x-msvideo) + - .webm - WebM video (video/webm) + + Example: + /extractor/video.m3u8?host=TurboVidPlay&d=...&redirect_stream=true + + This URL clearly indicates HLS content, making ExoPlayer use the correct source. + """ + ext_lower = ext.lower() + if ext_lower not in EXTRACTOR_EXT_CONTENT_TYPES: + raise HTTPException( + status_code=400, + detail=f"Unsupported extension: .{ext}. Supported: {', '.join('.' + e for e in EXTRACTOR_EXT_CONTENT_TYPES.keys())}", + ) + + return await _extract_url_impl(extractor_params, request, background_tasks, proxy_headers, ext=ext_lower) diff --git a/mediaflow_proxy/routes/playlist_builder.py b/mediaflow_proxy/routes/playlist_builder.py index 88b47d2..6951be5 100644 --- a/mediaflow_proxy/routes/playlist_builder.py +++ b/mediaflow_proxy/routes/playlist_builder.py @@ -5,167 +5,181 @@ from typing import Iterator, Dict, Optional from fastapi import APIRouter, Request, HTTPException, Query from fastapi.responses import StreamingResponse from starlette.responses import RedirectResponse -import httpx + from mediaflow_proxy.configs import settings from mediaflow_proxy.utils.http_utils import get_original_scheme +from mediaflow_proxy.utils.http_client import create_aiohttp_session import asyncio logger = logging.getLogger(__name__) playlist_builder_router = APIRouter() -def rewrite_m3u_links_streaming(m3u_lines_iterator: Iterator[str], base_url: str, api_password: Optional[str]) -> Iterator[str]: +def rewrite_m3u_links_streaming( + m3u_lines_iterator: Iterator[str], base_url: str, api_password: Optional[str] +) -> Iterator[str]: """ - Riscrive i link da un iteratore di linee M3U secondo le regole specificate, - includendo gli headers da #EXTVLCOPT e #EXTHTTP. Yields rewritten lines. + Rewrites links from an M3U line iterator according to the specified rules, + including headers from #EXTVLCOPT and #EXTHTTP. Yields rewritten lines. """ - current_ext_headers: Dict[str, str] = {} # Dizionario per conservare gli headers dalle direttive - current_kodi_props: Dict[str, str] = {} # Dizionario per conservare le proprietà KODI - + current_ext_headers: Dict[str, str] = {} # Dictionary to store headers from directives + current_kodi_props: Dict[str, str] = {} # Dictionary to store KODI properties + for line_with_newline in m3u_lines_iterator: - line_content = line_with_newline.rstrip('\n') + line_content = line_with_newline.rstrip("\n") logical_line = line_content.strip() - + is_header_tag = False - if logical_line.startswith('#EXTVLCOPT:'): + if logical_line.startswith("#EXTVLCOPT:"): # Yield the original line to preserve it yield line_with_newline - + is_header_tag = True try: - option_str = logical_line.split(':', 1)[1] - if '=' in option_str: - key_vlc, value_vlc = option_str.split('=', 1) + option_str = logical_line.split(":", 1)[1] + if "=" in option_str: + key_vlc, value_vlc = option_str.split("=", 1) key_vlc = key_vlc.strip() value_vlc = value_vlc.strip() - + # Gestione speciale per http-header che contiene "Key: Value" - if key_vlc == 'http-header' and ':' in value_vlc: - header_key, header_value = value_vlc.split(':', 1) + if key_vlc == "http-header" and ":" in value_vlc: + header_key, header_value = value_vlc.split(":", 1) header_key = header_key.strip() header_value = header_value.strip() current_ext_headers[header_key] = header_value - elif key_vlc.startswith('http-'): - # Gestisce http-user-agent, http-referer etc. - header_key = key_vlc[len('http-'):] + elif key_vlc.startswith("http-"): + # Handle http-user-agent, http-referer, etc. + header_key = key_vlc[len("http-") :] current_ext_headers[header_key] = value_vlc except Exception as e: - logger.error(f"⚠️ Error parsing #EXTVLCOPT '{logical_line}': {e}") - - elif logical_line.startswith('#EXTHTTP:'): + logger.error(f"Error parsing #EXTVLCOPT '{logical_line}': {e}") + + elif logical_line.startswith("#EXTHTTP:"): # Yield the original line to preserve it yield line_with_newline - + is_header_tag = True try: - json_str = logical_line.split(':', 1)[1] - # Sostituisce tutti gli header correnti con quelli del JSON + json_str = logical_line.split(":", 1)[1] + # Replace all current headers with those from the JSON current_ext_headers = json.loads(json_str) except Exception as e: - logger.error(f"⚠️ Error parsing #EXTHTTP '{logical_line}': {e}") - current_ext_headers = {} # Resetta in caso di errore - - elif logical_line.startswith('#KODIPROP:'): + logger.error(f"Error parsing #EXTHTTP '{logical_line}': {e}") + current_ext_headers = {} # Reset on error + + elif logical_line.startswith("#KODIPROP:"): # Yield the original line to preserve it yield line_with_newline - + is_header_tag = True try: - prop_str = logical_line.split(':', 1)[1] - if '=' in prop_str: - key_kodi, value_kodi = prop_str.split('=', 1) + prop_str = logical_line.split(":", 1)[1] + if "=" in prop_str: + key_kodi, value_kodi = prop_str.split("=", 1) current_kodi_props[key_kodi.strip()] = value_kodi.strip() except Exception as e: - logger.error(f"⚠️ Error parsing #KODIPROP '{logical_line}': {e}") - + logger.error(f"Error parsing #KODIPROP '{logical_line}': {e}") if is_header_tag: continue - - if logical_line and not logical_line.startswith('#') and \ - ('http://' in logical_line or 'https://' in logical_line): - + + if ( + logical_line + and not logical_line.startswith("#") + and ("http://" in logical_line or "https://" in logical_line) + ): processed_url_content = logical_line - + # Non modificare link pluto.tv - if 'pluto.tv' in logical_line: + if "pluto.tv" in logical_line: processed_url_content = logical_line - elif 'vavoo.to' in logical_line: - encoded_url = urllib.parse.quote(logical_line, safe='') + elif "vavoo.to" in logical_line: + encoded_url = urllib.parse.quote(logical_line, safe="") processed_url_content = f"{base_url}/proxy/hls/manifest.m3u8?d={encoded_url}" - elif 'vixsrc.to' in logical_line: - encoded_url = urllib.parse.quote(logical_line, safe='') + elif "vixsrc.to" in logical_line: + encoded_url = urllib.parse.quote(logical_line, safe="") processed_url_content = f"{base_url}/extractor/video?host=VixCloud&redirect_stream=true&d={encoded_url}&max_res=true&no_proxy=true" - elif '.m3u8' in logical_line: - encoded_url = urllib.parse.quote(logical_line, safe='') + elif ".m3u8" in logical_line: + encoded_url = urllib.parse.quote(logical_line, safe="") processed_url_content = f"{base_url}/proxy/hls/manifest.m3u8?d={encoded_url}" - elif '.mpd' in logical_line: + elif ".mpd" in logical_line: # Estrai parametri DRM dall'URL MPD se presenti (es. &key_id=...&key=...) from urllib.parse import urlparse, parse_qs, urlencode, urlunparse - + # Parse dell'URL per estrarre parametri parsed_url = urlparse(logical_line) query_params = parse_qs(parsed_url.query) - + # Estrai key_id e key se presenti nei parametri della query - key_id = query_params.get('key_id', [None])[0] - key = query_params.get('key', [None])[0] - + key_id = query_params.get("key_id", [None])[0] + key = query_params.get("key", [None])[0] + # Rimuovi key_id e key dai parametri originali - clean_query_params = {k: v for k, v in query_params.items() if k not in ['key_id', 'key']} - + clean_query_params = {k: v for k, v in query_params.items() if k not in ["key_id", "key"]} + # Ricostruisci l'URL senza i parametri DRM clean_query = urlencode(clean_query_params, doseq=True) - clean_url = urlunparse(( - parsed_url.scheme, - parsed_url.netloc, - parsed_url.path, - parsed_url.params, - clean_query, - '' # Rimuovi il frammento per evitare problemi - )) - + clean_url = urlunparse( + ( + parsed_url.scheme, + parsed_url.netloc, + parsed_url.path, + parsed_url.params, + clean_query, + "", # Rimuovi il frammento per evitare problemi + ) + ) + # Codifica l'URL pulito per il parametro 'd' - encoded_clean_url = urllib.parse.quote(clean_url, safe='') - + encoded_clean_url = urllib.parse.quote(clean_url, safe="") + # Costruisci l'URL MediaFlow con parametri DRM separati processed_url_content = f"{base_url}/proxy/mpd/manifest.m3u8?d={encoded_clean_url}" - + # Aggiungi i parametri DRM all'URL di MediaFlow se sono stati trovati if key_id: processed_url_content += f"&key_id={key_id}" if key: processed_url_content += f"&key={key}" - - # Aggiungi chiavi da #KODIPROP se presenti - license_key = current_kodi_props.get('inputstream.adaptive.license_key') - if license_key and ':' in license_key: - key_id_kodi, key_kodi = license_key.split(':', 1) - processed_url_content += f"&key_id={key_id_kodi}" - processed_url_content += f"&key={key_kodi}" - elif '.php' in logical_line: - encoded_url = urllib.parse.quote(logical_line, safe='') + elif ".php" in logical_line: + encoded_url = urllib.parse.quote(logical_line, safe="") processed_url_content = f"{base_url}/proxy/hls/manifest.m3u8?d={encoded_url}" else: # Per tutti gli altri link senza estensioni specifiche, trattali come .m3u8 con codifica - encoded_url = urllib.parse.quote(logical_line, safe='') + encoded_url = urllib.parse.quote(logical_line, safe="") processed_url_content = f"{base_url}/proxy/hls/manifest.m3u8?d={encoded_url}" - + + # Aggiungi chiavi da #KODIPROP se presenti + license_key = current_kodi_props.get("inputstream.adaptive.license_key") + if license_key and ":" in license_key: + key_id_kodi, key_kodi = license_key.split(":", 1) + # Aggiungi key_id e key solo se non sono già stati aggiunti (es. dall'URL MPD) + if "&key_id=" not in processed_url_content: + processed_url_content += f"&key_id={key_id_kodi}" + if "&key=" not in processed_url_content: + processed_url_content += f"&key={key_kodi}" + # Applica gli header raccolti prima di api_password if current_ext_headers: - header_params_str = "".join([f"&h_{urllib.parse.quote(key)}={urllib.parse.quote(value)}" for key, value in current_ext_headers.items()]) + header_params_str = "".join( + [ + f"&h_{urllib.parse.quote(key)}={urllib.parse.quote(value)}" + for key, value in current_ext_headers.items() + ] + ) processed_url_content += header_params_str current_ext_headers = {} - - # Resetta le proprietà KODI dopo averle usate + + # Reset KODI properties after using them current_kodi_props = {} - - # Aggiungi api_password sempre alla fine + + # Always append api_password at the end if api_password: processed_url_content += f"&api_password={api_password}" - - yield processed_url_content + '\n' + + yield processed_url_content + "\n" else: yield line_with_newline @@ -173,45 +187,46 @@ def rewrite_m3u_links_streaming(m3u_lines_iterator: Iterator[str], base_url: str async def async_download_m3u_playlist(url: str) -> list[str]: """Scarica una playlist M3U in modo asincrono e restituisce le righe.""" headers = { - 'User-Agent': settings.user_agent, - 'Accept': '*/*', - 'Accept-Language': 'en-US,en;q=0.9', - 'Accept-Encoding': 'gzip, deflate', - 'Connection': 'keep-alive' + "User-Agent": settings.user_agent, + "Accept": "*/*", + "Accept-Language": "en-US,en;q=0.9", + "Accept-Encoding": "gzip, deflate", + "Connection": "keep-alive", } lines = [] try: - async with httpx.AsyncClient(verify=True, timeout=30, follow_redirects=True) as client: - async with client.stream('GET', url, headers=headers) as response: - response.raise_for_status() - async for line_bytes in response.aiter_lines(): - if isinstance(line_bytes, bytes): - decoded_line = line_bytes.decode('utf-8', errors='replace') - else: - decoded_line = str(line_bytes) - lines.append(decoded_line + '\n' if decoded_line else '') + async with create_aiohttp_session(url, timeout=30) as (session, proxy_url): + response = await session.get(url, headers=headers, proxy=proxy_url) + response.raise_for_status() + content = await response.text() + # Split content into lines + for line in content.splitlines(): + lines.append(line + "\n" if line else "") except Exception as e: logger.error(f"Error downloading playlist (async): {str(e)}") raise return lines + def parse_channel_entries(lines: list[str]) -> list[list[str]]: """ - Analizza le linee di una playlist M3U e le raggruppa in entry di canali. - Ogni entry è una lista di linee che compongono un singolo canale - (da #EXTINF fino all'URL, incluse le righe intermedie). + Parse the lines of an M3U playlist and group them into channel entries. + Each entry is a list of lines that make up a single channel + (from #EXTINF to the URL, including intermediate lines). """ entries = [] current_entry = [] for line in lines: stripped_line = line.strip() - if stripped_line.startswith('#EXTINF:'): - if current_entry: # In caso di #EXTINF senza URL precedente - logger.warning(f"Found a new #EXTINF tag before a URL was found for the previous entry. Discarding: {current_entry}") + if stripped_line.startswith("#EXTINF:"): + if current_entry: # In case of #EXTINF without a preceding URL + logger.warning( + f"Found a new #EXTINF tag before a URL was found for the previous entry. Discarding: {current_entry}" + ) current_entry = [line] elif current_entry: current_entry.append(line) - if stripped_line and not stripped_line.startswith('#'): + if stripped_line and not stripped_line.startswith("#"): entries.append(current_entry) current_entry = [] return entries @@ -226,48 +241,52 @@ async def async_generate_combined_playlist(playlist_definitions: list[str], base playlist_url_str = definition should_sort = False - if definition.startswith('sort:'): + if definition.startswith("sort:"): should_sort = True - definition = definition[len('sort:'):] + definition = definition[len("sort:") :] - if definition.startswith('no_proxy:'): # Può essere combinato con sort: + if definition.startswith("no_proxy:"): # Can be combined with sort: should_proxy = False - playlist_url_str = definition[len('no_proxy:'):] + playlist_url_str = definition[len("no_proxy:") :] else: playlist_url_str = definition - download_tasks.append({ - "url": playlist_url_str, - "proxy": should_proxy, - "sort": should_sort - }) + download_tasks.append({"url": playlist_url_str, "proxy": should_proxy, "sort": should_sort}) + + # Download all playlists in parallel + results = await asyncio.gather( + *[async_download_m3u_playlist(task["url"]) for task in download_tasks], return_exceptions=True + ) - # Scarica tutte le playlist in parallelo - results = await asyncio.gather(*[async_download_m3u_playlist(task["url"]) for task in download_tasks], return_exceptions=True) - # Raggruppa le playlist da ordinare e quelle da non ordinare - sorted_playlist_lines = [] + channel_entries_to_sort = [] unsorted_playlists_data = [] - + for idx, result in enumerate(results): task_info = download_tasks[idx] if isinstance(result, Exception): # Aggiungi errore come playlist non ordinata - unsorted_playlists_data.append({'lines': [f"# ERROR processing playlist {task_info['url']}: {str(result)}\n"], 'proxy': False}) + unsorted_playlists_data.append( + {"lines": [f"# ERROR processing playlist {task_info['url']}: {str(result)}\n"], "proxy": False} + ) continue - + if task_info.get("sort", False): - sorted_playlist_lines.extend(result) + # Se la playlist deve essere ordinata, estraiamo i canali e li mettiamo nel pool globale da ordinare + entries = parse_channel_entries(result) + for entry_lines in entries: + channel_entries_to_sort.append((entry_lines, task_info["proxy"])) else: - unsorted_playlists_data.append({'lines': result, 'proxy': task_info['proxy']}) + unsorted_playlists_data.append({"lines": result, "proxy": task_info["proxy"]}) # Gestione dell'header #EXTM3U first_playlist_header_handled = False + def yield_header_once(lines_iter): nonlocal first_playlist_header_handled has_header = False for line in lines_iter: - is_extm3u = line.strip().startswith('#EXTM3U') + is_extm3u = line.strip().startswith("#EXTM3U") if is_extm3u: has_header = True if not first_playlist_header_handled: @@ -276,52 +295,40 @@ async def async_generate_combined_playlist(playlist_definitions: list[str], base else: yield line if has_header and not first_playlist_header_handled: - first_playlist_header_handled = True + first_playlist_header_handled = True # 1. Processa e ordina le playlist marcate con 'sort' - if sorted_playlist_lines: - # Estrai le entry dei canali - # Modifica: Estrai le entry e mantieni l'informazione sul proxy - channel_entries_with_proxy_info = [] - for idx, result in enumerate(results): - task_info = download_tasks[idx] - if task_info.get("sort") and isinstance(result, list): - entries = parse_channel_entries(result) # result è la lista di linee della playlist - for entry_lines in entries: - # L'opzione proxy si applica a tutto il blocco del canale - channel_entries_with_proxy_info.append((entry_lines, task_info["proxy"])) + if channel_entries_to_sort: + # Sort all entries from ALL sorted playlists together by channel name (from #EXTINF) + # The first line of each entry is always #EXTINF + channel_entries_to_sort.sort(key=lambda x: x[0][0].split(",")[-1].strip().lower()) - # Ordina le entry in base al nome del canale (da #EXTINF) - # La prima riga di ogni entry è sempre #EXTINF - channel_entries_with_proxy_info.sort(key=lambda x: x[0][0].split(',')[-1].strip()) - - # Gestisci l'header una sola volta per il blocco ordinato + # Handle the header only once for the sorted block if not first_playlist_header_handled: yield "#EXTM3U\n" first_playlist_header_handled = True - - # Applica la riscrittura dei link in modo selettivo - for entry_lines, should_proxy in channel_entries_with_proxy_info: - # L'URL è l'ultima riga dell'entry + + # Apply link rewriting selectively + for entry_lines, should_proxy in channel_entries_to_sort: + # The URL is the last line of the entry url = entry_lines[-1] # Yield tutte le righe prima dell'URL for line in entry_lines[:-1]: yield line - + if should_proxy: # Usa un iteratore fittizio per processare una sola linea rewritten_url_iter = rewrite_m3u_links_streaming(iter([url]), base_url, api_password) - yield next(rewritten_url_iter, url) # Prende l'URL riscritto, con fallback all'originale + yield next(rewritten_url_iter, url) # Prende l'URL riscritto, con fallback all'originale else: - yield url # Lascia l'URL invariato - + yield url # Lascia l'URL invariato # 2. Accoda le playlist non ordinate for playlist_data in unsorted_playlists_data: - lines_iterator = iter(playlist_data['lines']) - if playlist_data['proxy']: + lines_iterator = iter(playlist_data["lines"]) + if playlist_data["proxy"]: lines_iterator = rewrite_m3u_links_streaming(lines_iterator, base_url, api_password) - + for line in yield_header_once(lines_iterator): yield line @@ -334,7 +341,7 @@ async def proxy_handler( ): """ Endpoint per il proxy delle playlist M3U con supporto MFP. - + Formato query string: playlist1&url1;playlist2&url2 Esempio: https://mfp.com:pass123&http://provider.com/playlist.m3u """ @@ -346,21 +353,21 @@ async def proxy_handler( raise HTTPException(status_code=400, detail="Query string cannot be empty") # Validate that we have at least one valid definition - playlist_definitions = [def_.strip() for def_ in d.split(';') if def_.strip()] + playlist_definitions = [def_.strip() for def_ in d.split(";") if def_.strip()] if not playlist_definitions: raise HTTPException(status_code=400, detail="No valid playlist definitions found") - + # Costruisci base_url con lo schema corretto original_scheme = get_original_scheme(request) base_url = f"{original_scheme}://{request.url.netloc}" - + # Estrai base_url dalla prima definizione se presente - if playlist_definitions and '&' in playlist_definitions[0]: - parts = playlist_definitions[0].split('&', 1) - if ':' in parts[0] and not parts[0].startswith('http'): + if playlist_definitions and "&" in playlist_definitions[0]: + parts = playlist_definitions[0].split("&", 1) + if ":" in parts[0] and not parts[0].startswith("http"): # Estrai base_url dalla prima parte se contiene password - base_url_part = parts[0].rsplit(':', 1)[0] - if base_url_part.startswith('http'): + base_url_part = parts[0].rsplit(":", 1)[0] + if base_url_part.startswith("http"): base_url = base_url_part async def generate_response(): @@ -369,13 +376,10 @@ async def proxy_handler( return StreamingResponse( generate_response(), - media_type='application/vnd.apple.mpegurl', - headers={ - 'Content-Disposition': 'attachment; filename="playlist.m3u"', - 'Access-Control-Allow-Origin': '*' - } + media_type="application/vnd.apple.mpegurl", + headers={"Content-Disposition": 'attachment; filename="playlist.m3u"', "Access-Control-Allow-Origin": "*"}, ) - + except Exception as e: logger.error(f"General error in playlist handler: {str(e)}") raise HTTPException(status_code=500, detail=f"Error: {str(e)}") from e diff --git a/mediaflow_proxy/routes/proxy.py b/mediaflow_proxy/routes/proxy.py index a80eafa..47c33a2 100644 --- a/mediaflow_proxy/routes/proxy.py +++ b/mediaflow_proxy/routes/proxy.py @@ -1,14 +1,14 @@ import asyncio +import logging +import re from typing import Annotated from urllib.parse import quote, unquote -import re -import logging -import httpx -import time -from fastapi import Request, Depends, APIRouter, Query, HTTPException -from fastapi.responses import Response +import aiohttp +from fastapi import Request, Depends, APIRouter, Query, HTTPException, Response +from fastapi.datastructures import QueryParams +from mediaflow_proxy.configs import settings from mediaflow_proxy.handlers import ( handle_hls_stream_proxy, handle_stream_request, @@ -16,6 +16,7 @@ from mediaflow_proxy.handlers import ( get_manifest, get_playlist, get_segment, + get_init_segment, get_public_ip, ) from mediaflow_proxy.schemas import ( @@ -23,64 +24,75 @@ from mediaflow_proxy.schemas import ( MPDPlaylistParams, HLSManifestParams, MPDManifestParams, + MPDInitParams, ) +from mediaflow_proxy.utils.base64_utils import process_potential_base64_url +from mediaflow_proxy.utils.extractor_helpers import ( + check_and_extract_dlhd_stream, + check_and_extract_sportsonline_stream, +) +from mediaflow_proxy.utils.hls_prebuffer import hls_prebuffer +from mediaflow_proxy.utils.hls_utils import parse_hls_playlist, find_stream_by_resolution from mediaflow_proxy.utils.http_utils import ( get_proxy_headers, ProxyRequestHeaders, - create_httpx_client, + apply_header_manipulation, +) +from mediaflow_proxy.utils.http_client import create_aiohttp_session +from mediaflow_proxy.utils.m3u8_processor import M3U8Processor +from mediaflow_proxy.utils.stream_transformers import apply_transformer_to_bytes +from mediaflow_proxy.remuxer.media_source import HTTPMediaSource +from mediaflow_proxy.remuxer.transcode_handler import ( + handle_transcode, + handle_transcode_hls_init, + handle_transcode_hls_playlist, + handle_transcode_hls_segment, ) -from mediaflow_proxy.utils.base64_utils import process_potential_base64_url + +logger = logging.getLogger(__name__) proxy_router = APIRouter() -# DLHD extraction cache: {original_url: {"data": extraction_result, "timestamp": time.time()}} -_dlhd_extraction_cache = {} -_dlhd_cache_duration = 600 # 10 minutes in seconds - -_sportsonline_extraction_cache = {} -_sportsonline_cache_duration = 600 # 10 minutes in seconds - def sanitize_url(url: str) -> str: """ Sanitize URL to fix common encoding issues and handle base64 encoded URLs. - + Args: url (str): The URL to sanitize. - + Returns: str: The sanitized URL. """ - logger = logging.getLogger(__name__) original_url = url - + # First, try to process potential base64 encoded URLs url = process_potential_base64_url(url) - + # Fix malformed URLs where https%22// should be https:// - url = re.sub(r'https%22//', 'https://', url) - url = re.sub(r'http%22//', 'http://', url) - + url = re.sub(r"https%22//", "https://", url) + url = re.sub(r"http%22//", "http://", url) + # Fix malformed URLs where https%3A%22// should be https:// - url = re.sub(r'https%3A%22//', 'https://', url) - url = re.sub(r'http%3A%22//', 'http://', url) - + url = re.sub(r"https%3A%22//", "https://", url) + url = re.sub(r"http%3A%22//", "http://", url) + # Fix malformed URLs where https:"// should be https:// (after partial decoding) - url = re.sub(r'https:"//', 'https://', url) - url = re.sub(r'http:"//', 'http://', url) - + url = re.sub(r'https:"//', "https://", url) + url = re.sub(r'http:"//', "http://", url) + # Fix URLs where key_id and key parameters are incorrectly appended to the base URL # This happens when the URL contains &key_id= and &key= which should be handled as proxy parameters - if '&key_id=' in url and '&key=' in url: + if "&key_id=" in url and "&key=" in url: # Split the URL at the first occurrence of &key_id= to separate the base URL from the incorrectly appended parameters - base_url = url.split('&key_id=')[0] + base_url = url.split("&key_id=")[0] logger.info(f"Removed incorrectly appended key parameters from URL: '{url}' -> '{base_url}'") url = base_url - + # Log if URL was changed if url != original_url: logger.info(f"URL sanitized: '{original_url}' -> '{url}'") - + # Also try URL decoding to see what we get try: decoded_url = unquote(url) @@ -89,196 +101,52 @@ def sanitize_url(url: str) -> str: # If after decoding we still have malformed protocol, fix it if ':"/' in decoded_url: # Fix https:"// or http:"// patterns - fixed_decoded = re.sub(r'([a-z]+):"//', r'\1://', decoded_url) + fixed_decoded = re.sub(r'([a-z]+):"//', r"\1://", decoded_url) logger.info(f"Fixed decoded URL: '{fixed_decoded}'") return fixed_decoded except Exception as e: logger.warning(f"Error decoding URL '{url}': {e}") - + return url def extract_drm_params_from_url(url: str) -> tuple[str, str, str]: """ Extract DRM parameters (key_id and key) from a URL if they are incorrectly appended. - + Args: url (str): The URL that may contain appended DRM parameters. - + Returns: tuple: (clean_url, key_id, key) where clean_url has the parameters removed, and key_id/key are the extracted values (or None if not found). """ - logger = logging.getLogger(__name__) key_id = None key = None clean_url = url - + # Check if URL contains incorrectly appended key_id and key parameters - if '&key_id=' in url and '&key=' in url: + if "&key_id=" in url and "&key=" in url: # Extract key_id - key_id_match = re.search(r'&key_id=([^&]+)', url) + key_id_match = re.search(r"&key_id=([^&]+)", url) if key_id_match: key_id = key_id_match.group(1) - + # Extract key - key_match = re.search(r'&key=([^&]+)', url) + key_match = re.search(r"&key=([^&]+)", url) if key_match: key = key_match.group(1) - + # Remove the parameters from the URL - clean_url = re.sub(r'&key_id=[^&]*', '', url) - clean_url = re.sub(r'&key=[^&]*', '', clean_url) - + clean_url = re.sub(r"&key_id=[^&]*", "", url) + clean_url = re.sub(r"&key=[^&]*", "", clean_url) + logger.info(f"Extracted DRM parameters from URL: key_id={key_id}, key={key}") logger.info(f"Cleaned URL: '{url}' -> '{clean_url}'") - + return clean_url, key_id, key -def _invalidate_dlhd_cache(destination: str): - """Invalidate DLHD cache for a specific destination URL.""" - if destination in _dlhd_extraction_cache: - del _dlhd_extraction_cache[destination] - logger = logging.getLogger(__name__) - logger.info(f"DLHD cache invalidated for: {destination}") - - -async def _check_and_extract_dlhd_stream( - request: Request, - destination: str, - proxy_headers: ProxyRequestHeaders, - force_refresh: bool = False -) -> dict | None: - """ - Check if destination contains DLHD/DaddyLive patterns and extract stream directly. - Uses caching to avoid repeated extractions (10 minute cache). - - Args: - request (Request): The incoming HTTP request. - destination (str): The destination URL to check. - proxy_headers (ProxyRequestHeaders): The headers to include in the request. - force_refresh (bool): Force re-extraction even if cached data exists. - - Returns: - dict | None: Extracted stream data if DLHD link detected, None otherwise. - """ - import re - from urllib.parse import urlparse - from mediaflow_proxy.extractors.factory import ExtractorFactory - from mediaflow_proxy.extractors.base import ExtractorError - from mediaflow_proxy.utils.http_utils import DownloadError - - # Check for common DLHD/DaddyLive patterns in the URL - # This includes stream-XXX pattern and domain names like dlhd.dad or daddylive.sx - is_dlhd_link = ( - re.search(r'stream-\d+', destination) or - "dlhd.dad" in urlparse(destination).netloc or - "daddylive.sx" in urlparse(destination).netloc - ) - - if not is_dlhd_link: - return None - - logger = logging.getLogger(__name__) - logger.info(f"DLHD link detected: {destination}") - - # Check cache first (unless force_refresh is True) - current_time = time.time() - if not force_refresh and destination in _dlhd_extraction_cache: - cached_entry = _dlhd_extraction_cache[destination] - cache_age = current_time - cached_entry["timestamp"] - - if cache_age < _dlhd_cache_duration: - logger.info(f"Using cached DLHD data (age: {cache_age:.1f}s)") - return cached_entry["data"] - else: - logger.info(f"DLHD cache expired (age: {cache_age:.1f}s), re-extracting...") - del _dlhd_extraction_cache[destination] - - # Extract stream data - try: - logger.info(f"Extracting DLHD stream data from: {destination}") - extractor = ExtractorFactory.get_extractor("DLHD", proxy_headers.request) - result = await extractor.extract(destination) - - logger.info(f"DLHD extraction successful. Stream URL: {result.get('destination_url')}") - - # Cache the result - _dlhd_extraction_cache[destination] = { - "data": result, - "timestamp": current_time - } - logger.info(f"DLHD data cached for {_dlhd_cache_duration}s") - - return result - - except (ExtractorError, DownloadError) as e: - logger.error(f"DLHD extraction failed: {str(e)}") - raise HTTPException(status_code=400, detail=f"DLHD extraction failed: {str(e)}") - except Exception as e: - logger.exception(f"Unexpected error during DLHD extraction: {str(e)}") - raise HTTPException(status_code=500, detail=f"DLHD extraction failed: {str(e)}") - - -async def _check_and_extract_sportsonline_stream( - request: Request, - destination: str, - proxy_headers: ProxyRequestHeaders, - force_refresh: bool = False -) -> dict | None: - """ - Check if destination contains Sportsonline/Sportzonline patterns and extract stream directly. - Uses caching to avoid repeated extractions (10 minute cache). - - Args: - request (Request): The incoming HTTP request. - destination (str): The destination URL to check. - proxy_headers (ProxyRequestHeaders): The headers to include in the request. - force_refresh (bool): Force re-extraction even if cached data exists. - - Returns: - dict | None: Extracted stream data if Sportsonline link detected, None otherwise. - """ - import re - from urllib.parse import urlparse - from mediaflow_proxy.extractors.factory import ExtractorFactory - from mediaflow_proxy.extractors.base import ExtractorError - from mediaflow_proxy.utils.http_utils import DownloadError - - parsed_netloc = urlparse(destination).netloc - is_sportsonline_link = "sportzonline." in parsed_netloc or "sportsonline." in parsed_netloc - - if not is_sportsonline_link: - return None - - logger = logging.getLogger(__name__) - logger.info(f"Sportsonline link detected: {destination}") - - current_time = time.time() - if not force_refresh and destination in _sportsonline_extraction_cache: - cached_entry = _sportsonline_extraction_cache[destination] - if current_time - cached_entry["timestamp"] < _sportsonline_cache_duration: - logger.info(f"Using cached Sportsonline data (age: {current_time - cached_entry['timestamp']:.1f}s)") - return cached_entry["data"] - else: - logger.info("Sportsonline cache expired, re-extracting...") - del _sportsonline_extraction_cache[destination] - - try: - logger.info(f"Extracting Sportsonline stream data from: {destination}") - extractor = ExtractorFactory.get_extractor("Sportsonline", proxy_headers.request) - result = await extractor.extract(destination) - logger.info(f"Sportsonline extraction successful. Stream URL: {result.get('destination_url')}") - _sportsonline_extraction_cache[destination] = {"data": result, "timestamp": current_time} - logger.info(f"Sportsonline data cached for {_sportsonline_cache_duration}s") - return result - except (ExtractorError, DownloadError, Exception) as e: - logger.error(f"Sportsonline extraction failed: {str(e)}") - raise HTTPException(status_code=400, detail=f"Sportsonline extraction failed: {str(e)}") - - -@proxy_router.head("/hls/manifest.m3u8") @proxy_router.head("/hls/manifest.m3u8") @proxy_router.get("/hls/manifest.m3u8") async def hls_manifest_proxy( @@ -298,33 +166,35 @@ async def hls_manifest_proxy( Response: The HTTP response with the processed m3u8 playlist or streamed content. """ # Sanitize destination URL to fix common encoding issues - original_destination = hls_params.destination hls_params.destination = sanitize_url(hls_params.destination) - + # Check if this is a retry after 403 error (dlhd_retry parameter) force_refresh = request.query_params.get("dlhd_retry") == "1" - + # Check if destination contains DLHD pattern and extract stream directly - dlhd_result = await _check_and_extract_dlhd_stream( + dlhd_result = await check_and_extract_dlhd_stream( request, hls_params.destination, proxy_headers, force_refresh=force_refresh ) dlhd_original_url = None if dlhd_result: # Store original DLHD URL for cache invalidation on 403 errors dlhd_original_url = hls_params.destination - + # Update destination and headers with extracted stream data hls_params.destination = dlhd_result["destination_url"] extracted_headers = dlhd_result.get("request_headers", {}) proxy_headers.request.update(extracted_headers) - + # Check if extractor wants key-only proxy (DLHD uses hls_key_proxy endpoint) if dlhd_result.get("mediaflow_endpoint") == "hls_key_proxy": hls_params.key_only_proxy = True - + + # Check if extractor wants to force playlist proxy (needed for .css disguised m3u8) + if dlhd_result.get("force_playlist_proxy"): + hls_params.force_playlist_proxy = True + # Also add headers to query params so they propagate to key/segment requests # This is necessary because M3U8Processor encodes headers as h_* query params - from fastapi.datastructures import QueryParams query_dict = dict(request.query_params) for header_name, header_value in extracted_headers.items(): # Add header with h_ prefix to query params @@ -332,15 +202,20 @@ async def hls_manifest_proxy( # Add DLHD original URL to track for cache invalidation if dlhd_original_url: query_dict["dlhd_original"] = dlhd_original_url + # Add DLHD key params if present (for dynamic key header computation) + if dlhd_result.get("dlhd_channel_salt"): + query_dict["dlhd_salt"] = dlhd_result["dlhd_channel_salt"] + if dlhd_result.get("dlhd_auth_token"): + query_dict["dlhd_token"] = dlhd_result["dlhd_auth_token"] + if dlhd_result.get("dlhd_iframe_url"): + query_dict["dlhd_iframe"] = dlhd_result["dlhd_iframe_url"] # Remove retry flag from subsequent requests query_dict.pop("dlhd_retry", None) # Update request query params request._query_params = QueryParams(query_dict) # Check if destination contains Sportsonline pattern and extract stream directly - sportsonline_result = await _check_and_extract_sportsonline_stream( - request, hls_params.destination, proxy_headers - ) + sportsonline_result = await check_and_extract_sportsonline_stream(request, hls_params.destination, proxy_headers) if sportsonline_result: # Update destination and headers with extracted stream data hls_params.destination = sportsonline_result["destination_url"] @@ -352,7 +227,6 @@ async def hls_manifest_proxy( hls_params.key_only_proxy = True # Also add headers to query params so they propagate to key/segment requests - from fastapi.datastructures import QueryParams query_dict = dict(request.query_params) for header_name, header_value in extracted_headers.items(): # Add header with h_ prefix to query params @@ -369,67 +243,68 @@ async def hls_manifest_proxy( except HTTPException: raise except Exception as e: - logger = logging.getLogger(__name__) logger.exception(f"Unexpected error in hls_manifest_proxy: {e}") raise HTTPException(status_code=500, detail=str(e)) async def _handle_hls_with_dlhd_retry( - request: Request, - hls_params: HLSManifestParams, - proxy_headers: ProxyRequestHeaders, - dlhd_original_url: str | None + request: Request, hls_params: HLSManifestParams, proxy_headers: ProxyRequestHeaders, dlhd_original_url: str | None ): """ Handle HLS request with automatic retry on 403 errors for DLHD streams. """ - logger = logging.getLogger(__name__) - - if hls_params.max_res: - from mediaflow_proxy.utils.hls_utils import parse_hls_playlist - from mediaflow_proxy.utils.m3u8_processor import M3U8Processor - - async with create_httpx_client( - headers=proxy_headers.request, - follow_redirects=True, - ) as client: + # Check if resolution selection is needed (either max_res or specific resolution) + if hls_params.max_res or hls_params.resolution: + async with create_aiohttp_session(hls_params.destination) as (session, proxy_url): try: - response = await client.get(hls_params.destination) + response = await session.get( + hls_params.destination, + headers=proxy_headers.request, + proxy=proxy_url, + ) response.raise_for_status() - playlist_content = response.text - except httpx.HTTPStatusError as e: + playlist_content = await response.text() + except aiohttp.ClientResponseError as e: raise HTTPException( status_code=502, - detail=f"Failed to fetch HLS manifest from origin: {e.response.status_code} {e.response.reason_phrase}", + detail=f"Failed to fetch HLS manifest from origin: {e.status}", ) from e - except httpx.TimeoutException as e: + except asyncio.TimeoutError as e: raise HTTPException( status_code=504, detail=f"Timeout while fetching HLS manifest: {e}", ) from e - except httpx.RequestError as e: + except aiohttp.ClientError as e: raise HTTPException(status_code=502, detail=f"Network error fetching HLS manifest: {e}") from e - + streams = parse_hls_playlist(playlist_content, base_url=hls_params.destination) if not streams: - raise HTTPException( - status_code=404, detail="No streams found in the manifest." + raise HTTPException(status_code=404, detail="No streams found in the manifest.") + + # Select stream based on resolution parameter or max_res + if hls_params.resolution: + selected_stream = find_stream_by_resolution(streams, hls_params.resolution) + if not selected_stream: + raise HTTPException( + status_code=404, detail=f"No suitable stream found for resolution {hls_params.resolution}." + ) + else: + # max_res: select highest resolution + selected_stream = max( + streams, + key=lambda s: s.get("resolution", (0, 0))[0] * s.get("resolution", (0, 0))[1], ) - highest_res_stream = max( - streams, - key=lambda s: s.get("resolution", (0, 0))[0] - * s.get("resolution", (0, 0))[1], - ) - - if highest_res_stream.get("resolution", (0, 0)) == (0, 0): - logging.warning("Selected stream has resolution (0, 0); resolution parsing may have failed or not be available in the manifest.") + if selected_stream.get("resolution", (0, 0)) == (0, 0): + logger.warning( + "Selected stream has resolution (0, 0); resolution parsing may have failed or not be available in the manifest." + ) # Rebuild the manifest preserving master-level directives # but removing non-selected variant blocks lines = playlist_content.splitlines() - highest_variant_index = streams.index(highest_res_stream) - + selected_variant_index = streams.index(selected_stream) + variant_index = -1 new_manifest_lines = [] i = 0 @@ -440,30 +315,41 @@ async def _handle_hls_with_dlhd_retry( next_line = "" if i + 1 < len(lines) and not lines[i + 1].startswith("#"): next_line = lines[i + 1] - + # Only keep the selected variant - if variant_index == highest_variant_index: + if variant_index == selected_variant_index: new_manifest_lines.append(line) if next_line: new_manifest_lines.append(next_line) - + # Skip variant block (stream-inf + optional url) i += 2 if next_line else 1 continue - + # Preserve all other lines (master directives, media tags, etc.) new_manifest_lines.append(line) i += 1 - + new_manifest = "\n".join(new_manifest_lines) + # Parse skip segments (already returns list of dicts with 'start' and 'end' keys) + skip_segments_list = hls_params.get_skip_segments() + # Process the new manifest to proxy all URLs within it - processor = M3U8Processor(request, hls_params.key_url, hls_params.force_playlist_proxy, hls_params.key_only_proxy, hls_params.no_proxy) + processor = M3U8Processor( + request, + hls_params.key_url, + hls_params.force_playlist_proxy, + hls_params.key_only_proxy, + hls_params.no_proxy, + skip_segments_list, + hls_params.start_offset, + ) processed_manifest = await processor.process_m3u8(new_manifest, base_url=hls_params.destination) - + return Response(content=processed_manifest, media_type="application/vnd.apple.mpegurl") - - return await handle_hls_stream_proxy(request, hls_params, proxy_headers) + + return await handle_hls_stream_proxy(request, hls_params, proxy_headers, hls_params.transformer) @proxy_router.head("/hls/key_proxy/manifest.m3u8", name="hls_key_proxy") @@ -486,110 +372,231 @@ async def hls_key_proxy( """ # Sanitize destination URL to fix common encoding issues hls_params.destination = sanitize_url(hls_params.destination) - + # Set the key_only_proxy flag to True hls_params.key_only_proxy = True - - return await handle_hls_stream_proxy(request, hls_params, proxy_headers) + + return await handle_hls_stream_proxy(request, hls_params, proxy_headers, hls_params.transformer) -@proxy_router.get("/hls/segment") +# Map file extensions to MIME types for HLS segments +HLS_SEGMENT_MIME_TYPES = { + "ts": "video/mp2t", # MPEG-TS (traditional HLS) + "m4s": "video/mp4", # fMP4 segment (modern HLS/CMAF) + "mp4": "video/mp4", # fMP4 segment (alternative extension) + "m4a": "audio/mp4", # Audio-only fMP4 segment + "m4v": "video/mp4", # Video fMP4 segment (alternative) + "aac": "audio/aac", # AAC audio segment +} + + +@proxy_router.get("/hls/segment.{ext}", name="hls_segment_proxy") async def hls_segment_proxy( request: Request, proxy_headers: Annotated[ProxyRequestHeaders, Depends(get_proxy_headers)], - segment_url: str = Query(..., description="URL of the HLS segment"), + ext: str, + segment_url: str = Query(..., description="URL of the HLS segment", alias="d"), + transformer: str = Query(None, description="Stream transformer ID for content manipulation"), ): """ - Proxy HLS segments with optional pre-buffering support. + Proxy HLS segments with pre-buffering support. + + This endpoint supports multiple segment formats: + - /hls/segment.ts - MPEG-TS segments (traditional HLS) + - /hls/segment.m4s - fMP4 segments (modern HLS/CMAF) + - /hls/segment.mp4 - fMP4 segments (alternative) + - /hls/segment.m4a - Audio fMP4 segments + - /hls/segment.aac - AAC audio segments + + Uses event-based coordination to prevent duplicate downloads between + player requests and background prebuffering. Args: request (Request): The incoming HTTP request. + ext (str): File extension determining the segment format. segment_url (str): URL of the HLS segment to proxy. proxy_headers (ProxyRequestHeaders): The headers to include in the request. + transformer (str, optional): Stream transformer ID for content manipulation. Returns: Response: The HTTP response with the segment content. """ - from mediaflow_proxy.utils.hls_prebuffer import hls_prebuffer - from mediaflow_proxy.configs import settings + # Get MIME type for this extension + mime_type = HLS_SEGMENT_MIME_TYPES.get(ext.lower(), "application/octet-stream") # Sanitize segment URL to fix common encoding issues + original_url = segment_url segment_url = sanitize_url(segment_url) - + + logger.info(f"[hls_segment_proxy] Request for: {segment_url}") + if original_url != segment_url: + logger.warning(f"[hls_segment_proxy] URL was sanitized! Original: {original_url}") + # Extract headers for pre-buffering headers = {} for key, value in request.query_params.items(): if key.startswith("h_"): headers[key[2:]] = value - # Try to get segment from pre-buffer cache first if settings.enable_hls_prebuffer: - cached_segment = await hls_prebuffer.get_segment(segment_url, headers) - if cached_segment: - # Avvia prebuffer dei successivi in background - asyncio.create_task(hls_prebuffer.prebuffer_from_segment(segment_url, headers)) - return Response( - content=cached_segment, - media_type="video/mp2t", - headers={ - "Content-Type": "video/mp2t", - "Cache-Control": "public, max-age=3600", - "Access-Control-Allow-Origin": "*" - } - ) + # Notify the prefetcher that this segment is needed (priority download) + # This ensures the player's segment is downloaded first, then prefetcher + # continues with sequential prefetch of remaining segments + await hls_prebuffer.request_segment(segment_url) - # Fallback to direct streaming se non in cache: - # prima di restituire, prova comunque a far partire il prebuffer dei successivi - if settings.enable_hls_prebuffer: - asyncio.create_task(hls_prebuffer.prebuffer_from_segment(segment_url, headers)) - return await handle_stream_request("GET", segment_url, proxy_headers) + # Use cross-process coordination to get the segment + segment_data = await hls_prebuffer.get_or_download(segment_url, headers) + + if segment_data: + logger.info(f"[hls_segment_proxy] Serving from prebuffer ({len(segment_data)} bytes): {segment_url}") + + # Apply transformer if specified (e.g., PNG wrapper stripping) + if transformer: + segment_data = await apply_transformer_to_bytes(segment_data, transformer) + + # Return cached/downloaded segment + base_headers = { + "content-type": mime_type, + "cache-control": "public, max-age=3600", + "access-control-allow-origin": "*", + } + response_headers = apply_header_manipulation(base_headers, proxy_headers) + return Response(content=segment_data, media_type=mime_type, headers=response_headers) + + # get_or_download returned None (timeout or error) - fall through to streaming + logger.warning(f"[hls_segment_proxy] Prebuffer timeout, using direct streaming: {segment_url}") + + # Fallback to direct streaming + return await handle_stream_request("GET", segment_url, proxy_headers, transformer) -@proxy_router.get("/dash/segment") -async def dash_segment_proxy( +# ============================================================================= +# HLS Transcode endpoints (VOD playlist + init segment + media segments) +# ============================================================================= + + +@proxy_router.head("/transcode/playlist.m3u8") +@proxy_router.get("/transcode/playlist.m3u8") +async def transcode_hls_playlist( request: Request, proxy_headers: Annotated[ProxyRequestHeaders, Depends(get_proxy_headers)], - segment_url: str = Query(..., description="URL of the DASH segment"), + destination: str = Query(..., description="The URL of the source media.", alias="d"), ): """ - Proxy DASH segments with optional pre-buffering support. + Generate an HLS VOD M3U8 playlist for on-the-fly transcoded content. + + Probes the source file's keyframe index and generates a playlist where + each segment corresponds to one or more keyframe intervals. The playlist + references the init segment and media segment endpoints below. + + The generated playlist uses ``#EXT-X-VERSION:7`` with fMP4 (CMAF) + segments for universal browser and player compatibility. Args: - request (Request): The incoming HTTP request. - segment_url (str): URL of the DASH segment to proxy. - proxy_headers (ProxyRequestHeaders): The headers to include in the request. - - Returns: - Response: The HTTP response with the segment content. + request: The incoming HTTP request. + proxy_headers: Headers to forward to the source. + destination: URL of the source media file. """ - from mediaflow_proxy.utils.dash_prebuffer import dash_prebuffer - from mediaflow_proxy.configs import settings - - # Sanitize segment URL to fix common encoding issues - segment_url = sanitize_url(segment_url) - - # Extract headers for pre-buffering - headers = {} - for key, value in request.query_params.items(): + if not settings.enable_transcode: + raise HTTPException(status_code=503, detail="Transcoding support is disabled") + destination = sanitize_url(destination) + source = HTTPMediaSource(url=destination, headers=dict(proxy_headers.request)) + await source.resolve_file_size() + + # Build URLs for init and segment endpoints that preserve query params + # (api_password, headers, etc.) from the current request. + base_params = _build_hls_query_params(request, destination) + + init_url = f"/proxy/transcode/init.mp4?{base_params}" + segment_url_template = ( + f"/proxy/transcode/segment.m4s?{base_params}&seg={{seg}}&start_ms={{start_ms}}&end_ms={{end_ms}}" + ) + + return await handle_transcode_hls_playlist( + request, + source, + init_url=init_url, + segment_url_template=segment_url_template, + ) + + +@proxy_router.head("/transcode/init.mp4") +@proxy_router.get("/transcode/init.mp4") +async def transcode_hls_init( + request: Request, + proxy_headers: Annotated[ProxyRequestHeaders, Depends(get_proxy_headers)], + destination: str = Query(..., description="The URL of the source media.", alias="d"), +): + """ + Serve the fMP4 init segment (ftyp + moov) for HLS transcode playback. + + The init segment is built from probed track metadata without running + the full transcode pipeline. + + Args: + request: The incoming HTTP request. + proxy_headers: Headers to forward to the source. + destination: URL of the source media file. + """ + if not settings.enable_transcode: + raise HTTPException(status_code=503, detail="Transcoding support is disabled") + destination = sanitize_url(destination) + source = HTTPMediaSource(url=destination, headers=dict(proxy_headers.request)) + await source.resolve_file_size() + + return await handle_transcode_hls_init(request, source) + + +@proxy_router.get("/transcode/segment.m4s") +async def transcode_hls_segment( + request: Request, + proxy_headers: Annotated[ProxyRequestHeaders, Depends(get_proxy_headers)], + destination: str = Query(..., description="The URL of the source media.", alias="d"), + start_ms: float = Query(..., description="Segment start time in milliseconds."), + end_ms: float = Query(..., description="Segment end time in milliseconds."), + seg: int | None = Query(None, description="Segment number (informational, for logging)."), +): + """ + Serve a single HLS fMP4 media segment (moof + mdat). + + Each segment corresponds to a merged keyframe interval in the source + file. The time range is self-describing (from the playlist URL) so + no cue-point re-derivation is needed. + + Args: + request: The incoming HTTP request. + proxy_headers: Headers to forward to the source. + destination: URL of the source media file. + start_ms: Segment start time in milliseconds. + end_ms: Segment end time in milliseconds. + """ + if not settings.enable_transcode: + raise HTTPException(status_code=503, detail="Transcoding support is disabled") + destination = sanitize_url(destination) + source = HTTPMediaSource(url=destination, headers=dict(proxy_headers.request)) + await source.resolve_file_size() + + return await handle_transcode_hls_segment( + request, source, start_time_ms=start_ms, end_time_ms=end_ms, segment_number=seg + ) + + +def _build_hls_query_params(request: Request, destination: str) -> str: + """ + Build query string for HLS sub-requests, preserving auth and header params. + + Copies ``api_password``, header manipulation params (``h_*``), and the + destination URL from the original request. + """ + params = [f"d={quote(destination, safe='')}"] + original = request.query_params + if "api_password" in original: + params.append(f"api_password={quote(original['api_password'], safe='')}") + # Preserve header overrides (h_referer, h_origin, etc.) + for key in original: if key.startswith("h_"): - headers[key[2:]] = value - - # Try to get segment from pre-buffer cache first - if settings.enable_dash_prebuffer: - cached_segment = await dash_prebuffer.get_segment(segment_url, headers) - if cached_segment: - return Response( - content=cached_segment, - media_type="video/mp4", - headers={ - "Content-Type": "video/mp4", - "Cache-Control": "public, max-age=3600", - "Access-Control-Allow-Origin": "*" - } - ) - - # Fallback to direct streaming if not in cache - return await handle_stream_request("GET", segment_url, proxy_headers) + params.append(f"{key}={quote(original[key], safe='')}") + return "&".join(params) @proxy_router.head("/stream") @@ -601,39 +608,109 @@ async def proxy_stream_endpoint( proxy_headers: Annotated[ProxyRequestHeaders, Depends(get_proxy_headers)], destination: str = Query(..., description="The URL of the stream.", alias="d"), filename: str | None = None, + transformer: str = Query(None, description="Stream transformer ID for content manipulation"), + ratelimit: str = Query( + None, + description="Rate limit handler ID for host-specific rate limiting (e.g., 'vidoza', 'aggressive'). " + "If not specified, auto-detects based on destination URL hostname. " + "Set to 'none' to explicitly disable rate limiting.", + ), + transcode: bool = Query( + False, description="Transcode to browser-compatible fMP4 (re-encode video/audio as needed)" + ), + start: float | None = Query(None, description="Seek start time in seconds (used with transcode=true)"), ): """ Proxify stream requests to the given video URL. + This is a general-purpose stream proxy endpoint. For HLS segments with prebuffer + support, use the dedicated /hls/segment.ts endpoint instead. + + When transcode=true, the media is transcoded on-the-fly to browser-compatible + fMP4 (H.264 video + AAC audio). Video is re-encoded only if the source codec + is not browser-compatible (e.g. H.265, MPEG-2). Audio is transcoded to AAC + when needed (e.g. EAC3, AC3, DTS). GPU acceleration is used when available. + + Rate limiting can be controlled via the `ratelimit` parameter: + - Not specified: Auto-detects based on destination URL (e.g., Vidoza is auto-detected) + - "vidoza": Explicitly enable Vidoza rate limiting (5s cooldown between connections) + - "aggressive": Generic aggressive rate limiting (3s cooldown) + - "none": Explicitly disable all rate limiting + Args: request (Request): The incoming HTTP request. proxy_headers (ProxyRequestHeaders): The headers to include in the request. destination (str): The URL of the stream to be proxied. filename (str | None): The filename to be used in the response headers. + transformer (str, optional): Stream transformer ID for content manipulation. + ratelimit (str, optional): Rate limit handler ID for host-specific rate limiting. + transcode (bool): Transcode to browser-compatible format. + start (float, optional): Seek start time in seconds (transcode mode only). Returns: Response: The HTTP response with the streamed content. """ + # Log incoming request details for debugging seek issues + range_header = proxy_headers.request.get("range", "not set") + logger.info( + f"[proxy_stream] Request received - filename: {filename}, range: {range_header}, " + f"method: {request.method}, transcode: {transcode}" + ) + # Sanitize destination URL to fix common encoding issues destination = sanitize_url(destination) - + + # Check if this is a DLHD key URL request with key params in query + dlhd_salt = request.query_params.get("dlhd_salt") + dlhd_token = request.query_params.get("dlhd_token") + if dlhd_salt and "/key/" in destination: + # This is a DLHD key URL - compute dynamic headers via executor to avoid blocking + from mediaflow_proxy.extractors.dlhd import compute_key_headers + + key_headers = await asyncio.to_thread(compute_key_headers, destination, dlhd_salt) + if key_headers: + ts, nonce, key_path, fingerprint = key_headers + proxy_headers.request.update( + { + "X-Key-Timestamp": str(ts), + "X-Key-Nonce": str(nonce), + "X-Fingerprint": fingerprint, + "X-Key-Path": key_path, + } + ) + if dlhd_token: + proxy_headers.request["Authorization"] = f"Bearer {dlhd_token}" + logger.info(f"[proxy_stream] Computed DLHD key headers for: {destination}") + # Check if destination contains DLHD pattern and extract stream directly - dlhd_result = await _check_and_extract_dlhd_stream(request, destination, proxy_headers) + dlhd_result = await check_and_extract_dlhd_stream(request, destination, proxy_headers) if dlhd_result: # Update destination and headers with extracted stream data destination = dlhd_result["destination_url"] proxy_headers.request.update(dlhd_result.get("request_headers", {})) + + # Handle transcode mode — transcode uses time-based seeking, not byte ranges + if transcode: + if not settings.enable_transcode: + raise HTTPException(status_code=503, detail="Transcoding support is disabled") + transcode_headers = dict(proxy_headers.request) + transcode_headers.pop("range", None) + transcode_headers.pop("if-range", None) + source = HTTPMediaSource(url=destination, headers=transcode_headers) + await source.resolve_file_size() + return await handle_transcode(request, source, start_time=start) + if proxy_headers.request.get("range", "").strip() == "": proxy_headers.request.pop("range", None) if proxy_headers.request.get("if-range", "").strip() == "": proxy_headers.request.pop("if-range", None) - + if "range" not in proxy_headers.request: proxy_headers.request["range"] = "bytes=0-" - + if filename: - # If a filename is provided, set it in the headers using RFC 6266 format + # If a filename is provided (not a segment), set it in the headers using RFC 6266 format try: # Try to encode with latin-1 first (simple case) filename.encode("latin-1") @@ -645,9 +722,13 @@ async def proxy_stream_endpoint( proxy_headers.response.update({"content-disposition": content_disposition}) - return await proxy_stream(request.method, destination, proxy_headers) + # Handle "none" as explicit disable + rate_limit_handler_id = None if ratelimit == "none" else ratelimit + + return await proxy_stream(request.method, destination, proxy_headers, transformer, rate_limit_handler_id) +@proxy_router.head("/mpd/manifest.m3u8") @proxy_router.get("/mpd/manifest.m3u8") async def mpd_manifest_proxy( request: Request, @@ -667,22 +748,23 @@ async def mpd_manifest_proxy( """ # Extract DRM parameters from destination URL if they are incorrectly appended clean_url, extracted_key_id, extracted_key = extract_drm_params_from_url(manifest_params.destination) - + # Update the destination with the cleaned URL manifest_params.destination = clean_url - + # Use extracted parameters if they exist and the manifest params don't already have them if extracted_key_id and not manifest_params.key_id: manifest_params.key_id = extracted_key_id if extracted_key and not manifest_params.key: manifest_params.key = extracted_key - + # Sanitize destination URL to fix common encoding issues manifest_params.destination = sanitize_url(manifest_params.destination) - + return await get_manifest(request, manifest_params, proxy_headers) +@proxy_router.head("/mpd/playlist.m3u8") @proxy_router.get("/mpd/playlist.m3u8") async def playlist_endpoint( request: Request, @@ -702,19 +784,19 @@ async def playlist_endpoint( """ # Extract DRM parameters from destination URL if they are incorrectly appended clean_url, extracted_key_id, extracted_key = extract_drm_params_from_url(playlist_params.destination) - + # Update the destination with the cleaned URL playlist_params.destination = clean_url - + # Use extracted parameters if they exist and the playlist params don't already have them if extracted_key_id and not playlist_params.key_id: playlist_params.key_id = extracted_key_id if extracted_key and not playlist_params.key: playlist_params.key = extracted_key - + # Sanitize destination URL to fix common encoding issues playlist_params.destination = sanitize_url(playlist_params.destination) - + return await get_playlist(request, playlist_params, proxy_headers) @@ -726,6 +808,10 @@ async def segment_endpoint( """ Retrieves and processes a media segment, decrypting it if necessary. + This endpoint serves fMP4 segments without TS remuxing. The playlist generator + already selects /segment.mp4 vs /segment.ts based on the resolved remux mode, + so this endpoint explicitly disables remuxing regardless of global settings. + Args: segment_params (MPDSegmentParams): The parameters for the segment request. proxy_headers (ProxyRequestHeaders): The headers to include in the request. @@ -733,7 +819,46 @@ async def segment_endpoint( Returns: Response: The HTTP response with the processed segment. """ - return await get_segment(segment_params, proxy_headers) + return await get_segment(segment_params, proxy_headers, force_remux_ts=False) + + +@proxy_router.get("/mpd/segment.ts") +async def segment_ts_endpoint( + segment_params: Annotated[MPDSegmentParams, Query()], + proxy_headers: Annotated[ProxyRequestHeaders, Depends(get_proxy_headers)], +): + """ + Retrieves and processes a media segment, remuxing fMP4 to MPEG-TS. + + This endpoint is used for HLS playlists when remux_to_ts is enabled. + Unlike /mpd/segment.mp4, this forces TS remuxing regardless of global settings. + + Args: + segment_params (MPDSegmentParams): The parameters for the segment request. + proxy_headers (ProxyRequestHeaders): The headers to include in the request. + + Returns: + Response: The HTTP response with the MPEG-TS segment. + """ + return await get_segment(segment_params, proxy_headers, force_remux_ts=True) + + +@proxy_router.get("/mpd/init.mp4") +async def init_endpoint( + init_params: Annotated[MPDInitParams, Query()], + proxy_headers: Annotated[ProxyRequestHeaders, Depends(get_proxy_headers)], +): + """ + Retrieves and processes an initialization segment for use with EXT-X-MAP. + + Args: + init_params (MPDInitParams): The parameters for the init segment request. + proxy_headers (ProxyRequestHeaders): The headers to include in the request. + + Returns: + Response: The HTTP response with the processed init segment. + """ + return await get_init_segment(init_params, proxy_headers) @proxy_router.get("/ip") diff --git a/mediaflow_proxy/routes/telegram.py b/mediaflow_proxy/routes/telegram.py new file mode 100644 index 0000000..285a905 --- /dev/null +++ b/mediaflow_proxy/routes/telegram.py @@ -0,0 +1,975 @@ +""" +Telegram MTProto proxy routes. + +Provides endpoints for streaming Telegram media: +- /proxy/telegram/stream - Stream media from t.me links or file_id (&transcode=true for fMP4 audio transcode) +- /proxy/telegram/info - Get media metadata +- /proxy/telegram/status - Check session status +- /proxy/telegram/session/* - Session string generation +""" + +import asyncio +import logging +import re +import secrets +from typing import Annotated, Optional + +from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response +from pydantic import BaseModel + +from telethon import TelegramClient +from telethon.sessions import StringSession + +from mediaflow_proxy.configs import settings +from mediaflow_proxy.remuxer.media_source import TelegramMediaSource +from mediaflow_proxy.remuxer.transcode_handler import ( + handle_transcode, + handle_transcode_hls_init, + handle_transcode_hls_playlist, + handle_transcode_hls_segment, +) +from mediaflow_proxy.utils.http_utils import ( + EnhancedStreamingResponse, + ProxyRequestHeaders, + apply_header_manipulation, + get_proxy_headers, +) +from mediaflow_proxy.utils.telegram import ( + TelegramMediaRef, + parse_telegram_url, + telegram_manager, +) + +logger = logging.getLogger(__name__) +telegram_router = APIRouter() + + +def get_content_type(mime_type: str, file_name: Optional[str] = None) -> str: + """Determine content type from mime type or filename.""" + if mime_type: + return mime_type + + if file_name: + ext = file_name.rsplit(".", 1)[-1].lower() if "." in file_name else "" + mime_map = { + "mp4": "video/mp4", + "mkv": "video/x-matroska", + "avi": "video/x-msvideo", + "webm": "video/webm", + "mov": "video/quicktime", + "mp3": "audio/mpeg", + "m4a": "audio/mp4", + "flac": "audio/flac", + "ogg": "audio/ogg", + "jpg": "image/jpeg", + "jpeg": "image/jpeg", + "png": "image/png", + "gif": "image/gif", + "webp": "image/webp", + } + return mime_map.get(ext, "application/octet-stream") + + return "application/octet-stream" + + +def parse_range_header(range_header: Optional[str], file_size: int) -> tuple[int, int]: + """ + Parse HTTP Range header. + + Args: + range_header: The Range header value (e.g., "bytes=0-999") + file_size: Total file size + + Returns: + Tuple of (start, end) byte positions + """ + if not range_header: + return 0, file_size - 1 + + # Parse "bytes=start-end" format + match = re.match(r"bytes=(\d*)-(\d*)", range_header) + if not match: + return 0, file_size - 1 + + start_str, end_str = match.groups() + + if start_str and end_str: + start = int(start_str) + end = min(int(end_str), file_size - 1) + elif start_str: + start = int(start_str) + end = file_size - 1 + elif end_str: + # Suffix range: last N bytes + suffix_length = int(end_str) + start = max(0, file_size - suffix_length) + end = file_size - 1 + else: + start = 0 + end = file_size - 1 + + # Validate start <= end (handle malformed ranges like "bytes=999-0") + if start > end: + return 0, file_size - 1 + + return start, end + + +@telegram_router.head("/telegram/stream") +@telegram_router.get("/telegram/stream") +@telegram_router.head("/telegram/stream/{filename:path}") +@telegram_router.get("/telegram/stream/{filename:path}") +async def telegram_stream( + request: Request, + proxy_headers: Annotated[ProxyRequestHeaders, Depends(get_proxy_headers)], + d: Optional[str] = Query(None, description="t.me link or Telegram URL"), + url: Optional[str] = Query(None, description="Alias for 'd' parameter"), + chat_id: Optional[str] = Query(None, description="Chat/Channel ID (use with message_id)"), + message_id: Optional[int] = Query(None, description="Message ID (use with chat_id)"), + file_id: Optional[str] = Query(None, description="Bot API file_id (requires file_size parameter)"), + file_size: Optional[int] = Query(None, description="File size in bytes (required for file_id streaming)"), + transcode: bool = Query(False, description="Transcode to browser-compatible fMP4 (EAC3/AC3->AAC)"), + start: Optional[float] = Query(None, description="Seek start time in seconds (used with transcode=true)"), + filename: Optional[str] = None, +): + """ + Stream Telegram media with range request support and parallel downloads. + + Supports: + - t.me links: https://t.me/channel/123, https://t.me/c/123456789/456 + - chat_id + message_id: Direct reference by IDs (e.g., chat_id=-100123456&message_id=789) + - file_id + file_size: Direct streaming by Bot API file_id (requires file_size) + + When transcode=true, the media is remuxed to fragmented MP4 with + browser-compatible codecs. Audio is transcoded to AAC. Video is passed + through when the source codec is already browser-compatible (H.264); + otherwise it is re-encoded to H.264. Seeking is supported via standard + HTTP Range requests (byte offsets are converted to time positions using + an estimated fMP4 size). The 'start' query parameter can also be used + for explicit time-based seeking. + + Args: + request: The incoming HTTP request + proxy_headers: Headers for proxy requests + d: t.me link or Telegram URL + url: Alias for 'd' parameter + chat_id: Chat/Channel ID (numeric or username) + message_id: Message ID within the chat + file_id: Bot API file_id (requires file_size parameter) + file_size: File size in bytes (required for file_id streaming) + transcode: Transcode to browser-compatible format (EAC3/AC3->AAC) + filename: Optional filename for Content-Disposition + + Returns: + Streaming response with media content, or redirect to HLS manifest when transcoding + """ + if not settings.enable_telegram: + raise HTTPException(status_code=503, detail="Telegram proxy support is disabled") + + # Get the URL from either parameter + telegram_url = d or url + + # Determine which input method was used + if not telegram_url and not file_id and not (chat_id and message_id): + raise HTTPException( + status_code=400, + detail="Provide either 'd' (t.me URL), 'chat_id' + 'message_id', or 'file_id' + 'file_size' parameters", + ) + + try: + # Parse the reference based on input type + if telegram_url: + ref = parse_telegram_url(telegram_url) + elif chat_id and message_id: + # Direct chat_id + message_id + # Try to parse chat_id as int, otherwise treat as username + try: + parsed_chat_id: int | str = int(chat_id) + except ValueError: + parsed_chat_id = chat_id # Username + ref = TelegramMediaRef(chat_id=parsed_chat_id, message_id=message_id) + else: + # file_id mode + if not file_size: + raise HTTPException( + status_code=400, + detail="file_size parameter is required when using file_id. " + "The file_id doesn't contain size information needed for range requests.", + ) + ref = TelegramMediaRef(file_id=file_id) + + # Get media info (pass file_size for file_id mode) + media_info = await telegram_manager.get_media_info(ref, file_size=file_size) + actual_file_size = media_info.file_size + mime_type = media_info.mime_type + media_filename = filename or media_info.file_name + + # For file_id mode, validate access before starting stream + # This catches FileReferenceExpiredError early, before headers are sent + if ref.file_id and not ref.message_id: + await telegram_manager.validate_file_access(ref, file_size=file_size) + + # Handle transcode mode: stream as fMP4 with transcoded audio + if transcode: + if not settings.enable_transcode: + raise HTTPException(status_code=503, detail="Transcoding support is disabled") + return await _handle_transcode( + request, + ref, + actual_file_size, + start_time=start, + file_name=media_filename or "", + ) + + # Parse range header + range_header = request.headers.get("range") + start, end = parse_range_header(range_header, actual_file_size) + content_length = end - start + 1 + + # Handle HEAD requests + if request.method == "HEAD": + headers = { + "content-type": get_content_type(mime_type, media_filename), + "content-length": str(actual_file_size), + "accept-ranges": "bytes", + "access-control-allow-origin": "*", + } + if media_filename: + headers["content-disposition"] = f'inline; filename="{media_filename}"' + return Response(headers=headers) + + # Build response headers + is_range_request = range_header is not None + status_code = 206 if is_range_request else 200 + + base_headers = { + "content-type": get_content_type(mime_type, media_filename), + "content-length": str(content_length), + "accept-ranges": "bytes", + "access-control-allow-origin": "*", + } + + if is_range_request: + base_headers["content-range"] = f"bytes {start}-{end}/{actual_file_size}" + + if media_filename: + base_headers["content-disposition"] = f'inline; filename="{media_filename}"' + + response_headers = apply_header_manipulation(base_headers, proxy_headers) + + # Stream the content (pass file_size for file_id mode) + async def stream_content(): + try: + async for chunk in telegram_manager.stream_media( + ref, offset=start, limit=content_length, file_size=actual_file_size + ): + yield chunk + except asyncio.CancelledError: + # Client disconnected (e.g., seeking in video player) - this is normal + logger.debug("[telegram_stream] Stream cancelled by client") + except GeneratorExit: + # Generator closed - this is normal during cleanup + logger.debug("[telegram_stream] Stream generator closed") + except Exception as e: + error_name = type(e).__name__ + # Handle errors that occur mid-stream (after headers sent) + if error_name == "FileReferenceExpiredError": + logger.error( + "[telegram_stream] File reference expired mid-stream. " + "This file_id belongs to a different session or the reference is stale." + ) + # Don't re-raise - just end the stream to avoid protocol errors + return + elif error_name in ("ChannelPrivateError", "ChatAdminRequiredError", "UserNotParticipantError"): + logger.error(f"[telegram_stream] Access denied mid-stream: {error_name}") + return + else: + logger.error(f"[telegram_stream] Error streaming: {e}") + # For unknown errors, also don't re-raise to avoid protocol errors + return + + return EnhancedStreamingResponse( + stream_content(), + status_code=status_code, + headers=response_headers, + media_type=get_content_type(mime_type, media_filename), + ) + + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + # Handle specific Telegram errors + error_name = type(e).__name__ + + if error_name == "FloodWaitError": + wait_seconds = getattr(e, "seconds", 60) + logger.warning(f"[telegram_stream] Flood wait: {wait_seconds}s") + raise HTTPException( + status_code=429, + detail=f"Rate limited by Telegram. Please wait {wait_seconds} seconds.", + headers={"Retry-After": str(wait_seconds)}, + ) + elif error_name == "ChannelPrivateError": + raise HTTPException( + status_code=403, + detail="Cannot access private channel. The session user is not a member of this channel/group.", + ) + elif error_name == "ChatAdminRequiredError": + raise HTTPException( + status_code=403, + detail="Admin privileges required to access this chat.", + ) + elif error_name == "UserNotParticipantError": + raise HTTPException( + status_code=403, + detail="The session user is not a participant of this chat.", + ) + elif error_name == "MessageIdInvalidError": + raise HTTPException(status_code=404, detail="Message not found in the specified chat.") + elif error_name == "AuthKeyError": + raise HTTPException( + status_code=401, detail="Telegram session is invalid. Please regenerate the session string." + ) + elif error_name == "FileReferenceExpiredError": + raise HTTPException( + status_code=410, + detail="File reference expired or inaccessible. " + "This file_id belongs to a different bot/user session. " + "Use chat_id + message_id instead, or ensure the session has access to this file.", + ) + elif error_name == "UserBannedInChannelError": + raise HTTPException( + status_code=403, + detail="The session user is banned from this channel.", + ) + elif error_name == "ChannelInvalidError": + raise HTTPException( + status_code=404, + detail="Invalid channel. The channel may not exist or the ID is incorrect.", + ) + elif error_name == "PeerIdInvalidError": + raise HTTPException( + status_code=404, + detail="Invalid chat ID. The chat/channel/user ID is incorrect or inaccessible.", + ) + + logger.exception(f"[telegram_stream] Unexpected error: {e}") + raise HTTPException(status_code=500, detail=f"Internal error: {error_name}") + + +async def _handle_transcode( + request: Request, + ref: TelegramMediaRef, + file_size: int, + start_time: float | None = None, + file_name: str = "", +) -> Response: + """ + Handle transcode mode: delegate to the shared transcode handler. + + Wraps the Telegram media reference in a TelegramMediaSource and + passes it to the source-agnostic transcode handler which handles + cue probing, seeking, and pipeline selection. + """ + source = TelegramMediaSource(ref, file_size, file_name=file_name) + return await handle_transcode(request, source, start_time=start_time) + + +# --------------------------------------------------------------------------- +# HLS transcode endpoints for Telegram sources +# --------------------------------------------------------------------------- + + +async def _resolve_telegram_source( + d: str | None = None, + url: str | None = None, + chat_id: str | None = None, + message_id: int | None = None, + file_id: str | None = None, + file_size: int | None = None, + filename: str | None = None, + *, + use_single_client: bool = False, +) -> TelegramMediaSource: + """ + Resolve input parameters to a ``TelegramMediaSource``. + + Args: + use_single_client: When ``True``, the returned source will use + Telethon's built-in single-connection downloader instead of + the parallel ``ParallelTransferrer``. Should be ``True`` + for HLS requests (playlist, init, segments) where each + request fetches a small byte range and spinning up multiple + DC connections per request is wasteful. + """ + if not settings.enable_telegram: + from fastapi import HTTPException + + raise HTTPException(status_code=503, detail="Telegram proxy support is disabled") + + telegram_url = d or url + + if not telegram_url and not file_id and not (chat_id and message_id): + from fastapi import HTTPException + + raise HTTPException( + status_code=400, + detail="Provide either 'd' (t.me URL), 'chat_id' + 'message_id', or 'file_id' + 'file_size'", + ) + + if telegram_url: + ref = parse_telegram_url(telegram_url) + elif chat_id and message_id: + try: + parsed_chat_id: int | str = int(chat_id) + except ValueError: + parsed_chat_id = chat_id + ref = TelegramMediaRef(chat_id=parsed_chat_id, message_id=message_id) + else: + if not file_size: + from fastapi import HTTPException + + raise HTTPException( + status_code=400, + detail="file_size is required when using file_id", + ) + ref = TelegramMediaRef(file_id=file_id) + + media_info = await telegram_manager.get_media_info(ref, file_size=file_size) + actual_file_size = media_info.file_size + media_filename = filename or media_info.file_name + + return TelegramMediaSource( + ref, + actual_file_size, + file_name=media_filename or "", + use_single_client=use_single_client, + ) + + +@telegram_router.head("/telegram/transcode/playlist.m3u8") +@telegram_router.get("/telegram/transcode/playlist.m3u8") +async def telegram_transcode_hls_playlist( + request: Request, + d: Optional[str] = Query(None, description="t.me link or Telegram URL"), + url: Optional[str] = Query(None, description="Alias for 'd'"), + chat_id: Optional[str] = Query(None, description="Chat/Channel ID"), + message_id: Optional[int] = Query(None, description="Message ID"), + file_id: Optional[str] = Query(None, description="Bot API file_id"), + file_size: Optional[int] = Query(None, description="File size in bytes"), + filename: Optional[str] = Query(None, description="Optional filename"), +): + """Generate an HLS VOD M3U8 playlist for a Telegram media file.""" + if not settings.enable_transcode: + raise HTTPException(status_code=503, detail="Transcoding support is disabled") + source = await _resolve_telegram_source( + d, + url, + chat_id, + message_id, + file_id, + file_size, + filename, + use_single_client=True, + ) + + # Build sub-request params using the *resolved* file_id + file_size so + # that init/segment requests skip the Telegram API call for get_message. + base_params = _build_telegram_hls_resolved_params(request, source) + init_url = f"/proxy/telegram/transcode/init.mp4?{base_params}" + segment_url_template = ( + f"/proxy/telegram/transcode/segment.m4s?{base_params}&seg={{seg}}&start_ms={{start_ms}}&end_ms={{end_ms}}" + ) + + return await handle_transcode_hls_playlist( + request, + source, + init_url=init_url, + segment_url_template=segment_url_template, + ) + + +@telegram_router.head("/telegram/transcode/init.mp4") +@telegram_router.get("/telegram/transcode/init.mp4") +async def telegram_transcode_hls_init( + request: Request, + d: Optional[str] = Query(None, description="t.me link or Telegram URL"), + url: Optional[str] = Query(None, description="Alias for 'd'"), + chat_id: Optional[str] = Query(None, description="Chat/Channel ID"), + message_id: Optional[int] = Query(None, description="Message ID"), + file_id: Optional[str] = Query(None, description="Bot API file_id"), + file_size: Optional[int] = Query(None, description="File size in bytes"), + filename: Optional[str] = Query(None, description="Optional filename"), +): + """Serve the fMP4 init segment for a Telegram media file.""" + if not settings.enable_transcode: + raise HTTPException(status_code=503, detail="Transcoding support is disabled") + source = await _resolve_telegram_source( + d, + url, + chat_id, + message_id, + file_id, + file_size, + filename, + use_single_client=True, + ) + return await handle_transcode_hls_init(request, source) + + +@telegram_router.get("/telegram/transcode/segment.m4s") +async def telegram_transcode_hls_segment( + request: Request, + start_ms: float = Query(..., description="Segment start time in milliseconds"), + end_ms: float = Query(..., description="Segment end time in milliseconds"), + seg: int | None = Query(None, description="Segment number (informational, for logging)"), + d: Optional[str] = Query(None, description="t.me link or Telegram URL"), + url: Optional[str] = Query(None, description="Alias for 'd'"), + chat_id: Optional[str] = Query(None, description="Chat/Channel ID"), + message_id: Optional[int] = Query(None, description="Message ID"), + file_id: Optional[str] = Query(None, description="Bot API file_id"), + file_size: Optional[int] = Query(None, description="File size in bytes"), + filename: Optional[str] = Query(None, description="Optional filename"), +): + """Serve a single HLS fMP4 media segment for a Telegram media file.""" + if not settings.enable_transcode: + raise HTTPException(status_code=503, detail="Transcoding support is disabled") + source = await _resolve_telegram_source( + d, + url, + chat_id, + message_id, + file_id, + file_size, + filename, + use_single_client=True, + ) + return await handle_transcode_hls_segment( + request, source, start_time_ms=start_ms, end_time_ms=end_ms, segment_number=seg + ) + + +def _build_telegram_hls_params(request: Request) -> str: + """Build query string for Telegram HLS sub-requests, preserving all input params.""" + from urllib.parse import quote + + params = [] + original = request.query_params + # Copy all original params except segment-specific ones (added per-segment) + _seg_keys = {"seg", "start_ms", "end_ms"} + for key in original: + if key not in _seg_keys: + params.append(f"{key}={quote(original[key], safe='')}") + return "&".join(params) + + +def _build_telegram_hls_resolved_params( + request: Request, + source: "TelegramMediaSource", +) -> str: + """ + Build query string for HLS sub-request URLs using the *resolved* source. + + Unlike ``_build_telegram_hls_params`` which blindly copies the original + query params, this version replaces chat_id/message_id/d/url with the + resolved file reference so that init and segment requests can skip the + expensive ``get_message()`` Telegram API call. + + The original query params are used as a fallback for any extra parameters + (api_password, filename, etc.). + """ + from urllib.parse import quote + + ref = source._ref + params: dict[str, str] = {} + + # Carry over non-identifying params from the original request + # (api_password, filename, etc.) + _skip_keys = {"d", "url", "chat_id", "message_id", "file_id", "file_size", "seg", "start_ms", "end_ms"} + for key in request.query_params: + if key not in _skip_keys: + params[key] = request.query_params[key] + + # Use the resolved reference -- prefer chat_id + message_id (most reliable + # for streaming), but also include file_size from the resolved source. + if ref.chat_id is not None and ref.message_id is not None: + params["chat_id"] = str(ref.chat_id) + params["message_id"] = str(ref.message_id) + elif ref.file_id: + params["file_id"] = ref.file_id + # Always include file_size -- it prevents unnecessary lookups + params["file_size"] = str(source.file_size) + + return "&".join(f"{k}={quote(v, safe='')}" for k, v in params.items()) + + +@telegram_router.get("/telegram/info") +async def telegram_info( + d: Optional[str] = Query(None, description="t.me link or Telegram URL"), + url: Optional[str] = Query(None, description="Alias for 'd' parameter"), + chat_id: Optional[str] = Query(None, description="Chat/Channel ID (use with message_id)"), + message_id: Optional[int] = Query(None, description="Message ID (use with chat_id)"), + file_id: Optional[str] = Query(None, description="Bot API file_id"), + file_size: Optional[int] = Query(None, description="File size in bytes (optional for file_id)"), +): + """ + Get metadata about a Telegram media file. + + Args: + d: t.me link or Telegram URL + url: Alias for 'd' parameter + chat_id: Chat/Channel ID (numeric or username) + message_id: Message ID within the chat + file_id: Bot API file_id + file_size: File size in bytes (optional, will be 0 if not provided for file_id) + + Returns: + JSON with media information (size, mime_type, filename, dimensions, duration) + """ + if not settings.enable_telegram: + raise HTTPException(status_code=503, detail="Telegram proxy support is disabled") + + telegram_url = d or url + + if not telegram_url and not file_id and not (chat_id and message_id): + raise HTTPException( + status_code=400, + detail="Provide either 'd' (t.me URL), 'chat_id' + 'message_id', or 'file_id' parameter", + ) + + try: + if telegram_url: + ref = parse_telegram_url(telegram_url) + elif chat_id and message_id: + try: + parsed_chat_id: int | str = int(chat_id) + except ValueError: + parsed_chat_id = chat_id + ref = TelegramMediaRef(chat_id=parsed_chat_id, message_id=message_id) + else: + ref = TelegramMediaRef(file_id=file_id) + + media_info = await telegram_manager.get_media_info(ref, file_size=file_size) + + return { + "file_id": media_info.file_id, + "file_size": media_info.file_size, + "mime_type": media_info.mime_type, + "file_name": media_info.file_name, + "duration": media_info.duration, + "width": media_info.width, + "height": media_info.height, + "dc_id": media_info.dc_id, + } + + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + error_name = type(e).__name__ + if error_name == "ChannelPrivateError": + raise HTTPException( + status_code=403, + detail="Cannot access private channel. The session user is not a member.", + ) + elif error_name == "MessageIdInvalidError": + raise HTTPException(status_code=404, detail="Message not found in the specified chat.") + elif error_name == "FileReferenceExpiredError": + raise HTTPException( + status_code=410, + detail="File reference expired or inaccessible. This file_id belongs to a different session.", + ) + elif error_name == "PeerIdInvalidError": + raise HTTPException( + status_code=404, + detail="Invalid chat ID. The chat/channel/user ID is incorrect or inaccessible.", + ) + logger.exception(f"[telegram_info] Error: {e}") + raise HTTPException(status_code=500, detail=f"Internal error: {error_name}") + + +@telegram_router.get("/telegram/status") +async def telegram_status(): + """ + Get Telegram session status. + + Returns: + JSON with session status information + """ + if not settings.enable_telegram: + return { + "enabled": False, + "status": "disabled", + "message": "Telegram proxy support is disabled in configuration", + } + + # Check if credentials are configured + if not settings.telegram_api_id or not settings.telegram_api_hash: + return { + "enabled": True, + "status": "not_configured", + "message": "Telegram API credentials not configured (telegram_api_id, telegram_api_hash)", + } + + if not settings.telegram_session_string: + return { + "enabled": True, + "status": "no_session", + "message": "Session string not configured. Generate one using the web UI.", + } + + # Check if client is connected + if telegram_manager.is_initialized: + return { + "enabled": True, + "status": "connected", + "message": "Telegram client is connected and ready", + "max_connections": settings.telegram_max_connections, + } + + # Don't trigger connection - just report ready status + # Connection will be established on first actual request + return { + "enabled": True, + "status": "ready", + "message": "Telegram client is configured and ready. Will connect on first request.", + "max_connections": settings.telegram_max_connections, + } + + +# ============================================================================= +# Session String Generation Endpoints +# ============================================================================= + +# In-memory storage for pending session generation (simple approach for single-instance) +# Maps session_id -> { client, api_id, api_hash, phone_code_hash, step } +_pending_sessions: dict = {} + + +class SessionStartRequest(BaseModel): + """Request to start session generation.""" + + api_id: int + api_hash: str + auth_type: str # "phone" or "bot" + phone: Optional[str] = None + bot_token: Optional[str] = None + + +class SessionCodeRequest(BaseModel): + """Request to submit verification code.""" + + session_id: str + code: str + + +class Session2FARequest(BaseModel): + """Request to submit 2FA password.""" + + session_id: str + password: str + + +@telegram_router.post("/telegram/session/start") +async def session_start(request: SessionStartRequest): + """ + Start the session generation process. + + For phone auth: sends verification code to user's Telegram + For bot auth: validates the bot token immediately + + Returns: + session_id for subsequent requests, or session_string if bot auth succeeds + """ + session_id = secrets.token_urlsafe(16) + + try: + client = TelegramClient(StringSession(), request.api_id, request.api_hash) + await client.connect() + + if request.auth_type == "bot": + # Bot authentication - complete immediately + if not request.bot_token: + await client.disconnect() + raise HTTPException(status_code=400, detail="Bot token is required for bot authentication") + + try: + await client.sign_in(bot_token=request.bot_token) + session_string = client.session.save() + await client.disconnect() + + return { + "success": True, + "step": "complete", + "session_string": session_string, + "api_id": request.api_id, + "api_hash": request.api_hash, + } + except Exception as e: + await client.disconnect() + raise HTTPException(status_code=400, detail=f"Bot authentication failed: {str(e)}") + + else: + # Phone authentication - send code + phone = request.phone.strip() if request.phone else None + if not phone: + await client.disconnect() + raise HTTPException(status_code=400, detail="Phone number is required for phone authentication") + + logger.info(f"[session_start] Sending code to phone: {phone[:4]}***") + + try: + result = await client.send_code_request(phone) + + # Store pending session + _pending_sessions[session_id] = { + "client": client, + "api_id": request.api_id, + "api_hash": request.api_hash, + "phone": phone, + "phone_code_hash": result.phone_code_hash, + "step": "code_sent", + } + + return { + "success": True, + "session_id": session_id, + "step": "code_sent", + "message": "Verification code sent to your Telegram app", + } + except Exception as e: + await client.disconnect() + error_msg = str(e) + if "PHONE_NUMBER_INVALID" in error_msg: + raise HTTPException( + status_code=400, + detail="Invalid phone number format. Use international format (e.g., +1234567890)", + ) + elif "PHONE_NUMBER_BANNED" in error_msg: + raise HTTPException(status_code=400, detail="This phone number is banned from Telegram") + elif "FLOOD" in error_msg.upper(): + raise HTTPException(status_code=429, detail="Too many attempts. Please wait before trying again.") + raise HTTPException(status_code=400, detail=f"Failed to send code: {error_msg}") + + except HTTPException: + raise + except Exception as e: + logger.exception(f"[session_start] Error: {e}") + raise HTTPException(status_code=500, detail=f"Failed to start session: {type(e).__name__}: {str(e)}") + + +@telegram_router.post("/telegram/session/verify") +async def session_verify(request: SessionCodeRequest): + """ + Verify the code sent to user's Telegram. + + Returns: + session_string if successful, or indicates 2FA is required + """ + session_data = _pending_sessions.get(request.session_id) + if not session_data: + raise HTTPException(status_code=404, detail="Session not found or expired. Please start again.") + + client = session_data["client"] + phone = session_data["phone"] + + try: + await client.sign_in(phone, request.code, phone_code_hash=session_data["phone_code_hash"]) + + # Success - get session string + session_string = client.session.save() + await client.disconnect() + del _pending_sessions[request.session_id] + + return { + "success": True, + "step": "complete", + "session_string": session_string, + "api_id": session_data["api_id"], + "api_hash": session_data["api_hash"], + } + + except Exception as e: + error_msg = str(e) + + # Check for 2FA requirement + if ( + "Two-step verification" in error_msg + or "password" in error_msg.lower() + or "SessionPasswordNeededError" in type(e).__name__ + ): + session_data["step"] = "2fa_required" + return { + "success": True, + "session_id": request.session_id, + "step": "2fa_required", + "message": "Two-factor authentication is enabled. Please enter your 2FA password.", + } + + # Check for invalid code + if "PHONE_CODE_INVALID" in error_msg or "PHONE_CODE_EXPIRED" in error_msg: + raise HTTPException(status_code=400, detail="Invalid or expired verification code. Please try again.") + + # Other error - cleanup + await client.disconnect() + del _pending_sessions[request.session_id] + raise HTTPException(status_code=400, detail=f"Verification failed: {error_msg}") + + +@telegram_router.post("/telegram/session/2fa") +async def session_2fa(request: Session2FARequest): + """ + Complete 2FA authentication. + + Returns: + session_string on success + """ + session_data = _pending_sessions.get(request.session_id) + if not session_data: + raise HTTPException(status_code=404, detail="Session not found or expired. Please start again.") + + if session_data.get("step") != "2fa_required": + raise HTTPException(status_code=400, detail="2FA not required for this session") + + client = session_data["client"] + + try: + await client.sign_in(password=request.password) + + # Success - get session string + session_string = client.session.save() + await client.disconnect() + del _pending_sessions[request.session_id] + + return { + "success": True, + "step": "complete", + "session_string": session_string, + "api_id": session_data["api_id"], + "api_hash": session_data["api_hash"], + } + + except Exception as e: + error_msg = str(e) + + if "PASSWORD_HASH_INVALID" in error_msg: + raise HTTPException(status_code=400, detail="Incorrect 2FA password") + + # Other error - cleanup + await client.disconnect() + del _pending_sessions[request.session_id] + raise HTTPException(status_code=400, detail=f"2FA verification failed: {error_msg}") + + +@telegram_router.post("/telegram/session/cancel") +async def session_cancel(session_id: str = Query(..., description="Session ID to cancel")): + """ + Cancel a pending session generation. + """ + session_data = _pending_sessions.pop(session_id, None) + if session_data: + try: + await session_data["client"].disconnect() + except Exception: + pass + + return {"success": True, "message": "Session cancelled"} diff --git a/mediaflow_proxy/routes/xtream.py b/mediaflow_proxy/routes/xtream.py new file mode 100644 index 0000000..a8ccee8 --- /dev/null +++ b/mediaflow_proxy/routes/xtream.py @@ -0,0 +1,1146 @@ +""" +Xtream Codes (XC) API Proxy Routes. + +This module provides a stateless pass-through proxy for Xtream Codes API, +allowing users to use MediaFlow as an intermediary with any XC-compatible IPTV player. +All streams (live, VOD, series, catch-up/timeshift) are proxied without storing any data. + +Configuration: + Configure your IPTV player with: + - Server: http://your-mediaflow-server:8888 + - Username: {base64_upstream}:{actual_username}:{api_password} + - Password: your_xc_password + + Where: + - base64_upstream: Base64-encoded upstream XC server URL + - actual_username: Your actual XC username + - api_password: Your MediaFlow API password (if configured) + + The api_password part can be omitted if MediaFlow doesn't require authentication. +""" + +import base64 +import logging +import re +from typing import Annotated +from urllib.parse import urljoin, urlencode, urlparse + +from fastapi.responses import RedirectResponse +import aiohttp +from fastapi import APIRouter, Request, Depends, Query, Response, HTTPException + +from mediaflow_proxy.configs import settings +from mediaflow_proxy.handlers import proxy_stream +from mediaflow_proxy.remuxer.media_source import HTTPMediaSource +from mediaflow_proxy.remuxer.transcode_handler import ( + handle_transcode, + handle_transcode_hls_init, + handle_transcode_hls_playlist, + handle_transcode_hls_segment, +) +from mediaflow_proxy.utils.base64_utils import decode_base64_url +from mediaflow_proxy.utils.http_utils import ProxyRequestHeaders, get_proxy_headers +from mediaflow_proxy.utils.http_client import create_aiohttp_session + +logger = logging.getLogger(__name__) +xtream_root_router = APIRouter() + + +async def _handle_xtream_transcode(request, upstream_url: str, proxy_headers, start_time: float | None): + """Shared transcode handler for Xtream stream endpoints.""" + if not settings.enable_transcode: + raise HTTPException(status_code=503, detail="Transcoding support is disabled") + source = HTTPMediaSource(url=upstream_url, headers=dict(proxy_headers.request)) + await source.resolve_file_size() + return await handle_transcode(request, source, start_time=start_time) + + +async def _handle_xtream_hls_playlist(request, upstream_url: str, proxy_headers): + """Generate HLS VOD playlist for an Xtream stream.""" + if not settings.enable_transcode: + raise HTTPException(status_code=503, detail="Transcoding support is disabled") + from urllib.parse import quote + + source = HTTPMediaSource(url=upstream_url, headers=dict(proxy_headers.request)) + await source.resolve_file_size() + + # Build URLs using the generic proxy transcode endpoints with upstream URL + encoded_url = quote(upstream_url, safe="") + base_params = f"d={encoded_url}" + original = request.query_params + if "api_password" in original: + base_params += f"&api_password={quote(original['api_password'], safe='')}" + + init_url = f"/proxy/transcode/init.mp4?{base_params}" + segment_url_template = ( + f"/proxy/transcode/segment.m4s?{base_params}&seg={{seg}}&start_ms={{start_ms}}&end_ms={{end_ms}}" + ) + + return await handle_transcode_hls_playlist( + request, + source, + init_url=init_url, + segment_url_template=segment_url_template, + ) + + +async def _handle_xtream_hls_init(request, upstream_url: str, proxy_headers): + """Serve fMP4 init segment for an Xtream stream.""" + if not settings.enable_transcode: + raise HTTPException(status_code=503, detail="Transcoding support is disabled") + source = HTTPMediaSource(url=upstream_url, headers=dict(proxy_headers.request)) + await source.resolve_file_size() + return await handle_transcode_hls_init(request, source) + + +async def _handle_xtream_hls_segment( + request, + upstream_url: str, + proxy_headers, + start_ms: float, + end_ms: float, + seg: int | None = None, +): + """Serve a single HLS fMP4 segment for an Xtream stream.""" + if not settings.enable_transcode: + raise HTTPException(status_code=503, detail="Transcoding support is disabled") + source = HTTPMediaSource(url=upstream_url, headers=dict(proxy_headers.request)) + await source.resolve_file_size() + return await handle_transcode_hls_segment( + request, source, start_time_ms=start_ms, end_time_ms=end_ms, segment_number=seg + ) + + +def decode_upstream_url(upstream_encoded: str) -> str: + """ + Decode the base64-encoded upstream XC server URL. + + Args: + upstream_encoded: Base64-encoded upstream server URL. + + Returns: + The decoded upstream server URL. + + Raises: + HTTPException: If the URL cannot be decoded. + """ + decoded = decode_base64_url(upstream_encoded) + if not decoded: + raise HTTPException( + status_code=400, + detail="Invalid upstream server URL encoding. Must be base64-encoded.", + ) + # Ensure the URL has a trailing slash for proper URL joining + if not decoded.endswith("/"): + decoded += "/" + return decoded + + +def decode_base64_username(encoded: str) -> str | None: + """ + Try to decode a base64-encoded username string. + + Args: + encoded: The potentially base64-encoded string. + + Returns: + The decoded string if successful, None otherwise. + """ + try: + # Handle URL-safe base64 encoding (replace - with + and _ with /) + url_safe_encoded = encoded.replace("-", "+").replace("_", "/") + + # Add padding if necessary + missing_padding = len(url_safe_encoded) % 4 + if missing_padding: + url_safe_encoded += "=" * (4 - missing_padding) + + # Decode the base64 string + decoded_bytes = base64.b64decode(url_safe_encoded) + decoded = decoded_bytes.decode("utf-8") + + # Check if it looks like our format (contains colons and starts with http) + if ":" in decoded: + return decoded + + return None + except (base64.binascii.Error, UnicodeDecodeError, ValueError): + return None + + +def parse_username_with_upstream(username: str) -> tuple[str, str, str | None]: + """ + Parse username that contains encoded upstream URL and optional API password. + + Supports two formats: + 1. Base64-encoded format (NEW - recommended for IPTV apps): + Username is base64({upstream_url}:{actual_username}:{api_password}) + Or base64({upstream_url}:{actual_username}) + + 2. Legacy colon-separated format: + {base64_upstream}:{actual_username}:{api_password} + Or {base64_upstream}:{actual_username} + + Args: + username: The username field which contains upstream URL and optionally API password. + + Returns: + Tuple of (upstream_base_url, actual_username, api_password or None). + + Raises: + HTTPException: If format is invalid. + """ + # First, try to decode the entire username as base64 + # This is the new format where the whole string is base64-encoded + decoded_username = decode_base64_username(username) + + if decoded_username: + # Successfully decoded base64, now parse the decoded string + parts = decoded_username.split(":") + logger.debug(f"Decoded base64 username, found {len(parts)} parts") + + # The decoded format is: {upstream_url}:{actual_username}:{api_password} + # or {upstream_url}:{actual_username} + # Note: upstream_url contains "://" so we need to handle that + + # Find the protocol separator + if "://" not in decoded_username: + raise HTTPException( + status_code=400, + detail="Invalid username format. Decoded base64 doesn't contain valid upstream URL.", + ) + + # Split on :// first to get protocol + proto_split = decoded_username.split("://", 1) + if len(proto_split) != 2: + raise HTTPException( + status_code=400, + detail="Invalid username format. Could not parse upstream URL protocol.", + ) + + protocol = proto_split[0] + rest = proto_split[1] + + # Now split the rest by colons + rest_parts = rest.split(":") + + if len(rest_parts) == 2: + # Format: protocol://host:actual_username (no api_password, no port in URL) + host, actual_username = rest_parts + upstream_url = f"{protocol}://{host}" + api_password = None + elif len(rest_parts) == 3: + # Could be: + # - protocol://host:port:actual_username (no api_password) + # - protocol://host:actual_username:api_password (no port in URL) + # We need to determine which case by checking if the second part looks like a port + if rest_parts[1].isdigit() and len(rest_parts[1]) <= 5: + # Looks like a port: protocol://host:port:actual_username + host, port, actual_username = rest_parts + upstream_url = f"{protocol}://{host}:{port}" + api_password = None + else: + # No port: protocol://host:actual_username:api_password + host, actual_username, api_password = rest_parts + upstream_url = f"{protocol}://{host}" + api_password = api_password if api_password else None + elif len(rest_parts) == 4: + # Format: protocol://host:port:actual_username:api_password + host, port, actual_username, api_password = rest_parts + upstream_url = f"{protocol}://{host}:{port}" + api_password = api_password if api_password else None + else: + raise HTTPException( + status_code=400, + detail="Invalid username format. Could not parse base64-decoded username.", + ) + + # Ensure trailing slash for URL joining + if not upstream_url.endswith("/"): + upstream_url += "/" + + logger.info(f"Parsed base64 username: upstream={upstream_url}, user={actual_username}") + return upstream_url, actual_username, api_password + + # Legacy format: {base64_upstream}:{actual_username}:{api_password} + if ":" not in username: + raise HTTPException( + status_code=400, + detail="Invalid username format. Expected base64-encoded username or legacy format: {base64_upstream}:{actual_username}:{api_password}", + ) + + parts = username.split(":") + + if len(parts) == 2: + # Format: {base64_upstream}:{actual_username} + upstream_encoded, actual_username = parts + api_password = None + elif len(parts) == 3: + # Format: {base64_upstream}:{actual_username}:{api_password} + upstream_encoded, actual_username, api_password = parts + api_password = api_password if api_password else None + else: + raise HTTPException( + status_code=400, + detail="Invalid username format. Expected base64-encoded username or legacy format: {base64_upstream}:{actual_username}:{api_password}", + ) + + upstream_base = decode_upstream_url(upstream_encoded) + + logger.info(f"Parsed legacy username: upstream={upstream_base}, user={actual_username}") + return upstream_base, actual_username, api_password + + +def verify_xc_api_password(api_password: str | None): + """ + Verify the API password for XC endpoints. + + Args: + api_password: The API password from the username field. + + Raises: + HTTPException: If API password is required but not provided or invalid. + """ + # If no API password is configured on the server, allow access + if not settings.api_password: + return + + # If API password is required but not provided + if not api_password: + raise HTTPException( + status_code=403, + detail="API password required. Username format: {base64_upstream}:{actual_username}:{api_password}", + ) + + # Verify the password matches + if api_password != settings.api_password: + raise HTTPException( + status_code=403, + detail="Invalid API password", + ) + + +def get_mediaflow_base_url(request: Request) -> str: + """ + Get the MediaFlow base URL for URL rewriting. + + Args: + request: The incoming FastAPI request. + + Returns: + The MediaFlow base URL. + """ + scheme = request.headers.get("x-forwarded-proto", request.url.scheme) + host = request.headers.get("x-forwarded-host", request.headers.get("host", request.url.netloc)) + return f"{scheme}://{host}" + + +def encode_username_for_rewrite(upstream_base: str, actual_username: str, api_password: str | None) -> str: + """ + Create a base64-encoded username token for URL rewriting. + + Args: + upstream_base: The upstream XC server base URL. + actual_username: The actual XC username. + api_password: The MediaFlow API password (if any). + + Returns: + A base64-encoded username string. + """ + # Remove trailing slash from upstream for cleaner encoding + upstream_clean = upstream_base.rstrip("/") + + # Build the combined string + if api_password: + combined = f"{upstream_clean}:{actual_username}:{api_password}" + else: + combined = f"{upstream_clean}:{actual_username}" + + # Base64 encode (URL-safe) + encoded = base64.urlsafe_b64encode(combined.encode()).decode().rstrip("=") + return encoded + + +def rewrite_urls_for_api( + content: str, + upstream_base: str, + mediaflow_base: str, + actual_username: str, + api_password: str | None, +) -> str: + """ + Rewrite stream URLs in API responses to route through MediaFlow. + + This function replaces the upstream username in stream URLs with a base64-encoded + token containing upstream URL + username + api_password, so MediaFlow can properly + route the requests. + + Args: + content: The API response content. + upstream_base: The upstream XC server base URL. + mediaflow_base: The MediaFlow base URL. + actual_username: The actual XC username (to be replaced in URLs). + api_password: The MediaFlow API password (if any). + + Returns: + The content with rewritten URLs. + """ + + # Parse the upstream URL to get the origin for replacement + parsed = urlparse(upstream_base) + upstream_origin = f"{parsed.scheme}://{parsed.netloc}" + + # Create the encoded username token for MediaFlow + encoded_username = encode_username_for_rewrite(upstream_base, actual_username, api_password) + + # Pattern to match stream URLs with username in path + # Matches: http(s)://host(:port)/path/{username}/password/... + # We need to replace {username} with {encoded_username} + + # First, handle the common XC stream URL patterns where username appears in the path + # Pattern: /{prefix}/{username}/{password}/ where prefix is live, movie, series, etc. + # or /{username}/{password}/ for short format + + # Escape special regex characters in the origin and username + escaped_origin = re.escape(upstream_origin) + escaped_username = re.escape(actual_username) + + # Pattern for URLs like: https://upstream/live/{username}/{password}/... + # or https://upstream/{username}/{password}/... + # We want to replace the upstream origin AND the username in one go + + def replace_stream_url(match): + """Replace upstream origin with mediaflow and username with encoded token.""" + full_url = match.group(0) + # Replace the upstream origin with mediaflow base + new_url = full_url.replace(upstream_origin, mediaflow_base, 1) + # Replace the username in the path with encoded username + # The username appears after a / and before another / + new_url = re.sub( + r"(/(live|movie|series|timeshift|hlsr|hls)?/)" + escaped_username + r"/", + r"\1" + encoded_username + "/", + new_url, + ) + # Also handle short format: /{username}/{password}/ + new_url = re.sub( + r"^(" + re.escape(mediaflow_base) + ")/" + escaped_username + r"/([^/]+/\d+\.)", + r"\1/" + encoded_username + r"/\2", + new_url, + ) + return new_url + + # Find and replace all URLs that contain the upstream origin + # Match URLs that start with the upstream origin and contain the username + url_pattern = escaped_origin + r'[^"\s\\]*' + escaped_username + r'[^"\s\\]*' + content = re.sub(url_pattern, replace_stream_url, content) + + # Handle escaped URLs in JSON (where / is escaped as \/) + escaped_upstream_json = upstream_origin.replace("/", "\\/") + escaped_mediaflow_json = mediaflow_base.replace("/", "\\/") + escaped_username_json = actual_username.replace("/", "\\/") + + def replace_escaped_stream_url(match): + """Replace escaped upstream origin with mediaflow and username with encoded token.""" + full_url = match.group(0) + new_url = full_url.replace(escaped_upstream_json, escaped_mediaflow_json, 1) + # Replace username (handling escaped slashes) + new_url = re.sub( + r"(\\/(?:live|movie|series|timeshift|hlsr|hls)?\\/)" + re.escape(escaped_username_json) + r"\\/", + r"\1" + encoded_username + "\\/", + new_url, + ) + # Short format + new_url = re.sub( + r"^(" + + re.escape(escaped_mediaflow_json) + + ")\\/" + + re.escape(escaped_username_json) + + r"\\/([^\\/]+\\/\d+\.)", + r"\1\\/" + encoded_username + r"\\/\2", + new_url, + ) + return new_url + + escaped_url_pattern = re.escape(escaped_upstream_json) + r'[^"\s]*' + re.escape(escaped_username_json) + r'[^"\s]*' + content = re.sub(escaped_url_pattern, replace_escaped_stream_url, content) + + # Also do a simple domain replacement for any remaining URLs that don't have username in path + # (like server_info URLs) + content = content.replace(upstream_origin, mediaflow_base) + content = content.replace(escaped_upstream_json, escaped_mediaflow_json) + + # Also replace hostname-only version (without port) if the upstream has a non-standard port + # This handles cases where server_info.url doesn't include the port + if parsed.port and parsed.port not in (80, 443): + upstream_host_only = f"{parsed.scheme}://{parsed.hostname}" + escaped_host_only_json = upstream_host_only.replace("/", "\\/") + content = content.replace(upstream_host_only, mediaflow_base) + content = content.replace(escaped_host_only_json, escaped_mediaflow_json) + + # IMPORTANT: Rewrite user_info.username in the response + # Some IPTV players (like Tivimate) use the username from the response for subsequent API calls + # So we need to replace the actual username with the encoded username in user_info + # Pattern: "username":"actual_username" -> "username":"encoded_username" + content = re.sub( + r'"username"\s*:\s*"' + escaped_username + r'"', + f'"username":"{encoded_username}"', + content, + ) + + return content + + +async def forward_api_request( + upstream_url: str, + request: Request, + upstream_base: str, + actual_username: str, + api_password: str | None, +) -> Response: + """ + Forward an API request to upstream XC server. + + Args: + upstream_url: The full upstream URL. + request: The incoming FastAPI request. + upstream_base: The decoded upstream base URL. + actual_username: The actual XC username (for URL rewriting). + api_password: The MediaFlow API password (for URL rewriting). + + Returns: + The response from upstream with URLs rewritten. + """ + mediaflow_base = get_mediaflow_base_url(request) + + async with create_aiohttp_session(upstream_url) as (session, proxy_url): + try: + async with session.get(upstream_url, proxy=proxy_url, allow_redirects=True) as response: + response.raise_for_status() + + content = await response.text() + content_type = response.headers.get("content-type", "application/json") + + # Rewrite URLs in JSON responses + if "json" in content_type.lower(): + content = rewrite_urls_for_api( + content, upstream_base, mediaflow_base, actual_username, api_password + ) + + return Response( + content=content, + status_code=response.status, + media_type=content_type, + ) + except aiohttp.ClientResponseError as e: + logger.error(f"Upstream XC API error: {e.status}") + raise HTTPException( + status_code=e.status, + detail=f"Upstream XC server error: {e.status}", + ) + except aiohttp.ClientError as e: + logger.error(f"Failed to connect to upstream XC server: {e}") + raise HTTPException( + status_code=502, + detail=f"Failed to connect to upstream XC server: {str(e)}", + ) + + +# ============================================================================= +# XC API Endpoints +# ============================================================================= + + +@xtream_root_router.get("/player_api.php") +async def player_api( + request: Request, + username: str = Query(..., description="Format: {base64_upstream}:{actual_username}:{api_password}"), + password: str = Query(..., description="XC password"), + action: str = Query(None, description="API action"), +): + """ + Player API endpoint for IPTV player compatibility. + + Handles all XC API actions including authentication, categories, streams, and EPG. + + Args: + request: The incoming FastAPI request. + username: Combined upstream URL, username, and API password. + password: XC password. + action: The API action to perform. + + Returns: + The API response with stream URLs rewritten. + """ + upstream_base, actual_username, api_password = parse_username_with_upstream(username) + verify_xc_api_password(api_password) + + # Build query params for upstream (with actual username) + query_params = {"username": actual_username, "password": password} + if action: + query_params["action"] = action + + # Add any other query params except our special ones + for k, v in request.query_params.items(): + if k not in ("username", "password", "action", "api_password"): + query_params[k] = v + + upstream_url = f"{upstream_base}player_api.php?{urlencode(query_params)}" + + logger.info(f"XC player_api.php: action={action}, upstream={upstream_base}, user={actual_username}") + + return await forward_api_request(upstream_url, request, upstream_base, actual_username, api_password) + + +@xtream_root_router.get("/xmltv.php") +async def xmltv_api( + request: Request, + username: str = Query(..., description="Format: {base64_upstream}:{actual_username}:{api_password}"), + password: str = Query(..., description="XC password"), +): + """ + XMLTV/EPG endpoint for electronic program guide data. + + Args: + request: The incoming FastAPI request. + username: Combined upstream URL, username, and API password. + password: XC password. + + Returns: + The EPG XML data from upstream. + """ + upstream_base, actual_username, api_password = parse_username_with_upstream(username) + verify_xc_api_password(api_password) + + # Build query params for upstream + query_params = {"username": actual_username, "password": password} + for k, v in request.query_params.items(): + if k not in ("username", "password", "api_password"): + query_params[k] = v + + upstream_url = f"{upstream_base}xmltv.php?{urlencode(query_params)}" + + logger.info(f"XC xmltv.php: upstream={upstream_base}") + + async with create_aiohttp_session(upstream_url, timeout=60) as (session, proxy_url): + try: + async with session.get(upstream_url, proxy=proxy_url, allow_redirects=True) as response: + response.raise_for_status() + + return Response( + content=await response.read(), + status_code=response.status, + media_type=response.headers.get("content-type", "application/xml"), + ) + except aiohttp.ClientResponseError as e: + raise HTTPException(status_code=e.status, detail=f"Upstream error: {e.status}") + except aiohttp.ClientError as e: + raise HTTPException(status_code=502, detail=f"Failed to connect: {str(e)}") + + +@xtream_root_router.get("/get.php") +async def get_playlist( + request: Request, + username: str = Query(..., description="Format: base64({upstream}:{actual_username}:{api_password})"), + password: str = Query(..., description="XC password"), + type: str = Query("m3u_plus", description="Playlist type (m3u, m3u_plus)"), + output: str = Query("ts", description="Output format (ts, m3u8)"), +): + """ + M3U playlist generation endpoint (XC API v1). + + Redirects to /proxy/hls/manifest.m3u8 which handles M3U URL rewriting. + + Args: + request: The incoming FastAPI request. + username: Combined upstream URL, username, and API password. + password: XC password. + type: Playlist type (m3u, m3u_plus). + output: Output stream format (ts, m3u8). + + Returns: + Redirect to HLS proxy with upstream get.php URL. + """ + upstream_base, actual_username, api_password = parse_username_with_upstream(username) + verify_xc_api_password(api_password) + + # Build query params for upstream get.php + query_params = {"username": actual_username, "password": password, "type": type, "output": output} + for k, v in request.query_params.items(): + if k not in ("username", "password", "type", "output", "api_password"): + query_params[k] = v + + upstream_url = f"{upstream_base}get.php?{urlencode(query_params)}" + + logger.info(f"XC get.php: type={type}, output={output}, upstream={upstream_base}, user={actual_username}") + + # Redirect to HLS proxy which handles M3U URL rewriting + mediaflow_base = get_mediaflow_base_url(request) + hls_params = {"d": upstream_url} + if api_password: + hls_params["api_password"] = api_password + + redirect_url = f"{mediaflow_base}/proxy/hls/manifest.m3u8?{urlencode(hls_params)}" + return RedirectResponse(url=redirect_url, status_code=302) + + +@xtream_root_router.get("/panel_api.php") +async def panel_api( + request: Request, + username: str = Query(..., description="Format: {base64_upstream}:{actual_username}:{api_password}"), + password: str = Query(..., description="XC password"), +): + """ + Panel API endpoint (alternative API used by some XC implementations). + + Args: + request: The incoming FastAPI request. + username: Combined upstream URL, username, and API password. + password: XC password. + + Returns: + The API response with stream URLs rewritten. + """ + upstream_base, actual_username, api_password = parse_username_with_upstream(username) + verify_xc_api_password(api_password) + + query_params = {"username": actual_username, "password": password} + for k, v in request.query_params.items(): + if k not in ("username", "password", "api_password"): + query_params[k] = v + + upstream_url = f"{upstream_base}panel_api.php?{urlencode(query_params)}" + + logger.info(f"XC panel_api.php: upstream={upstream_base}") + return await forward_api_request(upstream_url, request, upstream_base, actual_username, api_password) + + +# ============================================================================= +# Stream Proxy Endpoints +# ============================================================================= + + +@xtream_root_router.head("/live/{username}/{password}/{stream_id}.{ext}") +@xtream_root_router.get("/live/{username}/{password}/{stream_id}.{ext}") +async def live_stream( + username: str, + password: str, + stream_id: str, + ext: str, + request: Request, + proxy_headers: Annotated[ProxyRequestHeaders, Depends(get_proxy_headers)], +): + """ + Live stream endpoint. + + Username format: {base64_upstream}:{actual_username}:{api_password} + """ + upstream_base, actual_username, api_password = parse_username_with_upstream(username) + verify_xc_api_password(api_password) + + stream_path = f"live/{actual_username}/{password}/{stream_id}.{ext}" + upstream_url = urljoin(upstream_base, stream_path) + + logger.info(f"XC live stream: {stream_path}") + + # For m3u8, redirect to HLS proxy + if ext in ("m3u8", "m3u"): + scheme = request.headers.get("x-forwarded-proto", request.url.scheme) + host = request.headers.get("x-forwarded-host", request.headers.get("host", request.url.netloc)) + hls_params = {"d": upstream_url} + if api_password: + hls_params["api_password"] = api_password + redirect_url = f"{scheme}://{host}/proxy/hls/manifest.m3u8?{urlencode(hls_params)}" + return RedirectResponse(url=redirect_url, status_code=302) + + return await proxy_stream(request.method, upstream_url, proxy_headers) + + +@xtream_root_router.head("/movie/{username}/{password}/{stream_id}.{ext}") +@xtream_root_router.get("/movie/{username}/{password}/{stream_id}.{ext}") +async def movie_stream( + username: str, + password: str, + stream_id: str, + ext: str, + request: Request, + proxy_headers: Annotated[ProxyRequestHeaders, Depends(get_proxy_headers)], + transcode: bool = Query(False, description="Transcode to browser-compatible fMP4"), + hls: bool = Query(False, description="Generate HLS VOD playlist for transcode (seekable)"), + hls_init: bool = Query(False, description="Serve fMP4 init segment"), + seg: int | None = Query(None, description="HLS segment number (informational)"), + start_ms: float | None = Query(None, description="HLS segment start time in milliseconds"), + end_ms: float | None = Query(None, description="HLS segment end time in milliseconds"), + start: float | None = Query(None, description="Seek start time in seconds (transcode mode)"), +): + """ + VOD/movie stream endpoint. + """ + upstream_base, actual_username, api_password = parse_username_with_upstream(username) + verify_xc_api_password(api_password) + + stream_path = f"movie/{actual_username}/{password}/{stream_id}.{ext}" + upstream_url = urljoin(upstream_base, stream_path) + + logger.info(f"XC movie stream: {stream_path}") + + if hls: + return await _handle_xtream_hls_playlist(request, upstream_url, proxy_headers) + if hls_init: + return await _handle_xtream_hls_init(request, upstream_url, proxy_headers) + if (start_ms is None) != (end_ms is None): + raise HTTPException(status_code=400, detail="Both start_ms and end_ms are required for segment requests") + if start_ms is not None and end_ms is not None: + return await _handle_xtream_hls_segment(request, upstream_url, proxy_headers, start_ms, end_ms, seg) + if transcode: + return await _handle_xtream_transcode(request, upstream_url, proxy_headers, start) + + return await proxy_stream(request.method, upstream_url, proxy_headers) + + +@xtream_root_router.head("/series/{username}/{password}/{stream_id}.{ext}") +@xtream_root_router.get("/series/{username}/{password}/{stream_id}.{ext}") +async def series_stream( + username: str, + password: str, + stream_id: str, + ext: str, + request: Request, + proxy_headers: Annotated[ProxyRequestHeaders, Depends(get_proxy_headers)], + transcode: bool = Query(False, description="Transcode to browser-compatible fMP4"), + hls: bool = Query(False, description="Generate HLS VOD playlist for transcode (seekable)"), + hls_init: bool = Query(False, description="Serve fMP4 init segment"), + seg: int | None = Query(None, description="HLS segment number (informational)"), + start_ms: float | None = Query(None, description="HLS segment start time in milliseconds"), + end_ms: float | None = Query(None, description="HLS segment end time in milliseconds"), + start: float | None = Query(None, description="Seek start time in seconds (transcode mode)"), +): + """ + Series/episode stream endpoint. + """ + upstream_base, actual_username, api_password = parse_username_with_upstream(username) + verify_xc_api_password(api_password) + + stream_path = f"series/{actual_username}/{password}/{stream_id}.{ext}" + upstream_url = urljoin(upstream_base, stream_path) + + logger.info(f"XC series stream: {stream_path}") + + if hls: + return await _handle_xtream_hls_playlist(request, upstream_url, proxy_headers) + if hls_init: + return await _handle_xtream_hls_init(request, upstream_url, proxy_headers) + if (start_ms is None) != (end_ms is None): + raise HTTPException(status_code=400, detail="Both start_ms and end_ms are required for segment requests") + if start_ms is not None and end_ms is not None: + return await _handle_xtream_hls_segment(request, upstream_url, proxy_headers, start_ms, end_ms, seg) + if transcode: + return await _handle_xtream_transcode(request, upstream_url, proxy_headers, start) + + return await proxy_stream(request.method, upstream_url, proxy_headers) + + +@xtream_root_router.head("/timeshift/{username}/{password}/{duration}/{start}/{stream_id}.{ext}") +@xtream_root_router.get("/timeshift/{username}/{password}/{duration}/{start}/{stream_id}.{ext}") +async def timeshift_stream( + username: str, + password: str, + duration: str, + start: str, + stream_id: str, + ext: str, + request: Request, + proxy_headers: Annotated[ProxyRequestHeaders, Depends(get_proxy_headers)], + transcode: bool = Query(False, description="Transcode to browser-compatible fMP4"), + seek: float | None = Query(None, description="Seek start time in seconds (transcode mode)"), +): + """ + Timeshift/catch-up stream endpoint. + """ + upstream_base, actual_username, api_password = parse_username_with_upstream(username) + verify_xc_api_password(api_password) + + stream_path = f"timeshift/{actual_username}/{password}/{duration}/{start}/{stream_id}.{ext}" + upstream_url = urljoin(upstream_base, stream_path) + + logger.info(f"XC timeshift stream: {stream_path}") + + if transcode: + return await _handle_xtream_transcode(request, upstream_url, proxy_headers, seek) + + return await proxy_stream(request.method, upstream_url, proxy_headers) + + +@xtream_root_router.head("/streaming/timeshift.php") +@xtream_root_router.get("/streaming/timeshift.php") +async def timeshift_php( + request: Request, + proxy_headers: Annotated[ProxyRequestHeaders, Depends(get_proxy_headers)], + username: str = Query(..., description="Format: {base64_upstream}:{actual_username}:{api_password}"), + password: str = Query(..., description="XC password"), + stream: str = Query(..., description="Stream ID"), + start: str = Query(..., description="Start time"), + duration: str = Query(None, description="Duration in minutes"), +): + """ + Timeshift.php catch-up endpoint (alternative format). + """ + upstream_base, actual_username, api_password = parse_username_with_upstream(username) + verify_xc_api_password(api_password) + + # Build query params for upstream + query_params = {"username": actual_username, "password": password, "stream": stream, "start": start} + if duration: + query_params["duration"] = duration + for k, v in request.query_params.items(): + if k not in ("username", "password", "stream", "start", "duration", "api_password"): + query_params[k] = v + + upstream_url = f"{upstream_base}streaming/timeshift.php?{urlencode(query_params)}" + + logger.info(f"XC timeshift.php: stream={stream}, start={start}") + + return await proxy_stream(request.method, upstream_url, proxy_headers) + + +@xtream_root_router.head("/hlsr/{token}/{username}/{password}/{channel_id}/{start}/{end}/index.m3u8") +@xtream_root_router.get("/hlsr/{token}/{username}/{password}/{channel_id}/{start}/{end}/index.m3u8") +async def hlsr_catchup( + token: str, + username: str, + password: str, + channel_id: str, + start: str, + end: str, + request: Request, + proxy_headers: Annotated[ProxyRequestHeaders, Depends(get_proxy_headers)], +): + """ + HLSR catch-up stream endpoint. + """ + upstream_base, actual_username, api_password = parse_username_with_upstream(username) + verify_xc_api_password(api_password) + + stream_path = f"hlsr/{token}/{actual_username}/{password}/{channel_id}/{start}/{end}/index.m3u8" + upstream_url = urljoin(upstream_base, stream_path) + + logger.info(f"XC HLSR catch-up: channel={channel_id}") + + # Redirect to HLS proxy for proper m3u8 handling + scheme = request.headers.get("x-forwarded-proto", request.url.scheme) + host = request.headers.get("x-forwarded-host", request.headers.get("host", request.url.netloc)) + hls_params = {"d": upstream_url} + if api_password: + hls_params["api_password"] = api_password + redirect_url = f"{scheme}://{host}/proxy/hls/manifest.m3u8?{urlencode(hls_params)}" + return RedirectResponse(url=redirect_url, status_code=302) + + +@xtream_root_router.head("/hls/{token}/{stream_id}.m3u8") +@xtream_root_router.get("/hls/{token}/{stream_id}.m3u8") +async def hls_stream( + token: str, + stream_id: str, + request: Request, + proxy_headers: Annotated[ProxyRequestHeaders, Depends(get_proxy_headers)], +): + """ + HLS stream endpoint with token authentication. + + Note: This endpoint doesn't use the username format since it's token-based. + The api_password should be passed as a query parameter if required. + """ + # For token-based HLS, check api_password from query params + api_password = request.query_params.get("api_password") + if settings.api_password and api_password != settings.api_password: + raise HTTPException(status_code=403, detail="Invalid API password") + + # Get upstream from query params (must be base64-encoded) + upstream_encoded = request.query_params.get("upstream") + if not upstream_encoded: + raise HTTPException(status_code=400, detail="Missing 'upstream' query parameter") + + upstream_base = decode_upstream_url(upstream_encoded) + stream_path = f"hls/{token}/{stream_id}.m3u8" + upstream_url = urljoin(upstream_base, stream_path) + + logger.info(f"XC HLS stream: {stream_path}") + + # Redirect to HLS proxy + scheme = request.headers.get("x-forwarded-proto", request.url.scheme) + host = request.headers.get("x-forwarded-host", request.headers.get("host", request.url.netloc)) + hls_params = {"d": upstream_url} + if api_password: + hls_params["api_password"] = api_password + redirect_url = f"{scheme}://{host}/proxy/hls/manifest.m3u8?{urlencode(hls_params)}" + return RedirectResponse(url=redirect_url, status_code=302) + + +@xtream_root_router.head("/{username}/{password}/{stream_id}.{ext}") +@xtream_root_router.get("/{username}/{password}/{stream_id}.{ext}") +async def live_stream_short( + username: str, + password: str, + stream_id: str, + ext: str, + request: Request, + proxy_headers: Annotated[ProxyRequestHeaders, Depends(get_proxy_headers)], +): + """ + Short format live stream endpoint (without /live/ prefix). + """ + upstream_base, actual_username, api_password = parse_username_with_upstream(username) + verify_xc_api_password(api_password) + + stream_path = f"{actual_username}/{password}/{stream_id}.{ext}" + upstream_url = urljoin(upstream_base, stream_path) + + logger.info(f"XC short live stream: {stream_path}") + + # For m3u8, redirect to HLS proxy + if ext in ("m3u8", "m3u"): + scheme = request.headers.get("x-forwarded-proto", request.url.scheme) + host = request.headers.get("x-forwarded-host", request.headers.get("host", request.url.netloc)) + hls_params = {"d": upstream_url} + if api_password: + hls_params["api_password"] = api_password + redirect_url = f"{scheme}://{host}/proxy/hls/manifest.m3u8?{urlencode(hls_params)}" + return RedirectResponse(url=redirect_url, status_code=302) + + return await proxy_stream(request.method, upstream_url, proxy_headers) + + +# ============================================================================= +# Stream Endpoints WITHOUT Extension (for players like IMPlayer) +# These handle URLs like /{username}/{password}/{stream_id} without .ts/.m3u8 +# ============================================================================= + + +@xtream_root_router.head("/live/{username}/{password}/{stream_id}") +@xtream_root_router.get("/live/{username}/{password}/{stream_id}") +async def live_stream_no_ext( + username: str, + password: str, + stream_id: str, + request: Request, + proxy_headers: Annotated[ProxyRequestHeaders, Depends(get_proxy_headers)], +): + """ + Live stream endpoint without extension (defaults to .ts). + Some players like IMPlayer don't include the extension in stream URLs. + """ + upstream_base, actual_username, api_password = parse_username_with_upstream(username) + verify_xc_api_password(api_password) + + # Default to .ts format when no extension provided + stream_path = f"live/{actual_username}/{password}/{stream_id}" + upstream_url = urljoin(upstream_base, stream_path) + + logger.info(f"XC live stream (no ext): {stream_path}") + + return await proxy_stream(request.method, upstream_url, proxy_headers) + + +@xtream_root_router.head("/movie/{username}/{password}/{stream_id}") +@xtream_root_router.get("/movie/{username}/{password}/{stream_id}") +async def movie_stream_no_ext( + username: str, + password: str, + stream_id: str, + request: Request, + proxy_headers: Annotated[ProxyRequestHeaders, Depends(get_proxy_headers)], + transcode: bool = Query(False, description="Transcode to browser-compatible fMP4"), + hls: bool = Query(False, description="Generate HLS VOD playlist for transcode (seekable)"), + hls_init: bool = Query(False, description="Serve fMP4 init segment"), + seg: int | None = Query(None, description="HLS segment number (informational)"), + start_ms: float | None = Query(None, description="HLS segment start time in milliseconds"), + end_ms: float | None = Query(None, description="HLS segment end time in milliseconds"), + start: float | None = Query(None, description="Seek start time in seconds (transcode mode)"), +): + """ + Movie stream endpoint without extension. + """ + upstream_base, actual_username, api_password = parse_username_with_upstream(username) + verify_xc_api_password(api_password) + + stream_path = f"movie/{actual_username}/{password}/{stream_id}" + upstream_url = urljoin(upstream_base, stream_path) + + logger.info(f"XC movie stream (no ext): {stream_path}") + + if hls: + return await _handle_xtream_hls_playlist(request, upstream_url, proxy_headers) + if hls_init: + return await _handle_xtream_hls_init(request, upstream_url, proxy_headers) + if (start_ms is None) != (end_ms is None): + raise HTTPException(status_code=400, detail="Both start_ms and end_ms are required for segment requests") + if start_ms is not None and end_ms is not None: + return await _handle_xtream_hls_segment(request, upstream_url, proxy_headers, start_ms, end_ms, seg) + if transcode: + return await _handle_xtream_transcode(request, upstream_url, proxy_headers, start) + + return await proxy_stream(request.method, upstream_url, proxy_headers) + + +@xtream_root_router.head("/series/{username}/{password}/{stream_id}") +@xtream_root_router.get("/series/{username}/{password}/{stream_id}") +async def series_stream_no_ext( + username: str, + password: str, + stream_id: str, + request: Request, + proxy_headers: Annotated[ProxyRequestHeaders, Depends(get_proxy_headers)], + transcode: bool = Query(False, description="Transcode to browser-compatible fMP4"), + hls: bool = Query(False, description="Generate HLS VOD playlist for transcode (seekable)"), + hls_init: bool = Query(False, description="Serve fMP4 init segment"), + seg: int | None = Query(None, description="HLS segment number (informational)"), + start_ms: float | None = Query(None, description="HLS segment start time in milliseconds"), + end_ms: float | None = Query(None, description="HLS segment end time in milliseconds"), + start: float | None = Query(None, description="Seek start time in seconds (transcode mode)"), +): + """ + Series stream endpoint without extension. + """ + upstream_base, actual_username, api_password = parse_username_with_upstream(username) + verify_xc_api_password(api_password) + + stream_path = f"series/{actual_username}/{password}/{stream_id}" + upstream_url = urljoin(upstream_base, stream_path) + + logger.info(f"XC series stream (no ext): {stream_path}") + + if hls: + return await _handle_xtream_hls_playlist(request, upstream_url, proxy_headers) + if hls_init: + return await _handle_xtream_hls_init(request, upstream_url, proxy_headers) + if (start_ms is None) != (end_ms is None): + raise HTTPException(status_code=400, detail="Both start_ms and end_ms are required for segment requests") + if start_ms is not None and end_ms is not None: + return await _handle_xtream_hls_segment(request, upstream_url, proxy_headers, start_ms, end_ms, seg) + if transcode: + return await _handle_xtream_transcode(request, upstream_url, proxy_headers, start) + + return await proxy_stream(request.method, upstream_url, proxy_headers) + + +@xtream_root_router.head("/{username}/{password}/{stream_id}") +@xtream_root_router.get("/{username}/{password}/{stream_id}") +async def live_stream_short_no_ext( + username: str, + password: str, + stream_id: str, + request: Request, + proxy_headers: Annotated[ProxyRequestHeaders, Depends(get_proxy_headers)], +): + """ + Short format live stream endpoint without extension (without /live/ prefix). + Some players like IMPlayer use this format without extension. + """ + upstream_base, actual_username, api_password = parse_username_with_upstream(username) + verify_xc_api_password(api_password) + + stream_path = f"{actual_username}/{password}/{stream_id}" + upstream_url = urljoin(upstream_base, stream_path) + + logger.info(f"XC short live stream (no ext): {stream_path}") + + return await proxy_stream(request.method, upstream_url, proxy_headers) diff --git a/mediaflow_proxy/schemas.py b/mediaflow_proxy/schemas.py index 6e763d8..198030b 100644 --- a/mediaflow_proxy/schemas.py +++ b/mediaflow_proxy/schemas.py @@ -1,9 +1,68 @@ import json -from typing import Literal, Dict, Any, Optional +import re +from typing import Annotated, Literal, Dict, Any, Optional from pydantic import BaseModel, Field, IPvAnyAddress, ConfigDict, field_validator +def validate_resolution_format(value: str) -> str: + """Validate and normalize resolution format (e.g., '1080p', '720p').""" + if not re.match(r"^\d+p$", value): + raise ValueError(f"Invalid resolution format '{value}'. Expected format: '1080p', '720p', etc.") + return value + + +def parse_skip_segments(skip_str: str) -> list[dict]: + """ + Parse compact skip segment format into list of segment dicts. + + Format: "start-end,start-end,..." (e.g., "0-112,280-300") + + Args: + skip_str: Comma-separated list of start-end ranges in seconds. + + Returns: + List of dicts with 'start' and 'end' keys. + + Raises: + ValueError: If format is invalid or end <= start. + """ + if not skip_str or not skip_str.strip(): + return [] + + segments = [] + for part in skip_str.split(","): + part = part.strip() + if not part: + continue + + if "-" not in part: + raise ValueError(f"Invalid segment format '{part}'. Expected 'start-end' (e.g., '0-112')") + + # Handle negative numbers by splitting only on the last hyphen for end + # But since times are always positive, we can split on first hyphen + parts = part.split("-", 1) + if len(parts) != 2: + raise ValueError(f"Invalid segment format '{part}'. Expected 'start-end' (e.g., '0-112')") + + try: + start = float(parts[0]) + end = float(parts[1]) + except ValueError: + raise ValueError(f"Invalid segment format '{part}'. Start and end must be numbers.") + + if start < 0: + raise ValueError(f"Start time cannot be negative: {start}") + if end < 0: + raise ValueError(f"End time cannot be negative: {end}") + if end <= start: + raise ValueError(f"End time ({end}) must be greater than start time ({start})") + + segments.append({"start": start, "end": end}) + + return segments + + class GenerateUrlRequest(BaseModel): mediaflow_proxy_url: str = Field(..., description="The base URL for the mediaflow proxy.") endpoint: Optional[str] = Field(None, description="The specific endpoint to be appended to the base URL.") @@ -15,7 +74,14 @@ class GenerateUrlRequest(BaseModel): ) request_headers: Optional[dict] = Field(default_factory=dict, description="Headers to be included in the request.") response_headers: Optional[dict] = Field( - default_factory=dict, description="Headers to be included in the response." + default_factory=dict, description="Headers to be included in the response (r_ prefix, manifest only)." + ) + propagate_response_headers: Optional[dict] = Field( + default_factory=dict, + description="Response headers that propagate to segments (rp_ prefix). Useful for overriding content-type on segment requests.", + ) + remove_response_headers: Optional[list[str]] = Field( + default_factory=list, description="List of response header names to remove from the proxied response." ) expiration: Optional[int] = Field( None, description="Expiration time for the URL in seconds. If not provided, the URL will not expire." @@ -40,7 +106,14 @@ class MultiUrlRequestItem(BaseModel): ) request_headers: Optional[dict] = Field(default_factory=dict, description="Headers to be included in the request.") response_headers: Optional[dict] = Field( - default_factory=dict, description="Headers to be included in the response." + default_factory=dict, description="Headers to be included in the response (r_ prefix, manifest only)." + ) + propagate_response_headers: Optional[dict] = Field( + default_factory=dict, + description="Response headers that propagate to segments (rp_ prefix). Useful for overriding content-type on segment requests.", + ) + remove_response_headers: Optional[list[str]] = Field( + default_factory=list, description="List of response header names to remove from the proxied response." ) filename: Optional[str] = Field(None, description="Filename to be preserved for media players like Infuse.") @@ -62,7 +135,7 @@ class GenericParams(BaseModel): class HLSManifestParams(GenericParams): - destination: str = Field(..., description="The URL of the HLS manifest.", alias="d") + destination: Annotated[str, Field(description="The URL of the HLS manifest.", alias="d")] key_url: Optional[str] = Field( None, description="The HLS Key URL to replace the original key URL. Defaults to None. (Useful for bypassing some sneaky protection)", @@ -83,19 +156,95 @@ class HLSManifestParams(GenericParams): False, description="If true, redirects to the highest resolution stream in the manifest.", ) + resolution: Optional[str] = Field( + None, + description="Select a specific resolution stream (e.g., '1080p', '720p', '480p'). Falls back to closest lower resolution if exact match not found.", + ) + skip: Optional[str] = Field( + None, + description="Time segments to skip, in compact format: 'start-end,start-end,...' (e.g., '0-112,280-300'). Segments are in seconds.", + ) + start_offset: Optional[float] = Field( + None, + description="Injects #EXT-X-START:TIME-OFFSET into the playlist. Use negative values for live streams to start behind the live edge (e.g., -18 to start 18 seconds behind). Enables prebuffer to work on live streams by creating headroom.", + ) + transformer: Optional[str] = Field( + None, + description="Stream transformer ID for host-specific content manipulation (e.g., 'ts_stream' for PNG/padding stripping).", + ) + + @field_validator("resolution", mode="before") + @classmethod + def validate_resolution(cls, value: Any) -> Optional[str]: + if value is None: + return None + return validate_resolution_format(str(value)) + + def get_skip_segments(self) -> Optional[list[dict]]: + """Parse and return skip segments as a list of dicts with 'start' and 'end' keys.""" + if self.skip is None: + return None + return parse_skip_segments(self.skip) class MPDManifestParams(GenericParams): - destination: str = Field(..., description="The URL of the MPD manifest.", alias="d") + destination: Annotated[str, Field(description="The URL of the MPD manifest.", alias="d")] key_id: Optional[str] = Field(None, description="The DRM key ID (optional).") key: Optional[str] = Field(None, description="The DRM key (optional).") + resolution: Optional[str] = Field( + None, + description="Select a specific resolution stream (e.g., '1080p', '720p', '480p'). Falls back to closest lower resolution if exact match not found.", + ) + skip: Optional[str] = Field( + None, + description="Time segments to skip, in compact format: 'start-end,start-end,...' (e.g., '0-112,280-300'). Segments are in seconds.", + ) + start_offset: Optional[float] = Field( + None, + description="Injects #EXT-X-START:TIME-OFFSET into live playlists. Use negative values for live streams to start behind the live edge (e.g., -18 to start 18 seconds behind). Enables prebuffer to work on live streams.", + ) + remux_to_ts: Optional[bool] = Field( + None, + description="Override global REMUX_TO_TS setting per-request. true = force TS remuxing, false = force fMP4 passthrough, omit = use server default.", + ) + + @field_validator("resolution", mode="before") + @classmethod + def validate_resolution(cls, value: Any) -> Optional[str]: + if value is None: + return None + return validate_resolution_format(str(value)) + + def get_skip_segments(self) -> Optional[list[dict]]: + """Parse and return skip segments as a list of dicts with 'start' and 'end' keys.""" + if self.skip is None: + return None + return parse_skip_segments(self.skip) class MPDPlaylistParams(GenericParams): - destination: str = Field(..., description="The URL of the MPD manifest.", alias="d") + destination: Annotated[str, Field(description="The URL of the MPD manifest.", alias="d")] profile_id: str = Field(..., description="The profile ID to generate the playlist for.") key_id: Optional[str] = Field(None, description="The DRM key ID (optional).") key: Optional[str] = Field(None, description="The DRM key (optional).") + skip: Optional[str] = Field( + None, + description="Time segments to skip, in compact format: 'start-end,start-end,...' (e.g., '0-112,280-300'). Segments are in seconds.", + ) + start_offset: Optional[float] = Field( + None, + description="Injects #EXT-X-START:TIME-OFFSET into the playlist. Use negative values for live streams to start behind the live edge (e.g., -18 to start 18 seconds behind). Enables prebuffer to work on live streams.", + ) + remux_to_ts: Optional[bool] = Field( + None, + description="Override global REMUX_TO_TS setting per-request. true = force TS remuxing, false = force fMP4 passthrough, omit = use server default.", + ) + + def get_skip_segments(self) -> Optional[list[dict]]: + """Parse and return skip segments as a list of dicts with 'start' and 'end' keys.""" + if self.skip is None: + return None + return parse_skip_segments(self.skip) class MPDSegmentParams(GenericParams): @@ -104,14 +253,57 @@ class MPDSegmentParams(GenericParams): mime_type: str = Field(..., description="The MIME type of the segment.") key_id: Optional[str] = Field(None, description="The DRM key ID (optional).") key: Optional[str] = Field(None, description="The DRM key (optional).") - is_live: Optional[bool] = Field(None, alias="is_live", description="Whether the parent MPD is live.") + is_live: Annotated[ + Optional[bool], Field(default=None, alias="is_live", description="Whether the parent MPD is live.") + ] + init_range: Optional[str] = Field( + None, description="Byte range for the initialization segment (e.g., '0-11568'). Used for SegmentBase MPDs." + ) + use_map: Optional[bool] = Field( + False, + description="Whether EXT-X-MAP is used (init sent separately). If true, don't concatenate init with segment.", + ) + + +class MPDInitParams(GenericParams): + init_url: str = Field(..., description="The URL of the initialization segment.") + mime_type: str = Field(..., description="The MIME type of the segment.") + key_id: Optional[str] = Field(None, description="The DRM key ID (optional).") + key: Optional[str] = Field(None, description="The DRM key (optional).") + is_live: Annotated[ + Optional[bool], Field(default=None, alias="is_live", description="Whether the parent MPD is live.") + ] + init_range: Optional[str] = Field( + None, description="Byte range for the initialization segment (e.g., '0-11568'). Used for SegmentBase MPDs." + ) class ExtractorURLParams(GenericParams): host: Literal[ - "Doodstream", "FileLions", "FileMoon", "F16Px", "Mixdrop", "Uqload", "Streamtape", "StreamWish", "Supervideo", "VixCloud", "Okru", "Maxstream", "LiveTV", "LuluStream", "DLHD", "Fastream", "TurboVidPlay", "Vidmoly", "Vidoza", "Voe", "Sportsonline" + "Doodstream", + "FileLions", + "FileMoon", + "F16Px", + "Mixdrop", + "Gupload", + "Uqload", + "Streamtape", + "StreamWish", + "Supervideo", + "VixCloud", + "Okru", + "Maxstream", + "LiveTV", + "LuluStream", + "DLHD", + "Fastream", + "TurboVidPlay", + "Vidmoly", + "Vidoza", + "Voe", + "Sportsonline", ] = Field(..., description="The host to extract the URL from.") - destination: str = Field(..., description="The URL of the stream.", alias="d") + destination: Annotated[str, Field(description="The URL of the stream.", alias="d")] redirect_stream: bool = Field(False, description="Whether to redirect to the stream endpoint automatically.") extra_params: Dict[str, Any] = Field( default_factory=dict, diff --git a/mediaflow_proxy/speedtest/__pycache__/__init__.cpython-313.pyc b/mediaflow_proxy/speedtest/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..df27dae4c46d383dbf202b5976b8738539ca3d49 GIT binary patch literal 172 zcmey&%ge<81k)3yXM*U*AOZ#$p^VQgK*m&tbOudEzm*I{OhDdekkl=2{fzwFRQ=MT zoP2$k{N&Qy)Vz{n{ov%H#DdJcbp6mgkIaa1$D;y% zZ@zxxd@KvXpVT=2)UI>(3p%d_UC_lXVN(S0Hu=()v?+tUser=U@>Xb51$9#c?Y1x~ z%nG_PF6bd6Y=}!rXC{bnTt!?n)FoVxK8SKOjA*0>9pGpb(SaT`#?cs}@g6kJ(FCH& z9yC!HOb@kaiL}U+HMdeqOH5gHasdl}Rt_A`wTg&DmM!1j@$z<&Y3o5y-h*O}2AzLm z*EyR+=e1x6AnF20x(Ko^fuhS0(iKn(p|r||^s;T|pV)rzIe0IeybUc4N3j90t)iK? zpMjHSQLA|F|14zF8Vj4IYn5!%WKq*BdHG6_{FrGzuUN%yBx#!a4){UQac$Sb@WAfc z<85(KU-%=Y=?7Ne;j$6p=x$C&`e5PdQ9c#bn9hhbCURE^cTuih~RwOP?mlS z%`2h#&U9_7Ig~owgpu>`6+*k`E}T8U@Kr+R1Vb@Gx^y2S+>^<>pf{@9@WO@-ci(H# zVob&)m_iis{E$pYAPz|~gJgzyH$MP|3|g-15jCDmjNG6&(bC!HP4Y(14NcDz`d(;8 zVu$O^X#CYirz7?0pU^Hwt|vqqF+zqK!kD4yorEL^_h1T7Qi4bi7UGx+(Sk$@+4H+; zQ$ATHIb|k0ewk@Gui^$!<$0tB`ehF@I0R^UAO%B;%q*EZWcsl%O#w2rn*SaeWMOo& zwtN(>4KWqe>iqUNX{8CugPK1nWvhuh8Bd$sNQd}Hy4hbPLt=J-@?^=Q7nc>M5G znd-Nw%gJFml)O3X1Tn6s|#dL8| zt$6OfQ)p4zER2=TZ!H#~LN~Fx{1!bBEY92c3h?U5vPcEzFdZz!4xx#@%H!7qCzM`* zj{FvAI@v+dW(cEAa;fzdVw$34XcC_WAis&}6J@%Y9Imf4hU@Fc-;n^vCTctN#S>+s zd3V0HarCkg{w+Cks?2w|rWyKk{Xc8*)^&Dw?dKI_vUL}C0Pmv7GL1C(tzBg|auoo) zd(A5%{SSR!()oy{hyb_ry2LuTy@T(AxYY`F5^d zyDa=+=h^!RKEQtT;`&d~{4I;3_`5LjhcNS(aKEie;#5O!JZTH)w4;)ksE?7KXh&sn n_Ndwx@TVP<#p$|3{&YL0i0Q_cZ2^DU30cfEoPQ9{@cjM-#X91= literal 0 HcmV?d00001 diff --git a/mediaflow_proxy/speedtest/__pycache__/service.cpython-313.pyc b/mediaflow_proxy/speedtest/__pycache__/service.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b6fdb14e2bdca9e4b8c2bb347ed04f56e7487d7e GIT binary patch literal 1862 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 - + MFP Playlist Builder + + + - -

-

🔗 MFP Playlist Builder

- -
- - -
- -
- - -
- -

Playlists to Merge

-
- -
- - -
- - - -
- -
The URL will appear here...
- -
+ + +
+
+ +
+ +
+ +
+
+ MediaFlow +

+ Playlist Builder +

+
+

+ Combine and proxy M3U playlists with automatic link rewriting +

+
+ + +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+

+ 📋 + Playlists to Merge +

+ +
+ +
+ +
+
+ + +
+ +
+ + + +
+
+ - \ No newline at end of file + diff --git a/mediaflow_proxy/static/speedtest.html b/mediaflow_proxy/static/speedtest.html index f6375b5..e161c5d 100644 --- a/mediaflow_proxy/static/speedtest.html +++ b/mediaflow_proxy/static/speedtest.html @@ -4,6 +4,7 @@ MediaFlow Speed Test + @@ -12,7 +13,13 @@ darkMode: 'class', theme: { extend: { + fontFamily: { + 'display': ['Satoshi', 'system-ui', 'sans-serif'], + 'mono': ['JetBrains Mono', 'Fira Code', 'monospace'], + }, animation: { + 'fade-in': 'fadeIn 0.3s ease-out', + 'slide-up': 'slideUp 0.3s ease-out', 'pulse-slow': 'pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite', 'bounce-slow': 'bounce 2s infinite', } @@ -21,6 +28,18 @@ } - - -
- -
- -
- -
-

- 🚀 MediaFlow Speed Test -

-

- Compare your connection speed through MediaFlow proxy vs direct connection -

+ + +
+
- -
-
-

Test Configuration

+ + -
- -
-
- - -
+
+ +
+
+ MediaFlow +

+ Speed Test +

+
+

+ Compare your connection speed through MediaFlow proxy vs direct connection +

+
- - -
+ +
+
+

+ ⚙️ + Test Configuration +

- -
- - -

- Required to fetch test configuration if this instance has API password protection -

-
- - -
-
- - -
- -
- -
- - -
- - -
-
-
-
- - -
-

CDN Locations

- - -
- -
- -
- -
- -
- - - -
-
- - -
-
-

Test Options

-
-
- - -
-
- - -
-
-
- -
-

Advanced Settings

-
-
- - + +
+ + +

+ Required to fetch test configuration if this instance has API password protection +

+
+ + +
+
+ + +
+ +
+ +
+
+ + +
+ + +
+
+
+
+
+ + +
+

+ + + + CDN Locations +

+ + +
+ +
+ +
+ +
+ +
+ + + +
+
+ + +
+
+
+

+ + + + Test Options +

+
+ + +
+
+ +
+

+ + + + Advanced Settings +

+
+ + +
+
+
+
+ + + +
-
- -
- -
- -
-
+ + themeToggle.addEventListener('click', () => { + html.classList.toggle('dark'); + const newTheme = html.classList.contains('dark') ? 'dark' : 'light'; + localStorage.setItem('theme', newTheme); + }); + - \ No newline at end of file + diff --git a/mediaflow_proxy/static/url_generator.html b/mediaflow_proxy/static/url_generator.html new file mode 100644 index 0000000..c33df0a --- /dev/null +++ b/mediaflow_proxy/static/url_generator.html @@ -0,0 +1,3051 @@ + + + + + + MediaFlow URL Generator + + + + + + + + + +
+ +
+ + + + +
+ +
+
+ MediaFlow +

+ URL Generator +

+
+

+ Generate proxy URLs, extractor links, and encoded URLs for MediaFlow +

+
+ + +
+
+
+ + + + + (optional - for protected instances) +
+ +
+
+ + +
+ + + + + + +
+ + +
+
+

+ 🔗 + Proxy URL Generator +

+ + +
+ +
+ + + +
+
+ + +
+
+ + +
+
+ + +

Replace the original key URL (useful for bypassing protection)

+
+ + +
+

Options

+
+ + + + +
+ + +
+ + +

Select specific resolution (falls back to closest lower if not available)

+
+ +
+

Force Playlist Proxy: Force all playlist URLs to be proxied regardless of content routing settings

+

Key Only Proxy: Only proxy the key URL, leaving segment URLs direct

+

No Proxy: Returns manifest without proxying internal URLs

+

Max Resolution: Redirects to the highest resolution stream

+
+
+ + +
+

Skip Segments (Intro/Outro Skip)

+
+ + +

Format: start-end,start-end (e.g., "0-90" skips first 90 seconds, "0-112,1750-1800" skips intro and outro). Supports decimal values.

+
+
+ + +
+

Start Offset (Live Stream Prebuffer)

+
+ + +

Use negative values to start behind the live edge (e.g., -18 to start 18 seconds behind). This enables the prebuffer to work on live streams by creating headroom for prefetching segments.

+
+
+ + +
+

Response Headers (Advanced)

+
+
+
+ + +
+
+ +
+
+ +
+
+ + +
+
+ +
+

Headers that propagate to segment URLs (e.g., content-type for disguised segments)

+
+ +
+ + +

Comma-separated list of response headers to remove

+
+
+
+
+ + + + + + + + +
+
+ + +
+
+ +
+

Headers will be encoded as h_HeaderName=value in the URL

+
+ + +
+ +
+ + + + + + +
+
+ + + + + + + + + + + + + + + +
+ + + + diff --git a/mediaflow_proxy/utils/__pycache__/__init__.cpython-313.pyc b/mediaflow_proxy/utils/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..cce4ae9d4ef168f9ad9edca8854212b3bcdc8d89 GIT binary patch literal 168 zcmey&%ge<81k)3yXM*U*AOZ#$p^VQgK*m&tbOudEzm*I{OhDdekkl=A{fzwFRQ=MT zoP2$k{N&Qy)Vz{n{ov%H#DdJcbp6mgkIag`*$B?WLP;|XAhno|I!hT`!zkNmqGD7sN^~B zTbz#*IUn!SZR16r-F2dl-33u#cfF`*cY|nPccW;;UD#&YY8K5~EuzIu`SshZTWz9k zt6j8j%@8xTW{R0C+^{Wct3z}!ziwN$n2of?Z8=+=qLYQ0w&iZk6Z72MkiNfBtzzO{ z$qf~>qMme#t|7CFtK@v9I5Hk`ADbLKJ`r#u$;;!Tfl!CF$*mRZlS|#f3Kcut zvM1#Br@lAjo(hQW@!;XfBmU5lC%rn&D>~f$fhjQ%Ld8+MJ2V~~L!}nIM=p74!hiCR zf8?lpuz!1q3JaYKj_f!-F+ILV9G?!LqCNg#)8ujV0ef~Io)q13N1h5hC@KJXJJXUL`IC2CFiItDI2M>bGC8_FG(9=xe&t9Y z=tg;3_Rl_IS27r~3n;DRf%k zqYA{&)T@u~!{X$z#Dv30V4RQnr`Nw2!m{ezwZ$8^Cyx!9IqjdOzKz?GIy!Dv7eU;q zC09mwcziT<^lrzZ{6$;Ryss#FJCgfzkKS<(9a4csS@k78o9mTZf0`WeLB1c=)B` z{s|=}Yk2tZxEPwA7!L-5lZZAA506fcz$0L$#S!ff1}9N7nivSxADWz;i02ItQ*Fmb zhW*pi;`pKC(*Z<^`RIWM_;K;g%||AW1)9;C$>vRyBgbh22{rphME?{mC!5*0HfxjAJ9SdbKyl2W?hVoI`A6J8>09pQcy?lq>Jo~UQV+!1DtfK+n5`>1fk?&<5vQ+&;MS)vtPz?g?l= z$W3UajPV}bjB9WTJ9EHImBC;zus%J0Ea0V0TTec*4kf+E$MvDW#NiO-c8e8o1K7JxT#_8y7A!X_y}507zmq&>X3WTd_inNby({~4}2b-*o<3T2m)}@ ztK3RIWU%%QE3b%`rSA_lz85(|g>cX>nK|DW|LXYHj-C=?#btNQu2^;Lhvo{_L2(mR z5je+))+*%DMtwUTp1!9Bx&Gu*ke99|;Ln462V$x*b7xj=JQ4s7G=3QC0Sit6GHxJX z=(Q2dQ3SEMDtFfS^hqzeg!NHU_lT=e@`CQk17woBcj6?#2BDXL0r6#vx+!hC9aLNy z9CVi>_pIeD%iFedqtUW8Vf;JRTwW8->XOV|izk&GdDsH6W6FMuLl+<*^-A9{2-T;i zL|1YB#@Q6@aJSi7K(?FAbwD+*9pFnWRm00{f-fP29 z>t8qh`CBwd{u3Dd z7n;vECz4gyMXT3`tJmKxD!JhKw&yzy_Y3R}>pW+-Smm{=XUJ~ylB2O?vvIXD8!NH& zd^|x|+!>_BT~MC&%H5#cjq<&pHs6%XXY!d%J#Q#rOT zT8_`&ZpFOKm?_`LumEk^G8{WGalKD5y#`X|r$>hCUuaGPG76ZHZ0{rg$V5nd7UQyD z+p|#RnQ1@gI!;FGnK<9W<%U=gqUF zf2sc~%U@Xjy)EKe7ptgw*Y%D|s(%bDNk_|1(%aUTOM0>3@Z_<=#d<@S=@%_kGT7K zgTXwX$z|n$FtFI)*c_9|hqRQ#FI=S;(S7fYhh1&2Z9Q#>=-Yl#SaxyxMK+u8QajAL@06+J|Pnq3*t2XV}PJ z9GvIqetGn_>^^VKG+58&o*S86b?(UdvWFZV=iQt!dtR4;h=JLv+0bm=`2+JD9;M}7 z59t0tK!OL9KyEHIas)r^qK>BP!JoicNCXoJ>=TMO@VE3s#&iJ3XYd(;&YKvrZ+=25 zaHl{@SbUZttIsfGLq2e%5Mm$7V9z?AZ2?)3>C5ziFNG9Yz6{YZX7pr%KuBS;d?cdU zGyh~|;WB5UDT(27SmD8FC`3BO3&@5p2DCtA`$qyNPJ-~>mBh+%gI0C>NBu-4GdQ>F zBp4Y;OLZBa^jaCm2lyb!g3-xf00e}4$}fVu5tyL&F*b+H+aO|uv4cWNE-?hm$v^Er z>>rZ7t_&-+i<^|jfweL@ax`ujp1^GpECshSj*~D z`mbl)%_})O^w!YZ2Ttk#SOI=U`MvUvu>gCerThkd57q&YRdMbm(K4Rf0^oQAZF-si zme6OgBkf*CI^eYyCHX-%05Q^l9NwT@{RB5tu0dYqwYs@|8ba4^NlAuBpKhN`4NYIZ zT2dyrN~ii6#}~C%f$0`7p%%7ej5l+AFXCsa;2Jp=kI|J|YUb=5ZM$@hqHTwDZmt}y zcY~%`FO+kK`8C2}-XqKuc8-p^{VGEeGn0te39!z*eKU3YQb%yV8_Bf^+lZMw?Vspy zdyFDs#o|slaif1~Di9ov8z&~m#sXs8Fd8^?d@L?Z1cKQAiS!W%;Vb}hf`4>0o}2nM zFG9aUN%z3{2$SU=Fs_cX9dCEUvhptMKfgbcRSy7nHsef2)KMLFRL}0d<7m9=$Ukd8 zW0%T%KM-zh`nw%J-4WURg4DZDDjmAx*#F4H<(5$)azURz=tkMEXLptGZ}Xkc7{4jN zceXRXODD`V+2EgR(}~qNwQqkW;j* zpY3>wS<2A0q9NSxA!*Bgsr0}d$8hQd6%XJA3@uzR!H8IV1HT7< zC~({XUfu-<_~bP|#$&fZlGKasin@h+N&0Q0fFGD&)CUboIn$8KmE0bkliO_! z>6$UxPHr>GnGArOGOD%QrQ3mgHtd-pUC`(=`b^9D)%+IpLvr5*P5X$A$F9A#YVV-D zN{%}YSTgnIpv7m}&iMhg2K7F36FAX9{SI*b28DjOsn?)ceZQOAQ7ylJP*6w0e~iJX z)J%9zJE{We@hskF-uqfQxV9GgMp4HdApFyhSif`etCDM2#toYL^A@L38M$afm)wRY z&7MJhHTtKTGjqLs-&W=}FxRX=H{Fq^fNscJCd0RoP9C?zD6#Jjes5NA`!u$OiSwg0 zf{8oc0(9i$JV@2^MczjBO@gMDnXk!spPT6RCDFWOTo8CB!xWzu=L6T9c(@RUx^eX# zKXwR$zRAPx&~e6cJ$!sZ2IasdMGP$0$6zhPp%$Qvv{2v1?}3*G6{c(-7)&l5(Slvl~|Vhv<2NFU_dG{QTfDj?#Dt&y?nYsej$=n zjyDGRPJT>~?}zz)8W${0nQ0cB58K9*%2X!^Lz)bI18Gqsc_6_6Q>B)HQAm7e+-aZ_ zOX?1Y;v|H^5V08;t_xI2^p6MQxnQd)LR5$W1Jm7Ds7Lb5r zaZC4!kpL5l#!avI#UL1^;6oGI73TvokAX(f?S4&W!Dq4e4};kyOSt3Bi=Z7#;avxu z-#-z0#+#Di`zS3$X#FRIr_Jju8Kp594?8z-$59!}FF8ALX5#H2@HEL$4gSJ?fy=J> zKWzT%4U%))15TGwnn2xd@mE4p>9#wL?RN`GKD+BI(c&}DWJ(pCS1PVJrSk4O zjvk7iHz4!pp)Gj-8+=zD{|D78K)p2Uf1qE%{cjZTBYp+<=XqT@!kp37StZPs84x&E zl|doPU0oT%RWpTL%`hP3YK{PZJX;o*35FEGr7*Bc+ zqvaUK0{lil&=fH)%#2Togd@nPgkfZ2G87QUsW7XI5kJdHV4W(4Ag`G^8P6p8FEFZ+ zU)IDL7m2is^RL8BP)&f4C?pRp1U&4CAfo#qZ4h+H?2J*$k)M84{}^#0+8)^8{Vs=d zV?bTK`PA{+W3W57$~A(d}UoSLnWw`R&YK)US?YtocFX>~p6k&-s6z>3**< zoUvwJ5DKmL$|~<0aDT)JPU|VtLn~KaIotU2vbHz-PW1r!KbQN}S7VN%bN(|~Z&~j- z%A$^%u%l-7@XsAh_bps``|mP2dl7N5X3oyMJ@fZvji-BJ1*PwnzEk>PfmdPAMqQ0z zSL4OqcU&#=I-#&4R#JIk`}ysE(nlpUJrX!q<=L5ebViY6E<7~@=h5@LMm(JE`21rp zm)Uk-Kn?E~A@L^{(;Qr~O3|2~_oXZ%&_wz_~&SovY@%8})NF9q?Z*&+f6BuP(Qezk~0|(qCQM zX~)Ag6W^02N#E>w^ z($B?npM~EI1{JirDW2Y+a;fnEy(#>G9pGFN_L}~^{TfdqQJ`AZQ}P~;0<^aRv}<5I39G^m zr2&>95ZEqgitk!;UG?<0ddkT?Ps=svoBWUjuthBiy9+E z-e|7(cCPoni7T$dc9@aY(?>RCgTeN~-VV;ha^g&EAkM^c{;Z=bU$|J+CFtjDd>3z?v+K$4C`0g^*M<~x z?R;0Rey+m^|5ctx>{Y?qm2JA3At23F2T%TN=FjCRO}??K$#k`Z?<%)nEwz*1V?sEV zlm_KQFvd7f_R@@cAL1Dh6kj6W5IG0o#0`f|LMkq9rT_+k%uvvQO5XHI@yitUIyvdM z@7pMXIq-x|!1+V&zRqYcf9%j1s^`rH!vTKQ6m_?R-7WJRJzdUy$nNu*2E##qc1^Tu zO}J_e#U11?Z=$$^{CsAcp=j1H&*65l>LK0cr+LnpIj_?hDo=-iWEd)mJuU}4GFuFl zqFb%X2t3-Q^7$T|!cLHmM@}4aXn%ql6-_?lklAM%GK0UZa=cAL)^tKvsL5Dl4!Dvh zV<>aTJd`CX(Gb?I@yH!M+fX*dc6Nkhusk_(9u@*kO>oJbzH%0pHIy-ArrfASZi~_9 zpjasB#iaufqe1$Bm6uu(nE0fcsuGvaUi&=O%YD2w{t*H`~uy8kWmNH+3uS~2QvwJGR zrcrf36?Gv+N%W%_TJLYcU|AodE&62L)E=l0LHCmBqwWfbO^WWMCIAO$$|{8JsmTdw^o?0(@{b*!>d10ff|G{9*L>x47ki zJ^%&E<)bGIfqbziB2wAKo+vlF5-=qloB~%|DFp;36cPGn+V(9m?)%+aAdMJDBWDO< zAl+kGU6vGe!KU^Y;})pYF!hBH^c1xo8J_lsj>a9PqYRw?Pn+3GITmb*J=q%q6kbcDffKWWz?Dd4FTh{D$pK zo0Qx4p?RmgF@ZD3$>hD(lL*n{$1pA8PA<^zNlt}0x=4lKahQkN*N?+Kf;wOs8Xh2* zOKv7Ns3YQ<;c%6`foUdRkVoKXKvoe+R4qZ%L^3jpLKF(Bc^znI61PUZ3jYfUMD$y5^KYPF|r; z806%npP~Yxt#BB^XUmG(O2hPDb1^sSSsV7Oje5H9f48D8TCw_e#p=I)CAwkY_J#pz z-~RB11L2C{Q=9JC%VJs1XjVm-kq^HQ(Kx@}>F%stC>$c-#)khH>|*lpmnCanrd^{d zZk~$gvz13Brd$d6WKbB?rCYm$g1WL7m8l=pYm*5))1=*)cnc{P_7A;UQZ?`i#`4<% ztiJ}dfn80kFyjmf_FOvdHyC_|Ma(!pXy`PIK^98)!@NpYC=$$N{Q-Z%wt;b8nSe;% zWYi@OapuaUVFcr!L9*_L70Q^Vs79S?r=fJ*nWw4bVav4Kb2K zX289OIM64FEr|yM2pbM#yNO2;%jN~KW>n43OzcelQEhu#tkb9-^g6AwW~;&Yjbtwfle_X_G+F1AyZRl{8wE{>Gn`{%CqIA zk^^YDdJPImylib5sIdyz)kH^$aCM=pHCF23nT(=RUcc<$BDb4BH1j z16P5q$mdWitjJcZ$SxyRWS36=z0PuY=hpFEdi~sbJ^aKjXoY$MPkudfjn=M}Sd6Z& zCgEzGfnu8s6tYr)f5Af3V4ChtctjirIJMoNl-|c^t5K4FzFq`atYdGj@Kr+zZ(X^jTn)bV&0t#Wm*PW?5x9aUCnK!1)>2h^WqYM?$?2?}Amv;H2;(@4rFl--W zlX|xXW;!iaG#S#2kB$%ppxI$CIXre5FIjGodMvl!9oolndLfJ}B(d zxVrl^Fp>%cND~6u1A4D6Xc*LKxEWU6-Wne>J>03^o9APuhl2v%Y+41K1%M7hUs5i$ zY=94QvamL20uS1hZuajD_$=*M*wCh1n8OU||6@QHc+ci^Sl*H~unbz#z29e5*S*&A zY+$8MwP8LRbS$j21tkNp0KY_O_T`+arxCR7%Tr4kw5EM`hA&f1y+n$vC#G;bF@+KM zx`OvF@s{jF8T&Nwy+qm^pHnSO?YSrB$@S%>=JDmXn?gFDnf}x`=ov1U18!LTFg=Y5 z=Gw5o{zv>W6pp|7X|y)R%(4~*kQSFZ8vUdPo^oNY2KgAY?IX=_cJ&kUIOXyc4BC?C zftw3prUz}EwiE=$<*&uwWmSv;{O(}j6;k$cH*vUS>7}PD?vB-7&0h0<{t}!9|Hc`Q4iDRNbEcvZq=e$%fDq4tga_u zL{3)n;MYM@Q+*Z8!WU?TXd5?_2bf85I_q?6zkeK%N!BCLv<1j&ONCd=-jJA&B!>=J zXG}ybcQiN0`3{DA!D7KE_X{(6i0j5jncc)p4q4td!^C_1F#6JaY}N5LX#B^Gr2l*j zX4?T$XP8M?KC5~VaWnX;tisGFD6UvVVx=dUMv4S!L|U^80`1-6_sL<@ zp#!QMp};VDh}k$F53x>@(NfZEk!>0-Q1LdI%aNqS@iz#DoC9wDMW)@d zmC653VFEoetoA3A_*n|mQ$NbMEzunew^bZj-J@`r4Q>GTbjoa3S(@<`+U)VkOc#yx zTq;W$!T*bJ7}{IH<(GbUZ?v=}T-qX)v|f7dFHc0*^o7^-Nvn5C&RwU>cQdoUS@U-7 z*CtL0u%sv`jXCon>d&#BGXBoW+4Jue*PL$o>UJTtp8%XKk_E9&rs9iBUm zhFED0sSAH+I9kwnT2G1%u-iJ<8+BHPoz-`Xs-$IWF6%Gv`EkajuSnIsk)q8~?&f=n z4UF|-SEQi%wEk{Eonr2C(R;ZvQn5Z(T5)0T`Mt9*UaE|gw#MAm@0#B+Uo5?>i@=;l zi>r&2u8q|-{>c2ESz6I^OBbozb{DD>xnwtU(R1m!NX42+N!#6un%U8JkGyl_;;WZm ziY)7lRCL|XGP`mfWpl1Ia*IATaLyIKDp>tb1&vU{V4;YR3RZviu#$5WM6)WxS(OlA zS#k(-+WzKtskmSA4M_ciQsM49_B}CsHVAY?e;)cCCdKQWHN6%5TrJ;Y*3Z@H$=~Gc zY2={e++#CdhB$Nf;x^*c0J23>W+Vz+jVcF=+K8F!frC0RQ2oSg3={Y%U01*W*;$fWsuTFR||o z>eut>%sPEMX?BKm3G3m6qFZ{a05a6`>Gi-_d9s+iO$_LqbPtIwKLE88lKLl}U5!ejavXl{De@V|B+ z4<_mVrel+UH>vd>@ZANyK$r_^tznUg*^}U%rsfHn22EX@dMua*53j0$GAe5IW~ndwK$os2^jt0g4vgC_OenE8pB4cybm@8o@1gZjh_ z-urC2RW-=hqB!&%ndYTjt;#da)uj2EhJp1IBa;TVTY6;B?#K6+W2ydPYOfV34Z-8y z91Z79nK?o8J~CBg*C4;IO7-unQGIGZsC^AOXqg(9xFb{@@I)ZdO!Y~RiWi}cX+xpq zlX|5aS6_C!IjDcah^|O23FEBs_EYC7#`pocS0B^lQ%F$gvxo+sZnIZM>sF& z3GKY71kXyN0sQP_DdnUnD3^ax`sZl~9F`^(@OQZP9Kjh;&~j%KkAfAbQbnf1=dSC4 zN-|DzL1R^_12P=>B$p+LBL%$$6$NdF;7GvAD2thvrAdW;oQ*|YmDIo`D?sT{xH!O+HW3yG8ydd9-#II5>EhmSzRGBqO5;Iw>XZOq9 zEnBjVBRp;w!EYrpiy626fP%7zUxxY!gc|{sN@5hANZUTMABUDmAQ*yH5K|HfiT{Bj zZjr+**co9+Tr!zJWdoNWQ2&A=7`>;cf&(ApM56b^za@ugySSNZ!?YGMm`|zqXgot5 z#bFY@h>QkgLJcdjB?v#6?8_r5Cg@SQpVY?0o0cN^(k6d_$h{sgK^eJM$JukwzIx`> zh^;1;UvxHjCOEq{obNr=8?!q}^YQCDf0K$b%mH&{0hk%CGK8ZB%N7dBtYh%WC8FYmlkf9p_W`E!xN{;0G6Ludb8D3WeFvrVczB#j)E zCMKn0lhVkPbm%4NrD@51{7&x6cgwudvbJzp+vUQ9VfP zyRPEzc7Lb$JN8(G2Rahriq$c9ovfmX*l4LYTd3 zj_TjQ#KeZvE%H0B)ZJQjtLsYBm2%9Y=lGgfO;Z#HThw$&)oU?_^44PxW#?cHxyqrY zpJwnrKFM>1gZ#%QcrK&$V~k|!v;4=Haz6fJm_#86U6$8z-^!Kj=I@tq*)7od&uIPR zAq*c%$u=clvt98?1A~%pw^X|4j$?1kkw;W_M*e3onMi%@ZF|e-q5q17`g&LHj`dK* z<+tbSuNIV&zqQl0qmaAC72@GqA-}!Qc&*4l{_2 zE&MFo0RPYO1?2r%VbP8b;b*J(9jnbhYcZ0)LqN$4mMxGbYGVB#V7E~eq<9roCf)ln z(w9$btfpe#z*ZO9Y3iT_gKaDI0{*U1-s!~rP=x}O4pk_FA^}-gssJlab84BOrHmbW z!F#}7{uFkzrYG4(GL4!;z)0KiE5gtAMXVH6i~nT2Qr6SPf&qEsga zMP(zZ5ApaIm9kVc;l~6je}p^)D*rjFAm((<>y4J(JUcRKWw>Nzq@*R9-vVf<@Gsl` za!<5%>+RO9ka>n%eZ9Qo+Eym2>3X z&)^DL|4|K%D50M{G${HzvRIWd2UW3ZA&FHb`(P|97dsV?+I=3nggU=&+34iI?(C}O zE|9vwY(0E)R%isk`maBsKtF0Ry!d0(pqfL0JBLx_CZ21Q?3P^k`&9JF`n%xMz&_nGe3a* zh#|dhzd~3+#r(+R#HgE2v{9Eah`vn$h5o`hnv5+Anh_^UdWl90%6?L!8##iL5SQc3 z1<1b?w4NQkNp?Iqx8|v7M>Q`fKLsnd_Q_P*W7NHsri`jJWV(PFaUp(yZklm1LJbUN zSlbdsr;%*p|A}Z>uw;;FhlONAJX1C?QgP>G)nkUKFVs5DQi%!W{qZGLiBb~Y#B>V1 zjk+)-I|oQMLsV^^m_6rSPX1Z%8SktsoKt_w6f;%+UX-yGc#*}5y~FN|+RDSW^4S#;TQyS}tO@7T%pQs4G)L{tVS6*w z0_`=wus8ocL`H^*|Z=-y|@GAi~sAzY~ z@>MAZdE+2p`J8)jtssP5uvcQgJ48po({{-YZkJD*!9inm7Pw0BH(EoJV91=9R(V|z z^V8w!#H)e1BoYU3|5JPNaqk}q z2uk34GfhjE@1xVR33*_s zr>Z2Llc<#ne&Y2@v2GTp{Uw2vb~pr59_DhEtZ!t#nJE=Qk9#i>_%94eyY}Duigds) z6&$)_9=T_8E@bRP^XtN3y|^OzD^K;_%gTqu8UOP-W451e5 z;WF;?CXEpHB&H(wI|dQWtH|Lyht((^Bb*YA_co1^7J;qoEr z;E}tovJ3X}cByti8r&Zp91RbSN&^9@=I|ZY7)s-*^HLYt)D0M z3+#BMs(sSXe&P~U3`cVhhI0>+sv%nUDFgx*@9#r2+OK!Ix|i{D#m-Hw+?qU8~?XRokys+R5*wGOw+`aRL0bmCWC2?XD26ZE$s$2-gb?2)|xpK*;q9 zJ^Tv*F_ETk;P+rPz~p}mBAgCrCx8GQ=%k3Hg(L)66UdP7nNK|KSz zUkI6nI;24Z+*ox0d1=75PZI(s3{uh^UYR%wk&*9zh4OUi0JHYSuPdlR8qku&h**Gr zLoG9HnGEgMLBMQG*MdP)lJ>p^NJ{=Cn|&@Veg zahif%;ti-@MzS&j(&JAPkuEx(5QsxnBDd;G%o#9f7Sz$r?JP;EWsRC6affg^QA@d`Y`jNmaHsUM=`iM{Z&$+Nf42dBfpJp&JPiFc+%0;x@%!;K8k?{tqB10SG zR{&wCTsn(V5BE$Ry=wB6AP_dNxBU3j35hIaSzr+S|M=BE}+9oWBF~DU015E zth`)$IZNuDlyavY3cBo^-@y#8l-cxoZ|8e2o`~ePMICKnM;jUGm44jLL>6X}Rou_y z>U#M9RCN$9!~gB7gP-Blq>>sK2%!rIt;#%~DS2Ly_5sz~FV!B1x`xB9VZv|_|0xa) z&t84s!DY99@;fSe(K~M|@BBRU2vg$qPV44EGOOspStA*G^5^9CHgR*y_?`m&Ty3Ww z4_6zVJssTD4SY|L{(Y_;{`cGYo)Y8x9Rm5+*Y~)EYqd7`udU>J%JkP(>B+x_3c7CN zdp-K=_6qVhx$%M<8GLVp{zj&W`~^J1Zxr!Jf1`xCWz2OOd)An4pb{SYjaobTSJAs} ztY+!gFn@=&cbRZg@9M1(Zk8BO&dmw~g)E~w-)!Jf{>`R}&H2KuOn!5=`Bs*R{P_aH zM=-gyi+~K#Fb_x~y3~gu4gnki5Neqco^%M6iZW4Eg#8dQzYH&D2L>;9VRA0!2x&Uk`8!aVP1rVXq1)x@8^`JW(m0C zT_EzDHX*#-Q zTX@a3$eJCIsy?zHceA{UOwXSUd!B{$c~~((Z@FF3a%mvi+8b`|jkIowRBS!9=|kvB z3tat1UUZ`p3#KZ;8Ab(M!lad{06O?3i8X?{r(O%r2uL&a)Ty-?hf0_+Vd|%wP){)p zoHzz_GoamZ)NCpT7;{!?{T56(65ga-bbbLd6C@y*a`^M}^}O{Sh)y13l*Z`-Hgn*Hv@ zc{Cb9r?3+j;NL_^^;a&=<+CtaXg6zN8mwHcxtlgR(hN$z5EU=C^~QrK1k3Q{csA{ z`LFT2gx^X0^1cWSfpY#WryMW~;VrgU`2rmQKUkZI-b>d;Qg~HoYc^wZCaF>~ngVQ2 zyyHKR>MyZ86LbaMroN0zr*?`O0QAxExj6JWxn7t*Ppnr?8kQ@2f8X~~>%rKq&KW;W zT4E;?u@4~7p+uVGn3U$|%pR4|3828vGh!$GC|~1J#5<%6xzrb^`WT5JE5hx348DJK zTxKe2#{{|Sg3z3lzt%~Je##p{9uOWMpf4E*G>M}5CBo$%m+7aU zP$AgUxlk&zR(wE3FDdOyYBzOO6JT6u8ysTI-pkCH*VSe0ff04-*<)`V`_s+Q>Mh~w zEs^}KQO8!vvGq5eCVW>W_+BvT?FxIlBHm3=Pj@)C=JaOh#x<;r*7t_%d++Sp8{IV( z-ZdrF_e%9INx8N63d*7d4dH@@i!VhAmjAqcyTWRXl^u(f*IpDZ=A8TTxyg$@5Co+w zLA(?ef$LdV!Lrpx3u?m!wevb>VTg~dXpOGu4X@~ptk@E5*m7=rtaeqjc1^f;P0U>z zbuSOQm*1^xj@GTeUAO*9XSl9s-oVwh;Uw3(visyP)2>)m!@C>a*>GtjQq>--Zg_Xc zJ3Ib#XRO{U9~XQ1l}LSWw7M6@Sa+-I-`)Ps_E!+`2_-*(z<_FCBPE+Am6C2(9oO=YJ~*HOoK-)ik`@_fB7|ez&w|SUNZ&`A4KZ zqtfmG$~ero#9G$<)y_ZPDXrRcg-AC~FOTNeEPGUl%Cc&I%<5jw)%WlpTfln$m1FCD zH`ln8zptZ4*}0m{{G$vtbt21W58cX%upypk$G-D@QWKc%e(6v^8VN|xAC~rxNn^*P zAT<7_q=uKGC1RvRB$^EU{28c2C?7gKVJDp`n^Eu!M?O0UnWxDC%{O)u@^ex@^d|?T z^1-NMci6F;G9&);5H0f8bzAcJKd9ZD!~Kx&Y0>|$U77H{&pCUGb?-a*o;Akzb6eoQ zX6Ad^&DSgf`R%Pe8w}TZzGtoZx~{Vb4>w9|h`0e$vGv9qFcpKp(SU+(tPoKAjTWB# zYj_H8XYN{_a;<0n4aQ!V<)(q}HD}&5+R2|oueq5gAlFS7^A}q;XBlqRlx?;eZV4uY z-?ADIax06{+{($_;uLNzqo&-dvygvAf^=yEzab5NHQZ&k}o+;Jj+h9Co_%1p$lzJ6j2 zrCcf+rXpK7SN8u_(C6;GdFfCm`fg*;h&T<6NEFH9OnXBeK)L8(_Qc8g>7>EzYyzF8 z)q?Xbv*-i@jp^>@c5DXvXh>(%?K3k}d=u<#MmoNUw{2MPwlt_OcJ59|LKW!u|K9R8R=UL#4>Z0MI9%GHhM9E(=Rf3+f*aeM8 zv38WSPz7nmyL3;m3Zilg&@3lIvqa*}$)CuheI;I)z?u|201a>Ylu~NAmiT*mBwCey zr!S!#DdSiFjv{_V&S&Hh-o!8{9FNva{#E3xh7-?Nh@BC^CR;FAsEgR!m~__qVv!g5;fZu$*+w%=yyo)+#FRYr@}g^SjmHr*vn^=HHGXHRD=#;<;^;8!tsRdiW<82|3}(_3P74N>pb zF#hYdO1U+pTkeSzv}tG+kdedE*o)GU7o`J7rTr5?HjeS$n0Lb!L2BA`r3-+%bTgqE z3Z@0T$W@+%qFs7E(%MI*+0NTyWzZ@glJ*~z4jhyQ{F3hwN*m#q#g;*uK07S6bW1&Z zq`mv3?jdRD0F``DE*Z5duZotf2$!w6R2L~*8+EO{?OJ=Uu=2yg#(zoKN2Kwo==hhz z<6oAJyehr=6?#>tjx{3vtM0clv?-gRO||z|a)s-_sxDmj3H0HYz-};VDt4nBGFR@4 zItIcHcAixRoiO(@=Edu}o(}#G3cFWxf5LY+>o06v0q->j-@Vd!E!#r=vQ7hT(2wtK zFDZ?UDJVM_bu}~JQ#TrtAgO12(19G|)3V7~2%(UjL7Q5ZE77s{U&V`= z+(`Yj8m+kGk5|QQWW}Sb{&>|A^rkl{(I3%5BJLMxku2voUVZab`9#avx=2=oWNui@ zUevmwk#)ltI@iCJH5~-KLaQq^P19B|cJYZ`xy4>;Wr9r`cT)Z364bVa;th{$Y)ay< zQKJc~!V#}o&eNO7peB-4C7G)hHZS7j{MaPZr${$9R7B1# zDE)i{HQC1BZlqr(Ef{w`x08L-(>&v>xc+%`cGe@00TVDe?|5luejf!KCTQQhGA0_2XNP zOc_zb;h>}YgZTX{vm7Khhs7auz9Tt}QG26gZ`4i$U;UwZ&HY*~yWx|&B>7eu26?~0Ij-Oa4rr(FC$6s%R64{TkHZ^j8!*8nB|3nYpoX~*4x#p~HGdK4P-wk7r z4SM*m3LOZ)x}NW@G+uqCGaC=rvPiw{nxlyP^*l0PYs~EygzKCRA=h<0`2~UeMjl06 zH|KVj3)f3nY#C4fa#CZvj*s3f<2`yNrenuLvk4^c|9z+@j(r~xd&pC@^p`2VMRVrQ z$?=gxYEAMvq8G?FOV0PnA(5j>%{+pUINaPm0ZF73TM zdS%ru;Y!<&gW=X44=HTkWikx%Qq%f*jyzYYAF}&=j>)i_zZjy>-Tb9#3Wc#_PKP1q zvM|r#cFTq1gnnAe{L-P&Zz*j4Ri3Nyz$o;-Wj}9dy_b_WZy*nrSYGKl-yAy19-jz2BNk#V;-3rAE{wf|2g}pW>mQVGeAeG{nSamOm}`OO zAGx}JxsK`z3a}b zNmKE4LY5Y!9Eh4@4qWml@K=z?Jy;Nlz9^v-+!1{BJHk@n4d^FHSH-n_TnnVC3& z_3YDU_RlIIzo0N&kpa_P0Oo)ggp+xaBZibAhWtQUR{DA_R}@`+dZDx}d~RBP)^bcC zvO6Bnme!3M)-W9I(m?3ufjJ-#2$vwNY)D)&BPXp z8bZP_cs+jIA0g@ixj&3eHl^hOZ%FR~XF`hi2Vez|8aC> z6kT22{nzM`S9t$$wzm=8B#zuiN{A5yX_SGXf&`D%Ut8}vqpD1x9M?eYq0k_T27b|@ zUu80;Czgdsaj_a+@Z0Jg74ghrKkz|4BatE{QAS^zGq{m@Xe)K^Y zHJP}%bc^HL_l1=u4w>|YrR8_9IUMGOvn3%I_ifKz=iKArj4kdup7rQ`yY`UrFlvj^ zdTA|;Z{r~q_EQ$l+SQuFs?7CG^b1wjtTH_ks)9K?q4J3Bg=*C+vrxYGF+T@)3M_LL zS9rJh(5tdyojYD}&9mwlMHCH-n>E|56gS-u?J_GDtE_CBJC3(YYutOXSFHQCBZ`pI zjoMzQQEI!kPiZ)fV=|O5JMj~cZ%8{a+YrI6!`sb7p`FS$Q@NArTq}{>|Gb&F{Fj59 z!Ad)s+FuHG4|Bnn?ev??bpB*6kG2!4RPe=Eb@@s|eXeC%>5C2ZyF@E<3ASWw;o5WU z;^5wW9^7foXB%tZ-)qmz?Vk&_4pYG=XP*2zMA!xJ#2WQ32-pkwb*}+AYf`Vn!y7X8 zQyF|OzTjpPke!QWwRW{l!P(nT70kOhY76e zK*)hcpc;w_by^9&I+9P5xg)cgESzYC(`12P0`30;n&a=1XMqf%#;sl({u67NO<)}e z1#q0ex*B|alsZjbJ$l?sE}m$Mr^&@0Rz1Q~cn@|ae+>x^7JnVchOS|EVLwB6#VLh0 zt~b;qNeY2d%Ny4LFqB+-QTM= z$5-tUoKS%j4!{354{PT)5klz$Zl782SwD3ptiljI4F9~ctzdr$>;%Wu} literal 0 HcmV?d00001 diff --git a/mediaflow_proxy/utils/__pycache__/aesgcm.cpython-313.pyc b/mediaflow_proxy/utils/__pycache__/aesgcm.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..07f23e643863ba59f1c62e3180ec2464db16aaf0 GIT binary patch literal 7016 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 aY5y&G^*_kb7w(%5&aJ=m2ZFy$%KroK@R%F` literal 0 HcmV?d00001 diff --git a/mediaflow_proxy/utils/__pycache__/base64_utils.cpython-313.pyc b/mediaflow_proxy/utils/__pycache__/base64_utils.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..4df3ee2d69a5c858c58bfc41ab118cdbf7e388bb GIT binary patch literal 4500 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 N5olK8KjVfh?Z0XWAf*5R literal 0 HcmV?d00001 diff --git a/mediaflow_proxy/utils/__pycache__/base_prebuffer.cpython-313.pyc b/mediaflow_proxy/utils/__pycache__/base_prebuffer.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..9ce289fb1652af1271e5827d1bd97953d07b016a GIT binary patch literal 18246 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 E2VhUmGynhq literal 0 HcmV?d00001 diff --git a/mediaflow_proxy/utils/__pycache__/constanttime.cpython-313.pyc b/mediaflow_proxy/utils/__pycache__/constanttime.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..86f620a5469dd159f7c9834b44242aabcc5d47d0 GIT binary patch literal 6973 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 VtuKGo+O5GK+w{=salB;U@ISYMdsP4c literal 0 HcmV?d00001 diff --git a/mediaflow_proxy/utils/__pycache__/crypto_utils.cpython-313.pyc b/mediaflow_proxy/utils/__pycache__/crypto_utils.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..da30a4dc1d2595879d790e16aa426c271e9999a5 GIT binary patch literal 8308 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 DbDgbg0R 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 aAvH={A3kvi1($Y7b!$VCU)n1C&;J4L_A00V literal 0 HcmV?d00001 diff --git a/mediaflow_proxy/utils/__pycache__/extractor_helpers.cpython-313.pyc b/mediaflow_proxy/utils/__pycache__/extractor_helpers.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..4df8555971e4b9c5481c5936222e0d8b83977e60 GIT binary patch literal 7752 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 XK{Lbx%@6}5W9a|cP73%c^znZIow$gc literal 0 HcmV?d00001 diff --git a/mediaflow_proxy/utils/__pycache__/hls_prebuffer.cpython-313.pyc b/mediaflow_proxy/utils/__pycache__/hls_prebuffer.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..8b8bea1927a8889fcc5fe31576de43e8802abf9d GIT binary patch literal 23113 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

>p>71nZv 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 QZ0TaIbV2wV5i(5vAA?(q_y7O^ literal 0 HcmV?d00001 diff --git a/mediaflow_proxy/utils/__pycache__/http_client.cpython-313.pyc b/mediaflow_proxy/utils/__pycache__/http_client.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..8ce43a46abbcc6e1bf93ef9d5b11547ff0b3e5f7 GIT binary patch literal 12690 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

lBLah{Bd04GTUHB>8Z_%6HJ8bZ8gL>!W)zv( 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 literal 0 HcmV?d00001 diff --git a/mediaflow_proxy/utils/__pycache__/packed.cpython-313.pyc b/mediaflow_proxy/utils/__pycache__/packed.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..76b1255e968f9e10c24f2bf8bdee97a406481c99 GIT binary patch literal 7812 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 K-(pW@vHlmaTd(r~ literal 0 HcmV?d00001 diff --git a/mediaflow_proxy/utils/__pycache__/python_aes.cpython-313.pyc b/mediaflow_proxy/utils/__pycache__/python_aes.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..5232470eaf3b7aba83eee81927896462ee61cce9 GIT binary patch literal 5982 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 P+u6U4{+d8l9LWCw^Y;_S(;ss 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 ucvw!^&EoweDKNIB~br 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 None: + """Update last access time.""" + self.last_access = time.time() + + def touch_segment(self) -> None: + """Update last segment request time (indicates active playback).""" + now = time.time() + self.last_access = now + self.last_segment_request = now + + def is_actively_streaming(self, timeout: float = 30.0) -> bool: + """Check if this session has recent segment activity.""" + return (time.time() - self.last_segment_request) < timeout + + def to_dict(self) -> Dict[str, Any]: + """Convert session to dictionary for file-based registry.""" + return { + "infohash": self.infohash, + "pid": self.pid, + "playback_url": self.playback_url, + "command_url": self.command_url, + "stat_url": self.stat_url, + "playback_session_id": self.playback_session_id, + "is_live": self.is_live, + "created_at": self.created_at, + "last_access": self.last_access, + "last_segment_request": self.last_segment_request, + "worker_pid": os.getpid(), + } + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "AcestreamSession": + """Create session from dictionary.""" + return cls( + infohash=data["infohash"], + pid=data["pid"], + playback_url=data["playback_url"], + command_url=data["command_url"], + stat_url=data["stat_url"], + playback_session_id=data["playback_session_id"], + is_live=data.get("is_live", True), + created_at=data.get("created_at", time.time()), + last_access=data.get("last_access", time.time()), + last_segment_request=data.get("last_segment_request", time.time()), + ) + + +class AsyncMultiWriter: + """ + Async multi-writer for fan-out streaming to multiple clients. + + Based on acexy's PMultiWriter but adapted for Python asyncio. + Writes are done in parallel to all connected writers. + Writers that fail are automatically removed. + """ + + def __init__(self): + self._writers: List[asyncio.StreamWriter] = [] + self._lock = asyncio.Lock() + + async def add(self, writer: asyncio.StreamWriter) -> None: + """Add a writer to the list.""" + async with self._lock: + if writer not in self._writers: + self._writers.append(writer) + logger.debug(f"[AsyncMultiWriter] Added writer, total: {len(self._writers)}") + + async def remove(self, writer: asyncio.StreamWriter) -> None: + """Remove a writer from the list.""" + async with self._lock: + if writer in self._writers: + self._writers.remove(writer) + logger.debug(f"[AsyncMultiWriter] Removed writer, total: {len(self._writers)}") + + async def write(self, data: bytes) -> int: + """ + Write data to all connected writers in parallel. + + Writers that fail are automatically removed. + + Returns: + Number of successful writes. + """ + if not data: + return 0 + + async with self._lock: + if not self._writers: + return 0 + + writers_copy = list(self._writers) + + failed_writers = [] + successful = 0 + + async def write_to_single(writer: asyncio.StreamWriter) -> bool: + try: + writer.write(data) + await writer.drain() + return True + except (ConnectionResetError, BrokenPipeError, ConnectionError) as e: + logger.debug(f"[AsyncMultiWriter] Writer disconnected: {e}") + return False + except Exception as e: + logger.warning(f"[AsyncMultiWriter] Write error: {e}") + return False + + # Write to all writers in parallel + results = await asyncio.gather( + *[write_to_single(w) for w in writers_copy], + return_exceptions=True, + ) + + for writer, result in zip(writers_copy, results): + if result is True: + successful += 1 + else: + failed_writers.append(writer) + + # Remove failed writers + if failed_writers: + async with self._lock: + for writer in failed_writers: + if writer in self._writers: + self._writers.remove(writer) + try: + writer.close() + except Exception: + pass + + return successful + + @property + def count(self) -> int: + """Number of connected writers.""" + return len(self._writers) + + async def close_all(self) -> None: + """Close all writers.""" + async with self._lock: + for writer in self._writers: + try: + writer.close() + await writer.wait_closed() + except Exception: + pass + self._writers.clear() + + +class AcestreamSessionManager: + """ + Manages acestream sessions with cross-process coordination. + + Features: + - Per-worker session tracking + - Redis-based session registry for cross-worker visibility + - Session creation via acestream's format=json API + - Session cleanup via command_url?method=stop + - Session keepalive via periodic stat_url polling + """ + + # Redis key prefixes + REGISTRY_PREFIX = "mfp:acestream:session:" + REGISTRY_TTL = 3600 # 1 hour + + def __init__(self): + # Per-worker session tracking (infohash -> session) + self._sessions: Dict[str, AcestreamSession] = {} + + # Keepalive task + self._keepalive_task: Optional[asyncio.Task] = None + self._cleanup_task: Optional[asyncio.Task] = None + + # HTTP client session + self._http_session: Optional[aiohttp.ClientSession] = None + + logger.info("[AcestreamSessionManager] Initialized with Redis backend") + + def _get_registry_key(self, infohash: str) -> str: + """Get the Redis key for an infohash.""" + hash_key = hashlib.md5(infohash.encode()).hexdigest() + return f"{self.REGISTRY_PREFIX}{hash_key}" + + async def _get_http_session(self) -> aiohttp.ClientSession: + """Get or create HTTP client session.""" + if self._http_session is None or self._http_session.closed: + self._http_session = aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=30)) + return self._http_session + + async def _read_registry(self, infohash: str) -> Optional[Dict[str, Any]]: + """Read session data from Redis registry.""" + try: + r = await redis_utils.get_redis() + key = self._get_registry_key(infohash) + data = await r.get(key) + if data: + return json.loads(data) + except Exception as e: + logger.warning(f"[AcestreamSessionManager] Error reading registry: {e}") + return None + + async def _write_registry(self, session: AcestreamSession) -> None: + """Write session data to Redis registry.""" + try: + r = await redis_utils.get_redis() + key = self._get_registry_key(session.infohash) + await r.set(key, json.dumps(session.to_dict()), ex=self.REGISTRY_TTL) + except Exception as e: + logger.warning(f"[AcestreamSessionManager] Error writing registry: {e}") + + async def _delete_registry(self, infohash: str) -> None: + """Delete session from Redis registry.""" + try: + r = await redis_utils.get_redis() + key = self._get_registry_key(infohash) + await r.delete(key) + except Exception as e: + logger.warning(f"[AcestreamSessionManager] Error deleting registry: {e}") + + async def _create_acestream_session(self, infohash: str, content_id: Optional[str] = None) -> AcestreamResponse: + """ + Create a new acestream session via format=json API. + + Args: + infohash: The infohash of the content (40-char hex from magnet link) + content_id: Optional content ID (alternative to infohash) + + Returns: + AcestreamResponse with playback URLs + + Raises: + Exception if session creation fails + """ + base_url = f"http://{settings.acestream_host}:{settings.acestream_port}" + pid = str(uuid4()) + + # Build URL with parameters + # Acestream uses different parameter names: + # - 'id' or 'content_id' for content IDs + # - 'infohash' for magnet link hashes (40-char hex) + params = { + "format": "json", + "pid": pid, + } + + if content_id: + # Content ID provided - use 'id' parameter + params["id"] = content_id + else: + # Only infohash provided - use 'infohash' parameter + params["infohash"] = infohash + + # Use manifest.m3u8 for HLS or getstream for MPEG-TS + # We'll use manifest.m3u8 as the primary since we leverage HLS infrastructure + url = f"{base_url}/ace/manifest.m3u8" + + session = await self._get_http_session() + try: + async with session.get(url, params=params) as response: + response.raise_for_status() + data = await response.json() + + if data.get("error"): + raise Exception(f"Acestream error: {data['error']}") + + resp = data.get("response", {}) + return AcestreamResponse( + playback_url=resp.get("playback_url", ""), + stat_url=resp.get("stat_url", ""), + command_url=resp.get("command_url", ""), + infohash=resp.get("infohash", infohash), + playback_session_id=resp.get("playback_session_id", ""), + is_live=bool(resp.get("is_live", 1)), + is_encrypted=bool(resp.get("is_encrypted", 0)), + ) + except aiohttp.ClientError as e: + logger.error(f"[AcestreamSessionManager] HTTP error creating session: {e}") + raise + + async def get_or_create_session( + self, + infohash: str, + content_id: Optional[str] = None, + increment_client: bool = True, + ) -> AcestreamSession: + """ + Get an existing session or create a new one. + + Uses Redis locking to coordinate session creation across workers. + + Args: + infohash: The infohash of the content + content_id: Optional content ID + increment_client: Whether to increment client count (False for manifest requests) + + Returns: + AcestreamSession instance + """ + # Check if we already have this session in this worker + if infohash in self._sessions: + session = self._sessions[infohash] + session.touch() + if increment_client: + session.client_count += 1 + logger.info( + f"[AcestreamSessionManager] Reusing existing session: {infohash[:16]}... " + f"(clients: {session.client_count})" + ) + return session + + # Need to create or fetch session - use Redis lock + lock_key = f"acestream_session:{infohash}" + lock_acquired = await redis_utils.acquire_lock(lock_key, ttl=30, timeout=30) + + if not lock_acquired: + raise Exception(f"Failed to acquire lock for acestream session: {infohash[:16]}...") + + try: + # Double-check after acquiring lock + if infohash in self._sessions: + session = self._sessions[infohash] + session.touch() + if increment_client: + session.client_count += 1 + return session + + # Check registry for existing session from another worker + registry_data = await self._read_registry(infohash) + + if registry_data: + # Validate session is still alive by checking stat_url + if await self._validate_session(registry_data.get("stat_url", "")): + logger.info(f"[AcestreamSessionManager] Using existing session from registry: {infohash[:16]}...") + session = AcestreamSession.from_dict(registry_data) + session.client_count = 1 if increment_client else 0 + self._sessions[infohash] = session + self._ensure_tasks() + return session + else: + # Session is stale, remove from registry + await self._delete_registry(infohash) + + # Create new session + logger.info(f"[AcestreamSessionManager] Creating new session: {infohash[:16]}...") + try: + response = await self._create_acestream_session(infohash, content_id) + + session = AcestreamSession( + infohash=infohash, + pid=str(uuid4()), + playback_url=response.playback_url, + command_url=response.command_url, + stat_url=response.stat_url, + playback_session_id=response.playback_session_id, + is_live=response.is_live, + client_count=1 if increment_client else 0, + ) + + self._sessions[infohash] = session + await self._write_registry(session) + self._ensure_tasks() + + logger.info( + f"[AcestreamSessionManager] Created session: {infohash[:16]}... " + f"playback_url: {response.playback_url}" + ) + return session + + except Exception as e: + logger.error(f"[AcestreamSessionManager] Failed to create session: {e}") + raise + finally: + await redis_utils.release_lock(lock_key) + + async def _validate_session(self, stat_url: str) -> bool: + """Check if a session is still valid by polling stat_url.""" + if not stat_url: + return False + + try: + session = await self._get_http_session() + async with session.get(stat_url, timeout=aiohttp.ClientTimeout(total=5)) as response: + if response.status == 200: + return True + except Exception as e: + logger.debug(f"[AcestreamSessionManager] Session validation failed: {e}") + return False + + async def release_session(self, infohash: str) -> None: + """ + Release a client's hold on a session. + + Decrements client count. When count reaches 0, the session is closed. + + Args: + infohash: The infohash of the session to release + """ + if infohash not in self._sessions: + return + + session = self._sessions[infohash] + session.client_count -= 1 + + logger.info( + f"[AcestreamSessionManager] Released client from session: {infohash[:16]}... " + f"(remaining clients: {session.client_count})" + ) + + if session.client_count <= 0: + await self._close_session(infohash) + + async def invalidate_session(self, infohash: str) -> None: + """ + Invalidate a stale session (e.g., when we get 403 from acestream). + + This forces the session to be closed and removed from registry, + so next request will create a fresh session. + + Args: + infohash: The infohash of the session to invalidate + """ + logger.warning(f"[AcestreamSessionManager] Invalidating stale session: {infohash[:16]}...") + + if infohash in self._sessions: + session = self._sessions.pop(infohash) + # Try to stop the session gracefully + if session.command_url: + try: + http_session = await self._get_http_session() + url = f"{session.command_url}?method=stop" + async with http_session.get(url, timeout=aiohttp.ClientTimeout(total=3)) as response: + logger.debug(f"[AcestreamSessionManager] Stop command sent: {response.status}") + except Exception as e: + logger.debug(f"[AcestreamSessionManager] Error stopping stale session: {e}") + + # Always remove from registry + await self._delete_registry(infohash) + logger.info(f"[AcestreamSessionManager] Session invalidated: {infohash[:16]}...") + + async def _close_session(self, infohash: str) -> None: + """ + Close an acestream session. + + Calls command_url?method=stop to properly close the session. + """ + if infohash not in self._sessions: + return + + session = self._sessions.pop(infohash) + + lock_key = f"acestream_session:{infohash}" + lock_acquired = await redis_utils.acquire_lock(lock_key, ttl=10, timeout=10) + + try: + # Check if this is the last worker using this session + registry_data = await self._read_registry(infohash) + + # Only close if we're the owner or session is stale + if registry_data and registry_data.get("worker_pid") == os.getpid(): + # We're the owner, close the session + if session.command_url: + try: + http_session = await self._get_http_session() + url = f"{session.command_url}?method=stop" + async with http_session.get(url, timeout=aiohttp.ClientTimeout(total=5)) as response: + logger.info( + f"[AcestreamSessionManager] Closed session: {infohash[:16]}... " + f"(status: {response.status})" + ) + except Exception as e: + logger.warning(f"[AcestreamSessionManager] Error closing session: {e}") + + await self._delete_registry(infohash) + else: + # Another worker may still be using this session + logger.debug( + f"[AcestreamSessionManager] Session {infohash[:16]}... owned by another worker, not closing" + ) + finally: + if lock_acquired: + await redis_utils.release_lock(lock_key) + + def _ensure_tasks(self) -> None: + """Ensure background tasks are running.""" + if self._keepalive_task is None or self._keepalive_task.done(): + self._keepalive_task = asyncio.create_task(self._keepalive_loop()) + + if self._cleanup_task is None or self._cleanup_task.done(): + self._cleanup_task = asyncio.create_task(self._cleanup_loop()) + + async def _keepalive_loop(self) -> None: + """Periodically poll stat_url to keep sessions alive with active clients or recent segment activity.""" + while True: + try: + await asyncio.sleep(settings.acestream_keepalive_interval) + + for infohash, session in list(self._sessions.items()): + # Keepalive sessions with active clients OR recent segment activity + # This ensures HLS streams (which don't use client_count) stay alive + has_recent_activity = session.is_actively_streaming(timeout=settings.acestream_empty_timeout) + + if session.client_count <= 0 and not has_recent_activity: + logger.debug( + f"[AcestreamSessionManager] Skipping keepalive (no clients, no recent segments): " + f"{infohash[:16]}..." + ) + continue + + if session.stat_url: + try: + http_session = await self._get_http_session() + async with http_session.get( + session.stat_url, + timeout=aiohttp.ClientTimeout(total=5), + ) as response: + if response.status == 200: + session.touch() + await self._write_registry(session) + logger.debug( + f"[AcestreamSessionManager] Keepalive OK: {infohash[:16]}... " + f"(clients: {session.client_count}, recent_activity: {has_recent_activity})" + ) + else: + logger.warning( + f"[AcestreamSessionManager] Keepalive failed: {infohash[:16]}... " + f"(status: {response.status})" + ) + except Exception as e: + logger.warning(f"[AcestreamSessionManager] Keepalive error: {infohash[:16]}... - {e}") + + except asyncio.CancelledError: + return + except Exception as e: + logger.warning(f"[AcestreamSessionManager] Keepalive loop error: {e}") + + async def _cleanup_loop(self) -> None: + """Periodically clean up stale sessions.""" + while True: + try: + await asyncio.sleep(15) # Check every 15 seconds + + now = time.time() + timeout = settings.acestream_session_timeout + empty_timeout = settings.acestream_empty_timeout + + for infohash, session in list(self._sessions.items()): + idle_time = now - session.last_access + segment_idle_time = now - session.last_segment_request + + # Don't clean up sessions with recent segment activity (active playback) + # Use empty_timeout as the threshold for "recent" activity + if segment_idle_time < empty_timeout: + logger.debug( + f"[AcestreamSessionManager] Session has recent segment activity: {infohash[:16]}... " + f"(segment idle: {segment_idle_time:.0f}s)" + ) + continue + + # Clean up sessions with no clients after empty_timeout (faster cleanup) + if session.client_count <= 0 and idle_time > empty_timeout: + logger.info( + f"[AcestreamSessionManager] Cleaning up empty session: {infohash[:16]}... " + f"(idle: {idle_time:.0f}s, segment idle: {segment_idle_time:.0f}s)" + ) + await self._close_session(infohash) + # Clean up any session after session_timeout regardless of client count + elif idle_time > timeout: + logger.info( + f"[AcestreamSessionManager] Cleaning up stale session: {infohash[:16]}... " + f"(idle: {idle_time:.0f}s, segment idle: {segment_idle_time:.0f}s, clients: {session.client_count})" + ) + await self._close_session(infohash) + + # Note: Redis entries expire via TTL, no manual cleanup needed + + except asyncio.CancelledError: + return + except Exception as e: + logger.warning(f"[AcestreamSessionManager] Cleanup loop error: {e}") + + def get_session(self, infohash: str) -> Optional[AcestreamSession]: + """Get a session by infohash if it exists in this worker.""" + return self._sessions.get(infohash) + + def get_active_sessions(self) -> Dict[str, AcestreamSession]: + """Get all active sessions in this worker.""" + return dict(self._sessions) + + async def close(self) -> None: + """Close the session manager and clean up resources.""" + # Cancel background tasks + if self._keepalive_task: + self._keepalive_task.cancel() + try: + await self._keepalive_task + except asyncio.CancelledError: + pass + + if self._cleanup_task: + self._cleanup_task.cancel() + try: + await self._cleanup_task + except asyncio.CancelledError: + pass + + # Close all sessions + for infohash in list(self._sessions.keys()): + await self._close_session(infohash) + + # Close HTTP session + if self._http_session and not self._http_session.closed: + await self._http_session.close() + + logger.info("[AcestreamSessionManager] Closed") + + +# Global session manager instance +acestream_manager = AcestreamSessionManager() diff --git a/mediaflow_proxy/utils/aes.py b/mediaflow_proxy/utils/aes.py index 830ff37..c5b24a6 100644 --- a/mediaflow_proxy/utils/aes.py +++ b/mediaflow_proxy/utils/aes.py @@ -3,6 +3,7 @@ """Abstract class for AES.""" + class AES(object): def __init__(self, key, mode, IV, implementation): if len(key) not in (16, 24, 32): @@ -19,21 +20,21 @@ class AES(object): self.isAEAD = False self.block_size = 16 self.implementation = implementation - if len(key)==16: + if len(key) == 16: self.name = "aes128" - elif len(key)==24: + elif len(key) == 24: self.name = "aes192" - elif len(key)==32: + elif len(key) == 32: self.name = "aes256" else: raise AssertionError() - #CBC-Mode encryption, returns ciphertext - #WARNING: *MAY* modify the input as well + # CBC-Mode encryption, returns ciphertext + # WARNING: *MAY* modify the input as well def encrypt(self, plaintext): - assert(len(plaintext) % 16 == 0) + assert len(plaintext) % 16 == 0 - #CBC-Mode decryption, returns plaintext - #WARNING: *MAY* modify the input as well + # CBC-Mode decryption, returns plaintext + # WARNING: *MAY* modify the input as well def decrypt(self, ciphertext): - assert(len(ciphertext) % 16 == 0) + assert len(ciphertext) % 16 == 0 diff --git a/mediaflow_proxy/utils/aesgcm.py b/mediaflow_proxy/utils/aesgcm.py index cf19e54..c9c2f98 100644 --- a/mediaflow_proxy/utils/aesgcm.py +++ b/mediaflow_proxy/utils/aesgcm.py @@ -18,6 +18,7 @@ from . import python_aes from .constanttime import ct_compare_digest from .cryptomath import bytesToNumber, numberToByteArray + class AESGCM(object): """ AES-GCM implementation. Note: this implementation does not attempt @@ -39,7 +40,7 @@ class AESGCM(object): self.key = key self._rawAesEncrypt = rawAesEncrypt - self._ctr = python_aes.new(self.key, 6, bytearray(b'\x00' * 16)) + self._ctr = python_aes.new(self.key, 6, bytearray(b"\x00" * 16)) # The GCM key is AES(0). h = bytesToNumber(self._rawAesEncrypt(bytearray(16))) @@ -51,11 +52,8 @@ class AESGCM(object): self._productTable = [0] * 16 self._productTable[self._reverseBits(1)] = h for i in range(2, 16, 2): - self._productTable[self._reverseBits(i)] = \ - self._gcmShift(self._productTable[self._reverseBits(i//2)]) - self._productTable[self._reverseBits(i+1)] = \ - self._gcmAdd(self._productTable[self._reverseBits(i)], h) - + self._productTable[self._reverseBits(i)] = self._gcmShift(self._productTable[self._reverseBits(i // 2)]) + self._productTable[self._reverseBits(i + 1)] = self._gcmAdd(self._productTable[self._reverseBits(i)], h) def _auth(self, ciphertext, ad, tagMask): y = 0 @@ -68,7 +66,7 @@ class AESGCM(object): def _update(self, y, data): for i in range(0, len(data) // 16): - y ^= bytesToNumber(data[16*i:16*i+16]) + y ^= bytesToNumber(data[16 * i : 16 * i + 16]) y = self._mul(y) extra = len(data) % 16 if extra != 0: @@ -79,26 +77,26 @@ class AESGCM(object): return y def _mul(self, y): - """ Returns y*H, where H is the GCM key. """ + """Returns y*H, where H is the GCM key.""" ret = 0 # Multiply H by y 4 bits at a time, starting with the highest power # terms. for i in range(0, 128, 4): # Multiply by x^4. The reduction for the top four terms is # precomputed. - retHigh = ret & 0xf + retHigh = ret & 0xF ret >>= 4 - ret ^= (AESGCM._gcmReductionTable[retHigh] << (128-16)) + ret ^= AESGCM._gcmReductionTable[retHigh] << (128 - 16) # Add in y' * H where y' are the next four terms of y, shifted down # to the x^0..x^4. This is one of the pre-computed multiples of # H. The multiplication by x^4 shifts them back into place. - ret ^= self._productTable[y & 0xf] + ret ^= self._productTable[y & 0xF] y >>= 4 assert y == 0 return ret - def seal(self, nonce, plaintext, data=''): + def seal(self, nonce, plaintext, data=""): """ Encrypts and authenticates plaintext using nonce and data. Returns the ciphertext, consisting of the encrypted plaintext and tag concatenated. @@ -123,7 +121,7 @@ class AESGCM(object): return ciphertext + tag - def open(self, nonce, ciphertext, data=''): + def open(self, nonce, ciphertext, data=""): """ Decrypts and authenticates ciphertext using nonce and data. If the tag is valid, the plaintext is returned. If the tag is invalid, @@ -156,8 +154,8 @@ class AESGCM(object): @staticmethod def _reverseBits(i): assert i < 16 - i = ((i << 2) & 0xc) | ((i >> 2) & 0x3) - i = ((i << 1) & 0xa) | ((i >> 1) & 0x5) + i = ((i << 2) & 0xC) | ((i >> 2) & 0x3) + i = ((i << 1) & 0xA) | ((i >> 1) & 0x5) return i @staticmethod @@ -173,12 +171,12 @@ class AESGCM(object): # The x^127 term was shifted up to x^128, so subtract a 1+x+x^2+x^7 # term. This is 0b11100001 or 0xe1 when represented as an 8-bit # polynomial. - x ^= 0xe1 << (128-8) + x ^= 0xE1 << (128 - 8) return x @staticmethod def _inc32(counter): - for i in range(len(counter)-1, len(counter)-5, -1): + for i in range(len(counter) - 1, len(counter) - 5, -1): counter[i] = (counter[i] + 1) % 256 if counter[i] != 0: break @@ -188,6 +186,20 @@ class AESGCM(object): # result is stored as a 16-bit polynomial. This is used in the reduction step to # multiply elements of GF(2^128) by x^4. _gcmReductionTable = [ - 0x0000, 0x1c20, 0x3840, 0x2460, 0x7080, 0x6ca0, 0x48c0, 0x54e0, - 0xe100, 0xfd20, 0xd940, 0xc560, 0x9180, 0x8da0, 0xa9c0, 0xb5e0, + 0x0000, + 0x1C20, + 0x3840, + 0x2460, + 0x7080, + 0x6CA0, + 0x48C0, + 0x54E0, + 0xE100, + 0xFD20, + 0xD940, + 0xC560, + 0x9180, + 0x8DA0, + 0xA9C0, + 0xB5E0, ] diff --git a/mediaflow_proxy/utils/base64_utils.py b/mediaflow_proxy/utils/base64_utils.py index 67063b2..e14c1ce 100644 --- a/mediaflow_proxy/utils/base64_utils.py +++ b/mediaflow_proxy/utils/base64_utils.py @@ -9,56 +9,56 @@ logger = logging.getLogger(__name__) def is_base64_url(url: str) -> bool: """ Check if a URL appears to be base64 encoded. - + Args: url (str): The URL to check. - + Returns: bool: True if the URL appears to be base64 encoded, False otherwise. """ # Check if the URL doesn't start with http/https and contains base64-like characters - if url.startswith(('http://', 'https://', 'ftp://', 'ftps://')): + if url.startswith(("http://", "https://", "ftp://", "ftps://")): return False - + # Base64 URLs typically contain only alphanumeric characters, +, /, and = - # and don't contain typical URL characters like :// - base64_chars = set('ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=') + # and don't contain typical URL characters like :// + base64_chars = set("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=") url_chars = set(url) - + # If the URL contains characters not in base64 charset, it's likely not base64 if not url_chars.issubset(base64_chars): return False - + # Additional heuristic: base64 strings are typically longer and don't contain common URL patterns if len(url) < 10: # Too short to be a meaningful base64 encoded URL return False - + return True def decode_base64_url(encoded_url: str) -> Optional[str]: """ Decode a base64 encoded URL. - + Args: encoded_url (str): The base64 encoded URL string. - + Returns: Optional[str]: The decoded URL if successful, None if decoding fails. """ try: # Handle URL-safe base64 encoding (replace - with + and _ with /) - url_safe_encoded = encoded_url.replace('-', '+').replace('_', '/') - + url_safe_encoded = encoded_url.replace("-", "+").replace("_", "/") + # Add padding if necessary missing_padding = len(url_safe_encoded) % 4 if missing_padding: - url_safe_encoded += '=' * (4 - missing_padding) - + url_safe_encoded += "=" * (4 - missing_padding) + # Decode the base64 string decoded_bytes = base64.b64decode(url_safe_encoded) - decoded_url = decoded_bytes.decode('utf-8') - + decoded_url = decoded_bytes.decode("utf-8") + # Validate that the decoded string is a valid URL parsed = urlparse(decoded_url) if parsed.scheme and parsed.netloc: @@ -67,7 +67,7 @@ def decode_base64_url(encoded_url: str) -> Optional[str]: else: logger.warning(f"Decoded string is not a valid URL: {decoded_url}") return None - + except (base64.binascii.Error, UnicodeDecodeError, ValueError) as e: logger.debug(f"Failed to decode base64 URL '{encoded_url[:50]}...': {e}") return None @@ -76,27 +76,27 @@ def decode_base64_url(encoded_url: str) -> Optional[str]: def encode_url_to_base64(url: str, url_safe: bool = True) -> str: """ Encode a URL to base64. - + Args: url (str): The URL to encode. url_safe (bool): Whether to use URL-safe base64 encoding (default: True). - + Returns: str: The base64 encoded URL. """ try: - url_bytes = url.encode('utf-8') + url_bytes = url.encode("utf-8") if url_safe: # Use URL-safe base64 encoding (replace + with - and / with _) - encoded = base64.urlsafe_b64encode(url_bytes).decode('utf-8') + encoded = base64.urlsafe_b64encode(url_bytes).decode("utf-8") # Remove padding for cleaner URLs - encoded = encoded.rstrip('=') + encoded = encoded.rstrip("=") else: - encoded = base64.b64encode(url_bytes).decode('utf-8') - + encoded = base64.b64encode(url_bytes).decode("utf-8") + logger.debug(f"Encoded URL to base64: {url} -> {encoded}") return encoded - + except Exception as e: logger.error(f"Failed to encode URL to base64: {e}") raise @@ -106,10 +106,10 @@ def process_potential_base64_url(url: str) -> str: """ Process a URL that might be base64 encoded. If it's base64 encoded, decode it. Otherwise, return the original URL. - + Args: url (str): The URL to process. - + Returns: str: The processed URL (decoded if it was base64, original otherwise). """ @@ -119,5 +119,5 @@ def process_potential_base64_url(url: str) -> str: return decoded_url else: logger.warning(f"URL appears to be base64 but failed to decode: {url[:50]}...") - - return url \ No newline at end of file + + return url diff --git a/mediaflow_proxy/utils/base_prebuffer.py b/mediaflow_proxy/utils/base_prebuffer.py new file mode 100644 index 0000000..057be19 --- /dev/null +++ b/mediaflow_proxy/utils/base_prebuffer.py @@ -0,0 +1,367 @@ +""" +Base prebuffer class with shared functionality for HLS and DASH prebuffering. + +This module provides cross-process download coordination using Redis-based locking +to prevent duplicate downloads across multiple uvicorn workers. Both player requests +and background prebuffer tasks use the same coordination mechanism. +""" + +import asyncio +import logging +import time +import psutil +from abc import ABC +from dataclasses import dataclass, field +from typing import Dict, Optional + +from mediaflow_proxy.utils.cache_utils import ( + get_cached_segment, + set_cached_segment, +) +from mediaflow_proxy.utils.http_utils import download_file_with_retry +from mediaflow_proxy.utils import redis_utils + +logger = logging.getLogger(__name__) + + +@dataclass +class PrebufferStats: + """Statistics for prebuffer performance tracking.""" + + cache_hits: int = 0 + cache_misses: int = 0 + segments_prebuffered: int = 0 + bytes_prebuffered: int = 0 + prefetch_triggered: int = 0 + downloads_coordinated: int = 0 # Times we waited for existing download + last_reset: float = field(default_factory=time.time) + + @property + def hit_rate(self) -> float: + """Calculate cache hit rate percentage.""" + total = self.cache_hits + self.cache_misses + return (self.cache_hits / total * 100) if total > 0 else 0.0 + + def reset(self) -> None: + """Reset statistics.""" + self.cache_hits = 0 + self.cache_misses = 0 + self.segments_prebuffered = 0 + self.bytes_prebuffered = 0 + self.prefetch_triggered = 0 + self.downloads_coordinated = 0 + self.last_reset = time.time() + + def to_dict(self) -> dict: + """Convert stats to dictionary for logging.""" + return { + "cache_hits": self.cache_hits, + "cache_misses": self.cache_misses, + "hit_rate": f"{self.hit_rate:.1f}%", + "segments_prebuffered": self.segments_prebuffered, + "bytes_prebuffered_mb": f"{self.bytes_prebuffered / 1024 / 1024:.2f}", + "prefetch_triggered": self.prefetch_triggered, + "downloads_coordinated": self.downloads_coordinated, + "uptime_seconds": int(time.time() - self.last_reset), + } + + +class BasePrebuffer(ABC): + """ + Base class for prebuffer systems with cross-process download coordination. + + This class provides: + - Cross-process coordination using Redis locks to prevent duplicate downloads + - Memory usage monitoring + - Cache statistics tracking + - Shared download and caching logic + + The Redis-based locking ensures that even with multiple uvicorn workers, + only one worker downloads any given segment at a time. + + Subclasses should implement protocol-specific logic (HLS playlist parsing, + DASH MPD handling, etc.) while inheriting the core download coordination. + """ + + def __init__( + self, + max_cache_size: int, + prebuffer_segments: int, + max_memory_percent: float, + emergency_threshold: float, + segment_ttl: int = 60, + ): + """ + Initialize the base prebuffer. + + Args: + max_cache_size: Maximum number of segments to track + prebuffer_segments: Number of segments to pre-buffer ahead + max_memory_percent: Maximum memory usage percentage before skipping prebuffer + emergency_threshold: Memory threshold for emergency cleanup + segment_ttl: TTL for cached segments in seconds + """ + self.max_cache_size = max_cache_size + self.prebuffer_segment_count = prebuffer_segments + self.max_memory_percent = max_memory_percent + self.emergency_threshold = emergency_threshold + self.segment_ttl = segment_ttl + + # Statistics (per-worker, not shared - but that's fine for monitoring) + self.stats = PrebufferStats() + + # Stats logging task + self._stats_task: Optional[asyncio.Task] = None + self._stats_interval = 60 # Log stats every 60 seconds + + def _get_memory_usage_percent(self) -> float: + """Get current memory usage percentage.""" + try: + memory = psutil.virtual_memory() + return memory.percent + except Exception as e: + logger.warning(f"Failed to get memory usage: {e}") + return 0.0 + + def _check_memory_threshold(self) -> bool: + """Check if memory usage exceeds the emergency threshold.""" + return self._get_memory_usage_percent() > self.emergency_threshold + + def _should_skip_for_memory(self) -> bool: + """Check if we should skip prebuffering due to high memory usage.""" + return self._get_memory_usage_percent() > self.max_memory_percent + + def record_cache_hit(self) -> None: + """Record a cache hit for statistics.""" + self.stats.cache_hits += 1 + self._ensure_stats_logging() + + def record_cache_miss(self) -> None: + """Record a cache miss for statistics.""" + self.stats.cache_misses += 1 + self._ensure_stats_logging() + + def _ensure_stats_logging(self) -> None: + """Ensure the stats logging task is running.""" + if self._stats_task is None or self._stats_task.done(): + self._stats_task = asyncio.create_task(self._periodic_stats_logging()) + + async def _periodic_stats_logging(self) -> None: + """Periodically log prebuffer statistics.""" + while True: + try: + await asyncio.sleep(self._stats_interval) + + # Only log if there's been activity + if self.stats.cache_hits > 0 or self.stats.cache_misses > 0: + self.log_stats() + except asyncio.CancelledError: + return + except Exception as e: + logger.warning(f"Error in stats logging: {e}") + + async def get_or_download( + self, + url: str, + headers: Dict[str, str], + timeout: float = 10.0, + ) -> Optional[bytes]: + """ + Get a segment from cache or download it, with cross-process coordination. + + This is the primary method for getting segments. It: + 1. Checks cache first (immediate return if hit) + 2. Acquires Redis lock to prevent duplicate downloads across workers + 3. Double-checks cache after acquiring lock + 4. Downloads and caches if needed + + The Redis-based locking ensures that even with multiple uvicorn workers, + only one worker downloads any given segment at a time. + + Args: + url: URL of the segment to get + headers: Headers to use for the request + timeout: Maximum time to wait for lock acquisition (seconds). + Keep this short (10s) for player requests - if lock is held + too long, fall back to direct streaming. + + Returns: + Segment data if successful, None if failed or timed out + """ + self._ensure_stats_logging() + + # Check cache first (Redis cache is shared across workers) + cached = await get_cached_segment(url) + if cached: + self.record_cache_hit() + logger.info(f"[get_or_download] CACHE HIT ({len(cached)} bytes): {url}") + return cached + + # Cache miss - need to coordinate download across workers + logger.info(f"[get_or_download] CACHE MISS: {url}") + + lock_key = f"segment_download:{url}" + lock_acquired = False + + try: + # Acquire Redis lock - only one worker downloads at a time + lock_acquired = await redis_utils.acquire_lock(lock_key, ttl=30, timeout=timeout) + + if not lock_acquired: + logger.warning(f"[get_or_download] Lock TIMEOUT ({timeout}s), falling back to streaming: {url}") + return None + + # Double-check cache after acquiring lock + # Another worker may have completed the download while we waited + cached = await get_cached_segment(url) + if cached: + # Count this as a cache hit since we didn't download + self.record_cache_hit() + self.stats.downloads_coordinated += 1 + logger.info(f"[get_or_download] Found in cache after lock (coordinated): {url}") + return cached + + # We're the one who needs to download - count as miss now + self.record_cache_miss() + + # We're the first - download and cache + logger.info(f"[get_or_download] Downloading: {url}") + content = await self._download_and_cache(url, headers) + return content + + except Exception as e: + logger.warning(f"[get_or_download] Error during download coordination: {e}") + return None + finally: + if lock_acquired: + await redis_utils.release_lock(lock_key) + + async def _download_and_cache( + self, + url: str, + headers: Dict[str, str], + ) -> Optional[bytes]: + """ + Download a segment and cache it. + + This method should only be called while holding the Redis lock. + + Args: + url: URL to download + headers: Headers for the request + + Returns: + Downloaded content if successful, None otherwise + """ + try: + content = await download_file_with_retry(url, headers) + if content: + logger.info(f"[_download_and_cache] Downloaded {len(content)} bytes, caching: {url}") + await set_cached_segment(url, content, ttl=self.segment_ttl) + self.stats.segments_prebuffered += 1 + self.stats.bytes_prebuffered += len(content) + return content + else: + logger.warning(f"[_download_and_cache] Download returned empty: {url}") + return None + except Exception as e: + logger.warning(f"[_download_and_cache] Failed to download: {url} - {e}") + return None + + async def try_get_cached(self, url: str) -> Optional[bytes]: + """ + Check cache only, don't download. + + Use this for background prebuffer tasks that shouldn't block + if segment isn't available yet. + + Args: + url: URL to check in cache + + Returns: + Cached data if available, None otherwise + """ + return await get_cached_segment(url) + + async def prebuffer_segment(self, url: str, headers: Dict[str, str]) -> None: + """ + Prebuffer a single segment in the background. + + This method uses Redis locking to prevent duplicate downloads + across multiple workers. + + Args: + url: URL of segment to prebuffer + headers: Headers for the request + """ + if self._should_skip_for_memory(): + logger.debug("Skipping prebuffer due to high memory usage") + return + + # Check if already cached + cached = await get_cached_segment(url) + if cached: + logger.debug(f"[prebuffer_segment] Already cached, skipping: {url}") + return + + lock_key = f"segment_download:{url}" + lock_acquired = False + + try: + # Try to acquire lock with short timeout for prebuffering + # If lock is held by another process, skip this segment + lock_acquired = await redis_utils.acquire_lock(lock_key, ttl=30, timeout=1.0) + + if not lock_acquired: + # Another process is downloading, skip this segment + logger.debug(f"[prebuffer_segment] Lock busy, skipping: {url}") + return + + # Double-check cache after acquiring lock + cached = await get_cached_segment(url) + if cached: + logger.debug(f"[prebuffer_segment] Found in cache after lock: {url}") + return + + # Download and cache + logger.info(f"[prebuffer_segment] Downloading: {url}") + await self._download_and_cache(url, headers) + + except Exception as e: + logger.warning(f"[prebuffer_segment] Error: {e}") + finally: + if lock_acquired: + await redis_utils.release_lock(lock_key) + + async def prebuffer_segments_batch( + self, + urls: list, + headers: Dict[str, str], + max_concurrent: int = 2, + ) -> None: + """ + Prebuffer multiple segments with concurrency control. + + Args: + urls: List of segment URLs to prebuffer + headers: Headers for requests + max_concurrent: Maximum concurrent downloads (default 2 to avoid + lock contention with player requests) + """ + if self._should_skip_for_memory(): + logger.warning("Skipping prebuffer due to high memory usage") + return + + semaphore = asyncio.Semaphore(max_concurrent) + + async def limited_prebuffer(url: str): + async with semaphore: + await self.prebuffer_segment(url, headers) + + # Start all prebuffer tasks + tasks = [limited_prebuffer(url) for url in urls] + await asyncio.gather(*tasks, return_exceptions=True) + + def log_stats(self) -> None: + """Log current prebuffer statistics.""" + logger.info(f"Prebuffer Stats: {self.stats.to_dict()}") diff --git a/mediaflow_proxy/utils/cache_utils.py b/mediaflow_proxy/utils/cache_utils.py index 5c04312..c2eafb7 100644 --- a/mediaflow_proxy/utils/cache_utils.py +++ b/mediaflow_proxy/utils/cache_utils.py @@ -1,308 +1,31 @@ -import asyncio -import hashlib -import json -import logging -import os -import tempfile -import threading -import time -from collections import OrderedDict -from concurrent.futures import ThreadPoolExecutor -from dataclasses import dataclass -from pathlib import Path -from typing import Optional, Union, Any +""" +Cache utilities for mediaflow-proxy. -import aiofiles -import aiofiles.os +All caching is now done via Redis for cross-worker sharing. +See redis_utils.py for the underlying Redis operations. +""" + +import logging +from typing import Optional from mediaflow_proxy.utils.http_utils import download_file_with_retry, DownloadError from mediaflow_proxy.utils.mpd_utils import parse_mpd, parse_mpd_dict +from mediaflow_proxy.utils import redis_utils logger = logging.getLogger(__name__) -@dataclass -class CacheEntry: - """Represents a cache entry with metadata.""" - - data: bytes - expires_at: float - access_count: int = 0 - last_access: float = 0.0 - size: int = 0 +# ============================================================================= +# Init Segment Cache +# ============================================================================= -class LRUMemoryCache: - """Thread-safe LRU memory cache with support.""" - - def __init__(self, maxsize: int): - self.maxsize = maxsize - self._cache: OrderedDict[str, CacheEntry] = OrderedDict() - self._lock = threading.Lock() - self._current_size = 0 - - def get(self, key: str) -> Optional[CacheEntry]: - with self._lock: - if key in self._cache: - entry = self._cache.pop(key) # Remove and re-insert for LRU - if time.time() < entry.expires_at: - entry.access_count += 1 - entry.last_access = time.time() - self._cache[key] = entry - return entry - else: - # Remove expired entry - self._current_size -= entry.size - self._cache.pop(key, None) - return None - - def set(self, key: str, entry: CacheEntry) -> None: - with self._lock: - if key in self._cache: - old_entry = self._cache[key] - self._current_size -= old_entry.size - - # Check if we need to make space - while self._current_size + entry.size > self.maxsize and self._cache: - _, removed_entry = self._cache.popitem(last=False) - self._current_size -= removed_entry.size - - self._cache[key] = entry - self._current_size += entry.size - - def remove(self, key: str) -> None: - with self._lock: - if key in self._cache: - entry = self._cache.pop(key) - self._current_size -= entry.size - - -class HybridCache: - """High-performance hybrid cache combining memory and file storage.""" - - def __init__( - self, - cache_dir_name: str, - ttl: int, - max_memory_size: int = 100 * 1024 * 1024, # 100MB default - executor_workers: int = 4, - ): - self.cache_dir = Path(tempfile.gettempdir()) / cache_dir_name - self.ttl = ttl - self.memory_cache = LRUMemoryCache(maxsize=max_memory_size) - self._executor = ThreadPoolExecutor(max_workers=executor_workers) - self._lock = asyncio.Lock() - - # Initialize cache directories - self._init_cache_dirs() - - def _init_cache_dirs(self): - """Initialize sharded cache directories.""" - os.makedirs(self.cache_dir, exist_ok=True) - - def _get_md5_hash(self, key: str) -> str: - """Get the MD5 hash of a cache key.""" - return hashlib.md5(key.encode()).hexdigest() - - def _get_file_path(self, key: str) -> Path: - """Get the file path for a cache key.""" - return self.cache_dir / key - - async def get(self, key: str, default: Any = None) -> Optional[bytes]: - """ - Get value from cache, trying memory first then file. - - Args: - key: Cache key - default: Default value if key not found - - Returns: - Cached value or default if not found - """ - key = self._get_md5_hash(key) - # Try memory cache first - entry = self.memory_cache.get(key) - if entry is not None: - return entry.data - - # Try file cache - try: - file_path = self._get_file_path(key) - async with aiofiles.open(file_path, "rb") as f: - metadata_size = await f.read(8) - metadata_length = int.from_bytes(metadata_size, "big") - metadata_bytes = await f.read(metadata_length) - metadata = json.loads(metadata_bytes.decode()) - - # Check expiration - if metadata["expires_at"] < time.time(): - await self.delete(key) - return default - - # Read data - data = await f.read() - - # Update memory cache in background - entry = CacheEntry( - data=data, - expires_at=metadata["expires_at"], - access_count=metadata["access_count"] + 1, - last_access=time.time(), - size=len(data), - ) - self.memory_cache.set(key, entry) - - return data - - except FileNotFoundError: - return default - except Exception as e: - logger.error(f"Error reading from cache: {e}") - return default - - async def set(self, key: str, data: Union[bytes, bytearray, memoryview], ttl: Optional[int] = None) -> bool: - """ - Set value in both memory and file cache. - - Args: - key: Cache key - data: Data to cache - ttl: Optional TTL override - - Returns: - bool: Success status - """ - if not isinstance(data, (bytes, bytearray, memoryview)): - raise ValueError("Data must be bytes, bytearray, or memoryview") - - ttl_seconds = self.ttl if ttl is None else ttl - - key = self._get_md5_hash(key) - - if ttl_seconds <= 0: - # Explicit request to avoid caching - remove any previous entry and return success - self.memory_cache.remove(key) - try: - file_path = self._get_file_path(key) - await aiofiles.os.remove(file_path) - except FileNotFoundError: - pass - except Exception as e: - logger.error(f"Error removing cache file: {e}") - return True - - expires_at = time.time() + ttl_seconds - - # Create cache entry - entry = CacheEntry(data=data, expires_at=expires_at, access_count=0, last_access=time.time(), size=len(data)) - - # Update memory cache - self.memory_cache.set(key, entry) - file_path = self._get_file_path(key) - temp_path = file_path.with_suffix(".tmp") - - # Update file cache - try: - metadata = {"expires_at": expires_at, "access_count": 0, "last_access": time.time()} - metadata_bytes = json.dumps(metadata).encode() - metadata_size = len(metadata_bytes).to_bytes(8, "big") - - async with aiofiles.open(temp_path, "wb") as f: - await f.write(metadata_size) - await f.write(metadata_bytes) - await f.write(data) - - await aiofiles.os.rename(temp_path, file_path) - return True - - except Exception as e: - logger.error(f"Error writing to cache: {e}") - try: - await aiofiles.os.remove(temp_path) - except: - pass - return False - - async def delete(self, key: str) -> bool: - """Delete item from both caches.""" - hashed_key = self._get_md5_hash(key) - self.memory_cache.remove(hashed_key) - - try: - file_path = self._get_file_path(hashed_key) - await aiofiles.os.remove(file_path) - return True - except FileNotFoundError: - return True - except Exception as e: - logger.error(f"Error deleting from cache: {e}") - return False - - -class AsyncMemoryCache: - """Async wrapper around LRUMemoryCache.""" - - def __init__(self, max_memory_size: int): - self.memory_cache = LRUMemoryCache(maxsize=max_memory_size) - - async def get(self, key: str, default: Any = None) -> Optional[bytes]: - """Get value from cache.""" - entry = self.memory_cache.get(key) - return entry.data if entry is not None else default - - async def set(self, key: str, data: Union[bytes, bytearray, memoryview], ttl: Optional[int] = None) -> bool: - """Set value in cache.""" - try: - ttl_seconds = 3600 if ttl is None else ttl - - if ttl_seconds <= 0: - self.memory_cache.remove(key) - return True - - expires_at = time.time() + ttl_seconds - entry = CacheEntry( - data=data, expires_at=expires_at, access_count=0, last_access=time.time(), size=len(data) - ) - self.memory_cache.set(key, entry) - return True - except Exception as e: - logger.error(f"Error setting cache value: {e}") - return False - - async def delete(self, key: str) -> bool: - """Delete item from cache.""" - try: - self.memory_cache.remove(key) - return True - except Exception as e: - logger.error(f"Error deleting from cache: {e}") - return False - - -# Create cache instances -INIT_SEGMENT_CACHE = HybridCache( - cache_dir_name="init_segment_cache", - ttl=3600, # 1 hour - max_memory_size=500 * 1024 * 1024, # 500MB for init segments -) - -MPD_CACHE = AsyncMemoryCache( - max_memory_size=100 * 1024 * 1024, # 100MB for MPD files -) - -EXTRACTOR_CACHE = HybridCache( - cache_dir_name="extractor_cache", - ttl=5 * 60, # 5 minutes - max_memory_size=50 * 1024 * 1024, -) - - -# Specific cache implementations async def get_cached_init_segment( init_url: str, headers: dict, cache_token: str | None = None, ttl: Optional[int] = None, + byte_range: str | None = None, ) -> Optional[bytes]: """Get initialization segment from cache or download it. @@ -310,29 +33,39 @@ async def get_cached_init_segment( rely on different DRM keys or initialization payloads (e.g. key rotation). ttl overrides the default cache TTL; pass a value <= 0 to skip caching entirely. - """ + byte_range specifies a byte range for SegmentBase MPDs (e.g., '0-11568'). + """ use_cache = ttl is None or ttl > 0 - cache_key = f"{init_url}|{cache_token}" if cache_token else init_url + # Include byte_range in cache key for SegmentBase + cache_key = f"{init_url}|{cache_token}|{byte_range}" if cache_token or byte_range else init_url if use_cache: - cached_data = await INIT_SEGMENT_CACHE.get(cache_key) + cached_data = await redis_utils.get_cached_init_segment(cache_key) if cached_data is not None: return cached_data - else: - # Remove any previously cached entry when caching is disabled - await INIT_SEGMENT_CACHE.delete(cache_key) try: - init_content = await download_file_with_retry(init_url, headers) + # Add Range header if byte_range is specified (for SegmentBase MPDs) + request_headers = dict(headers) + if byte_range: + request_headers["Range"] = f"bytes={byte_range}" + + init_content = await download_file_with_retry(init_url, request_headers) if init_content and use_cache: - await INIT_SEGMENT_CACHE.set(cache_key, init_content, ttl=ttl) + cache_ttl = ttl if ttl is not None else redis_utils.DEFAULT_INIT_CACHE_TTL + await redis_utils.set_cached_init_segment(cache_key, init_content, ttl=cache_ttl) return init_content except Exception as e: logger.error(f"Error downloading init segment: {e}") return None +# ============================================================================= +# MPD Cache +# ============================================================================= + + async def get_cached_mpd( mpd_url: str, headers: dict, @@ -341,13 +74,13 @@ async def get_cached_mpd( ) -> dict: """Get MPD from cache or download and parse it.""" # Try cache first - cached_data = await MPD_CACHE.get(mpd_url) + cached_data = await redis_utils.get_cached_mpd(mpd_url) if cached_data is not None: try: - mpd_dict = json.loads(cached_data) - return parse_mpd_dict(mpd_dict, mpd_url, parse_drm, parse_segment_profile_id) - except json.JSONDecodeError: - await MPD_CACHE.delete(mpd_url) + return parse_mpd_dict(cached_data, mpd_url, parse_drm, parse_segment_profile_id) + except Exception: + # Invalid cached data, will re-download + pass # Download and parse if not cached try: @@ -355,8 +88,9 @@ async def get_cached_mpd( mpd_dict = parse_mpd(mpd_content) parsed_dict = parse_mpd_dict(mpd_dict, mpd_url, parse_drm, parse_segment_profile_id) - # Cache the original MPD dict - await MPD_CACHE.set(mpd_url, json.dumps(mpd_dict).encode(), ttl=parsed_dict.get("minimumUpdatePeriod")) + # Cache the original MPD dict with TTL from minimumUpdatePeriod + cache_ttl = parsed_dict.get("minimumUpdatePeriod") or redis_utils.DEFAULT_MPD_CACHE_TTL + await redis_utils.set_cached_mpd(mpd_url, mpd_dict, ttl=cache_ttl) return parsed_dict except DownloadError as error: logger.error(f"Error downloading MPD: {error}") @@ -366,21 +100,158 @@ async def get_cached_mpd( raise error +# ============================================================================= +# Extractor Cache +# ============================================================================= + + async def get_cached_extractor_result(key: str) -> Optional[dict]: """Get extractor result from cache.""" - cached_data = await EXTRACTOR_CACHE.get(key) - if cached_data is not None: - try: - return json.loads(cached_data) - except json.JSONDecodeError: - await EXTRACTOR_CACHE.delete(key) - return None + return await redis_utils.get_cached_extractor(key) async def set_cache_extractor_result(key: str, result: dict) -> bool: """Cache extractor result.""" try: - return await EXTRACTOR_CACHE.set(key, json.dumps(result).encode()) + await redis_utils.set_cached_extractor(key, result) + return True except Exception as e: logger.error(f"Error caching extractor result: {e}") return False + + +# ============================================================================= +# Processed Init Segment Cache +# ============================================================================= + + +async def get_cached_processed_init( + init_url: str, + key_id: str, +) -> Optional[bytes]: + """Get processed (DRM-stripped) init segment from cache. + + Args: + init_url: URL of the init segment + key_id: DRM key ID used for processing + + Returns: + Processed init segment bytes if cached, None otherwise + """ + cache_key = f"processed|{init_url}|{key_id}" + return await redis_utils.get_cached_processed_init(cache_key) + + +async def set_cached_processed_init( + init_url: str, + key_id: str, + processed_content: bytes, + ttl: Optional[int] = None, +) -> bool: + """Cache processed (DRM-stripped) init segment. + + Args: + init_url: URL of the init segment + key_id: DRM key ID used for processing + processed_content: The processed init segment bytes + ttl: Optional TTL override + + Returns: + True if cached successfully + """ + cache_key = f"processed|{init_url}|{key_id}" + try: + cache_ttl = ttl if ttl is not None else redis_utils.DEFAULT_PROCESSED_INIT_TTL + await redis_utils.set_cached_processed_init(cache_key, processed_content, ttl=cache_ttl) + return True + except Exception as e: + logger.error(f"Error caching processed init segment: {e}") + return False + + +# ============================================================================= +# Processed Segment Cache (decrypted/remuxed segments) +# ============================================================================= + + +async def get_cached_processed_segment( + segment_url: str, + key_id: str = None, + remux: bool = False, +) -> Optional[bytes]: + """Get processed (decrypted/remuxed) segment from cache. + + Args: + segment_url: URL of the segment + key_id: DRM key ID if decrypted + remux: Whether the segment was remuxed to TS + + Returns: + Processed segment bytes if cached, None otherwise + """ + cache_key = f"proc|{segment_url}|{key_id or ''}|{remux}" + return await redis_utils.get_cached_segment(cache_key) + + +async def set_cached_processed_segment( + segment_url: str, + content: bytes, + key_id: str = None, + remux: bool = False, + ttl: int = 60, +) -> bool: + """Cache processed (decrypted/remuxed) segment. + + Args: + segment_url: URL of the segment + content: Processed segment bytes + key_id: DRM key ID if decrypted + remux: Whether the segment was remuxed to TS + ttl: Time to live in seconds + + Returns: + True if cached successfully + """ + cache_key = f"proc|{segment_url}|{key_id or ''}|{remux}" + try: + await redis_utils.set_cached_segment(cache_key, content, ttl=ttl) + return True + except Exception as e: + logger.error(f"Error caching processed segment: {e}") + return False + + +# ============================================================================= +# Segment Cache +# ============================================================================= + + +async def get_cached_segment(segment_url: str) -> Optional[bytes]: + """Get media segment from prebuffer cache. + + Args: + segment_url: URL of the segment + + Returns: + Segment bytes if cached, None otherwise + """ + return await redis_utils.get_cached_segment(segment_url) + + +async def set_cached_segment(segment_url: str, content: bytes, ttl: int = 60) -> bool: + """Cache media segment with configurable TTL. + + Args: + segment_url: URL of the segment + content: Segment bytes + ttl: Time to live in seconds (default 60s, configurable via dash_segment_cache_ttl) + + Returns: + True if cached successfully + """ + try: + await redis_utils.set_cached_segment(segment_url, content, ttl=ttl) + return True + except Exception as e: + logger.error(f"Error caching segment: {e}") + return False diff --git a/mediaflow_proxy/utils/codec.py b/mediaflow_proxy/utils/codec.py index 71fb306..fbb3c32 100644 --- a/mediaflow_proxy/utils/codec.py +++ b/mediaflow_proxy/utils/codec.py @@ -1,27 +1,27 @@ # Author: Trevor Perrin -# See the LICENSE file for legal information regarding use of this file. +# See the LICENSE file for legal information regarding use of this file """Classes for reading/writing binary data (such as TLS records).""" -from __future__ import division - -import sys import struct from struct import pack + from .compat import bytes_to_int class DecodeError(SyntaxError): """Exception raised in case of decoding errors.""" + pass class BadCertificateError(SyntaxError): """Exception raised in case of bad certificate.""" + pass -class Writer(object): +class Writer: """Serialisation helper for complex byte-based structures.""" def __init__(self): @@ -32,102 +32,51 @@ class Writer(object): """Add a single-byte wide element to buffer, see add().""" self.bytes.append(val) - if sys.version_info < (2, 7): - # struct.pack on Python2.6 does not raise exception if the value - # is larger than can fit inside the specified size - def addTwo(self, val): - """Add a double-byte wide element to buffer, see add().""" - if not 0 <= val <= 0xffff: - raise ValueError("Can't represent value in specified length") - self.bytes += pack('>H', val) + def addTwo(self, val): + """Add a double-byte wide element to buffer, see add().""" + try: + self.bytes += pack(">H", val) + except struct.error: + raise ValueError("Can't represent value in specified length") - def addThree(self, val): - """Add a three-byte wide element to buffer, see add().""" - if not 0 <= val <= 0xffffff: - raise ValueError("Can't represent value in specified length") - self.bytes += pack('>BH', val >> 16, val & 0xffff) + def addThree(self, val): + """Add a three-byte wide element to buffer, see add().""" + try: + self.bytes += pack(">BH", val >> 16, val & 0xFFFF) + except struct.error: + raise ValueError("Can't represent value in specified length") - def addFour(self, val): - """Add a four-byte wide element to buffer, see add().""" - if not 0 <= val <= 0xffffffff: - raise ValueError("Can't represent value in specified length") - self.bytes += pack('>I', val) - else: - def addTwo(self, val): - """Add a double-byte wide element to buffer, see add().""" - try: - self.bytes += pack('>H', val) - except struct.error: - raise ValueError("Can't represent value in specified length") + def addFour(self, val): + """Add a four-byte wide element to buffer, see add().""" + try: + self.bytes += pack(">I", val) + except struct.error: + raise ValueError("Can't represent value in specified length") - def addThree(self, val): - """Add a three-byte wide element to buffer, see add().""" - try: - self.bytes += pack('>BH', val >> 16, val & 0xffff) - except struct.error: - raise ValueError("Can't represent value in specified length") + def add(self, x, length): + """ + Add a single positive integer value x, encode it in length bytes. - def addFour(self, val): - """Add a four-byte wide element to buffer, see add().""" - try: - self.bytes += pack('>I', val) - except struct.error: - raise ValueError("Can't represent value in specified length") + Encode positive integer x in big-endian format using length bytes, + add to the internal buffer. - if sys.version_info >= (3, 0): - # the method is called thousands of times, so it's better to extern - # the version info check - def add(self, x, length): - """ - Add a single positive integer value x, encode it in length bytes + :type x: int + :param x: value to encode - Encode positive integer x in big-endian format using length bytes, - add to the internal buffer. - - :type x: int - :param x: value to encode - - :type length: int - :param length: number of bytes to use for encoding the value - """ - try: - self.bytes += x.to_bytes(length, 'big') - except OverflowError: - raise ValueError("Can't represent value in specified length") - else: - _addMethods = {1: addOne, 2: addTwo, 3: addThree, 4: addFour} - - def add(self, x, length): - """ - Add a single positive integer value x, encode it in length bytes - - Encode positive iteger x in big-endian format using length bytes, - add to the internal buffer. - - :type x: int - :param x: value to encode - - :type length: int - :param length: number of bytes to use for encoding the value - """ - try: - self._addMethods[length](self, x) - except KeyError: - self.bytes += bytearray(length) - newIndex = len(self.bytes) - 1 - for i in range(newIndex, newIndex - length, -1): - self.bytes[i] = x & 0xFF - x >>= 8 - if x != 0: - raise ValueError("Can't represent value in specified " - "length") + :type length: int + :param length: number of bytes to use for encoding the value + """ + try: + self.bytes += x.to_bytes(length, "big") + except OverflowError: + raise ValueError("Can't represent value in specified length") def addFixSeq(self, seq, length): """ - Add a list of items, encode every item in length bytes + Add a list of items, encode every item in length bytes. Uses the unbounded iterable seq to produce items, each of - which is then encoded to length bytes + which is then encoded to length bytes. :type seq: iterable of int :param seq: list of positive integers to encode @@ -138,72 +87,35 @@ class Writer(object): for e in seq: self.add(e, length) - if sys.version_info < (2, 7): - # struct.pack on Python2.6 does not raise exception if the value - # is larger than can fit inside the specified size - def _addVarSeqTwo(self, seq): - """Helper method for addVarSeq""" - if not all(0 <= i <= 0xffff for i in seq): - raise ValueError("Can't represent value in specified " - "length") - self.bytes += pack('>' + 'H' * len(seq), *seq) + def addVarSeq(self, seq, length, lengthLength): + """ + Add a bounded list of same-sized values. - def addVarSeq(self, seq, length, lengthLength): - """ - Add a bounded list of same-sized values + Create a list of specific length with all items being of the same + size. - Create a list of specific length with all items being of the same - size + :type seq: list of int + :param seq: list of positive integers to encode - :type seq: list of int - :param seq: list of positive integers to encode + :type length: int + :param length: amount of bytes in which to encode every item - :type length: int - :param length: amount of bytes in which to encode every item - - :type lengthLength: int - :param lengthLength: amount of bytes in which to encode the overall - length of the array - """ - self.add(len(seq)*length, lengthLength) - if length == 1: - self.bytes.extend(seq) - elif length == 2: - self._addVarSeqTwo(seq) - else: - for i in seq: - self.add(i, length) - else: - def addVarSeq(self, seq, length, lengthLength): - """ - Add a bounded list of same-sized values - - Create a list of specific length with all items being of the same - size - - :type seq: list of int - :param seq: list of positive integers to encode - - :type length: int - :param length: amount of bytes in which to encode every item - - :type lengthLength: int - :param lengthLength: amount of bytes in which to encode the overall - length of the array - """ - seqLen = len(seq) - self.add(seqLen*length, lengthLength) - if length == 1: - self.bytes.extend(seq) - elif length == 2: - try: - self.bytes += pack('>' + 'H' * seqLen, *seq) - except struct.error: - raise ValueError("Can't represent value in specified " - "length") - else: - for i in seq: - self.add(i, length) + :type lengthLength: int + :param lengthLength: amount of bytes in which to encode the overall + length of the array + """ + seqLen = len(seq) + self.add(seqLen * length, lengthLength) + if length == 1: + self.bytes.extend(seq) + elif length == 2: + try: + self.bytes += pack(">" + "H" * seqLen, *seq) + except struct.error: + raise ValueError("Can't represent value in specified length") + else: + for i in seq: + self.add(i, length) def addVarTupleSeq(self, seq, length, lengthLength): """ @@ -257,7 +169,7 @@ class Writer(object): self.bytes += data -class Parser(object): +class Parser: """ Parser for TLV and LV byte-based encodings. @@ -269,9 +181,6 @@ class Parser(object): read a 4-byte integer from a 2-byte buffer), most methods will raise a DecodeError exception. - TODO: don't use an exception used by language parser to indicate errors - in application code. - :vartype bytes: bytearray :ivar bytes: data to be interpreted (buffer) @@ -285,14 +194,14 @@ class Parser(object): :ivar indexCheck: position at which the structure begins in buffer """ - def __init__(self, bytes): + def __init__(self, data): """ Bind raw bytes with parser. - :type bytes: bytearray - :param bytes: bytes to be parsed/interpreted + :type data: bytearray + :param data: bytes to be parsed/interpreted """ - self.bytes = bytes + self.bytes = data self.index = 0 self.indexCheck = 0 self.lengthCheck = 0 @@ -307,7 +216,7 @@ class Parser(object): :rtype: int """ ret = self.getFixBytes(length) - return bytes_to_int(ret, 'big') + return bytes_to_int(ret, "big") def getFixBytes(self, lengthBytes): """ @@ -358,10 +267,10 @@ class Parser(object): :rtype: list of int """ - l = [0] * lengthList + result = [0] * lengthList for x in range(lengthList): - l[x] = self.get(length) - return l + result[x] = self.get(length) + return result def getVarList(self, length, lengthLength): """ @@ -377,13 +286,12 @@ class Parser(object): """ lengthList = self.get(lengthLength) if lengthList % length != 0: - raise DecodeError("Encoded length not a multiple of element " - "length") + raise DecodeError("Encoded length not a multiple of element length") lengthList = lengthList // length - l = [0] * lengthList + result = [0] * lengthList for x in range(lengthList): - l[x] = self.get(length) - return l + result[x] = self.get(length) + return result def getVarTupleList(self, elemLength, elemNum, lengthLength): """ @@ -402,8 +310,7 @@ class Parser(object): """ lengthList = self.get(lengthLength) if lengthList % (elemLength * elemNum) != 0: - raise DecodeError("Encoded length not a multiple of element " - "length") + raise DecodeError("Encoded length not a multiple of element length") tupleCount = lengthList // (elemLength * elemNum) tupleList = [] for _ in range(tupleCount): diff --git a/mediaflow_proxy/utils/compat.py b/mediaflow_proxy/utils/compat.py index e6131f9..72a4c43 100644 --- a/mediaflow_proxy/utils/compat.py +++ b/mediaflow_proxy/utils/compat.py @@ -1,223 +1,114 @@ # Author: Trevor Perrin -# See the LICENSE file for legal information regarding use of this file. +# See the LICENSE file for legal information regarding use of this file -"""Miscellaneous functions to mask Python version differences.""" +"""Miscellaneous utility functions for Python 3.13+.""" -import sys -import re -import platform import binascii -import traceback +import re import time -if sys.version_info >= (3,0): +def compat26Str(x): + """Identity function for compatibility.""" + return x - def compat26Str(x): return x - # Python 3.3 requires bytes instead of bytearrays for HMAC - # So, python 2.6 requires strings, python 3 requires 'bytes', - # and python 2.7 and 3.5 can handle bytearrays... - # pylint: disable=invalid-name - # we need to keep compatHMAC and `x` for API compatibility - if sys.version_info < (3, 4): - def compatHMAC(x): - """Convert bytes-like input to format acceptable for HMAC.""" - return bytes(x) - else: - def compatHMAC(x): - """Convert bytes-like input to format acceptable for HMAC.""" - return x - # pylint: enable=invalid-name +def compatHMAC(x): + """Convert bytes-like input to format acceptable for HMAC.""" + return x - def compatAscii2Bytes(val): - """Convert ASCII string to bytes.""" - if isinstance(val, str): - return bytes(val, 'ascii') - return val - def compat_b2a(val): - """Convert an ASCII bytes string to string.""" - return str(val, 'ascii') +def compatAscii2Bytes(val): + """Convert ASCII string to bytes.""" + if isinstance(val, str): + return bytes(val, "ascii") + return val - def raw_input(s): - return input(s) - - # So, the python3 binascii module deals with bytearrays, and python2 - # deals with strings... I would rather deal with the "a" part as - # strings, and the "b" part as bytearrays, regardless of python version, - # so... - def a2b_hex(s): - try: - b = bytearray(binascii.a2b_hex(bytearray(s, "ascii"))) - except Exception as e: - raise SyntaxError("base16 error: %s" % e) - return b - def a2b_base64(s): - try: - if isinstance(s, str): - s = bytearray(s, "ascii") - b = bytearray(binascii.a2b_base64(s)) - except Exception as e: - raise SyntaxError("base64 error: %s" % e) - return b +def compat_b2a(val): + """Convert an ASCII bytes string to string.""" + return str(val, "ascii") - def b2a_hex(b): - return binascii.b2a_hex(b).decode("ascii") - - def b2a_base64(b): - return binascii.b2a_base64(b).decode("ascii") - def readStdinBinary(): - return sys.stdin.buffer.read() +def a2b_hex(s): + """Convert hex string to bytearray.""" + try: + b = bytearray(binascii.a2b_hex(bytearray(s, "ascii"))) + except Exception as e: + raise SyntaxError(f"base16 error: {e}") from e + return b - def compatLong(num): - return int(num) - int_types = tuple([int]) +def a2b_base64(s): + """Convert base64 string to bytearray.""" + try: + if isinstance(s, str): + s = bytearray(s, "ascii") + b = bytearray(binascii.a2b_base64(s)) + except Exception as e: + raise SyntaxError(f"base64 error: {e}") from e + return b - def formatExceptionTrace(e): - """Return exception information formatted as string""" - return str(e) - def time_stamp(): - """Returns system time as a float""" - if sys.version_info >= (3, 3): - return time.perf_counter() - return time.clock() +def b2a_hex(b): + """Convert bytes to hex string.""" + return binascii.b2a_hex(b).decode("ascii") - def remove_whitespace(text): - """Removes all whitespace from passed in string""" - return re.sub(r"\s+", "", text, flags=re.UNICODE) - # pylint: disable=invalid-name - # pylint is stupid here and deson't notice it's a function, not - # constant - bytes_to_int = int.from_bytes - # pylint: enable=invalid-name +def b2a_base64(b): + """Convert bytes to base64 string.""" + return binascii.b2a_base64(b).decode("ascii") - def bit_length(val): - """Return number of bits necessary to represent an integer.""" - return val.bit_length() - def int_to_bytes(val, length=None, byteorder="big"): - """Return number converted to bytes""" - if length is None: - if val: - length = byte_length(val) - else: - length = 1 - # for gmpy we need to convert back to native int - if type(val) != int: - val = int(val) - return bytearray(val.to_bytes(length=length, byteorder=byteorder)) +def readStdinBinary(): + """Read binary data from stdin.""" + import sys -else: - # Python 2.6 requires strings instead of bytearrays in a couple places, - # so we define this function so it does the conversion if needed. - # same thing with very old 2.7 versions - # or on Jython - if sys.version_info < (2, 7) or sys.version_info < (2, 7, 4) \ - or platform.system() == 'Java': - def compat26Str(x): return str(x) + return sys.stdin.buffer.read() - def remove_whitespace(text): - """Removes all whitespace from passed in string""" - return re.sub(r"\s+", "", text) - def bit_length(val): - """Return number of bits necessary to represent an integer.""" - if val == 0: - return 0 - return len(bin(val))-2 - else: - def compat26Str(x): return x +def compatLong(num): + """Convert to int (compatibility function).""" + return int(num) - def remove_whitespace(text): - """Removes all whitespace from passed in string""" - return re.sub(r"\s+", "", text, flags=re.UNICODE) - def bit_length(val): - """Return number of bits necessary to represent an integer.""" - return val.bit_length() +int_types = (int,) - def compatAscii2Bytes(val): - """Convert ASCII string to bytes.""" - return val - def compat_b2a(val): - """Convert an ASCII bytes string to string.""" - return str(val) +def formatExceptionTrace(e): + """Return exception information formatted as string.""" + return str(e) - # So, python 2.6 requires strings, python 3 requires 'bytes', - # and python 2.7 can handle bytearrays... - def compatHMAC(x): return compat26Str(x) - def a2b_hex(s): - try: - b = bytearray(binascii.a2b_hex(s)) - except Exception as e: - raise SyntaxError("base16 error: %s" % e) - return b +def time_stamp(): + """Returns system time as a float.""" + return time.perf_counter() - def a2b_base64(s): - try: - b = bytearray(binascii.a2b_base64(s)) - except Exception as e: - raise SyntaxError("base64 error: %s" % e) - return b - - def b2a_hex(b): - return binascii.b2a_hex(compat26Str(b)) - - def b2a_base64(b): - return binascii.b2a_base64(compat26Str(b)) - def compatLong(num): - return long(num) +def remove_whitespace(text): + """Removes all whitespace from passed in string.""" + return re.sub(r"\s+", "", text, flags=re.UNICODE) - int_types = (int, long) - # pylint on Python3 goes nuts for the sys dereferences... +bytes_to_int = int.from_bytes - #pylint: disable=no-member - def formatExceptionTrace(e): - """Return exception information formatted as string""" - newStr = "".join(traceback.format_exception(sys.exc_type, - sys.exc_value, - sys.exc_traceback)) - return newStr - #pylint: enable=no-member - def time_stamp(): - """Returns system time as a float""" - return time.clock() +def bit_length(val): + """Return number of bits necessary to represent an integer.""" + return val.bit_length() - def bytes_to_int(val, byteorder): - """Convert bytes to an int.""" - if not val: - return 0 - if byteorder == "big": - return int(b2a_hex(val), 16) - if byteorder == "little": - return int(b2a_hex(val[::-1]), 16) - raise ValueError("Only 'big' and 'little' endian supported") - def int_to_bytes(val, length=None, byteorder="big"): - """Return number converted to bytes""" - if length is None: - if val: - length = byte_length(val) - else: - length = 1 - if byteorder == "big": - return bytearray((val >> i) & 0xff - for i in reversed(range(0, length*8, 8))) - if byteorder == "little": - return bytearray((val >> i) & 0xff - for i in range(0, length*8, 8)) - raise ValueError("Only 'big' or 'little' endian supported") +def int_to_bytes(val, length=None, byteorder="big"): + """Return number converted to bytes.""" + if length is None: + if val: + length = byte_length(val) + else: + length = 1 + # for gmpy we need to convert back to native int + if not isinstance(val, int): + val = int(val) + return bytearray(val.to_bytes(length=length, byteorder=byteorder)) def byte_length(val): diff --git a/mediaflow_proxy/utils/constanttime.py b/mediaflow_proxy/utils/constanttime.py index 8d4541e..c8286e4 100644 --- a/mediaflow_proxy/utils/constanttime.py +++ b/mediaflow_proxy/utils/constanttime.py @@ -8,6 +8,7 @@ from __future__ import division from .compat import compatHMAC import hmac + def ct_lt_u32(val_a, val_b): """ Returns 1 if val_a < val_b, 0 otherwise. Constant time. @@ -18,10 +19,10 @@ def ct_lt_u32(val_a, val_b): :param val_b: an unsigned integer representable as a 32 bit value :rtype: int """ - val_a &= 0xffffffff - val_b &= 0xffffffff + val_a &= 0xFFFFFFFF + val_b &= 0xFFFFFFFF - return (val_a^((val_a^val_b)|(((val_a-val_b)&0xffffffff)^val_b)))>>31 + return (val_a ^ ((val_a ^ val_b) | (((val_a - val_b) & 0xFFFFFFFF) ^ val_b))) >> 31 def ct_gt_u32(val_a, val_b): @@ -77,8 +78,8 @@ def ct_isnonzero_u32(val): :param val: an unsigned integer representable as a 32 bit value :rtype: int """ - val &= 0xffffffff - return (val|(-val&0xffffffff)) >> 31 + val &= 0xFFFFFFFF + return (val | (-val & 0xFFFFFFFF)) >> 31 def ct_neq_u32(val_a, val_b): @@ -91,10 +92,11 @@ def ct_neq_u32(val_a, val_b): :param val_b: an unsigned integer representable as a 32 bit value :rtype: int """ - val_a &= 0xffffffff - val_b &= 0xffffffff + val_a &= 0xFFFFFFFF + val_b &= 0xFFFFFFFF + + return (((val_a - val_b) & 0xFFFFFFFF) | ((val_b - val_a) & 0xFFFFFFFF)) >> 31 - return (((val_a-val_b)&0xffffffff) | ((val_b-val_a)&0xffffffff)) >> 31 def ct_eq_u32(val_a, val_b): """ @@ -108,8 +110,8 @@ def ct_eq_u32(val_a, val_b): """ return 1 ^ ct_neq_u32(val_a, val_b) -def ct_check_cbc_mac_and_pad(data, mac, seqnumBytes, contentType, version, - block_size=16): + +def ct_check_cbc_mac_and_pad(data, mac, seqnumBytes, contentType, version, block_size=16): """ Check CBC cipher HMAC and padding. Close to constant time. @@ -135,7 +137,7 @@ def ct_check_cbc_mac_and_pad(data, mac, seqnumBytes, contentType, version, assert version in ((3, 0), (3, 1), (3, 2), (3, 3)) data_len = len(data) - if mac.digest_size + 1 > data_len: # data_len is public + if mac.digest_size + 1 > data_len: # data_len is public return False # 0 - OK @@ -144,11 +146,11 @@ def ct_check_cbc_mac_and_pad(data, mac, seqnumBytes, contentType, version, # # check padding # - pad_length = data[data_len-1] + pad_length = data[data_len - 1] pad_start = data_len - pad_length - 1 pad_start = max(0, pad_start) - if version == (3, 0): # version is public + if version == (3, 0): # version is public # in SSLv3 we can only check if pad is not longer than the cipher # block size @@ -179,33 +181,35 @@ def ct_check_cbc_mac_and_pad(data, mac, seqnumBytes, contentType, version, data_mac = mac.copy() data_mac.update(compatHMAC(seqnumBytes)) data_mac.update(compatHMAC(bytearray([contentType]))) - if version != (3, 0): # version is public + if version != (3, 0): # version is public data_mac.update(compatHMAC(bytearray([version[0]]))) data_mac.update(compatHMAC(bytearray([version[1]]))) data_mac.update(compatHMAC(bytearray([mac_start >> 8]))) - data_mac.update(compatHMAC(bytearray([mac_start & 0xff]))) + data_mac.update(compatHMAC(bytearray([mac_start & 0xFF]))) data_mac.update(compatHMAC(data[:start_pos])) # don't check past the array end (already checked to be >= zero) end_pos = data_len - mac.digest_size # calculate all possible - for i in range(start_pos, end_pos): # constant for given overall length + for i in range(start_pos, end_pos): # constant for given overall length cur_mac = data_mac.copy() cur_mac.update(compatHMAC(data[start_pos:i])) mac_compare = bytearray(cur_mac.digest()) # compare the hash for real only if it's the place where mac is # supposed to be mask = ct_lsb_prop_u8(ct_eq_u32(i, mac_start)) - for j in range(0, mac.digest_size): # digest_size is public - result |= (data[i+j] ^ mac_compare[j]) & mask + for j in range(0, mac.digest_size): # digest_size is public + result |= (data[i + j] ^ mac_compare[j]) & mask # return python boolean return result == 0 -if hasattr(hmac, 'compare_digest'): + +if hasattr(hmac, "compare_digest"): ct_compare_digest = hmac.compare_digest else: + def ct_compare_digest(val_a, val_b): """Compares if string like objects are equal. Constant time.""" if len(val_a) != len(val_b): diff --git a/mediaflow_proxy/utils/crypto_utils.py b/mediaflow_proxy/utils/crypto_utils.py index 3dc87a8..f4feddd 100644 --- a/mediaflow_proxy/utils/crypto_utils.py +++ b/mediaflow_proxy/utils/crypto_utils.py @@ -52,7 +52,7 @@ class EncryptionHandler: del data["ip"] # Remove IP from the data return data - except Exception as e: + except Exception: raise HTTPException(status_code=401, detail="Invalid or expired token") diff --git a/mediaflow_proxy/utils/cryptomath.py b/mediaflow_proxy/utils/cryptomath.py index b75d51c..bfb962d 100644 --- a/mediaflow_proxy/utils/cryptomath.py +++ b/mediaflow_proxy/utils/cryptomath.py @@ -1,22 +1,27 @@ -# Authors: +# Authors: # Trevor Perrin # Martin von Loewis - python 3 port # Yngve Pettersen (ported by Paul Sokolovsky) - TLS 1.2 # -# See the LICENSE file for legal information regarding use of this file. +# See the LICENSE file for legal information regarding use of this file """cryptomath module This module has basic math/crypto code.""" -from __future__ import print_function -import os -import math -import base64 -import binascii -from .compat import compat26Str, compatHMAC, compatLong, \ - bytes_to_int, int_to_bytes, bit_length, byte_length +import math +import os +import zlib + from .codec import Writer +from .compat import ( + bit_length, + byte_length, + bytes_to_int, + compat26Str, + compatHMAC, + int_to_bytes, +) from . import tlshashlib as hashlib from . import tlshmac as hmac @@ -33,27 +38,31 @@ pycryptoLoaded = False # ************************************************************************** # Check that os.urandom works -import zlib assert len(zlib.compress(os.urandom(1000))) > 900 + def getRandomBytes(howMany): b = bytearray(os.urandom(howMany)) - assert(len(b) == howMany) + assert len(b) == howMany return b + prngName = "os.urandom" # ************************************************************************** # Simple hash functions # ************************************************************************** + def MD5(b): """Return a MD5 digest of data""" - return secureHash(b, 'md5') + return secureHash(b, "md5") + def SHA1(b): """Return a SHA1 digest of data""" - return secureHash(b, 'sha1') + return secureHash(b, "sha1") + def secureHash(data, algorithm): """Return a digest of `data` using `algorithm`""" @@ -61,33 +70,40 @@ def secureHash(data, algorithm): hashInstance.update(compat26Str(data)) return bytearray(hashInstance.digest()) + def secureHMAC(k, b, algorithm): """Return a HMAC using `b` and `k` using `algorithm`""" k = compatHMAC(k) b = compatHMAC(b) return bytearray(hmac.new(k, b, getattr(hashlib, algorithm)).digest()) + def HMAC_MD5(k, b): - return secureHMAC(k, b, 'md5') + return secureHMAC(k, b, "md5") + def HMAC_SHA1(k, b): - return secureHMAC(k, b, 'sha1') + return secureHMAC(k, b, "sha1") + def HMAC_SHA256(k, b): - return secureHMAC(k, b, 'sha256') + return secureHMAC(k, b, "sha256") + def HMAC_SHA384(k, b): - return secureHMAC(k, b, 'sha384') + return secureHMAC(k, b, "sha384") + def HKDF_expand(PRK, info, L, algorithm): N = divceil(L, getattr(hashlib, algorithm)().digest_size) T = bytearray() Titer = bytearray() - for x in range(1, N+2): + for x in range(1, N + 2): T += Titer Titer = secureHMAC(PRK, Titer + info + bytearray([x]), algorithm) return T[:L] + def HKDF_expand_label(secret, label, hashValue, length, algorithm): """ TLS1.3 key derivation function (HKDF-Expand-Label). @@ -108,6 +124,7 @@ def HKDF_expand_label(secret, label, hashValue, length, algorithm): return HKDF_expand(secret, hkdfLabel.bytes, length, algorithm) + def derive_secret(secret, label, handshake_hashes, algorithm): """ TLS1.3 key derivation function (Derive-Secret). @@ -123,17 +140,17 @@ def derive_secret(secret, label, handshake_hashes, algorithm): :rtype: bytearray """ if handshake_hashes is None: - hs_hash = secureHash(bytearray(b''), algorithm) + hs_hash = secureHash(bytearray(b""), algorithm) else: hs_hash = handshake_hashes.digest(algorithm) - return HKDF_expand_label(secret, label, hs_hash, - getattr(hashlib, algorithm)().digest_size, - algorithm) + return HKDF_expand_label(secret, label, hs_hash, getattr(hashlib, algorithm)().digest_size, algorithm) + # ************************************************************************** # Converter Functions # ************************************************************************** + def bytesToNumber(b, endian="big"): """ Convert a number stored in bytearray to an integer. @@ -156,7 +173,7 @@ def numberToByteArray(n, howManyBytes=None, endian="big"): if howManyBytes < length: ret = int_to_bytes(n, length, endian) if endian == "big": - return ret[length-howManyBytes:length] + return ret[length - howManyBytes : length] return ret[:howManyBytes] return int_to_bytes(n, howManyBytes, endian) @@ -172,12 +189,12 @@ def mpiToNumber(mpi): def numberToMPI(n): b = numberToByteArray(n) ext = 0 - #If the high-order bit is going to be set, - #add an extra byte of zeros - if (numBits(n) & 0x7)==0: + # If the high-order bit is going to be set, + # add an extra byte of zeros + if (numBits(n) & 0x7) == 0: ext = 1 length = numBytes(n) + ext - b = bytearray(4+ext) + b + b = bytearray(4 + ext) + b b[0] = (length >> 24) & 0xFF b[1] = (length >> 16) & 0xFF b[2] = (length >> 8) & 0xFF @@ -190,75 +207,57 @@ def numberToMPI(n): # ************************************************************************** -# pylint: disable=invalid-name -# pylint recognises them as constants, not function names, also -# we can't change their names without API change numBits = bit_length numBytes = byte_length -# pylint: enable=invalid-name # ************************************************************************** # Big Number Math # ************************************************************************** + def getRandomNumber(low, high): assert low < high howManyBits = numBits(high) howManyBytes = numBytes(high) lastBits = howManyBits % 8 - while 1: - bytes = getRandomBytes(howManyBytes) + while True: + random_bytes = getRandomBytes(howManyBytes) if lastBits: - bytes[0] = bytes[0] % (1 << lastBits) - n = bytesToNumber(bytes) - if n >= low and n < high: + random_bytes[0] = random_bytes[0] % (1 << lastBits) + n = bytesToNumber(random_bytes) + if low <= n < high: return n -def gcd(a,b): - a, b = max(a,b), min(a,b) + +def gcd(a, b): + a, b = max(a, b), min(a, b) while b: a, b = b, a % b return a + def lcm(a, b): return (a * b) // gcd(a, b) -# pylint: disable=invalid-name -# disable pylint check as the (a, b) are part of the API -if GMPY2_LOADED: - def invMod(a, b): - """Return inverse of a mod b, zero if none.""" - if a == 0: - return 0 - return powmod(a, -1, b) -else: - # Use Extended Euclidean Algorithm - def invMod(a, b): - """Return inverse of a mod b, zero if none.""" - c, d = a, b - uc, ud = 1, 0 - while c != 0: - q = d // c - c, d = d-(q*c), c - uc, ud = ud - (q * uc), uc - if d == 1: - return ud % b - return 0 -# pylint: enable=invalid-name + +def invMod(a, b): + """Return inverse of a mod b, zero if none.""" + c, d = a, b + uc, ud = 1, 0 + while c != 0: + q = d // c + c, d = d - (q * c), c + uc, ud = ud - (q * uc), uc + if d == 1: + return ud % b + return 0 -if gmpyLoaded or GMPY2_LOADED: - def powMod(base, power, modulus): - base = mpz(base) - power = mpz(power) - modulus = mpz(modulus) - result = pow(base, power, modulus) - return compatLong(result) -else: - powMod = pow +# Use built-in pow for modular exponentiation (Python 3 handles this efficiently) +powMod = pow def divceil(divident, divisor): @@ -267,10 +266,10 @@ def divceil(divident, divisor): return quot + int(bool(r)) -#Pre-calculate a sieve of the ~100 primes < 1000: +# Pre-calculate a sieve of the ~100 primes < 1000: def makeSieve(n): sieve = list(range(n)) - for count in range(2, int(math.sqrt(n))+1): + for count in range(2, int(math.sqrt(n)) + 1): if sieve[count] == 0: continue x = sieve[count] * 2 @@ -280,30 +279,34 @@ def makeSieve(n): sieve = [x for x in sieve[2:] if x] return sieve + def isPrime(n, iterations=5, display=False, sieve=makeSieve(1000)): - #Trial division with sieve + # Trial division with sieve for x in sieve: - if x >= n: return True - if n % x == 0: return False - #Passed trial division, proceed to Rabin-Miller - #Rabin-Miller implemented per Ferguson & Schneier - #Compute s, t for Rabin-Miller - if display: print("*", end=' ') - s, t = n-1, 0 + if x >= n: + return True + if n % x == 0: + return False + # Passed trial division, proceed to Rabin-Miller + # Rabin-Miller implemented per Ferguson & Schneier + # Compute s, t for Rabin-Miller + if display: + print("*", end=" ") + s, t = n - 1, 0 while s % 2 == 0: - s, t = s//2, t+1 - #Repeat Rabin-Miller x times - a = 2 #Use 2 as a base for first iteration speedup, per HAC - for count in range(iterations): + s, t = s // 2, t + 1 + # Repeat Rabin-Miller x times + a = 2 # Use 2 as a base for first iteration speedup, per HAC + for _ in range(iterations): v = powMod(a, s, n) - if v==1: + if v == 1: continue i = 0 - while v != n-1: - if i == t-1: + while v != n - 1: + if i == t - 1: return False else: - v, i = powMod(v, 2, n), i+1 + v, i = powMod(v, 2, n), i + 1 a = getRandomNumber(2, n) return True @@ -316,16 +319,16 @@ def getRandomPrime(bits, display=False): larger than `(2^(bits-1) * 3 ) / 2` but smaller than 2^bits. """ assert bits >= 10 - #The 1.5 ensures the 2 MSBs are set - #Thus, when used for p,q in RSA, n will have its MSB set + # The 1.5 ensures the 2 MSBs are set + # Thus, when used for p,q in RSA, n will have its MSB set # - #Since 30 is lcm(2,3,5), we'll set our test numbers to - #29 % 30 and keep them there - low = ((2 ** (bits-1)) * 3) // 2 - high = 2 ** bits - 30 + # Since 30 is lcm(2,3,5), we'll set our test numbers to + # 29 % 30 and keep them there + low = ((2 ** (bits - 1)) * 3) // 2 + high = 2**bits - 30 while True: if display: - print(".", end=' ') + print(".", end=" ") cand_p = getRandomNumber(low, high) # make odd if cand_p % 2 == 0: @@ -334,7 +337,7 @@ def getRandomPrime(bits, display=False): return cand_p -#Unused at the moment... +# Unused at the moment... def getRandomSafePrime(bits, display=False): """Generate a random safe prime. @@ -342,23 +345,24 @@ def getRandomSafePrime(bits, display=False): the (p-1)/2 will also be prime. """ assert bits >= 10 - #The 1.5 ensures the 2 MSBs are set - #Thus, when used for p,q in RSA, n will have its MSB set + # The 1.5 ensures the 2 MSBs are set + # Thus, when used for p,q in RSA, n will have its MSB set # - #Since 30 is lcm(2,3,5), we'll set our test numbers to - #29 % 30 and keep them there - low = (2 ** (bits-2)) * 3//2 - high = (2 ** (bits-1)) - 30 + # Since 30 is lcm(2,3,5), we'll set our test numbers to + # 29 % 30 and keep them there + low = (2 ** (bits - 2)) * 3 // 2 + high = (2 ** (bits - 1)) - 30 q = getRandomNumber(low, high) q += 29 - (q % 30) - while 1: - if display: print(".", end=' ') + while True: + if display: + print(".", end=" ") q += 30 - if (q >= high): + if q >= high: q = getRandomNumber(low, high) q += 29 - (q % 30) - #Ideas from Tom Wu's SRP code - #Do trial division on p and q before Rabin-Miller + # Ideas from Tom Wu's SRP code + # Do trial division on p and q before Rabin-Miller if isPrime(q, 0, display=display): p = (2 * q) + 1 if isPrime(p, display=display): diff --git a/mediaflow_proxy/utils/dash_prebuffer.py b/mediaflow_proxy/utils/dash_prebuffer.py index c312ebd..cd22c8e 100644 --- a/mediaflow_proxy/utils/dash_prebuffer.py +++ b/mediaflow_proxy/utils/dash_prebuffer.py @@ -1,373 +1,402 @@ -import logging -import psutil -from typing import Dict, Optional, List -from urllib.parse import urljoin -import xmltodict -from mediaflow_proxy.utils.http_utils import create_httpx_client -from mediaflow_proxy.configs import settings - -logger = logging.getLogger(__name__) - - -class DASHPreBuffer: - """ - Pre-buffer system for DASH streams to reduce latency and improve streaming performance. - """ - - def __init__(self, max_cache_size: Optional[int] = None, prebuffer_segments: Optional[int] = None): - """ - Initialize the DASH pre-buffer system. - - Args: - max_cache_size (int): Maximum number of segments to cache (uses config if None) - prebuffer_segments (int): Number of segments to pre-buffer ahead (uses config if None) - """ - self.max_cache_size = max_cache_size or settings.dash_prebuffer_cache_size - self.prebuffer_segments = prebuffer_segments or settings.dash_prebuffer_segments - self.max_memory_percent = settings.dash_prebuffer_max_memory_percent - self.emergency_threshold = settings.dash_prebuffer_emergency_threshold - - # Cache for different types of DASH content - self.segment_cache: Dict[str, bytes] = {} - self.init_segment_cache: Dict[str, bytes] = {} - self.manifest_cache: Dict[str, dict] = {} - - # Track segment URLs for each adaptation set - self.adaptation_segments: Dict[str, List[str]] = {} - self.client = create_httpx_client() - - def _get_memory_usage_percent(self) -> float: - """ - Get current memory usage percentage. - - Returns: - float: Memory usage percentage - """ - try: - memory = psutil.virtual_memory() - return memory.percent - except Exception as e: - logger.warning(f"Failed to get memory usage: {e}") - return 0.0 - - def _check_memory_threshold(self) -> bool: - """ - Check if memory usage exceeds the emergency threshold. - - Returns: - bool: True if emergency cleanup is needed - """ - memory_percent = self._get_memory_usage_percent() - return memory_percent > self.emergency_threshold - - def _emergency_cache_cleanup(self) -> None: - """ - Perform emergency cache cleanup when memory usage is high. - """ - if self._check_memory_threshold(): - logger.warning("Emergency DASH cache cleanup triggered due to high memory usage") - - # Clear 50% of segment cache - segment_cache_size = len(self.segment_cache) - segment_keys_to_remove = list(self.segment_cache.keys())[:segment_cache_size // 2] - for key in segment_keys_to_remove: - del self.segment_cache[key] - - # Clear 50% of init segment cache - init_cache_size = len(self.init_segment_cache) - init_keys_to_remove = list(self.init_segment_cache.keys())[:init_cache_size // 2] - for key in init_keys_to_remove: - del self.init_segment_cache[key] - - logger.info(f"Emergency cleanup removed {len(segment_keys_to_remove)} segments and {len(init_keys_to_remove)} init segments from cache") - - async def prebuffer_dash_manifest(self, mpd_url: str, headers: Dict[str, str]) -> None: - """ - Pre-buffer segments from a DASH manifest. - - Args: - mpd_url (str): URL of the DASH manifest - headers (Dict[str, str]): Headers to use for requests - """ - try: - # Download and parse MPD manifest - response = await self.client.get(mpd_url, headers=headers) - response.raise_for_status() - mpd_content = response.text - - # Parse MPD XML - mpd_dict = xmltodict.parse(mpd_content) - - # Store manifest in cache - self.manifest_cache[mpd_url] = mpd_dict - - # Extract initialization segments and first few segments - await self._extract_and_prebuffer_segments(mpd_dict, mpd_url, headers) - - logger.info(f"Pre-buffered DASH manifest: {mpd_url}") - - except Exception as e: - logger.warning(f"Failed to pre-buffer DASH manifest {mpd_url}: {e}") - - async def _extract_and_prebuffer_segments(self, mpd_dict: dict, base_url: str, headers: Dict[str, str]) -> None: - """ - Extract and pre-buffer segments from MPD manifest. - - Args: - mpd_dict (dict): Parsed MPD manifest - base_url (str): Base URL for resolving relative URLs - headers (Dict[str, str]): Headers to use for requests - """ - try: - # Extract Period and AdaptationSet information - mpd = mpd_dict.get('MPD', {}) - periods = mpd.get('Period', []) - if not isinstance(periods, list): - periods = [periods] - - for period in periods: - adaptation_sets = period.get('AdaptationSet', []) - if not isinstance(adaptation_sets, list): - adaptation_sets = [adaptation_sets] - - for adaptation_set in adaptation_sets: - # Extract initialization segment - init_segment = adaptation_set.get('SegmentTemplate', {}).get('@initialization') - if init_segment: - init_url = urljoin(base_url, init_segment) - await self._download_init_segment(init_url, headers) - - # Extract segment template - segment_template = adaptation_set.get('SegmentTemplate', {}) - if segment_template: - await self._prebuffer_template_segments(segment_template, base_url, headers) - - # Extract segment list - segment_list = adaptation_set.get('SegmentList', {}) - if segment_list: - await self._prebuffer_list_segments(segment_list, base_url, headers) - - except Exception as e: - logger.warning(f"Failed to extract segments from MPD: {e}") - - async def _download_init_segment(self, init_url: str, headers: Dict[str, str]) -> None: - """ - Download and cache initialization segment. - - Args: - init_url (str): URL of the initialization segment - headers (Dict[str, str]): Headers to use for request - """ - try: - # Check memory usage before downloading - memory_percent = self._get_memory_usage_percent() - if memory_percent > self.max_memory_percent: - logger.warning(f"Memory usage {memory_percent}% exceeds limit {self.max_memory_percent}%, skipping init segment download") - return - - response = await self.client.get(init_url, headers=headers) - response.raise_for_status() - - # Cache the init segment - self.init_segment_cache[init_url] = response.content - - # Check for emergency cleanup - if self._check_memory_threshold(): - self._emergency_cache_cleanup() - - logger.debug(f"Cached init segment: {init_url}") - - except Exception as e: - logger.warning(f"Failed to download init segment {init_url}: {e}") - - async def _prebuffer_template_segments(self, segment_template: dict, base_url: str, headers: Dict[str, str]) -> None: - """ - Pre-buffer segments using segment template. - - Args: - segment_template (dict): Segment template from MPD - base_url (str): Base URL for resolving relative URLs - headers (Dict[str, str]): Headers to use for requests - """ - try: - media_template = segment_template.get('@media') - if not media_template: - return - - # Extract template parameters - start_number = int(segment_template.get('@startNumber', 1)) - duration = float(segment_template.get('@duration', 0)) - timescale = float(segment_template.get('@timescale', 1)) - - # Pre-buffer first few segments - for i in range(self.prebuffer_segments): - segment_number = start_number + i - segment_url = media_template.replace('$Number$', str(segment_number)) - full_url = urljoin(base_url, segment_url) - - await self._download_segment(full_url, headers) - - except Exception as e: - logger.warning(f"Failed to pre-buffer template segments: {e}") - - async def _prebuffer_list_segments(self, segment_list: dict, base_url: str, headers: Dict[str, str]) -> None: - """ - Pre-buffer segments from segment list. - - Args: - segment_list (dict): Segment list from MPD - base_url (str): Base URL for resolving relative URLs - headers (Dict[str, str]): Headers to use for requests - """ - try: - segments = segment_list.get('SegmentURL', []) - if not isinstance(segments, list): - segments = [segments] - - # Pre-buffer first few segments - for segment in segments[:self.prebuffer_segments]: - segment_url = segment.get('@src') - if segment_url: - full_url = urljoin(base_url, segment_url) - await self._download_segment(full_url, headers) - - except Exception as e: - logger.warning(f"Failed to pre-buffer list segments: {e}") - - async def _download_segment(self, segment_url: str, headers: Dict[str, str]) -> None: - """ - Download a single segment and cache it. - - Args: - segment_url (str): URL of the segment to download - headers (Dict[str, str]): Headers to use for request - """ - try: - # Check memory usage before downloading - memory_percent = self._get_memory_usage_percent() - if memory_percent > self.max_memory_percent: - logger.warning(f"Memory usage {memory_percent}% exceeds limit {self.max_memory_percent}%, skipping segment download") - return - - response = await self.client.get(segment_url, headers=headers) - response.raise_for_status() - - # Cache the segment - self.segment_cache[segment_url] = response.content - - # Check for emergency cleanup - if self._check_memory_threshold(): - self._emergency_cache_cleanup() - # Maintain cache size - elif len(self.segment_cache) > self.max_cache_size: - # Remove oldest entries (simple FIFO) - oldest_key = next(iter(self.segment_cache)) - del self.segment_cache[oldest_key] - - logger.debug(f"Cached DASH segment: {segment_url}") - - except Exception as e: - logger.warning(f"Failed to download DASH segment {segment_url}: {e}") - - async def get_segment(self, segment_url: str, headers: Dict[str, str]) -> Optional[bytes]: - """ - Get a segment from cache or download it. - - Args: - segment_url (str): URL of the segment - headers (Dict[str, str]): Headers to use for request - - Returns: - Optional[bytes]: Cached segment data or None if not available - """ - # Check segment cache first - if segment_url in self.segment_cache: - logger.debug(f"DASH cache hit for segment: {segment_url}") - return self.segment_cache[segment_url] - - # Check init segment cache - if segment_url in self.init_segment_cache: - logger.debug(f"DASH cache hit for init segment: {segment_url}") - return self.init_segment_cache[segment_url] - - # Check memory usage before downloading - memory_percent = self._get_memory_usage_percent() - if memory_percent > self.max_memory_percent: - logger.warning(f"Memory usage {memory_percent}% exceeds limit {self.max_memory_percent}%, skipping download") - return None - - # Download if not in cache - try: - response = await self.client.get(segment_url, headers=headers) - response.raise_for_status() - segment_data = response.content - - # Determine if it's an init segment or regular segment - if 'init' in segment_url.lower() or segment_url.endswith('.mp4'): - self.init_segment_cache[segment_url] = segment_data - else: - self.segment_cache[segment_url] = segment_data - - # Check for emergency cleanup - if self._check_memory_threshold(): - self._emergency_cache_cleanup() - # Maintain cache size - elif len(self.segment_cache) > self.max_cache_size: - oldest_key = next(iter(self.segment_cache)) - del self.segment_cache[oldest_key] - - logger.debug(f"Downloaded and cached DASH segment: {segment_url}") - return segment_data - - except Exception as e: - logger.warning(f"Failed to get DASH segment {segment_url}: {e}") - return None - - async def get_manifest(self, mpd_url: str, headers: Dict[str, str]) -> Optional[dict]: - """ - Get MPD manifest from cache or download it. - - Args: - mpd_url (str): URL of the MPD manifest - headers (Dict[str, str]): Headers to use for request - - Returns: - Optional[dict]: Cached manifest data or None if not available - """ - # Check cache first - if mpd_url in self.manifest_cache: - logger.debug(f"DASH cache hit for manifest: {mpd_url}") - return self.manifest_cache[mpd_url] - - # Download if not in cache - try: - response = await self.client.get(mpd_url, headers=headers) - response.raise_for_status() - mpd_content = response.text - mpd_dict = xmltodict.parse(mpd_content) - - # Cache the manifest - self.manifest_cache[mpd_url] = mpd_dict - - logger.debug(f"Downloaded and cached DASH manifest: {mpd_url}") - return mpd_dict - - except Exception as e: - logger.warning(f"Failed to get DASH manifest {mpd_url}: {e}") - return None - - def clear_cache(self) -> None: - """Clear the DASH cache.""" - self.segment_cache.clear() - self.init_segment_cache.clear() - self.manifest_cache.clear() - self.adaptation_segments.clear() - logger.info("DASH pre-buffer cache cleared") - - async def close(self) -> None: - """Close the pre-buffer system.""" - await self.client.aclose() - - -# Global DASH pre-buffer instance -dash_prebuffer = DASHPreBuffer() +""" +DASH Pre-buffer system for reducing latency and improving streaming performance. + +This module extends BasePrebuffer with DASH-specific functionality including +MPD parsing integration, profile handling, and init segment management. +""" + +import asyncio +import logging +import time +from typing import Dict, Optional, List + +from mediaflow_proxy.utils.base_prebuffer import BasePrebuffer +from mediaflow_proxy.utils.cache_utils import ( + get_cached_mpd, + get_cached_init_segment, +) +from mediaflow_proxy.configs import settings + +logger = logging.getLogger(__name__) + + +class DASHPreBuffer(BasePrebuffer): + """ + Pre-buffer system for DASH streams. + + Extends BasePrebuffer with DASH-specific features: + - MPD manifest parsing and profile handling + - Init segment prebuffering + - Live stream segment tracking + - Profile-based segment prefetching + + Uses event-based download coordination from BasePrebuffer to prevent + duplicate downloads between player requests and background prebuffering. + """ + + def __init__( + self, + max_cache_size: Optional[int] = None, + prebuffer_segments: Optional[int] = None, + ): + """ + Initialize the DASH pre-buffer system. + + Args: + max_cache_size: Maximum number of segments to cache (uses config if None) + prebuffer_segments: Number of segments to pre-buffer ahead (uses config if None) + """ + super().__init__( + max_cache_size=max_cache_size or settings.dash_prebuffer_cache_size, + prebuffer_segments=prebuffer_segments or settings.dash_prebuffer_segments, + max_memory_percent=settings.dash_prebuffer_max_memory_percent, + emergency_threshold=settings.dash_prebuffer_emergency_threshold, + segment_ttl=settings.dash_segment_cache_ttl, + ) + + self.inactivity_timeout = settings.dash_prebuffer_inactivity_timeout + + # DASH-specific state + # Track active streams for prefetching: mpd_url -> stream_info + self.active_streams: Dict[str, dict] = {} + self.prefetch_tasks: Dict[str, asyncio.Task] = {} + + # Additional stats for DASH + self.init_segments_prebuffered = 0 + + # Cleanup task + self._cleanup_task: Optional[asyncio.Task] = None + + def log_stats(self) -> None: + """Log current prebuffer statistics with DASH-specific info.""" + stats = self.stats.to_dict() + stats["init_segments_prebuffered"] = self.init_segments_prebuffered + stats["active_streams"] = len(self.active_streams) + logger.info(f"DASH Prebuffer Stats: {stats}") + + async def prebuffer_dash_manifest( + self, + mpd_url: str, + headers: Dict[str, str], + ) -> None: + """ + Pre-buffer segments from a DASH manifest using existing MPD parsing. + + Args: + mpd_url: URL of the DASH manifest + headers: Headers to use for requests + """ + try: + # First get the basic MPD info without segments + parsed_mpd = await get_cached_mpd(mpd_url, headers, parse_drm=False) + if not parsed_mpd: + logger.warning(f"Failed to get parsed MPD for prebuffering: {mpd_url}") + return + + is_live = parsed_mpd.get("isLive", False) + base_profiles = parsed_mpd.get("profiles", []) + + if not base_profiles: + logger.warning(f"No profiles found in MPD for prebuffering: {mpd_url}") + return + + # Now get segments for each profile by parsing with profile_id + profiles_with_segments = [] + for profile in base_profiles: + profile_id = profile.get("id") + if profile_id: + parsed_with_segments = await get_cached_mpd( + mpd_url, headers, parse_drm=False, parse_segment_profile_id=profile_id + ) + # Find the matching profile with segments + for p in parsed_with_segments.get("profiles", []): + if p.get("id") == profile_id: + profiles_with_segments.append(p) + break + + # Store stream info for ongoing prefetching + self.active_streams[mpd_url] = { + "headers": headers, + "is_live": is_live, + "profiles": profiles_with_segments, + "last_access": time.time(), + } + + # Prebuffer init segments and media segments + await self._prebuffer_profiles(profiles_with_segments, headers, is_live) + + # Start cleanup task if not running + self._ensure_cleanup_task_running() + + logger.info( + f"Pre-buffered DASH manifest: {mpd_url} (live={is_live}, profiles={len(profiles_with_segments)})" + ) + + except Exception as e: + logger.warning(f"Failed to pre-buffer DASH manifest {mpd_url}: {e}") + + async def _prebuffer_profiles( + self, + profiles: List[dict], + headers: Dict[str, str], + is_live: bool = False, + ) -> None: + """ + Pre-buffer init segments and media segments for all profiles. + + For live streams, prebuffers from the END of the segment list. + For VOD, prebuffers from the beginning. + + Args: + profiles: List of parsed profiles with resolved URLs + headers: Headers to use for requests + is_live: Whether this is a live stream + """ + if self._should_skip_for_memory(): + logger.warning("Memory usage too high, skipping prebuffer") + return + + # Collect all segment URLs to prebuffer + segment_urls = [] + init_urls = [] + + for profile in profiles: + # Collect init segment URL + init_url = profile.get("initUrl") + if init_url: + init_urls.append(init_url) + + # Get segments to prebuffer + segments = profile.get("segments", []) + if not segments: + continue + + # For live streams, prebuffer from the END (most recent) + if is_live: + segments_to_buffer = segments[-self.prebuffer_segment_count :] + else: + segments_to_buffer = segments[: self.prebuffer_segment_count] + + for segment in segments_to_buffer: + segment_url = segment.get("media") + if segment_url: + segment_urls.append(segment_url) + + # Prebuffer init segments (using special init cache) + for init_url in init_urls: + asyncio.create_task(self._prebuffer_init_segment(init_url, headers)) + + # Prebuffer media segments using base class method + if segment_urls: + await self.prebuffer_segments_batch(segment_urls, headers) + + async def _prebuffer_init_segment( + self, + init_url: str, + headers: Dict[str, str], + ) -> None: + """ + Prebuffer an init segment using the init segment cache. + + Args: + init_url: URL of the init segment + headers: Headers for the request + """ + try: + # get_cached_init_segment handles both caching and downloading + content = await get_cached_init_segment(init_url, headers) + if content: + self.init_segments_prebuffered += 1 + self.stats.bytes_prebuffered += len(content) + logger.debug(f"Prebuffered init segment ({len(content)} bytes)") + except Exception as e: + logger.warning(f"Failed to prebuffer init segment: {e}") + + async def prefetch_upcoming_segments( + self, + mpd_url: str, + current_segment_url: str, + headers: Dict[str, str], + profile_id: Optional[str] = None, + ) -> None: + """ + Prefetch upcoming segments based on current playback position. + + Called when a segment is requested to prefetch the next N segments. + + Args: + mpd_url: URL of the MPD manifest + current_segment_url: URL of the currently requested segment + headers: Headers to use for requests + profile_id: Optional profile ID to limit prefetching to + """ + self.stats.prefetch_triggered += 1 + + try: + # First check if we have cached profiles with segments + if mpd_url in self.active_streams: + # Update last access time + self.active_streams[mpd_url]["last_access"] = time.time() + profiles = self.active_streams[mpd_url].get("profiles", []) + else: + # Get parsed MPD + parsed_mpd = await get_cached_mpd(mpd_url, headers, parse_drm=False) + if not parsed_mpd: + return + profiles = parsed_mpd.get("profiles", []) + + for profile in profiles: + pid = profile.get("id") + if profile_id and pid != profile_id: + continue + + segments = profile.get("segments", []) + + # If no segments, try to get them by parsing with profile_id + if not segments and pid: + parsed_with_segments = await get_cached_mpd( + mpd_url, headers, parse_drm=False, parse_segment_profile_id=pid + ) + for p in parsed_with_segments.get("profiles", []): + if p.get("id") == pid: + segments = p.get("segments", []) + break + + # Find current segment index + current_index = -1 + for i, segment in enumerate(segments): + if segment.get("media") == current_segment_url: + current_index = i + break + + if current_index < 0: + continue + + # Collect next N segment URLs + segment_urls = [] + end_index = min(current_index + 1 + self.prebuffer_segment_count, len(segments)) + for i in range(current_index + 1, end_index): + segment_url = segments[i].get("media") + if segment_url: + segment_urls.append(segment_url) + + if segment_urls: + logger.debug(f"Prefetching {len(segment_urls)} upcoming segments from index {current_index + 1}") + # Run prefetch in background + asyncio.create_task(self.prebuffer_segments_batch(segment_urls, headers, max_concurrent=3)) + + except Exception as e: + logger.warning(f"Failed to prefetch upcoming segments: {e}") + + async def prefetch_for_live_playlist( + self, + profiles: List[dict], + headers: Dict[str, str], + ) -> None: + """ + Prefetch segments for a live playlist refresh. + + Called from process_playlist to ensure upcoming segments are cached. + + Args: + profiles: List of profiles with resolved segment URLs + headers: Headers to use for requests + """ + segment_urls = [] + + for profile in profiles: + segments = profile.get("segments", []) + if not segments: + continue + + # For live, prefetch the last N segments (most recent) + segments_to_prefetch = segments[-self.prebuffer_segment_count :] + + for segment in segments_to_prefetch: + segment_url = segment.get("media") + if segment_url: + # Check if already cached before adding + cached = await self.try_get_cached(segment_url) + if not cached: + segment_urls.append(segment_url) + + if segment_urls: + logger.debug(f"Live playlist prefetch: {len(segment_urls)} segments") + asyncio.create_task(self.prebuffer_segments_batch(segment_urls, headers, max_concurrent=3)) + + def _ensure_cleanup_task_running(self) -> None: + """Ensure the cleanup task is running.""" + if self._cleanup_task is None or self._cleanup_task.done(): + self._cleanup_task = asyncio.create_task(self._cleanup_inactive_streams()) + + async def _cleanup_inactive_streams(self) -> None: + """ + Periodically check for and clean up inactive streams. + + Runs in the background and removes streams that haven't been + accessed recently. + """ + while True: + try: + await asyncio.sleep(30) # Check every 30 seconds + + if not self.active_streams: + logger.debug("No active DASH streams to monitor, stopping cleanup") + return + + current_time = time.time() + streams_to_remove = [] + + for mpd_url, stream_info in self.active_streams.items(): + last_access = stream_info.get("last_access", 0) + time_since_access = current_time - last_access + + if time_since_access > self.inactivity_timeout: + streams_to_remove.append(mpd_url) + logger.info(f"Cleaning up inactive DASH stream ({time_since_access:.0f}s idle)") + + # Remove inactive streams + for mpd_url in streams_to_remove: + self.active_streams.pop(mpd_url, None) + task = self.prefetch_tasks.pop(mpd_url, None) + if task: + task.cancel() + + if streams_to_remove: + logger.info(f"Cleaned up {len(streams_to_remove)} inactive DASH stream(s)") + + except asyncio.CancelledError: + logger.debug("DASH cleanup task cancelled") + return + except Exception as e: + logger.warning(f"Error in DASH cleanup task: {e}") + + def get_stats(self) -> dict: + """Get current prebuffer statistics.""" + stats = self.stats.to_dict() + stats["init_segments_prebuffered"] = self.init_segments_prebuffered + stats["active_streams"] = len(self.active_streams) + return stats + + def clear_cache(self) -> None: + """Clear active streams tracking and log final stats.""" + self.log_stats() + self.active_streams.clear() + for task in self.prefetch_tasks.values(): + task.cancel() + self.prefetch_tasks.clear() + # Cancel cleanup task + if self._cleanup_task and not self._cleanup_task.done(): + self._cleanup_task.cancel() + self._cleanup_task = None + self.stats.reset() + self.init_segments_prebuffered = 0 + logger.info("DASH pre-buffer state cleared") + + async def close(self) -> None: + """Close the pre-buffer system.""" + self.clear_cache() + + +# Global DASH pre-buffer instance +dash_prebuffer = DASHPreBuffer() diff --git a/mediaflow_proxy/utils/deprecations.py b/mediaflow_proxy/utils/deprecations.py index b5a9175..2da2518 100644 --- a/mediaflow_proxy/utils/deprecations.py +++ b/mediaflow_proxy/utils/deprecations.py @@ -2,14 +2,13 @@ # # See the LICENSE file for legal information regarding use of this file. """Methods for deprecating old names for arguments or attributes.""" + import warnings import inspect from functools import wraps -def deprecated_class_name(old_name, - warn="Class name '{old_name}' is deprecated, " - "please use '{new_name}'"): +def deprecated_class_name(old_name, warn="Class name '{old_name}' is deprecated, please use '{new_name}'"): """ Class decorator to deprecate a use of class. @@ -21,14 +20,12 @@ def deprecated_class_name(old_name, keyword name and the 'new_name' for the current one. Example: "Old name: {old_nam}, use '{new_name}' instead". """ + def _wrap(obj): assert callable(obj) def _warn(): - warnings.warn(warn.format(old_name=old_name, - new_name=obj.__name__), - DeprecationWarning, - stacklevel=3) + warnings.warn(warn.format(old_name=old_name, new_name=obj.__name__), DeprecationWarning, stacklevel=3) def _wrap_with_warn(func, is_inspect): @wraps(func) @@ -41,12 +38,12 @@ def deprecated_class_name(old_name, # isinstance(old_name(), new_name) to work frame = inspect.currentframe().f_back code = inspect.getframeinfo(frame).code_context - if [line for line in code - if '{0}('.format(old_name) in line]: + if [line for line in code if "{0}(".format(old_name) in line]: _warn() else: _warn() return func(*args, **kwargs) + return _func # Make old name available. @@ -63,11 +60,11 @@ def deprecated_class_name(old_name, frame.f_globals[old_name] = placeholder return obj + return _wrap -def deprecated_params(names, warn="Param name '{old_name}' is deprecated, " - "please use '{new_name}'"): +def deprecated_params(names, warn="Param name '{old_name}' is deprecated, please use '{new_name}'"): """Decorator to translate obsolete names and warn about their use. :param dict names: dictionary with pairs of new_name: old_name @@ -78,27 +75,24 @@ def deprecated_params(names, warn="Param name '{old_name}' is deprecated, " deprecated keyword name and 'new_name' for the current one. Example: "Old name: {old_name}, use {new_name} instead". """ + def decorator(func): @wraps(func) def wrapper(*args, **kwargs): for new_name, old_name in names.items(): if old_name in kwargs: if new_name in kwargs: - raise TypeError("got multiple values for keyword " - "argument '{0}'".format(new_name)) - warnings.warn(warn.format(old_name=old_name, - new_name=new_name), - DeprecationWarning, - stacklevel=2) + raise TypeError("got multiple values for keyword argument '{0}'".format(new_name)) + warnings.warn(warn.format(old_name=old_name, new_name=new_name), DeprecationWarning, stacklevel=2) kwargs[new_name] = kwargs.pop(old_name) return func(*args, **kwargs) + return wrapper + return decorator -def deprecated_instance_attrs(names, - warn="Attribute '{old_name}' is deprecated, " - "please use '{new_name}'"): +def deprecated_instance_attrs(names, warn="Attribute '{old_name}' is deprecated, please use '{new_name}'"): """Decorator to deprecate class instance attributes. Translates all names in `names` to use new names and emits warnings @@ -119,27 +113,20 @@ def deprecated_instance_attrs(names, def decorator(clazz): def getx(self, name, __old_getx=getattr(clazz, "__getattr__", None)): if name in names: - warnings.warn(warn.format(old_name=name, - new_name=names[name]), - DeprecationWarning, - stacklevel=2) + warnings.warn(warn.format(old_name=name, new_name=names[name]), DeprecationWarning, stacklevel=2) return getattr(self, names[name]) if __old_getx: if hasattr(__old_getx, "__func__"): return __old_getx.__func__(self, name) return __old_getx(self, name) - raise AttributeError("'{0}' object has no attribute '{1}'" - .format(clazz.__name__, name)) + raise AttributeError("'{0}' object has no attribute '{1}'".format(clazz.__name__, name)) getx.__name__ = "__getattr__" clazz.__getattr__ = getx def setx(self, name, value, __old_setx=getattr(clazz, "__setattr__")): if name in names: - warnings.warn(warn.format(old_name=name, - new_name=names[name]), - DeprecationWarning, - stacklevel=2) + warnings.warn(warn.format(old_name=name, new_name=names[name]), DeprecationWarning, stacklevel=2) setattr(self, names[name], value) else: __old_setx(self, name, value) @@ -149,10 +136,7 @@ def deprecated_instance_attrs(names, def delx(self, name, __old_delx=getattr(clazz, "__delattr__")): if name in names: - warnings.warn(warn.format(old_name=name, - new_name=names[name]), - DeprecationWarning, - stacklevel=2) + warnings.warn(warn.format(old_name=name, new_name=names[name]), DeprecationWarning, stacklevel=2) delattr(self, names[name]) else: __old_delx(self, name) @@ -161,11 +145,11 @@ def deprecated_instance_attrs(names, clazz.__delattr__ = delx return clazz + return decorator -def deprecated_attrs(names, warn="Attribute '{old_name}' is deprecated, " - "please use '{new_name}'"): +def deprecated_attrs(names, warn="Attribute '{old_name}' is deprecated, please use '{new_name}'"): """Decorator to deprecate all specified attributes in class. Translates all names in `names` to use new names and emits warnings @@ -180,6 +164,7 @@ def deprecated_attrs(names, warn="Attribute '{old_name}' is deprecated, " deprecated keyword name and 'new_name' for the current one. Example: "Old name: {old_name}, use {new_name} instead". """ + # prepare metaclass for handling all the class methods, class variables # and static methods (as they don't go through instance's __getattr__) class DeprecatedProps(type): @@ -192,27 +177,33 @@ def deprecated_attrs(names, warn="Attribute '{old_name}' is deprecated, " # apply metaclass orig_vars = cls.__dict__.copy() - slots = orig_vars.get('__slots__') + slots = orig_vars.get("__slots__") if slots is not None: if isinstance(slots, str): slots = [slots] for slots_var in slots: orig_vars.pop(slots_var) - orig_vars.pop('__dict__', None) - orig_vars.pop('__weakref__', None) + orig_vars.pop("__dict__", None) + orig_vars.pop("__weakref__", None) return metaclass(cls.__name__, cls.__bases__, orig_vars) + return wrapper + def deprecated_method(message): """Decorator for deprecating methods. :param ste message: The message you want to display. """ + def decorator(func): @wraps(func) def wrapper(*args, **kwargs): - warnings.warn("{0} is a deprecated method. {1}".format(func.__name__, message), - DeprecationWarning, stacklevel=2) + warnings.warn( + "{0} is a deprecated method. {1}".format(func.__name__, message), DeprecationWarning, stacklevel=2 + ) return func(*args, **kwargs) + return wrapper + return decorator diff --git a/mediaflow_proxy/utils/extractor_helpers.py b/mediaflow_proxy/utils/extractor_helpers.py new file mode 100644 index 0000000..63c9e54 --- /dev/null +++ b/mediaflow_proxy/utils/extractor_helpers.py @@ -0,0 +1,151 @@ +""" +Helper functions for automatic stream extraction in proxy routes. + +This module provides caching and extraction helpers for DLHD/DaddyLive +and Sportsonline/Sportzonline streams that are auto-detected in proxy routes. +""" + +import logging +import re +import time +from urllib.parse import urlparse + +from fastapi import Request, HTTPException + +from mediaflow_proxy.extractors.base import ExtractorError +from mediaflow_proxy.extractors.factory import ExtractorFactory +from mediaflow_proxy.utils.http_utils import ProxyRequestHeaders, DownloadError + + +logger = logging.getLogger(__name__) + +# DLHD extraction cache: {original_url: {"data": extraction_result, "timestamp": time.time()}} +_dlhd_extraction_cache: dict = {} +_dlhd_cache_duration = 600 # 10 minutes in seconds + +# Sportsonline extraction cache +_sportsonline_extraction_cache: dict = {} +_sportsonline_cache_duration = 600 # 10 minutes in seconds + + +async def check_and_extract_dlhd_stream( + request: Request, destination: str, proxy_headers: ProxyRequestHeaders, force_refresh: bool = False +) -> dict | None: + """ + Check if destination contains DLHD/DaddyLive patterns and extract stream directly. + Uses caching to avoid repeated extractions (10 minute cache). + + Args: + request (Request): The incoming HTTP request. + destination (str): The destination URL to check. + proxy_headers (ProxyRequestHeaders): The headers to include in the request. + force_refresh (bool): Force re-extraction even if cached data exists. + + Returns: + dict | None: Extracted stream data if DLHD link detected, None otherwise. + """ + # Check for common DLHD/DaddyLive patterns in the URL + # This includes stream-XXX pattern and domain names like dlhd.dad or daddylive.sx + is_dlhd_link = ( + re.search(r"stream-\d+", destination) + or "dlhd.dad" in urlparse(destination).netloc + or "daddylive.sx" in urlparse(destination).netloc + ) + + if not is_dlhd_link: + return None + + logger.info(f"DLHD link detected: {destination}") + + # Check cache first (unless force_refresh is True) + current_time = time.time() + if not force_refresh and destination in _dlhd_extraction_cache: + cached_entry = _dlhd_extraction_cache[destination] + cache_age = current_time - cached_entry["timestamp"] + + if cache_age < _dlhd_cache_duration: + logger.info(f"Using cached DLHD data (age: {cache_age:.1f}s)") + return cached_entry["data"] + else: + logger.info(f"DLHD cache expired (age: {cache_age:.1f}s), re-extracting...") + del _dlhd_extraction_cache[destination] + + # Extract stream data + try: + logger.info(f"Extracting DLHD stream data from: {destination}") + extractor = ExtractorFactory.get_extractor("DLHD", proxy_headers.request) + result = await extractor.extract(destination) + + logger.info(f"DLHD extraction successful. Stream URL: {result.get('destination_url')}") + + # Handle dlhd_key_params - encode them for URL passing + if "dlhd_key_params" in result: + key_params = result.pop("dlhd_key_params") + # Add key params as special query parameters for key URL handling + result["dlhd_channel_salt"] = key_params.get("channel_salt", "") + result["dlhd_auth_token"] = key_params.get("auth_token", "") + result["dlhd_iframe_url"] = key_params.get("iframe_url", "") + logger.info("DLHD key params extracted for dynamic header computation") + + # Cache a copy of result to prevent downstream mutations from corrupting the cache + _dlhd_extraction_cache[destination] = {"data": result.copy(), "timestamp": current_time} + logger.info(f"DLHD data cached for {_dlhd_cache_duration}s") + + return result + + except (ExtractorError, DownloadError) as e: + logger.error(f"DLHD extraction failed: {str(e)}") + raise HTTPException(status_code=400, detail=f"DLHD extraction failed: {str(e)}") + except Exception as e: + logger.exception(f"Unexpected error during DLHD extraction: {str(e)}") + raise HTTPException(status_code=500, detail=f"DLHD extraction failed: {str(e)}") + + +async def check_and_extract_sportsonline_stream( + request: Request, destination: str, proxy_headers: ProxyRequestHeaders, force_refresh: bool = False +) -> dict | None: + """ + Check if destination contains Sportsonline/Sportzonline patterns and extract stream directly. + Uses caching to avoid repeated extractions (10 minute cache). + + Args: + request (Request): The incoming HTTP request. + destination (str): The destination URL to check. + proxy_headers (ProxyRequestHeaders): The headers to include in the request. + force_refresh (bool): Force re-extraction even if cached data exists. + + Returns: + dict | None: Extracted stream data if Sportsonline link detected, None otherwise. + """ + parsed_netloc = urlparse(destination).netloc + is_sportsonline_link = "sportzonline." in parsed_netloc or "sportsonline." in parsed_netloc + + if not is_sportsonline_link: + return None + + logger.info(f"Sportsonline link detected: {destination}") + + current_time = time.time() + if not force_refresh and destination in _sportsonline_extraction_cache: + cached_entry = _sportsonline_extraction_cache[destination] + if current_time - cached_entry["timestamp"] < _sportsonline_cache_duration: + logger.info(f"Using cached Sportsonline data (age: {current_time - cached_entry['timestamp']:.1f}s)") + return cached_entry["data"] + else: + logger.info("Sportsonline cache expired, re-extracting...") + del _sportsonline_extraction_cache[destination] + + try: + logger.info(f"Extracting Sportsonline stream data from: {destination}") + extractor = ExtractorFactory.get_extractor("Sportsonline", proxy_headers.request) + result = await extractor.extract(destination) + logger.info(f"Sportsonline extraction successful. Stream URL: {result.get('destination_url')}") + _sportsonline_extraction_cache[destination] = {"data": result, "timestamp": current_time} + logger.info(f"Sportsonline data cached for {_sportsonline_cache_duration}s") + return result + except (ExtractorError, DownloadError) as e: + logger.error(f"Sportsonline extraction failed: {str(e)}") + raise HTTPException(status_code=400, detail=f"Sportsonline extraction failed: {str(e)}") + except Exception as e: + logger.exception(f"Unexpected error during Sportsonline extraction: {str(e)}") + raise HTTPException(status_code=500, detail=f"Sportsonline extraction failed: {str(e)}") diff --git a/mediaflow_proxy/utils/hls_prebuffer.py b/mediaflow_proxy/utils/hls_prebuffer.py index 3141189..87474cc 100644 --- a/mediaflow_proxy/utils/hls_prebuffer.py +++ b/mediaflow_proxy/utils/hls_prebuffer.py @@ -1,490 +1,478 @@ -import asyncio -import logging -import psutil -from typing import Dict, Optional, List -from urllib.parse import urlparse -import httpx -from mediaflow_proxy.utils.http_utils import create_httpx_client -from mediaflow_proxy.configs import settings -from collections import OrderedDict -import time -from urllib.parse import urljoin - -logger = logging.getLogger(__name__) - - -class HLSPreBuffer: - """ - Pre-buffer system for HLS streams to reduce latency and improve streaming performance. - """ - - def __init__(self, max_cache_size: Optional[int] = None, prebuffer_segments: Optional[int] = None): - """ - Initialize the HLS pre-buffer system. - - Args: - max_cache_size (int): Maximum number of segments to cache (uses config if None) - prebuffer_segments (int): Number of segments to pre-buffer ahead (uses config if None) - """ - from collections import OrderedDict - import time - from urllib.parse import urljoin - self.max_cache_size = max_cache_size or settings.hls_prebuffer_cache_size - self.prebuffer_segments = prebuffer_segments or settings.hls_prebuffer_segments - self.max_memory_percent = settings.hls_prebuffer_max_memory_percent - self.emergency_threshold = settings.hls_prebuffer_emergency_threshold - # Cache LRU - self.segment_cache: "OrderedDict[str, bytes]" = OrderedDict() - # Mappa playlist -> lista segmenti - self.segment_urls: Dict[str, List[str]] = {} - # Mappa inversa segmento -> (playlist_url, index) - self.segment_to_playlist: Dict[str, tuple[str, int]] = {} - # Stato per playlist: {headers, last_access, refresh_task, target_duration} - self.playlist_state: Dict[str, dict] = {} - self.client = create_httpx_client() - - async def prebuffer_playlist(self, playlist_url: str, headers: Dict[str, str]) -> None: - """ - Pre-buffer segments from an HLS playlist. - - Args: - playlist_url (str): URL of the HLS playlist - headers (Dict[str, str]): Headers to use for requests - """ - try: - logger.debug(f"Starting pre-buffer for playlist: {playlist_url}") - response = await self.client.get(playlist_url, headers=headers) - response.raise_for_status() - playlist_content = response.text - - # Se master playlist: prendi la prima variante (fix relativo) - if "#EXT-X-STREAM-INF" in playlist_content: - logger.debug(f"Master playlist detected, finding first variant") - variant_urls = self._extract_variant_urls(playlist_content, playlist_url) - if variant_urls: - first_variant_url = variant_urls[0] - logger.debug(f"Pre-buffering first variant: {first_variant_url}") - await self.prebuffer_playlist(first_variant_url, headers) - else: - logger.warning("No variants found in master playlist") - return - - # Media playlist: estrai segmenti, salva stato e lancia refresh loop - segment_urls = self._extract_segment_urls(playlist_content, playlist_url) - self.segment_urls[playlist_url] = segment_urls - # aggiorna mappa inversa - for idx, u in enumerate(segment_urls): - self.segment_to_playlist[u] = (playlist_url, idx) - - # prebuffer iniziale - await self._prebuffer_segments(segment_urls[:self.prebuffer_segments], headers) - logger.info(f"Pre-buffered {min(self.prebuffer_segments, len(segment_urls))} segments for {playlist_url}") - - # setup refresh loop se non già attivo - target_duration = self._parse_target_duration(playlist_content) or 6 - st = self.playlist_state.get(playlist_url, {}) - if not st.get("refresh_task") or st["refresh_task"].done(): - task = asyncio.create_task(self._refresh_playlist_loop(playlist_url, headers, target_duration)) - self.playlist_state[playlist_url] = { - "headers": headers, - "last_access": asyncio.get_event_loop().time(), - "refresh_task": task, - "target_duration": target_duration, - } - except Exception as e: - logger.warning(f"Failed to pre-buffer playlist {playlist_url}: {e}") - - def _extract_segment_urls(self, playlist_content: str, base_url: str) -> List[str]: - """ - Extract segment URLs from HLS playlist content. - - Args: - playlist_content (str): Content of the HLS playlist - base_url (str): Base URL for resolving relative URLs - - Returns: - List[str]: List of segment URLs - """ - segment_urls = [] - lines = playlist_content.split('\n') - - logger.debug(f"Analyzing playlist with {len(lines)} lines") - - for line in lines: - line = line.strip() - if line and not line.startswith('#'): - # Check if line contains a URL (http/https) or is a relative path - if 'http://' in line or 'https://' in line: - segment_urls.append(line) - logger.debug(f"Found absolute URL: {line}") - elif line and not line.startswith('#'): - # This might be a relative path to a segment - parsed_base = urlparse(base_url) - # Ensure proper path joining - if line.startswith('/'): - segment_url = f"{parsed_base.scheme}://{parsed_base.netloc}{line}" - else: - # Get the directory path from base_url - base_path = parsed_base.path.rsplit('/', 1)[0] if '/' in parsed_base.path else '' - segment_url = f"{parsed_base.scheme}://{parsed_base.netloc}{base_path}/{line}" - segment_urls.append(segment_url) - logger.debug(f"Found relative path: {line} -> {segment_url}") - - logger.debug(f"Extracted {len(segment_urls)} segment URLs from playlist") - if segment_urls: - logger.debug(f"First segment URL: {segment_urls[0]}") - else: - logger.debug("No segment URLs found in playlist") - # Log first few lines for debugging - for i, line in enumerate(lines[:10]): - logger.debug(f"Line {i}: {line}") - - return segment_urls - - def _extract_variant_urls(self, playlist_content: str, base_url: str) -> List[str]: - """ - Estrae le varianti dal master playlist. Corretto per gestire URI relativi: - prende la riga non-commento successiva a #EXT-X-STREAM-INF e la risolve rispetto a base_url. - """ - from urllib.parse import urljoin - variant_urls = [] - lines = [l.strip() for l in playlist_content.split('\n')] - take_next_uri = False - for line in lines: - if line.startswith("#EXT-X-STREAM-INF"): - take_next_uri = True - continue - if take_next_uri: - take_next_uri = False - if line and not line.startswith('#'): - variant_urls.append(urljoin(base_url, line)) - logger.debug(f"Extracted {len(variant_urls)} variant URLs from master playlist") - if variant_urls: - logger.debug(f"First variant URL: {variant_urls[0]}") - return variant_urls - - async def _prebuffer_segments(self, segment_urls: List[str], headers: Dict[str, str]) -> None: - """ - Pre-buffer specific segments. - - Args: - segment_urls (List[str]): List of segment URLs to pre-buffer - headers (Dict[str, str]): Headers to use for requests - """ - tasks = [] - for url in segment_urls: - if url not in self.segment_cache: - tasks.append(self._download_segment(url, headers)) - if tasks: - await asyncio.gather(*tasks, return_exceptions=True) - - def _get_memory_usage_percent(self) -> float: - """ - Get current memory usage percentage. - - Returns: - float: Memory usage percentage - """ - try: - memory = psutil.virtual_memory() - return memory.percent - except Exception as e: - logger.warning(f"Failed to get memory usage: {e}") - return 0.0 - - def _check_memory_threshold(self) -> bool: - """ - Check if memory usage exceeds the emergency threshold. - - Returns: - bool: True if emergency cleanup is needed - """ - memory_percent = self._get_memory_usage_percent() - return memory_percent > self.emergency_threshold - - def _emergency_cache_cleanup(self) -> None: - """ - Esegue cleanup LRU rimuovendo il 50% più vecchio. - """ - if self._check_memory_threshold(): - logger.warning("Emergency cache cleanup triggered due to high memory usage") - to_remove = max(1, len(self.segment_cache) // 2) - removed = 0 - while removed < to_remove and self.segment_cache: - self.segment_cache.popitem(last=False) # rimuovi LRU - removed += 1 - logger.info(f"Emergency cleanup removed {removed} segments from cache") - - async def _download_segment(self, segment_url: str, headers: Dict[str, str]) -> None: - """ - Download a single segment and cache it. - - Args: - segment_url (str): URL of the segment to download - headers (Dict[str, str]): Headers to use for request - """ - try: - memory_percent = self._get_memory_usage_percent() - if memory_percent > self.max_memory_percent: - logger.warning(f"Memory usage {memory_percent}% exceeds limit {self.max_memory_percent}%, skipping download") - return - - response = await self.client.get(segment_url, headers=headers) - response.raise_for_status() - - # Cache LRU - self.segment_cache[segment_url] = response.content - self.segment_cache.move_to_end(segment_url, last=True) - - if self._check_memory_threshold(): - self._emergency_cache_cleanup() - elif len(self.segment_cache) > self.max_cache_size: - # Evict LRU finché non rientra - while len(self.segment_cache) > self.max_cache_size: - self.segment_cache.popitem(last=False) - - logger.debug(f"Cached segment: {segment_url}") - except Exception as e: - logger.warning(f"Failed to download segment {segment_url}: {e}") - - async def get_segment(self, segment_url: str, headers: Dict[str, str]) -> Optional[bytes]: - """ - Get a segment from cache or download it. - - Args: - segment_url (str): URL of the segment - headers (Dict[str, str]): Headers to use for request - - Returns: - Optional[bytes]: Cached segment data or None if not available - """ - # Check cache first - if segment_url in self.segment_cache: - logger.debug(f"Cache hit for segment: {segment_url}") - # LRU touch - data = self.segment_cache[segment_url] - self.segment_cache.move_to_end(segment_url, last=True) - # aggiorna last_access per la playlist se mappata - pl = self.segment_to_playlist.get(segment_url) - if pl: - st = self.playlist_state.get(pl[0]) - if st: - st["last_access"] = asyncio.get_event_loop().time() - return data - - memory_percent = self._get_memory_usage_percent() - if memory_percent > self.max_memory_percent: - logger.warning(f"Memory usage {memory_percent}% exceeds limit {self.max_memory_percent}%, skipping download") - return None - - try: - response = await self.client.get(segment_url, headers=headers) - response.raise_for_status() - segment_data = response.content - - # Cache LRU - self.segment_cache[segment_url] = segment_data - self.segment_cache.move_to_end(segment_url, last=True) - - if self._check_memory_threshold(): - self._emergency_cache_cleanup() - elif len(self.segment_cache) > self.max_cache_size: - while len(self.segment_cache) > self.max_cache_size: - self.segment_cache.popitem(last=False) - - # aggiorna last_access per playlist - pl = self.segment_to_playlist.get(segment_url) - if pl: - st = self.playlist_state.get(pl[0]) - if st: - st["last_access"] = asyncio.get_event_loop().time() - - logger.debug(f"Downloaded and cached segment: {segment_url}") - return segment_data - except Exception as e: - logger.warning(f"Failed to get segment {segment_url}: {e}") - return None - - async def prebuffer_from_segment(self, segment_url: str, headers: Dict[str, str]) -> None: - """ - Dato un URL di segmento, prebuffer i successivi in base alla playlist e all'indice mappato. - """ - mapped = self.segment_to_playlist.get(segment_url) - if not mapped: - return - playlist_url, idx = mapped - # aggiorna access time - st = self.playlist_state.get(playlist_url) - if st: - st["last_access"] = asyncio.get_event_loop().time() - await self.prebuffer_next_segments(playlist_url, idx, headers) - - async def prebuffer_next_segments(self, playlist_url: str, current_segment_index: int, headers: Dict[str, str]) -> None: - """ - Pre-buffer next segments based on current playback position. - - Args: - playlist_url (str): URL of the playlist - current_segment_index (int): Index of current segment - headers (Dict[str, str]): Headers to use for requests - """ - if playlist_url not in self.segment_urls: - return - segment_urls = self.segment_urls[playlist_url] - next_segments = segment_urls[current_segment_index + 1:current_segment_index + 1 + self.prebuffer_segments] - if next_segments: - await self._prebuffer_segments(next_segments, headers) - - def clear_cache(self) -> None: - """Clear the segment cache.""" - self.segment_cache.clear() - self.segment_urls.clear() - self.segment_to_playlist.clear() - self.playlist_state.clear() - logger.info("HLS pre-buffer cache cleared") - - async def close(self) -> None: - """Close the pre-buffer system.""" - await self.client.aclose() - - -# Global pre-buffer instance -hls_prebuffer = HLSPreBuffer() - - -class HLSPreBuffer: - def _parse_target_duration(self, playlist_content: str) -> Optional[int]: - """ - Parse EXT-X-TARGETDURATION from a media playlist and return duration in seconds. - Returns None if not present or unparsable. - """ - for line in playlist_content.splitlines(): - line = line.strip() - if line.startswith("#EXT-X-TARGETDURATION:"): - try: - value = line.split(":", 1)[1].strip() - return int(float(value)) - except Exception: - return None - return None - - async def _refresh_playlist_loop(self, playlist_url: str, headers: Dict[str, str], target_duration: int) -> None: - """ - Aggiorna periodicamente la playlist per seguire la sliding window e mantenere la cache coerente. - Interrompe e pulisce dopo inattività prolungata. - """ - sleep_s = max(2, min(15, int(target_duration))) - inactivity_timeout = 600 # 10 minuti - while True: - try: - st = self.playlist_state.get(playlist_url) - now = asyncio.get_event_loop().time() - if not st: - return - if now - st.get("last_access", now) > inactivity_timeout: - # cleanup specifico della playlist - urls = set(self.segment_urls.get(playlist_url, [])) - if urls: - # rimuovi dalla cache solo i segmenti di questa playlist - for u in list(self.segment_cache.keys()): - if u in urls: - self.segment_cache.pop(u, None) - # rimuovi mapping - for u in urls: - self.segment_to_playlist.pop(u, None) - self.segment_urls.pop(playlist_url, None) - self.playlist_state.pop(playlist_url, None) - logger.info(f"Stopped HLS prebuffer for inactive playlist: {playlist_url}") - return - - # refresh manifest - resp = await self.client.get(playlist_url, headers=headers) - resp.raise_for_status() - content = resp.text - new_target = self._parse_target_duration(content) - if new_target: - sleep_s = max(2, min(15, int(new_target))) - - new_urls = self._extract_segment_urls(content, playlist_url) - if new_urls: - self.segment_urls[playlist_url] = new_urls - # rebuild reverse map per gli ultimi N (limita la memoria) - for idx, u in enumerate(new_urls[-(self.max_cache_size * 2):]): - # rimappiando sovrascrivi eventuali entry - real_idx = len(new_urls) - (self.max_cache_size * 2) + idx if len(new_urls) > (self.max_cache_size * 2) else idx - self.segment_to_playlist[u] = (playlist_url, real_idx) - - # tenta un prebuffer proattivo: se conosciamo l'ultimo segmento accessibile, anticipa i successivi - # Non conosciamo l'indice di riproduzione corrente qui, quindi non facciamo nulla di aggressivo. - - except Exception as e: - logger.debug(f"Playlist refresh error for {playlist_url}: {e}") - await asyncio.sleep(sleep_s) - def _extract_segment_urls(self, playlist_content: str, base_url: str) -> List[str]: - """ - Extract segment URLs from HLS playlist content. - - Args: - playlist_content (str): Content of the HLS playlist - base_url (str): Base URL for resolving relative URLs - - Returns: - List[str]: List of segment URLs - """ - segment_urls = [] - lines = playlist_content.split('\n') - - logger.debug(f"Analyzing playlist with {len(lines)} lines") - - for line in lines: - line = line.strip() - if line and not line.startswith('#'): - # Check if line contains a URL (http/https) or is a relative path - if 'http://' in line or 'https://' in line: - segment_urls.append(line) - logger.debug(f"Found absolute URL: {line}") - elif line and not line.startswith('#'): - # This might be a relative path to a segment - parsed_base = urlparse(base_url) - # Ensure proper path joining - if line.startswith('/'): - segment_url = f"{parsed_base.scheme}://{parsed_base.netloc}{line}" - else: - # Get the directory path from base_url - base_path = parsed_base.path.rsplit('/', 1)[0] if '/' in parsed_base.path else '' - segment_url = f"{parsed_base.scheme}://{parsed_base.netloc}{base_path}/{line}" - segment_urls.append(segment_url) - logger.debug(f"Found relative path: {line} -> {segment_url}") - - logger.debug(f"Extracted {len(segment_urls)} segment URLs from playlist") - if segment_urls: - logger.debug(f"First segment URL: {segment_urls[0]}") - else: - logger.debug("No segment URLs found in playlist") - # Log first few lines for debugging - for i, line in enumerate(lines[:10]): - logger.debug(f"Line {i}: {line}") - - return segment_urls - - def _extract_variant_urls(self, playlist_content: str, base_url: str) -> List[str]: - """ - Estrae le varianti dal master playlist. Corretto per gestire URI relativi: - prende la riga non-commento successiva a #EXT-X-STREAM-INF e la risolve rispetto a base_url. - """ - from urllib.parse import urljoin - variant_urls = [] - lines = [l.strip() for l in playlist_content.split('\n')] - take_next_uri = False - for line in lines: - if line.startswith("#EXT-X-STREAM-INF"): - take_next_uri = True - continue - if take_next_uri: - take_next_uri = False - if line and not line.startswith('#'): - variant_urls.append(urljoin(base_url, line)) - logger.debug(f"Extracted {len(variant_urls)} variant URLs from master playlist") - if variant_urls: - logger.debug(f"First variant URL: {variant_urls[0]}") - return variant_urls \ No newline at end of file +""" +HLS Pre-buffer system with priority-based sequential prefetching. + +This module provides a smart prebuffering system that: +- Prioritizes player-requested segments (downloaded immediately) +- Prefetches remaining segments sequentially in background +- Supports multiple users watching the same channel (shared prefetcher) +- Cleans up inactive prefetchers automatically + +Architecture: +1. When playlist is fetched, register_playlist() creates a PlaylistPrefetcher +2. PlaylistPrefetcher runs a background loop: priority queue -> sequential prefetch +3. When player requests a segment, request_segment() adds it to priority queue +4. Prefetcher downloads priority segment first, then continues sequential +""" + +import asyncio +import logging +import time +from typing import Dict, Optional, List +from urllib.parse import urljoin + +from mediaflow_proxy.utils.base_prebuffer import BasePrebuffer +from mediaflow_proxy.utils.cache_utils import get_cached_segment +from mediaflow_proxy.configs import settings + +logger = logging.getLogger(__name__) + + +class PlaylistPrefetcher: + """ + Manages prefetching for a single playlist with priority support. + + Key design for live streams with changing tokens: + - Does NOT start prefetching immediately on registration + - Only starts prefetching AFTER player requests a segment + - This ensures we prefetch from the CURRENT playlist, not stale ones + + The prefetcher runs a background loop that: + 1. Waits for player to request a segment (priority) + 2. Downloads the priority segment first + 3. Then prefetches subsequent segments sequentially + 4. Stops when cancelled or all segments are prefetched + """ + + def __init__( + self, + playlist_url: str, + segment_urls: List[str], + headers: Dict[str, str], + prebuffer: "HLSPreBuffer", + prefetch_limit: int = 5, + ): + """ + Initialize a playlist prefetcher. + + Args: + playlist_url: URL of the HLS playlist + segment_urls: Ordered list of segment URLs from the playlist + headers: Headers to use for requests + prebuffer: Parent HLSPreBuffer instance for download methods + prefetch_limit: Maximum number of segments to prefetch ahead of player position + """ + self.playlist_url = playlist_url + self.segment_urls = segment_urls + self.headers = headers + self.prebuffer = prebuffer + self.prefetch_limit = prefetch_limit + + self.last_access = time.time() + self.current_index = 0 # Next segment to prefetch sequentially + self.player_index = 0 # Last segment index requested by player + self.priority_event = asyncio.Event() # Signals priority segment available + self.priority_url: Optional[str] = None # Current priority segment + self.cancelled = False + self._task: Optional[asyncio.Task] = None + self._lock = asyncio.Lock() # Protects priority_url + + # Track which segments are already cached or being downloaded + self.downloading: set = set() + + # Track if prefetching has been activated by a player request + self.activated = False + + def start(self) -> None: + """Start the prefetch background task.""" + if self._task is None or self._task.done(): + self._task = asyncio.create_task(self._run()) + logger.info(f"[PlaylistPrefetcher] Started (waiting for activation): {self.playlist_url}") + + def stop(self) -> None: + """Stop the prefetch background task.""" + self.cancelled = True + self.priority_event.set() # Wake up the loop + if self._task and not self._task.done(): + self._task.cancel() + logger.info(f"[PlaylistPrefetcher] Stopped for: {self.playlist_url}") + + def update_segments(self, segment_urls: List[str]) -> None: + """ + Update segment URLs (called when playlist is refreshed). + + Args: + segment_urls: New list of segment URLs + """ + self.segment_urls = segment_urls + self.last_access = time.time() + logger.debug(f"[PlaylistPrefetcher] Updated segments ({len(segment_urls)}): {self.playlist_url}") + + async def request_priority(self, segment_url: str) -> None: + """ + Player requested this segment - update indices and activate prefetching. + + The player will download this segment via get_or_download(). + The prefetcher's job is to prefetch segments AHEAD of the player, + not to download the segment the player is already requesting. + + For VOD/movie streams: handles seek by detecting large jumps in segment + index and resetting the prefetch window accordingly. + + Args: + segment_url: URL of the segment the player needs + """ + self.last_access = time.time() + self.activated = True # Activate prefetching + + # Update player position for prefetch limit calculation + segment_index = self._find_segment_index(segment_url) + if segment_index >= 0: + old_player_index = self.player_index + self.player_index = segment_index + # Start prefetching from the NEXT segment (player handles current one) + self.current_index = segment_index + 1 + + # Detect seek: if player jumped more than prefetch_limit segments + # This handles VOD seek scenarios where user jumps to different position + jump_distance = abs(segment_index - old_player_index) + if jump_distance > self.prefetch_limit and old_player_index >= 0: + logger.info( + f"[PlaylistPrefetcher] Seek detected: jumped {jump_distance} segments " + f"(from {old_player_index} to {segment_index})" + ) + + # Signal the prefetch loop to wake up and start prefetching ahead + async with self._lock: + self.priority_url = segment_url + self.priority_event.set() + + def _find_segment_index(self, segment_url: str) -> int: + """Find the index of a segment URL in the list.""" + try: + return self.segment_urls.index(segment_url) + except ValueError: + return -1 + + async def _run(self) -> None: + """ + Main prefetch loop. + + For live streams: waits until activated by player request before prefetching. + Priority: Player-requested segment > Sequential prefetch + After downloading priority segment, continue sequential from that point. + + Prefetching is LIMITED to `prefetch_limit` segments ahead of the player's + current position to avoid downloading the entire stream. + """ + logger.info(f"[PlaylistPrefetcher] Loop started for: {self.playlist_url}") + + while not self.cancelled: + try: + # Wait for activation (player request) before doing anything + if not self.activated: + try: + await asyncio.wait_for(self.priority_event.wait(), timeout=1.0) + except asyncio.TimeoutError: + continue + + # Check for priority segment first + async with self._lock: + priority_url = self.priority_url + self.priority_url = None + self.priority_event.clear() + + if priority_url: + # Player is already downloading this segment via get_or_download() + # We just need to update our indices and skip to prefetching NEXT segments + # This avoids duplicate download attempts and inflated cache miss stats + priority_index = self._find_segment_index(priority_url) + if priority_index >= 0: + self.player_index = priority_index + self.current_index = priority_index + 1 # Start prefetching from next segment + logger.info( + f"[PlaylistPrefetcher] Player at index {self.player_index}, " + f"will prefetch up to {self.prefetch_limit} segments ahead" + ) + continue + + # Calculate prefetch limit based on player position + max_prefetch_index = self.player_index + self.prefetch_limit + 1 + + # No priority - prefetch next sequential segment (only if within limit) + if ( + self.activated + and self.current_index < len(self.segment_urls) + and self.current_index < max_prefetch_index + ): + url = self.segment_urls[self.current_index] + + # Skip if already cached or being downloaded + if url not in self.downloading: + cached = await get_cached_segment(url) + if not cached: + logger.info( + f"[PlaylistPrefetcher] Prefetching [{self.current_index}] " + f"(player at {self.player_index}, limit {self.prefetch_limit}): {url}" + ) + await self._download_segment(url) + else: + logger.debug(f"[PlaylistPrefetcher] Already cached [{self.current_index}]: {url}") + + self.current_index += 1 + else: + # Reached prefetch limit or end of segments - wait for player to advance + try: + await asyncio.wait_for(self.priority_event.wait(), timeout=1.0) + except asyncio.TimeoutError: + pass + + except asyncio.CancelledError: + logger.info(f"[PlaylistPrefetcher] Loop cancelled: {self.playlist_url}") + return + except Exception as e: + logger.warning(f"[PlaylistPrefetcher] Error in loop: {e}") + await asyncio.sleep(0.5) + + logger.info(f"[PlaylistPrefetcher] Loop ended: {self.playlist_url}") + + async def _download_segment(self, url: str) -> None: + """ + Download and cache a segment using the parent prebuffer. + + Args: + url: URL of the segment to download + """ + if url in self.downloading: + return + + self.downloading.add(url) + try: + # Use the base prebuffer's get_or_download for cross-process coordination + await self.prebuffer.get_or_download(url, self.headers) + finally: + self.downloading.discard(url) + + +class HLSPreBuffer(BasePrebuffer): + """ + Pre-buffer system for HLS streams with priority-based prefetching. + + Features: + - Priority queue: Player-requested segments downloaded first + - Sequential prefetch: Background prefetch of remaining segments + - Multi-user support: Multiple users share same prefetcher + - Automatic cleanup: Inactive prefetchers removed after timeout + """ + + def __init__( + self, + max_cache_size: Optional[int] = None, + prebuffer_segments: Optional[int] = None, + ): + """ + Initialize the HLS pre-buffer system. + + Args: + max_cache_size: Maximum number of segments to cache (uses config if None) + prebuffer_segments: Number of segments to pre-buffer ahead (uses config if None) + """ + super().__init__( + max_cache_size=max_cache_size or settings.hls_prebuffer_cache_size, + prebuffer_segments=prebuffer_segments or settings.hls_prebuffer_segments, + max_memory_percent=settings.hls_prebuffer_max_memory_percent, + emergency_threshold=settings.hls_prebuffer_emergency_threshold, + segment_ttl=settings.hls_segment_cache_ttl, + ) + + self.inactivity_timeout = settings.hls_prebuffer_inactivity_timeout + + # Active prefetchers: playlist_url -> PlaylistPrefetcher + self.active_prefetchers: Dict[str, PlaylistPrefetcher] = {} + + # Reverse mapping: segment URL -> playlist_url + self.segment_to_playlist: Dict[str, str] = {} + + # Lock for prefetcher management + self._prefetcher_lock = asyncio.Lock() + + # Cleanup task + self._cleanup_task: Optional[asyncio.Task] = None + self._cleanup_interval = 30 # Check every 30 seconds + + def log_stats(self) -> None: + """Log current prebuffer statistics with HLS-specific info.""" + stats = self.stats.to_dict() + stats["active_prefetchers"] = len(self.active_prefetchers) + logger.info(f"HLS Prebuffer Stats: {stats}") + + def _extract_segment_urls(self, playlist_content: str, base_url: str) -> List[str]: + """ + Extract segment URLs from HLS playlist content. + + Args: + playlist_content: Content of the HLS playlist + base_url: Base URL for resolving relative URLs + + Returns: + List of segment URLs + """ + segment_urls = [] + lines = playlist_content.split("\n") + + for line in lines: + line = line.strip() + if line and not line.startswith("#"): + # Absolute URL + if line.startswith("http://") or line.startswith("https://"): + segment_urls.append(line) + else: + # Relative URL - resolve against base + segment_url = urljoin(base_url, line) + segment_urls.append(segment_url) + + return segment_urls + + def _is_master_playlist(self, playlist_content: str) -> bool: + """Check if this is a master playlist (contains variant streams).""" + return "#EXT-X-STREAM-INF" in playlist_content + + async def register_playlist( + self, + playlist_url: str, + segment_urls: List[str], + headers: Dict[str, str], + ) -> None: + """ + Register a playlist for prefetching. + + Creates a new PlaylistPrefetcher or updates existing one. + Called by M3U8 processor when a playlist is fetched. + + Args: + playlist_url: URL of the HLS playlist + segment_urls: Ordered list of segment URLs from the playlist + headers: Headers to use for requests + """ + if not segment_urls: + logger.debug(f"[register_playlist] No segments, skipping: {playlist_url}") + return + + async with self._prefetcher_lock: + # Update reverse mapping + for url in segment_urls: + self.segment_to_playlist[url] = playlist_url + + if playlist_url in self.active_prefetchers: + # Update existing prefetcher + prefetcher = self.active_prefetchers[playlist_url] + prefetcher.update_segments(segment_urls) + prefetcher.headers = headers + logger.info(f"[register_playlist] Updated existing prefetcher: {playlist_url}") + else: + # Create new prefetcher with configured prefetch limit + prefetcher = PlaylistPrefetcher( + playlist_url=playlist_url, + segment_urls=segment_urls, + headers=headers, + prebuffer=self, + prefetch_limit=settings.hls_prebuffer_segments, + ) + self.active_prefetchers[playlist_url] = prefetcher + prefetcher.start() + logger.info( + f"[register_playlist] Created new prefetcher ({len(segment_urls)} segments, " + f"prefetch_limit={settings.hls_prebuffer_segments}): {playlist_url}" + ) + + # Ensure cleanup task is running + self._ensure_cleanup_task() + + async def request_segment(self, segment_url: str) -> None: + """ + Player requested a segment - set as priority for prefetching. + + Finds the prefetcher for this segment and adds it to priority queue. + Called by the segment endpoint when a segment is requested. + + Args: + segment_url: URL of the segment the player needs + """ + playlist_url = self.segment_to_playlist.get(segment_url) + if not playlist_url: + logger.debug(f"[request_segment] No prefetcher found for: {segment_url}") + return + + prefetcher = self.active_prefetchers.get(playlist_url) + if prefetcher: + await prefetcher.request_priority(segment_url) + else: + logger.debug(f"[request_segment] Prefetcher not active for: {playlist_url}") + + def _ensure_cleanup_task(self) -> None: + """Ensure the cleanup task is running.""" + if self._cleanup_task is None or self._cleanup_task.done(): + self._cleanup_task = asyncio.create_task(self._cleanup_loop()) + + async def _cleanup_loop(self) -> None: + """Periodically clean up inactive prefetchers.""" + while True: + try: + await asyncio.sleep(self._cleanup_interval) + await self._cleanup_inactive_prefetchers() + except asyncio.CancelledError: + return + except Exception as e: + logger.warning(f"[cleanup_loop] Error: {e}") + + async def _cleanup_inactive_prefetchers(self) -> None: + """Remove prefetchers that haven't been accessed recently.""" + now = time.time() + to_remove = [] + + async with self._prefetcher_lock: + for playlist_url, prefetcher in self.active_prefetchers.items(): + inactive_time = now - prefetcher.last_access + if inactive_time > self.inactivity_timeout: + to_remove.append(playlist_url) + logger.info(f"[cleanup] Removing inactive prefetcher ({inactive_time:.0f}s): {playlist_url}") + + for playlist_url in to_remove: + prefetcher = self.active_prefetchers.pop(playlist_url, None) + if prefetcher: + prefetcher.stop() + # Clean up reverse mapping + for url in prefetcher.segment_urls: + self.segment_to_playlist.pop(url, None) + + if to_remove: + logger.info(f"[cleanup] Removed {len(to_remove)} inactive prefetchers") + + def get_stats(self) -> dict: + """Get current prebuffer statistics.""" + stats = self.stats.to_dict() + stats["active_prefetchers"] = len(self.active_prefetchers) + return stats + + def clear_cache(self) -> None: + """Clear all prebuffer state and log final stats.""" + self.log_stats() + + # Stop all prefetchers + for prefetcher in self.active_prefetchers.values(): + prefetcher.stop() + + self.active_prefetchers.clear() + self.segment_to_playlist.clear() + self.stats.reset() + + logger.info("HLS pre-buffer state cleared") + + async def close(self) -> None: + """Close the pre-buffer system.""" + self.clear_cache() + if self._cleanup_task: + self._cleanup_task.cancel() + + +# Global HLS pre-buffer instance +hls_prebuffer = HLSPreBuffer() diff --git a/mediaflow_proxy/utils/hls_utils.py b/mediaflow_proxy/utils/hls_utils.py index 2591595..0ddb923 100644 --- a/mediaflow_proxy/utils/hls_utils.py +++ b/mediaflow_proxy/utils/hls_utils.py @@ -1,11 +1,47 @@ import logging import re -from typing import List, Dict, Any, Optional, Tuple +from typing import List, Dict, Any, Optional from urllib.parse import urljoin logger = logging.getLogger(__name__) +def find_stream_by_resolution(streams: List[Dict[str, Any]], target_resolution: str) -> Optional[Dict[str, Any]]: + """ + Find stream matching target resolution (e.g., '1080p', '720p'). + Falls back to closest lower resolution if exact match not found. + + Args: + streams: List of stream dictionaries with 'resolution' key as (width, height) tuple. + target_resolution: Target resolution string (e.g., '1080p', '720p'). + + Returns: + The matching stream dictionary, or None if no streams available. + """ + # Parse target height from "1080p" -> 1080 + target_height = int(target_resolution.rstrip("p")) + + # Filter streams with valid resolution (height > 0), sort by height descending + valid_streams = [s for s in streams if s.get("resolution", (0, 0))[1] > 0] + if not valid_streams: + logger.warning("No streams with valid resolution found") + return streams[0] if streams else None + + sorted_streams = sorted(valid_streams, key=lambda s: s["resolution"][1], reverse=True) + + # Find exact match or closest lower + for stream in sorted_streams: + stream_height = stream["resolution"][1] + if stream_height <= target_height: + logger.info(f"Selected stream with resolution {stream['resolution']} for target {target_resolution}") + return stream + + # If all streams are higher than target, return lowest available + lowest_stream = sorted_streams[-1] + logger.info(f"All streams higher than target {target_resolution}, using lowest: {lowest_stream['resolution']}") + return lowest_stream + + def parse_hls_playlist(playlist_content: str, base_url: Optional[str] = None) -> List[Dict[str, Any]]: """ Parses an HLS master playlist to extract stream information. @@ -18,37 +54,37 @@ def parse_hls_playlist(playlist_content: str, base_url: Optional[str] = None) -> List[Dict[str, Any]]: A list of dictionaries, each representing a stream variant. """ streams = [] - lines = playlist_content.strip().split('\n') - + lines = playlist_content.strip().split("\n") + # Regex to capture attributes from #EXT-X-STREAM-INF - stream_inf_pattern = re.compile(r'#EXT-X-STREAM-INF:(.*)') - + stream_inf_pattern = re.compile(r"#EXT-X-STREAM-INF:(.*)") + for i, line in enumerate(lines): - if line.startswith('#EXT-X-STREAM-INF'): - stream_info = {'raw_stream_inf': line} + if line.startswith("#EXT-X-STREAM-INF"): + stream_info = {"raw_stream_inf": line} match = stream_inf_pattern.match(line) if not match: logger.warning(f"Could not parse #EXT-X-STREAM-INF line: {line}") continue attributes_str = match.group(1) - + # Parse attributes like BANDWIDTH, RESOLUTION, etc. attributes = re.findall(r'([A-Z-]+)=("([^"]+)"|([^,]+))', attributes_str) for key, _, quoted_val, unquoted_val in attributes: value = quoted_val if quoted_val else unquoted_val - if key == 'RESOLUTION': + if key == "RESOLUTION": try: - width, height = map(int, value.split('x')) - stream_info['resolution'] = (width, height) + width, height = map(int, value.split("x")) + stream_info["resolution"] = (width, height) except ValueError: - stream_info['resolution'] = (0, 0) + stream_info["resolution"] = (0, 0) else: - stream_info[key.lower().replace('-', '_')] = value - + stream_info[key.lower().replace("-", "_")] = value + # The next line should be the stream URL - if i + 1 < len(lines) and not lines[i + 1].startswith('#'): + if i + 1 < len(lines) and not lines[i + 1].startswith("#"): stream_url = lines[i + 1].strip() - stream_info['url'] = urljoin(base_url, stream_url) if base_url else stream_url + stream_info["url"] = urljoin(base_url, stream_url) if base_url else stream_url streams.append(stream_info) - - return streams \ No newline at end of file + + return streams diff --git a/mediaflow_proxy/utils/http_client.py b/mediaflow_proxy/utils/http_client.py new file mode 100644 index 0000000..d3522fb --- /dev/null +++ b/mediaflow_proxy/utils/http_client.py @@ -0,0 +1,362 @@ +""" +aiohttp client factory with URL-based SSL verification and proxy routing. + +This module provides a centralized HTTP client factory for aiohttp, +allowing per-URL configuration of SSL verification and proxy routing. +""" + +import logging +import ssl +import typing +from contextlib import asynccontextmanager +from dataclasses import dataclass, field +from typing import Dict, Optional, Tuple +from urllib.parse import urlparse + +import aiohttp +from aiohttp import ClientSession, ClientTimeout, TCPConnector + +logger = logging.getLogger(__name__) + + +@dataclass +class RouteMatch: + """Configuration for a matched route.""" + + verify_ssl: bool = True + proxy_url: Optional[str] = None + + +@dataclass +class URLRoutingConfig: + """ + URL-based routing configuration for SSL verification and proxy settings. + + Supports pattern matching: + - "all://*.example.com" - matches all protocols for *.example.com + - "https://api.example.com" - matches specific protocol and host + - "all://" - default fallback for all URLs + """ + + # Pattern -> (verify_ssl, proxy_url) + routes: Dict[str, Tuple[bool, Optional[str]]] = field(default_factory=dict) + + # Global defaults + default_verify_ssl: bool = True + default_proxy_url: Optional[str] = None + + def add_route( + self, + pattern: str, + verify_ssl: bool = True, + proxy_url: Optional[str] = None, + ) -> None: + """ + Add a route configuration. + + Args: + pattern: URL pattern (e.g., "all://*.example.com", "https://api.example.com") + verify_ssl: Whether to verify SSL for this pattern + proxy_url: Proxy URL to use for this pattern (None = no proxy) + """ + self.routes[pattern] = (verify_ssl, proxy_url) + + def match_url(self, url: str) -> RouteMatch: + """ + Find the best matching route for a URL. + + Args: + url: The URL to match + + Returns: + RouteMatch with SSL and proxy settings + """ + if not url: + return RouteMatch( + verify_ssl=self.default_verify_ssl, + proxy_url=self.default_proxy_url, + ) + + parsed = urlparse(url) + scheme = parsed.scheme.lower() + host = parsed.netloc.lower() + + # Remove port from host for matching + if ":" in host: + host = host.split(":")[0] + + best_match: Optional[RouteMatch] = None + best_specificity = -1 + + for pattern, (verify_ssl, proxy_url) in self.routes.items(): + specificity = self._match_pattern(pattern, scheme, host) + if specificity > best_specificity: + best_specificity = specificity + best_match = RouteMatch(verify_ssl=verify_ssl, proxy_url=proxy_url) + + if best_match: + return best_match + + # Return defaults + return RouteMatch( + verify_ssl=self.default_verify_ssl, + proxy_url=self.default_proxy_url, + ) + + def _match_pattern(self, pattern: str, scheme: str, host: str) -> int: + """ + Check if a pattern matches the given scheme and host. + + Returns specificity score (higher = more specific match): + - -1: No match + - 0: Default match (all://) + - 1: Scheme match only + - 2: Wildcard host match + - 3: Exact host match + """ + # Parse pattern + if "://" in pattern: + pattern_scheme, pattern_host = pattern.split("://", 1) + else: + return -1 + + # Check scheme + scheme_matches = pattern_scheme.lower() == "all" or pattern_scheme.lower() == scheme + + if not scheme_matches: + return -1 + + # Empty host = default route + if not pattern_host: + return 0 + + # Check host with wildcard support + if pattern_host.startswith("*."): + # Wildcard subdomain match + suffix = pattern_host[1:] # Remove the * + if host.endswith(suffix) or host == pattern_host[2:]: + return 2 + return -1 + elif pattern_host == host: + # Exact match + return 3 + else: + return -1 + + +# Global routing configuration - will be initialized from settings +_global_routing_config: Optional[URLRoutingConfig] = None +_routing_initialized = False + + +def get_routing_config() -> URLRoutingConfig: + """Get the global URL routing configuration.""" + global _global_routing_config + if _global_routing_config is None: + _global_routing_config = URLRoutingConfig() + return _global_routing_config + + +def initialize_routing_from_config(transport_config) -> None: + """ + Initialize the global routing configuration from TransportConfig. + + Args: + transport_config: The TransportConfig instance from settings + """ + global _global_routing_config, _routing_initialized + + config = URLRoutingConfig( + default_verify_ssl=not transport_config.disable_ssl_verification_globally, + default_proxy_url=transport_config.proxy_url if transport_config.all_proxy else None, + ) + + # Add configured routes + for pattern, route in transport_config.transport_routes.items(): + global_verify = not transport_config.disable_ssl_verification_globally + verify_ssl = route.verify_ssl if global_verify else False + proxy_url = route.proxy_url or transport_config.proxy_url if route.proxy else None + config.add_route(pattern, verify_ssl=verify_ssl, proxy_url=proxy_url) + + # Hardcoded routes for specific domains (SSL verification disabled) + hardcoded_domains = [ + "all://jxoplay.xyz", + "all://dlhd.dad", + "all://*.newkso.ru", + ] + + for domain in hardcoded_domains: + proxy_url = transport_config.proxy_url if transport_config.all_proxy else None + config.add_route(domain, verify_ssl=False, proxy_url=proxy_url) + + # Default route for global settings + if transport_config.all_proxy or transport_config.disable_ssl_verification_globally: + default_proxy = transport_config.proxy_url if transport_config.all_proxy else None + config.add_route( + "all://", + verify_ssl=not transport_config.disable_ssl_verification_globally, + proxy_url=default_proxy, + ) + + _global_routing_config = config + _routing_initialized = True + + logger.info(f"Initialized aiohttp routing with {len(config.routes)} routes") + + +def _ensure_routing_initialized(): + """Ensure routing configuration is initialized from settings.""" + global _routing_initialized + if not _routing_initialized: + from mediaflow_proxy.configs import settings + + initialize_routing_from_config(settings.transport_config) + + +def _get_ssl_context(verify: bool) -> ssl.SSLContext: + """Get an SSL context with the specified verification setting.""" + if verify: + return ssl.create_default_context() + else: + ctx = ssl.create_default_context() + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE + return ctx + + +def create_proxy_connector(proxy_url: str, verify_ssl: bool = True) -> aiohttp.BaseConnector: + """ + Create a connector for proxy connections, supporting SOCKS5 and HTTP proxies. + + Args: + proxy_url: The proxy URL (socks5://..., http://..., https://...) + verify_ssl: Whether to verify SSL certificates + + Returns: + Appropriate connector for the proxy type + """ + parsed = urlparse(proxy_url) + scheme = parsed.scheme.lower() + + ssl_context = _get_ssl_context(verify_ssl) + + if scheme in ("socks5", "socks5h", "socks4", "socks4a"): + try: + from aiohttp_socks import ProxyConnector, ProxyType + + proxy_type_map = { + "socks5": ProxyType.SOCKS5, + "socks5h": ProxyType.SOCKS5, + "socks4": ProxyType.SOCKS4, + "socks4a": ProxyType.SOCKS4, + } + + return ProxyConnector( + proxy_type=proxy_type_map[scheme], + host=parsed.hostname, + port=parsed.port or 1080, + username=parsed.username, + password=parsed.password, + rdns=scheme.endswith("h"), # Remote DNS resolution for socks5h + ssl=ssl_context if not verify_ssl else None, + ) + except ImportError: + logger.warning("aiohttp-socks not installed, SOCKS proxy support unavailable") + raise + else: + # HTTP/HTTPS proxy - use standard connector + # The proxy URL will be passed to the request method + return TCPConnector( + ssl=ssl_context, + limit=100, + limit_per_host=10, + ) + + +def _create_connector(proxy_url: Optional[str], verify_ssl: bool) -> Tuple[aiohttp.BaseConnector, Optional[str]]: + """ + Create an appropriate connector based on proxy configuration. + + Args: + proxy_url: The proxy URL or None + verify_ssl: Whether to verify SSL certificates + + Returns: + Tuple of (connector, effective_proxy_url) + For SOCKS proxies, effective_proxy_url is None (handled by connector) + For HTTP proxies, effective_proxy_url is passed to requests + """ + if proxy_url: + parsed_proxy = urlparse(proxy_url) + if parsed_proxy.scheme in ("socks5", "socks5h", "socks4", "socks4a"): + # SOCKS proxy - use special connector, proxy handled internally + connector = create_proxy_connector(proxy_url, verify_ssl) + return connector, None + else: + # HTTP proxy - use standard connector, pass proxy to request + ssl_ctx = _get_ssl_context(verify_ssl) + connector = TCPConnector(ssl=ssl_ctx, limit=100, limit_per_host=10) + return connector, proxy_url + else: + ssl_ctx = _get_ssl_context(verify_ssl) + connector = TCPConnector(ssl=ssl_ctx, limit=100, limit_per_host=10) + return connector, None + + +@asynccontextmanager +async def create_aiohttp_session( + url: str = None, + timeout: typing.Union[int, float, ClientTimeout] = None, + headers: typing.Optional[typing.Dict[str, str]] = None, + verify: typing.Optional[bool] = None, +) -> typing.AsyncGenerator[typing.Tuple[ClientSession, typing.Optional[str]], None]: + """ + Create an aiohttp ClientSession with configured proxy routing and SSL settings. + + This is the primary way to create HTTP sessions in the application. + It automatically applies URL-based routing for SSL verification and proxy settings. + + Args: + url: The URL to configure the session for (used for routing) + timeout: Request timeout (int/float for total seconds, or ClientTimeout) + headers: Default headers for the session + verify: Override SSL verification (None = use routing config) + + Yields: + Tuple of (session, proxy_url) - proxy_url should be passed to request methods + """ + _ensure_routing_initialized() + + # Get routing configuration for the URL + routing_config = get_routing_config() + route_match = routing_config.match_url(url) + + # Determine SSL verification + if verify is not None: + use_verify = verify + else: + use_verify = route_match.verify_ssl + + # Create timeout + if timeout is None: + from mediaflow_proxy.configs import settings + + timeout_config = ClientTimeout(total=settings.transport_config.timeout) + elif isinstance(timeout, (int, float)): + timeout_config = ClientTimeout(total=timeout) + else: + timeout_config = timeout + + # Create connector + connector, effective_proxy_url = _create_connector(route_match.proxy_url, use_verify) + + session = ClientSession( + connector=connector, + timeout=timeout_config, + headers=headers, + ) + + try: + yield session, effective_proxy_url + finally: + await session.close() diff --git a/mediaflow_proxy/utils/http_utils.py b/mediaflow_proxy/utils/http_utils.py index 1278ccd..f6081d2 100644 --- a/mediaflow_proxy/utils/http_utils.py +++ b/mediaflow_proxy/utils/http_utils.py @@ -1,25 +1,33 @@ +import asyncio import logging import typing from dataclasses import dataclass from functools import partial from urllib import parse -from urllib.parse import urlencode, urlparse +from urllib.parse import urlencode +import aiohttp +from aiohttp import ClientSession, ClientTimeout, ClientResponse import anyio -import h11 -import httpx import tenacity from fastapi import Response from starlette.background import BackgroundTask from starlette.concurrency import iterate_in_threadpool from starlette.requests import Request from starlette.types import Receive, Send, Scope -from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception_type +from tenacity import retry, stop_after_attempt, wait_exponential from tqdm.asyncio import tqdm as tqdm_asyncio from mediaflow_proxy.configs import settings from mediaflow_proxy.const import SUPPORTED_REQUEST_HEADERS from mediaflow_proxy.utils.crypto_utils import EncryptionHandler +from mediaflow_proxy.utils.stream_transformers import StreamTransformer +from mediaflow_proxy.utils.http_client import ( + create_aiohttp_session, + get_routing_config, + _ensure_routing_initialized, + _create_connector, +) logger = logging.getLogger(__name__) @@ -31,218 +39,279 @@ class DownloadError(Exception): super().__init__(message) -def create_httpx_client(follow_redirects: bool = True, **kwargs) -> httpx.AsyncClient: - """Creates an HTTPX client with configured proxy routing""" - mounts = settings.transport_config.get_mounts() - kwargs.setdefault("timeout", settings.transport_config.timeout) - client = httpx.AsyncClient(mounts=mounts, follow_redirects=follow_redirects, **kwargs) - return client +def retry_if_download_error_not_404(retry_state): + """Retry on DownloadError except for 404 errors.""" + if retry_state.outcome.failed: + exception = retry_state.outcome.exception() + if isinstance(exception, DownloadError): + return exception.status_code != 404 + return False @retry( stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=4, max=10), - retry=retry_if_exception_type(DownloadError), + retry=retry_if_download_error_not_404, ) -async def fetch_with_retry(client, method, url, headers, follow_redirects=True, **kwargs): +async def fetch_with_retry( + session: ClientSession, + method: str, + url: str, + headers: dict, + proxy: typing.Optional[str] = None, + **kwargs, +) -> ClientResponse: """ - Fetches a URL with retry logic. + Fetches a URL with retry logic using native aiohttp. Args: - client (httpx.AsyncClient): The HTTP client to use for the request. - method (str): The HTTP method to use (e.g., GET, POST). - url (str): The URL to fetch. - headers (dict): The headers to include in the request. - follow_redirects (bool, optional): Whether to follow redirects. Defaults to True. + session: The aiohttp ClientSession to use for the request. + method: The HTTP method to use (e.g., GET, POST). + url: The URL to fetch. + headers: The headers to include in the request. + proxy: Optional proxy URL for HTTP proxies. **kwargs: Additional arguments to pass to the request. Returns: - httpx.Response: The HTTP response. + ClientResponse: The HTTP response. Raises: DownloadError: If the request fails after retries. """ try: - response = await client.request(method, url, headers=headers, follow_redirects=follow_redirects, **kwargs) + response = await session.request(method, url, headers=headers, proxy=proxy, **kwargs) response.raise_for_status() return response - except httpx.TimeoutException: + except asyncio.TimeoutError: logger.warning(f"Timeout while downloading {url}") raise DownloadError(409, f"Timeout while downloading {url}") - except httpx.HTTPStatusError as e: - logger.error(f"HTTP error {e.response.status_code} while downloading {url}") - if e.response.status_code == 404: - logger.error(f"Segment Resource not found: {url}") - raise e - raise DownloadError(e.response.status_code, f"HTTP error {e.response.status_code} while downloading {url}") + except aiohttp.ClientResponseError as e: + if e.status == 404: + logger.debug(f"Segment not found (404): {url}") + raise DownloadError(404, f"Not found (404): {url}") + logger.error(f"HTTP error {e.status} while downloading {url}") + raise DownloadError(e.status, f"HTTP error {e.status} while downloading {url}") + except aiohttp.ClientError as e: + logger.error(f"Client error downloading {url}: {e}") + raise DownloadError(502, f"Client error downloading {url}: {e}") except Exception as e: logger.error(f"Error downloading {url}: {e}") raise class Streamer: - # PNG signature and IEND marker for fake PNG header detection (StreamWish/FileMoon) - _PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n" - _PNG_IEND_MARKER = b"\x49\x45\x4E\x44\xAE\x42\x60\x82" + """Handles streaming HTTP responses using aiohttp.""" - def __init__(self, client): + def __init__(self, session: ClientSession, proxy_url: typing.Optional[str] = None): """ - Initializes the Streamer with an HTTP client. + Initializes the Streamer with an aiohttp session. Args: - client (httpx.AsyncClient): The HTTP client to use for streaming. + session: The aiohttp ClientSession to use for streaming. + proxy_url: Optional proxy URL for HTTP proxies. """ - self.client = client - self.response = None + self.session = session + self.proxy_url = proxy_url + self.response: typing.Optional[ClientResponse] = None self.progress_bar = None self.bytes_transferred = 0 self.start_byte = 0 self.end_byte = 0 self.total_size = 0 + # Store request details for potential retry during streaming + self._current_url: typing.Optional[str] = None + self._current_headers: typing.Optional[dict] = None @retry( stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=4, max=10), - retry=retry_if_exception_type(DownloadError), + retry=retry_if_download_error_not_404, ) - async def create_streaming_response(self, url: str, headers: dict): + async def create_streaming_response(self, url: str, headers: dict, method: str = "GET"): """ Creates and sends a streaming request. Args: - url (str): The URL to stream from. - headers (dict): The headers to include in the request. - + url: The URL to stream from. + headers: The headers to include in the request. + method: HTTP method to use (GET or HEAD). Defaults to GET. + For HEAD requests, will fallback to GET if server doesn't support HEAD. """ + # Store request details for potential retry during streaming + self._current_url = url + self._current_headers = headers.copy() + try: - request = self.client.build_request("GET", url, headers=headers) - self.response = await self.client.send(request, stream=True, follow_redirects=True) - self.response.raise_for_status() - except httpx.TimeoutException: + if method.upper() == "HEAD": + # Try HEAD first, fallback to GET if server doesn't support it + try: + self.response = await self.session.head(url, headers=headers, proxy=self.proxy_url) + self.response.raise_for_status() + except (aiohttp.ClientResponseError, aiohttp.ClientError) as head_error: + # HEAD failed, fallback to GET (some servers don't support HEAD) + logger.debug(f"HEAD request failed ({head_error}), falling back to GET") + self.response = await self.session.get(url, headers=headers, proxy=self.proxy_url) + self.response.raise_for_status() + else: + self.response = await self.session.get(url, headers=headers, proxy=self.proxy_url) + self.response.raise_for_status() + except asyncio.TimeoutError: logger.warning("Timeout while creating streaming response") raise DownloadError(409, "Timeout while creating streaming response") - except httpx.HTTPStatusError as e: - logger.error(f"HTTP error {e.response.status_code} while creating streaming response") - if e.response.status_code == 404: - logger.error(f"Segment Resource not found: {url}") - raise e - raise DownloadError( - e.response.status_code, f"HTTP error {e.response.status_code} while creating streaming response" - ) - except httpx.RequestError as e: + except aiohttp.ClientResponseError as e: + if e.status == 404: + logger.debug(f"Segment not found (404): {url}") + raise DownloadError(404, f"Not found (404): {url}") + # Don't retry rate-limit errors (429, 509) - retrying while other connections + # are still active just wastes time. Let the player handle its own retry logic. + if e.status in (429, 509): + logger.warning(f"Rate limited ({e.status}) by upstream: {url}") + raise aiohttp.ClientResponseError(e.request_info, e.history, status=e.status, message=e.message) + logger.error(f"HTTP error {e.status} while creating streaming response") + raise DownloadError(e.status, f"HTTP error {e.status} while creating streaming response") + except aiohttp.ClientError as e: logger.error(f"Error creating streaming response: {e}") raise DownloadError(502, f"Error creating streaming response: {e}") except Exception as e: logger.error(f"Error creating streaming response: {e}") raise RuntimeError(f"Error creating streaming response: {e}") - @staticmethod - def _strip_fake_png_wrapper(chunk: bytes) -> bytes: + async def _retry_connection(self, from_byte: int) -> bool: """ - Strip fake PNG wrapper from chunk data. - - Some streaming services (StreamWish, FileMoon) prepend a fake PNG image - to video data to evade detection. This method detects and removes it. + Attempt to reconnect to the upstream using Range header. Args: - chunk: The raw chunk data that may contain a fake PNG header. + from_byte: The byte position to resume from. Returns: - The chunk with fake PNG wrapper removed, or original chunk if not present. + bool: True if reconnection was successful, False otherwise. """ - if not chunk.startswith(Streamer._PNG_SIGNATURE): - return chunk + if not self._current_url or not self._current_headers: + return False - # Find the IEND marker that signals end of PNG data - iend_pos = chunk.find(Streamer._PNG_IEND_MARKER) - if iend_pos == -1: - # IEND not found in this chunk - return as-is to avoid data corruption - logger.debug("PNG signature detected but IEND marker not found in chunk") - return chunk + # Close existing response if any + if self.response: + self.response.close() + self.response = None - # Calculate position after IEND marker - content_start = iend_pos + len(Streamer._PNG_IEND_MARKER) + # Create new headers with Range + retry_headers = self._current_headers.copy() + if self.total_size > 0: + retry_headers["Range"] = f"bytes={from_byte}-{self.total_size - 1}" + else: + retry_headers["Range"] = f"bytes={from_byte}-" - # Skip any padding bytes (null or 0xFF) between PNG and actual content - while content_start < len(chunk) and chunk[content_start] in (0x00, 0xFF): - content_start += 1 + try: + self.response = await self.session.get(self._current_url, headers=retry_headers, proxy=self.proxy_url) + # Accept both 200 and 206 (Partial Content) as valid responses + if self.response.status in (200, 206): + logger.info(f"Successfully reconnected at byte {from_byte}") + return True + else: + logger.warning(f"Retry connection returned unexpected status: {self.response.status}") + return False + except Exception as e: + logger.warning(f"Failed to reconnect: {e}") + return False - stripped_bytes = content_start - logger.debug(f"Stripped {stripped_bytes} bytes of fake PNG wrapper from stream") + async def stream_content( + self, transformer: typing.Optional[StreamTransformer] = None + ) -> typing.AsyncGenerator[bytes, None]: + """ + Stream content from the response, optionally applying a transformer. - return chunk[content_start:] + Includes automatic retry logic when upstream disconnects mid-stream, + using Range headers to resume from the last successful byte. - async def stream_content(self) -> typing.AsyncGenerator[bytes, None]: + Args: + transformer: Optional StreamTransformer to apply host-specific + content manipulation (e.g., PNG stripping, TS detection). + If None, content is streamed directly without modification. + + Yields: + Bytes chunks from the upstream response. + """ if not self.response: raise RuntimeError("No response available for streaming") - is_first_chunk = True + retry_count = 0 + max_retries = settings.upstream_retry_attempts if settings.upstream_retry_on_disconnect else 0 - try: - self.parse_content_range() + while True: + try: + self.parse_content_range() - if settings.enable_streaming_progress: - with tqdm_asyncio( - total=self.total_size, - initial=self.start_byte, - unit="B", - unit_scale=True, - unit_divisor=1024, - desc="Streaming", - ncols=100, - mininterval=1, - ) as self.progress_bar: - async for chunk in self.response.aiter_bytes(): - if is_first_chunk: - is_first_chunk = False - chunk = self._strip_fake_png_wrapper(chunk) + # Create async generator from response content + async def raw_chunks(): + async for chunk in self.response.content.iter_any(): + yield chunk + # Choose the chunk source based on whether we have a transformer + # Note: Transformer state may not survive reconnection properly for all transformers + if transformer and retry_count == 0: + chunk_source = transformer.transform(raw_chunks()) + else: + chunk_source = raw_chunks() + + if settings.enable_streaming_progress: + with tqdm_asyncio( + total=self.total_size, + initial=self.start_byte, + unit="B", + unit_scale=True, + unit_divisor=1024, + desc="Streaming", + ncols=100, + mininterval=1, + ) as self.progress_bar: + async for chunk in chunk_source: + yield chunk + self.bytes_transferred += len(chunk) + self.progress_bar.update(len(chunk)) + else: + async for chunk in chunk_source: yield chunk self.bytes_transferred += len(chunk) - self.progress_bar.update(len(chunk)) - else: - async for chunk in self.response.aiter_bytes(): - if is_first_chunk: - is_first_chunk = False - chunk = self._strip_fake_png_wrapper(chunk) - yield chunk - self.bytes_transferred += len(chunk) + # Successfully completed streaming + return - except httpx.TimeoutException: - logger.warning("Timeout while streaming") - raise DownloadError(409, "Timeout while streaming") - except httpx.RemoteProtocolError as e: - # Special handling for connection closed errors - if "peer closed connection without sending complete message body" in str(e): - logger.warning(f"Remote server closed connection prematurely: {e}") - # If we've received some data, just log the warning and return normally + except asyncio.TimeoutError: + logger.warning("Timeout while streaming") + raise DownloadError(409, "Timeout while streaming") + except (aiohttp.ServerDisconnectedError, aiohttp.ClientPayloadError, aiohttp.ClientError) as e: + # Handle connection errors with potential retry + error_type = type(e).__name__ + logger.warning(f"{error_type} while streaming after {self.bytes_transferred} bytes: {e}") + + # Check if we should retry + if retry_count < max_retries and self.bytes_transferred > 0: + retry_count += 1 + resume_from = self.start_byte + self.bytes_transferred + logger.info(f"Attempting reconnection (retry {retry_count}/{max_retries}) from byte {resume_from}") + + # Wait before retry + await asyncio.sleep(settings.upstream_retry_delay) + + if await self._retry_connection(resume_from): + # Successfully reconnected, continue the loop to resume streaming + continue + else: + logger.warning(f"Reconnection failed on retry {retry_count}") + + # No more retries or reconnection failed if self.bytes_transferred > 0: logger.info( - f"Partial content received ({self.bytes_transferred} bytes). Continuing with available data." + f"Partial content received ({self.bytes_transferred} bytes). " + f"Graceful termination after {retry_count} retry attempts." ) return else: - # If we haven't received any data, raise an error - raise DownloadError(502, f"Remote server closed connection without sending any data: {e}") - else: - logger.error(f"Protocol error while streaming: {e}") - raise DownloadError(502, f"Protocol error while streaming: {e}") - except GeneratorExit: - logger.info("Streaming session stopped by the user") - except httpx.ReadError as e: - # Handle network read errors gracefully - these occur when upstream connection drops - logger.warning(f"ReadError while streaming: {e}") - if self.bytes_transferred > 0: - logger.info(f"Partial content received ({self.bytes_transferred} bytes) before ReadError. Graceful termination.") + raise DownloadError(502, f"{error_type} while streaming: {e}") + except GeneratorExit: + logger.info("Streaming session stopped by the user") return - else: - raise DownloadError(502, f"ReadError while streaming: {e}") - except Exception as e: - logger.error(f"Error streaming content: {e}") - raise - @staticmethod def format_bytes(size) -> str: power = 2**10 @@ -263,41 +332,41 @@ class Streamer: self.total_size = int(self.response.headers.get("Content-Length", 0)) self.end_byte = self.total_size - 1 if self.total_size > 0 else 0 - async def get_text(self, url: str, headers: dict): + async def get_text(self, url: str, headers: dict) -> str: """ Sends a GET request to a URL and returns the response text. Args: - url (str): The URL to send the GET request to. - headers (dict): The headers to include in the request. + url: The URL to send the GET request to. + headers: The headers to include in the request. Returns: str: The response text. """ try: - self.response = await fetch_with_retry(self.client, "GET", url, headers) + self.response = await fetch_with_retry(self.session, "GET", url, headers, proxy=self.proxy_url) + return await self.response.text() except tenacity.RetryError as e: raise e.last_attempt.result() - return self.response.text async def close(self): """ - Closes the HTTP client and response. + Closes the HTTP response and session. """ if self.response: - await self.response.aclose() + self.response.close() if self.progress_bar: self.progress_bar.close() - await self.client.aclose() + await self.session.close() -async def download_file_with_retry(url: str, headers: dict): +async def download_file_with_retry(url: str, headers: dict) -> bytes: """ Downloads a file with retry logic. Args: - url (str): The URL of the file to download. - headers (dict): The headers to include in the request. + url: The URL of the file to download. + headers: The headers to include in the request. Returns: bytes: The downloaded file content. @@ -305,10 +374,10 @@ async def download_file_with_retry(url: str, headers: dict): Raises: DownloadError: If the download fails after retries. """ - async with create_httpx_client() as client: + async with create_aiohttp_session(url) as (session, proxy_url): try: - response = await fetch_with_retry(client, "GET", url, headers) - return response.content + response = await fetch_with_retry(session, "GET", url, headers, proxy=proxy_url) + return await response.read() except DownloadError as e: logger.error(f"Failed to download file: {e}") raise e @@ -316,31 +385,85 @@ async def download_file_with_retry(url: str, headers: dict): raise DownloadError(502, f"Failed to download file: {e.last_attempt.result()}") -async def request_with_retry(method: str, url: str, headers: dict, **kwargs) -> httpx.Response: +async def request_with_retry(method: str, url: str, headers: dict, **kwargs) -> ClientResponse: """ Sends an HTTP request with retry logic. Args: - method (str): The HTTP method to use (e.g., GET, POST). - url (str): The URL to send the request to. - headers (dict): The headers to include in the request. + method: The HTTP method to use (e.g., GET, POST). + url: The URL to send the request to. + headers: The headers to include in the request. **kwargs: Additional arguments to pass to the request. Returns: - httpx.Response: The HTTP response. + ClientResponse: The HTTP response. Raises: DownloadError: If the request fails after retries. """ - async with create_httpx_client() as client: + async with create_aiohttp_session(url) as (session, proxy_url): try: - response = await fetch_with_retry(client, method, url, headers, **kwargs) + response = await fetch_with_retry(session, method, url, headers, proxy=proxy_url, **kwargs) + # Read the content so it's available after session closes + await response.read() return response except DownloadError as e: - logger.error(f"Failed to download file: {e}") + logger.error(f"Failed to make request: {e}") raise +async def create_streamer(url: str = None) -> Streamer: + """ + Create a Streamer configured for the given URL. + + The Streamer manages its own session lifecycle. Call streamer.close() + when done to release resources. + + Args: + url: Optional URL for routing configuration (SSL/proxy settings). + + Returns: + Streamer: A configured Streamer instance. + """ + _ensure_routing_initialized() + + routing_config = get_routing_config() + route_match = routing_config.match_url(url) + + # Use sock_read timeout: no total timeout, but timeout if no data received + # for sock_read seconds. This correctly handles: + # - Live streams (indefinite duration) + # - Large file downloads (total time depends on file size) + # - Seek operations (upstream may take time to seek) + # - Dead connection detection (timeout if no data flows) + timeout_config = ClientTimeout( + total=None, + sock_read=settings.transport_config.timeout, + ) + + connector, proxy_url = _create_connector(route_match.proxy_url, route_match.verify_ssl) + + session = ClientSession(connector=connector, timeout=timeout_config) + return Streamer(session, proxy_url) + + +# Keep setup_streamer as alias for backward compatibility during transition +async def setup_streamer(url: str = None) -> typing.Tuple[ClientSession, str, Streamer]: + """ + Set up an aiohttp session and streamer. + + DEPRECATED: Use create_streamer() instead which returns only the Streamer. + + Args: + url: Optional URL for routing configuration. + + Returns: + Tuple of (session, proxy_url, streamer) + """ + streamer = await create_streamer(url) + return streamer.session, streamer.proxy_url, streamer + + def encode_mediaflow_proxy_url( mediaflow_proxy_url: str, endpoint: typing.Optional[str] = None, @@ -348,25 +471,31 @@ def encode_mediaflow_proxy_url( query_params: typing.Optional[dict] = None, request_headers: typing.Optional[dict] = None, response_headers: typing.Optional[dict] = None, + propagate_response_headers: typing.Optional[dict] = None, + remove_response_headers: typing.Optional[list[str]] = None, encryption_handler: EncryptionHandler = None, expiration: int = None, ip: str = None, filename: typing.Optional[str] = None, + stream_transformer: typing.Optional[str] = None, ) -> str: """ Encodes & Encrypt (Optional) a MediaFlow proxy URL with query parameters and headers. Args: - mediaflow_proxy_url (str): The base MediaFlow proxy URL. - endpoint (str, optional): The endpoint to append to the base URL. Defaults to None. - destination_url (str, optional): The destination URL to include in the query parameters. Defaults to None. - query_params (dict, optional): Additional query parameters to include. Defaults to None. - request_headers (dict, optional): Headers to include as query parameters. Defaults to None. - response_headers (dict, optional): Headers to include as query parameters. Defaults to None. - encryption_handler (EncryptionHandler, optional): The encryption handler to use. Defaults to None. - expiration (int, optional): The expiration time for the encrypted token. Defaults to None. - ip (str, optional): The public IP address to include in the query parameters. Defaults to None. - filename (str, optional): Filename to be preserved for media players like Infuse. Defaults to None. + mediaflow_proxy_url: The base MediaFlow proxy URL. + endpoint: The endpoint to append to the base URL. Defaults to None. + destination_url: The destination URL to include in the query parameters. Defaults to None. + query_params: Additional query parameters to include. Defaults to None. + request_headers: Headers to include as query parameters. Defaults to None. + response_headers: Headers to include as query parameters (r_ prefix). Defaults to None. + propagate_response_headers: Response headers that propagate to segments (rp_ prefix). Defaults to None. + remove_response_headers: List of response header names to remove. Defaults to None. + encryption_handler: The encryption handler to use. Defaults to None. + expiration: The expiration time for the encrypted token. Defaults to None. + ip: The public IP address to include in the query parameters. Defaults to None. + filename: Filename to be preserved for media players like Infuse. Defaults to None. + stream_transformer: ID of the stream transformer to apply. Defaults to None. Returns: str: The encoded MediaFlow proxy URL. @@ -376,15 +505,45 @@ def encode_mediaflow_proxy_url( if destination_url is not None: query_params["d"] = destination_url - # Add headers if provided + # Add headers if provided (always use lowercase prefix for consistency) + # Filter out empty values to avoid URLs like &h_if-range=&h_referer=... + # Also exclude dynamic per-request headers (range, if-range) that are already handled + # via SUPPORTED_REQUEST_HEADERS from the player's actual request. Encoding them as h_ + # query params would bake in stale values that override the player's real headers on + # subsequent requests (e.g., when seeking to a different position). if request_headers: query_params.update( - {key if key.startswith("h_") else f"h_{key}": value for key, value in request_headers.items()} + { + key if key.lower().startswith("h_") else f"h_{key}": value + for key, value in request_headers.items() + if value and (key.lower().removeprefix("h_") not in SUPPORTED_REQUEST_HEADERS) + } ) if response_headers: query_params.update( - {key if key.startswith("r_") else f"r_{key}": value for key, value in response_headers.items()} + { + key if key.lower().startswith("r_") else f"r_{key}": value + for key, value in response_headers.items() + if value # Skip empty/None values + } ) + # Add propagate response headers (rp_ prefix - these propagate to segments) + if propagate_response_headers: + query_params.update( + { + key if key.lower().startswith("rp_") else f"rp_{key}": value + for key, value in propagate_response_headers.items() + if value # Skip empty/None values + } + ) + + # Add remove headers if provided (x_ prefix for "exclude") + if remove_response_headers: + query_params["x_headers"] = ",".join(remove_response_headers) + + # Add stream transformer if provided + if stream_transformer: + query_params["transformer"] = stream_transformer # Construct the base URL if endpoint is None: @@ -441,10 +600,10 @@ def encode_stremio_proxy_url( Format: http://127.0.0.1:11470/proxy/d=&h=&r=/ Args: - stremio_proxy_url (str): The base Stremio proxy URL. - destination_url (str): The destination URL to proxy. - request_headers (dict, optional): Headers to include as query parameters. Defaults to None. - response_headers (dict, optional): Response headers to include as query parameters. Defaults to None. + stremio_proxy_url: The base Stremio proxy URL. + destination_url: The destination URL to proxy. + request_headers: Headers to include as query parameters. Defaults to None. + response_headers: Response headers to include as query parameters. Defaults to None. Returns: str: The encoded Stremio proxy URL. @@ -498,7 +657,7 @@ def get_original_scheme(request: Request) -> str: Determines the original scheme (http or https) of the request. Args: - request (Request): The incoming HTTP request. + request: The incoming HTTP request. Returns: str: The original scheme ('http' or 'https') @@ -528,6 +687,35 @@ def get_original_scheme(request: Request) -> str: class ProxyRequestHeaders: request: dict response: dict + remove: list # headers to remove from response + propagate: dict # response headers to propagate to segments (rp_ prefix) + + +def apply_header_manipulation( + base_headers: dict, proxy_headers: ProxyRequestHeaders, include_propagate: bool = True +) -> dict: + """ + Apply response header additions and removals. + + This function filters out headers specified in proxy_headers.remove, + then merges in headers from proxy_headers.response and optionally proxy_headers.propagate. + + Args: + base_headers: The base headers to start with. + proxy_headers: The proxy headers containing response additions and removals. + include_propagate: Whether to include propagate headers (rp_). + Set to False for manifests, True for segments. Defaults to True. + + Returns: + dict: The manipulated headers. + """ + remove_set = set(h.lower() for h in proxy_headers.remove) + result = {k: v for k, v in base_headers.items() if k.lower() not in remove_set} + # Apply propagate headers first (for segments), then response headers (response takes precedence) + if include_propagate: + result.update(proxy_headers.propagate) + result.update(proxy_headers.response) + return result def get_proxy_headers(request: Request) -> ProxyRequestHeaders: @@ -535,32 +723,42 @@ def get_proxy_headers(request: Request) -> ProxyRequestHeaders: Extracts proxy headers from the request query parameters. Args: - request (Request): The incoming HTTP request. + request: The incoming HTTP request. Returns: - ProxyRequest: A named tuple containing the request headers and response headers. + ProxyRequest: A named tuple containing the request headers, response headers, and headers to remove. """ - request_headers = {k: v for k, v in request.headers.items() if k in SUPPORTED_REQUEST_HEADERS} - request_headers.update({k[2:].lower(): v for k, v in request.query_params.items() if k.startswith("h_")}) + request_headers = {k: v for k, v in request.headers.items() if k in SUPPORTED_REQUEST_HEADERS and v} + + # Extract h_ prefixed headers from query params, filtering out empty values + for k, v in request.query_params.items(): + if k.lower().startswith("h_") and v: # Skip empty values + request_headers[k[2:].lower()] = v + request_headers.setdefault("user-agent", settings.user_agent) # Handle common misspelling of referer if "referrer" in request_headers: if "referer" not in request_headers: request_headers["referer"] = request_headers.pop("referrer") - - dest = request.query_params.get("d", "") - host = urlparse(dest).netloc.lower() - - if "vidoza" in host or "videzz" in host: - # Remove ALL empty headers - for h in list(request_headers.keys()): - v = request_headers[h] - if v is None or v.strip() == "": - request_headers.pop(h, None) - response_headers = {k[2:].lower(): v for k, v in request.query_params.items() if k.startswith("r_")} - return ProxyRequestHeaders(request_headers, response_headers) + # r_ prefix: response headers (manifest only, not propagated to segments) + # Filter out empty values + response_headers = { + k[2:].lower(): v + for k, v in request.query_params.items() + if k.lower().startswith("r_") and not k.lower().startswith("rp_") and v + } + + # rp_ prefix: response headers that propagate to segments + # Filter out empty values + propagate_headers = {k[3:].lower(): v for k, v in request.query_params.items() if k.lower().startswith("rp_") and v} + + # Parse headers to remove from response (x_headers parameter) + x_headers_param = request.query_params.get("x_headers", "") + remove_headers = [h.strip().lower() for h in x_headers_param.split(",") if h.strip()] if x_headers_param else [] + + return ProxyRequestHeaders(request_headers, response_headers, remove_headers, propagate_headers) class EnhancedStreamingResponse(Response): @@ -632,19 +830,19 @@ class EnhancedStreamingResponse(Response): # Successfully streamed all content await send({"type": "http.response.body", "body": b"", "more_body": False}) finalization_sent = True - except (httpx.RemoteProtocolError, httpx.ReadError, h11._util.LocalProtocolError) as e: + except (aiohttp.ServerDisconnectedError, aiohttp.ClientPayloadError, aiohttp.ClientError) as e: # Handle connection closed / read errors gracefully if data_sent: - # We've sent some data to the client, so try to complete the response - logger.warning(f"Upstream connection error after partial streaming: {e}") - try: - await send({"type": "http.response.body", "body": b"", "more_body": False}) - finalization_sent = True - logger.info( - f"Response finalized after partial content ({self.actual_content_length} bytes transferred)" - ) - except Exception as close_err: - logger.warning(f"Could not finalize response after upstream error: {close_err}") + # We've sent some data to the client. With Content-Length set, we cannot + # gracefully finalize a partial response - h11 will raise LocalProtocolError + # if we try to send more_body: False without delivering all promised bytes. + # The best we can do is log and return silently, letting the client handle + # the incomplete response (most players will just stop or retry). + logger.warning( + f"Upstream connection error after partial streaming ({self.actual_content_length} bytes transferred): {e}" + ) + # Don't try to finalize - just return and let the connection close naturally + return else: # No data was sent, re-raise the error logger.error(f"Upstream error before any data was streamed: {e}") @@ -667,13 +865,16 @@ class EnhancedStreamingResponse(Response): except Exception: # If we can't send an error response, just log it pass - elif response_started and not finalization_sent: - # Response already started but not finalized - gracefully close the stream + elif response_started and not finalization_sent and not data_sent: + # Response started but no data sent yet - we can safely finalize + # (If data was sent with Content-Length, we can't finalize without h11 error) try: await send({"type": "http.response.body", "body": b"", "more_body": False}) finalization_sent = True except Exception: pass + # If data was sent but streaming failed, just return silently + # The client will see an incomplete response which is unavoidable with Content-Length async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: async with anyio.create_task_group() as task_group: diff --git a/mediaflow_proxy/utils/m3u8_processor.py b/mediaflow_proxy/utils/m3u8_processor.py index eb65c95..4050ce8 100644 --- a/mediaflow_proxy/utils/m3u8_processor.py +++ b/mediaflow_proxy/utils/m3u8_processor.py @@ -1,7 +1,9 @@ import asyncio import codecs +import logging import re -from typing import AsyncGenerator +from typing import AsyncGenerator, List, Optional + from urllib import parse from mediaflow_proxy.configs import settings @@ -9,9 +11,121 @@ from mediaflow_proxy.utils.crypto_utils import encryption_handler from mediaflow_proxy.utils.http_utils import encode_mediaflow_proxy_url, encode_stremio_proxy_url, get_original_scheme from mediaflow_proxy.utils.hls_prebuffer import hls_prebuffer +logger = logging.getLogger(__name__) + + +def generate_graceful_end_playlist(message: str = "Stream ended") -> str: + """ + Generate a minimal valid m3u8 playlist that signals stream end. + + This is used when upstream fails but we want to provide a graceful + end to the player instead of an abrupt error. Most players will + interpret this as the stream ending normally. + + Args: + message: Optional message to include as a comment. + + Returns: + str: A valid m3u8 playlist string with EXT-X-ENDLIST. + """ + return f"""#EXTM3U +#EXT-X-VERSION:3 +#EXT-X-TARGETDURATION:1 +#EXT-X-PLAYLIST-TYPE:VOD +# {message} +#EXT-X-ENDLIST +""" + + +def generate_error_playlist(error_message: str = "Stream unavailable") -> str: + """ + Generate a minimal valid m3u8 playlist for error scenarios. + + Unlike generate_graceful_end_playlist, this includes a very short + segment duration to signal something went wrong while still being + a valid playlist that players can parse. + + Args: + error_message: Error message to include as a comment. + + Returns: + str: A valid m3u8 playlist string. + """ + return f"""#EXTM3U +#EXT-X-VERSION:3 +#EXT-X-TARGETDURATION:1 +#EXT-X-PLAYLIST-TYPE:VOD +# Error: {error_message} +#EXT-X-ENDLIST +""" + + +class SkipSegmentFilter: + """ + Helper class to filter HLS segments based on time ranges. + + Tracks cumulative playback time and determines which segments + should be skipped based on the provided skip segment list. + """ + + def __init__(self, skip_segments: Optional[List[dict]] = None): + """ + Initialize the skip segment filter. + + Args: + skip_segments: List of skip segment dicts with 'start' and 'end' keys. + """ + self.skip_segments = skip_segments or [] + self.current_time = 0.0 # Cumulative playback time in seconds + + def should_skip_segment(self, duration: float) -> bool: + """ + Determine if the current segment should be skipped. + + Args: + duration: Duration of the current segment in seconds. + + Returns: + True if the segment overlaps with any skip range, False otherwise. + """ + segment_start = self.current_time + segment_end = self.current_time + duration + + # Check if this segment overlaps with any skip range + for skip in self.skip_segments: + skip_start = skip.get("start", 0) + skip_end = skip.get("end", 0) + + # Check for overlap: segment overlaps if it starts before skip ends AND ends after skip starts + if segment_start < skip_end and segment_end > skip_start: + logger.debug( + f"Skipping segment at {segment_start:.2f}s-{segment_end:.2f}s " + f"(overlaps with skip range {skip_start:.2f}s-{skip_end:.2f}s)" + ) + return True + + return False + + def advance_time(self, duration: float): + """Advance the cumulative playback time.""" + self.current_time += duration + + def has_skip_segments(self) -> bool: + """Check if there are any skip segments configured.""" + return bool(self.skip_segments) + class M3U8Processor: - def __init__(self, request, key_url: str = None, force_playlist_proxy: bool = None, key_only_proxy: bool = False, no_proxy: bool = False): + def __init__( + self, + request, + key_url: str = None, + force_playlist_proxy: bool = None, + key_only_proxy: bool = False, + no_proxy: bool = False, + skip_segments: Optional[List[dict]] = None, + start_offset: Optional[float] = None, + ): """ Initializes the M3U8Processor with the request and URL prefix. @@ -21,21 +135,65 @@ class M3U8Processor: force_playlist_proxy (bool, optional): Force all playlist URLs to be proxied through MediaFlow. Defaults to None. key_only_proxy (bool, optional): Only proxy the key URL, leaving segment URLs direct. Defaults to False. no_proxy (bool, optional): If True, returns the manifest without proxying any URLs. Defaults to False. + skip_segments (List[dict], optional): List of time segments to skip. Each dict should have + 'start', 'end' (in seconds), and optionally 'type'. + start_offset (float, optional): Time offset in seconds for EXT-X-START tag. Use negative values + for live streams to start behind the live edge. """ self.request = request self.key_url = parse.urlparse(key_url) if key_url else None self.key_only_proxy = key_only_proxy self.no_proxy = no_proxy self.force_playlist_proxy = force_playlist_proxy + self.skip_filter = SkipSegmentFilter(skip_segments) + # Track if user explicitly provided start_offset (vs using default) + self._user_provided_start_offset = start_offset is not None + # Store the explicit value or default (will be applied conditionally for live streams) + self._start_offset_value = start_offset if start_offset is not None else settings.livestream_start_offset self.mediaflow_proxy_url = str( request.url_for("hls_manifest_proxy").replace(scheme=get_original_scheme(request)) ) + # Base URL for segment proxy - extension will be appended based on actual segment + # url_for with path param returns URL with placeholder, so we build it manually + self.segment_proxy_base_url = str( + request.url_for("hls_manifest_proxy").replace(scheme=get_original_scheme(request)) + ).replace("/hls/manifest.m3u8", "/hls/segment") self.playlist_url = None # Will be set when processing starts + def _should_apply_start_offset(self, content: str) -> bool: + """ + Determine if start_offset should be applied to this playlist. + + Args: + content: The playlist content to check. + + Returns: + True if start_offset should be applied, False otherwise. + """ + if self._start_offset_value is None: + return False + + # If user explicitly provided start_offset, always use it + if self._user_provided_start_offset: + return True + + # Using default from settings - only apply for live streams + # VOD playlists have #EXT-X-ENDLIST tag or #EXT-X-PLAYLIST-TYPE:VOD + # Also skip master playlists (they have #EXT-X-STREAM-INF) + is_vod = "#EXT-X-ENDLIST" in content or "#EXT-X-PLAYLIST-TYPE:VOD" in content + is_master = "#EXT-X-STREAM-INF" in content + + return not is_vod and not is_master + async def process_m3u8(self, content: str, base_url: str) -> str: """ Processes the m3u8 content, proxying URLs and handling key lines. + For content filtering with skip_segments, this follows the IntroHater approach: + - Segments within skip ranges are completely removed (EXTINF + URL) + - A #EXT-X-DISCONTINUITY marker is added BEFORE the URL of the first segment + after a skipped section (not before the EXTINF) + Args: content (str): The m3u8 content to process. base_url (str): The base URL to resolve relative URLs. @@ -45,35 +203,131 @@ class M3U8Processor: """ # Store the playlist URL for prebuffering self.playlist_url = base_url - + lines = content.splitlines() processed_lines = [] - for line in lines: + + # Track if we need to add discontinuity before next URL (after skipping segments) + discontinuity_pending = False + # Buffer the current EXTINF line - only output when we output the URL + pending_extinf: Optional[str] = None + # Track if we've injected EXT-X-START tag + start_offset_injected = False + # Determine if we should apply start_offset (checks if live stream) + apply_start_offset = self._should_apply_start_offset(content) + + i = 0 + while i < len(lines): + line = lines[i] + + # Inject EXT-X-START tag right after #EXTM3U (only for live streams or if user explicitly requested) + if line.strip() == "#EXTM3U" and apply_start_offset and not start_offset_injected: + processed_lines.append(line) + processed_lines.append(f"#EXT-X-START:TIME-OFFSET={self._start_offset_value:.1f},PRECISE=YES") + start_offset_injected = True + i += 1 + continue + + # Handle EXTINF lines (segment duration markers) + if line.startswith("#EXTINF:"): + duration = self._parse_extinf_duration(line) + + if self.skip_filter.has_skip_segments() and self.skip_filter.should_skip_segment(duration): + # Skip this segment entirely - don't buffer the EXTINF + discontinuity_pending = True # Mark that we need discontinuity before next kept segment + self.skip_filter.advance_time(duration) + pending_extinf = None + i += 1 + continue + else: + # Keep this segment + self.skip_filter.advance_time(duration) + pending_extinf = line + i += 1 + continue + + # Handle segment URLs (non-comment, non-empty lines) + if not line.startswith("#") and line.strip(): + if pending_extinf is None: + # No pending EXTINF means this segment was skipped + i += 1 + continue + + # Add discontinuity BEFORE the EXTINF if we just skipped segments + # Per HLS spec, EXT-X-DISCONTINUITY must appear before the first segment of the new content + if discontinuity_pending: + processed_lines.append("#EXT-X-DISCONTINUITY") + discontinuity_pending = False + + # Output the buffered EXTINF and proxied URL + processed_lines.append(pending_extinf) + processed_lines.append(await self.proxy_content_url(line, base_url)) + pending_extinf = None + i += 1 + continue + + # Handle existing discontinuity markers - pass through but reset pending flag + if line.startswith("#EXT-X-DISCONTINUITY"): + processed_lines.append(line) + discontinuity_pending = False # Don't add duplicate + i += 1 + continue + + # Handle key lines if "URI=" in line: processed_lines.append(await self.process_key_line(line, base_url)) - elif not line.startswith("#") and line.strip(): - processed_lines.append(await self.proxy_content_url(line, base_url)) - else: - processed_lines.append(line) - - # Pre-buffer segments if enabled and this is a playlist - if (settings.enable_hls_prebuffer and - "#EXTM3U" in content and - self.playlist_url): - - # Extract headers from request for pre-buffering - headers = {} - for key, value in self.request.query_params.items(): - if key.startswith("h_"): - headers[key[2:]] = value - - # Start pre-buffering in background using the actual playlist URL - asyncio.create_task( - hls_prebuffer.prebuffer_playlist(self.playlist_url, headers) - ) - + i += 1 + continue + + # All other lines (headers, comments, etc.) + processed_lines.append(line) + i += 1 + + # Log skip statistics + if self.skip_filter.has_skip_segments(): + logger.info(f"Content filtering: processed playlist with {len(self.skip_filter.skip_segments)} skip ranges") + + # Register playlist with the priority-based prefetcher + if settings.enable_hls_prebuffer and "#EXTM3U" in content and self.playlist_url: + # Skip master playlists + if "#EXT-X-STREAM-INF" not in content: + segment_urls = self._extract_segment_urls_from_content(content, self.playlist_url) + + if segment_urls: + headers = {} + for key, value in self.request.query_params.items(): + if key.startswith("h_"): + headers[key[2:]] = value + + logger.info( + f"[M3U8Processor] Registering playlist ({len(segment_urls)} segments): {self.playlist_url}" + ) + asyncio.create_task( + hls_prebuffer.register_playlist( + self.playlist_url, + segment_urls, + headers, + ) + ) + return "\n".join(processed_lines) + def _parse_extinf_duration(self, line: str) -> float: + """ + Parse the duration from an #EXTINF line. + + Args: + line: The #EXTINF line (e.g., "#EXTINF:10.0," or "#EXTINF:10,title") + + Returns: + The duration in seconds as a float. + """ + # Format: #EXTINF:[,] + match = re.match(r"#EXTINF:(\d+(?:\.\d+)?)", line) + if match: + return float(match.group(1)) + return 0.0 + async def process_m3u8_streaming( self, content_iterator: AsyncGenerator[bytes, None], base_url: str ) -> AsyncGenerator[str, None]: @@ -81,20 +335,37 @@ class M3U8Processor: Processes the m3u8 content on-the-fly, yielding processed lines as they are read. Optimized to avoid accumulating the entire playlist content in memory. + Note: When skip_segments are configured, this method buffers lines to properly + handle EXTINF + segment URL pairs that need to be skipped together. + Args: content_iterator: An async iterator that yields chunks of the m3u8 content. base_url (str): The base URL to resolve relative URLs. Yields: str: Processed lines of the m3u8 content. + + Raises: + ValueError: If the content is not a valid m3u8 playlist (e.g., HTML error page). """ # Store the playlist URL for prebuffering self.playlist_url = base_url - + buffer = "" # String buffer for decoded content + raw_content = "" # Accumulate raw content for prebuffer decoder = codecs.getincrementaldecoder("utf-8")(errors="replace") is_playlist_detected = False - is_prebuffer_started = False + is_html_detected = False + initial_check_done = False + + # State for skip segment filtering + discontinuity_pending = False # Track if we need discontinuity before next URL + pending_extinf = None # Buffer EXTINF line until we decide to emit it + # Track if we've injected EXT-X-START tag + start_offset_injected = False + # Buffer header lines until we know if it's a master playlist (for default start_offset) + header_buffer = [] + header_flushed = False # Process the content chunk by chunk async for chunk in content_iterator: @@ -104,6 +375,24 @@ class M3U8Processor: # Incrementally decode the chunk decoded_chunk = decoder.decode(chunk) buffer += decoded_chunk + raw_content += decoded_chunk # Accumulate for prebuffer + + # Early detection: check if this is HTML instead of m3u8 + # This helps catch upstream error pages quickly + if not initial_check_done and len(buffer) > 50: + initial_check_done = True + buffer_lower = buffer.lower().strip() + # Check for HTML markers + if buffer_lower.startswith("<!doctype") or buffer_lower.startswith("<html"): + is_html_detected = True + logger.error(f"Upstream returned HTML instead of m3u8 playlist: {base_url}") + # Raise an error so the HTTP handler returns a proper error response + # This allows the player to retry or show an error instead of thinking + # the stream has ended normally + raise ValueError( + f"Upstream returned HTML instead of m3u8 playlist. " + f"The stream may be offline or unavailable: {base_url}" + ) # Check for playlist marker early to avoid accumulating content if not is_playlist_detected and "#EXTM3U" in buffer: @@ -114,40 +403,246 @@ class M3U8Processor: if len(lines) > 1: # Process all complete lines except the last one for line in lines[:-1]: - if line: # Skip empty lines + if not line: # Skip empty lines + continue + + # Buffer header lines until we can determine playlist type + # This allows us to decide whether to inject EXT-X-START + if not header_flushed: + # Always buffer the current line first + header_buffer.append(line) + + # Check if we can now determine playlist type + # Only check the current line, not raw_content (which may contain future content) + is_master = "#EXT-X-STREAM-INF" in line + is_media = "#EXTINF" in line + + if is_master or is_media: + # For non-user-provided (default) start_offset, determine if this + # is a live stream before injecting. We need to avoid injecting + # EXT-X-START with negative offsets into VOD playlists, as players + # like VLC interpret negative offsets as "from the end" and start + # playing near the end of the video. + # + # Live stream indicators (checked in header): + # - No #EXT-X-PLAYLIST-TYPE:VOD tag + # - No #EXT-X-ENDLIST tag (may not be visible yet in streaming) + # - #EXT-X-MEDIA-SEQUENCE > 0 (live windows have rolling sequence) + # + # VOD indicators: + # - #EXT-X-PLAYLIST-TYPE:VOD in header + # - #EXT-X-ENDLIST in raw_content (if small enough to be buffered) + # - #EXT-X-MEDIA-SEQUENCE:0 or absent (VOD starts from beginning) + header_content = "\n".join(header_buffer) + all_content = header_content + "\n" + raw_content + + is_explicitly_vod = ( + "#EXT-X-PLAYLIST-TYPE:VOD" in all_content or "#EXT-X-ENDLIST" in all_content + ) + + # Check for live stream indicator: #EXT-X-MEDIA-SEQUENCE with value > 0 + # Live streams have a rolling window so their media sequence increments + is_likely_live = False + seq_match = re.search(r"#EXT-X-MEDIA-SEQUENCE:\s*(\d+)", all_content) + if seq_match and int(seq_match.group(1)) > 0: + is_likely_live = True + + # Flush header buffer with or without EXT-X-START + should_inject = ( + self._start_offset_value is not None + and not is_master + and ( + self._user_provided_start_offset + or (is_media and not is_explicitly_vod and is_likely_live) + ) # User provided OR it's a live media playlist + ) + + for header_line in header_buffer: + # Process header lines to rewrite URLs (e.g., #EXT-X-KEY) + processed_header_line = await self.process_line(header_line, base_url) + yield processed_header_line + "\n" + if header_line.strip() == "#EXTM3U" and should_inject and not start_offset_injected: + yield f"#EXT-X-START:TIME-OFFSET={self._start_offset_value:.1f},PRECISE=YES\n" + start_offset_injected = True + + header_buffer = [] + header_flushed = True + # If not master/media yet, continue buffering (line already added above) + continue + + # If user explicitly provided start_offset and we haven't injected yet + # (handles edge case where we flush header before seeing EXTINF/STREAM-INF) + if ( + line.strip() == "#EXTM3U" + and self._user_provided_start_offset + and self._start_offset_value is not None + and not start_offset_injected + ): + yield line + "\n" + yield f"#EXT-X-START:TIME-OFFSET={self._start_offset_value:.1f},PRECISE=YES\n" + start_offset_injected = True + continue + + # Handle segment filtering if skip_segments are configured + if self.skip_filter.has_skip_segments(): + result = await self._process_line_with_filtering( + line, base_url, discontinuity_pending, pending_extinf + ) + processed_line, discontinuity_pending, pending_extinf = result + if processed_line is not None: + yield processed_line + "\n" + else: + # No filtering, process normally processed_line = await self.process_line(line, base_url) yield processed_line + "\n" # Keep the last line in the buffer (it might be incomplete) buffer = lines[-1] - # Start pre-buffering early once we detect this is a playlist - # This avoids waiting until the entire playlist is processed - if (settings.enable_hls_prebuffer and - is_playlist_detected and - not is_prebuffer_started and - self.playlist_url): - - # Extract headers from request for pre-buffering - headers = {} - for key, value in self.request.query_params.items(): - if key.startswith("h_"): - headers[key[2:]] = value - - # Start pre-buffering in background using the actual playlist URL - asyncio.create_task( - hls_prebuffer.prebuffer_playlist(self.playlist_url, headers) + # If HTML was detected, we already returned an error playlist + if is_html_detected: + return + + # Flush any remaining header buffer (for short playlists or edge cases) + # At this point we have the full raw_content so we can make a definitive determination + if header_buffer and not header_flushed: + is_master = "#EXT-X-STREAM-INF" in raw_content + is_vod = "#EXT-X-ENDLIST" in raw_content or "#EXT-X-PLAYLIST-TYPE:VOD" in raw_content + # For default offset, also require positive live indicator + is_likely_live = False + seq_match = re.search(r"#EXT-X-MEDIA-SEQUENCE:\s*(\d+)", raw_content) + if seq_match and int(seq_match.group(1)) > 0: + is_likely_live = True + should_inject = ( + self._start_offset_value is not None + and not is_master + and ( + self._user_provided_start_offset + or (not is_vod and is_likely_live) # Default offset: only inject for live streams ) - is_prebuffer_started = True + ) + for header_line in header_buffer: + yield header_line + "\n" + if header_line.strip() == "#EXTM3U" and should_inject and not start_offset_injected: + yield f"#EXT-X-START:TIME-OFFSET={self._start_offset_value:.1f},PRECISE=YES\n" + start_offset_injected = True + header_buffer = [] # Process any remaining data in the buffer plus final bytes final_chunk = decoder.decode(b"", final=True) if final_chunk: buffer += final_chunk + # Final validation: if we never detected a valid m3u8 playlist marker + if not is_playlist_detected: + logger.error(f"Invalid m3u8 content from upstream (no #EXTM3U marker found): {base_url}") + yield "#EXTM3U\n" + yield "#EXT-X-PLAYLIST-TYPE:VOD\n" + yield "# ERROR: Invalid m3u8 content from upstream (no #EXTM3U marker found)\n" + yield "# The upstream server may have returned an error page\n" + yield "#EXT-X-ENDLIST\n" + return + if buffer: # Process the last line if it's not empty - processed_line = await self.process_line(buffer, base_url) - yield processed_line + if self.skip_filter.has_skip_segments(): + result = await self._process_line_with_filtering( + buffer, base_url, discontinuity_pending, pending_extinf + ) + processed_line, _, _ = result + if processed_line is not None: + yield processed_line + else: + processed_line = await self.process_line(buffer, base_url) + yield processed_line + + # Log skip statistics + if self.skip_filter.has_skip_segments(): + logger.info(f"Content filtering: processed playlist with {len(self.skip_filter.skip_segments)} skip ranges") + + # Register playlist with the priority-based prefetcher + # The prefetcher uses a smart approach: + # 1. When player requests a segment, it gets priority (downloaded first) + # 2. After serving priority segment, prefetcher continues sequentially + # 3. Multiple users watching same channel share the prefetcher + # 4. Inactive prefetchers are cleaned up automatically + if settings.enable_hls_prebuffer and is_playlist_detected and self.playlist_url and raw_content: + # Skip master playlists (they contain variant streams, not segments) + if "#EXT-X-STREAM-INF" not in raw_content: + # Extract segment URLs from the playlist + segment_urls = self._extract_segment_urls_from_content(raw_content, self.playlist_url) + + if segment_urls: + # Extract headers for prefetcher + headers = {} + for key, value in self.request.query_params.items(): + if key.startswith("h_"): + headers[key[2:]] = value + + logger.info( + f"[M3U8Processor] Registering playlist ({len(segment_urls)} segments): {self.playlist_url}" + ) + asyncio.create_task( + hls_prebuffer.register_playlist( + self.playlist_url, + segment_urls, + headers, + ) + ) + + async def _process_line_with_filtering( + self, line: str, base_url: str, discontinuity_pending: bool, pending_extinf: Optional[str] + ) -> tuple: + """ + Process a single line with segment filtering (skip/mute/black). + + Uses the IntroHater approach: discontinuity is added BEFORE the URL of the + first segment after a skipped section, not before the EXTINF. + + Returns a tuple of (processed_lines, discontinuity_pending, pending_extinf). + processed_lines is None if the line should be skipped, otherwise a string to output. + """ + # Handle EXTINF lines (segment duration markers) + if line.startswith("#EXTINF:"): + duration = self._parse_extinf_duration(line) + + if self.skip_filter.should_skip_segment(duration): + # Skip this segment - don't buffer the EXTINF + self.skip_filter.advance_time(duration) + return (None, True, None) # discontinuity_pending = True, clear pending + else: + # Keep this segment + self.skip_filter.advance_time(duration) + return (None, discontinuity_pending, line) # Buffer EXTINF + + # Handle segment URLs (non-comment, non-empty lines) + if not line.startswith("#") and line.strip(): + if pending_extinf is None: + # No pending EXTINF means this segment was skipped + return (None, discontinuity_pending, None) + + # Build output: optional discontinuity + EXTINF + URL + # Per HLS spec, EXT-X-DISCONTINUITY must appear before the first segment of the new content + processed_url = await self.proxy_content_url(line, base_url) + + output_lines = [] + if discontinuity_pending: + output_lines.append("#EXT-X-DISCONTINUITY") + output_lines.append(pending_extinf) + output_lines.append(processed_url) + + return ("\n".join(output_lines), False, None) + + # Handle existing discontinuity markers - pass through and reset pending + if line.startswith("#EXT-X-DISCONTINUITY"): + return (line, False, pending_extinf) + + # Handle key lines + if "URI=" in line: + processed = await self.process_key_line(line, base_url) + return (processed, discontinuity_pending, pending_extinf) + + # All other lines (headers, comments, etc.) + return (line, discontinuity_pending, pending_extinf) async def process_line(self, line: str, base_url: str) -> str: """ @@ -186,14 +681,23 @@ class M3U8Processor: full_url = parse.urljoin(base_url, original_uri) line = line.replace(f'URI="{original_uri}"', f'URI="{full_url}"') return line - + uri_match = re.search(r'URI="([^"]+)"', line) if uri_match: original_uri = uri_match.group(1) uri = parse.urlparse(original_uri) - if self.key_url: + # Only substitute key_url scheme/netloc for actual EXT-X-KEY lines. + # EXT-X-MAP (init segments) and other tags must keep their original host, + # otherwise the proxied destination URL gets the wrong upstream hostname. + if self.key_url and line.startswith("#EXT-X-KEY"): uri = uri._replace(scheme=self.key_url.scheme, netloc=self.key_url.netloc) - new_uri = await self.proxy_url(uri.geturl(), base_url) + # Check if this is a DLHD stream with key params (needs stream endpoint for header computation) + query_params = dict(self.request.query_params) + is_dlhd_key_request = "dlhd_salt" in query_params and "/key/" in uri.geturl() + # Use stream endpoint for DLHD key URLs, manifest endpoint for others + new_uri = await self.proxy_url( + uri.geturl(), base_url, use_full_url=True, is_playlist=not is_dlhd_key_request + ) line = line.replace(f'URI="{original_uri}"', f'URI="{new_uri}"') return line @@ -223,16 +727,19 @@ class M3U8Processor: # Check if we should force MediaFlow proxy for all playlist URLs if self.force_playlist_proxy: - return await self.proxy_url(full_url, base_url, use_full_url=True) + return await self.proxy_url(full_url, base_url, use_full_url=True, is_playlist=True) # For playlist URLs, always use MediaFlow proxy regardless of strategy # Check for actual playlist file extensions, not just substring matches parsed_url = parse.urlparse(full_url) - if (parsed_url.path.endswith((".m3u", ".m3u8", ".m3u_plus")) or - parse.parse_qs(parsed_url.query).get("type", [""])[0] in ["m3u", "m3u8", "m3u_plus"]): - return await self.proxy_url(full_url, base_url, use_full_url=True) + is_playlist_url = parsed_url.path.endswith((".m3u", ".m3u8", ".m3u_plus")) or parse.parse_qs( + parsed_url.query + ).get("type", [""])[0] in ["m3u", "m3u8", "m3u_plus"] - # Route non-playlist content URLs based on strategy + if is_playlist_url: + return await self.proxy_url(full_url, base_url, use_full_url=True, is_playlist=True) + + # Route non-playlist content URLs (segments) based on strategy if routing_strategy == "direct": # Return the URL directly without any proxying return full_url @@ -250,9 +757,33 @@ class M3U8Processor: ) else: # Default to MediaFlow proxy (routing_strategy == "mediaflow" or fallback) - return await self.proxy_url(full_url, base_url, use_full_url=True) + # Use stream endpoint for segment URLs + return await self.proxy_url(full_url, base_url, use_full_url=True, is_playlist=False) - async def proxy_url(self, url: str, base_url: str, use_full_url: bool = False) -> str: + def _extract_segment_urls_from_content(self, content: str, base_url: str) -> list: + """ + Extract segment URLs from HLS playlist content. + + Args: + content: Raw playlist content + base_url: Base URL for resolving relative URLs + + Returns: + List of absolute segment URLs + """ + segment_urls = [] + for line in content.split("\n"): + line = line.strip() + if line and not line.startswith("#"): + # Absolute URL + if line.startswith("http://") or line.startswith("https://"): + segment_urls.append(line) + else: + # Relative URL - resolve against base + segment_urls.append(parse.urljoin(base_url, line)) + return segment_urls + + async def proxy_url(self, url: str, base_url: str, use_full_url: bool = False, is_playlist: bool = True) -> str: """ Proxies a URL, encoding it with the MediaFlow proxy URL. @@ -260,6 +791,7 @@ class M3U8Processor: url (str): The URL to proxy. base_url (str): The base URL to resolve relative URLs. use_full_url (bool): Whether to use the URL as-is (True) or join with base_url (False). + is_playlist (bool): Whether this is a playlist URL (uses manifest endpoint) or segment URL (uses stream endpoint). Returns: str: The proxied URL. @@ -271,15 +803,52 @@ class M3U8Processor: query_params = dict(self.request.query_params) has_encrypted = query_params.pop("has_encrypted", False) - # Remove the response headers from the query params to avoid it being added to the consecutive requests - [query_params.pop(key, None) for key in list(query_params.keys()) if key.startswith("r_")] - # Remove force_playlist_proxy to avoid it being added to subsequent requests + # Remove the response headers (r_) from the query params to avoid it being added to the consecutive requests + # BUT keep rp_ (response propagate) headers as they should propagate to segments + [ + query_params.pop(key, None) + for key in list(query_params.keys()) + if key.lower().startswith("r_") and not key.lower().startswith("rp_") + ] + # Remove manifest-only parameters to avoid them being added to subsequent requests query_params.pop("force_playlist_proxy", None) + if not is_playlist: + query_params.pop("start_offset", None) + + # Use appropriate proxy URL based on content type + if is_playlist: + proxy_url = self.mediaflow_proxy_url + else: + # Check if this is a DLHD key URL (needs /stream endpoint for header computation) + is_dlhd_key = "dlhd_salt" in query_params and "/key/" in full_url + if is_dlhd_key: + # Use /stream endpoint for DLHD key URLs + proxy_url = self.mediaflow_proxy_url.replace("/hls/manifest.m3u8", "/stream") + else: + # Determine segment extension from the URL + # Default to .ts for traditional HLS, but detect fMP4 extensions + segment_ext = "ts" + url_lower = full_url.lower() + # Check for fMP4/CMAF extensions + if url_lower.endswith(".m4s"): + segment_ext = "m4s" + elif url_lower.endswith(".mp4"): + segment_ext = "mp4" + elif url_lower.endswith(".m4a"): + segment_ext = "m4a" + elif url_lower.endswith(".m4v"): + segment_ext = "m4v" + elif url_lower.endswith(".aac"): + segment_ext = "aac" + # Build segment proxy URL with correct extension + proxy_url = f"{self.segment_proxy_base_url}.{segment_ext}" + # Remove h_range header - each segment should handle its own range requests + query_params.pop("h_range", None) return encode_mediaflow_proxy_url( - self.mediaflow_proxy_url, - "", + proxy_url, + None, # No endpoint - URL is already complete full_url, query_params=query_params, encryption_handler=encryption_handler if has_encrypted else None, - ) \ No newline at end of file + ) diff --git a/mediaflow_proxy/utils/mpd_utils.py b/mediaflow_proxy/utils/mpd_utils.py index 719f7dc..ddef702 100644 --- a/mediaflow_proxy/utils/mpd_utils.py +++ b/mediaflow_proxy/utils/mpd_utils.py @@ -10,6 +10,35 @@ import xmltodict logger = logging.getLogger(__name__) +def resolve_url(base_url: str, relative_url: str) -> str: + """ + Resolve a relative URL against a base URL. + + Handles three cases: + 1. Absolute URL (starts with http:// or https://) - return as-is + 2. Absolute path (starts with /) - resolve against origin (scheme + host) + 3. Relative path - resolve against base URL directory + + Args: + base_url: The base URL (typically the MPD URL) + relative_url: The URL to resolve + + Returns: + The resolved absolute URL + """ + if not relative_url: + return base_url + + # Already absolute URL + if relative_url.startswith(("http://", "https://")): + return relative_url + + # Use urljoin which correctly handles: + # - Absolute paths (starting with /) -> resolves against origin + # - Relative paths -> resolves against base URL + return urljoin(base_url, relative_url) + + def parse_mpd(mpd_content: Union[str, bytes]) -> dict: """ Parses the MPD content into a dictionary. @@ -43,7 +72,6 @@ def parse_mpd_dict( """ profiles = [] parsed_dict = {} - source = "/".join(mpd_url.split("/")[:-1]) is_live = mpd_dict["MPD"].get("@type", "static").lower() == "dynamic" parsed_dict["isLive"] = is_live @@ -66,7 +94,10 @@ def parse_mpd_dict( for period in periods: parsed_dict["PeriodStart"] = parse_duration(period.get("@start", "PT0S")) - for adaptation in period["AdaptationSet"]: + adaptation_sets = period["AdaptationSet"] + adaptation_sets = adaptation_sets if isinstance(adaptation_sets, list) else [adaptation_sets] + + for adaptation in adaptation_sets: representations = adaptation["Representation"] representations = representations if isinstance(representations, list) else [representations] @@ -75,7 +106,7 @@ def parse_mpd_dict( parsed_dict, representation, adaptation, - source, + mpd_url, media_presentation_duration, parse_segment_profile_id, ) @@ -195,7 +226,7 @@ def parse_representation( parsed_dict: dict, representation: dict, adaptation: dict, - source: str, + mpd_url: str, media_presentation_duration: str, parse_segment_profile_id: Optional[str], ) -> Optional[dict]: @@ -206,7 +237,7 @@ def parse_representation( parsed_dict (dict): The parsed MPD data. representation (dict): The representation data. adaptation (dict): The adaptation set data. - source (str): The source URL. + mpd_url (str): The URL of the MPD manifest. media_presentation_duration (str): The media presentation duration. parse_segment_profile_id (str, optional): The profile ID to parse segments for. Defaults to None. @@ -263,14 +294,50 @@ def parse_representation( else: profile["segment_template_start_number"] = 1 + # For SegmentBase profiles, we need to set initUrl even when not parsing segments + # This is needed for the HLS playlist builder to reference the init URL + segment_base_data = representation.get("SegmentBase") + if segment_base_data and "initUrl" not in profile: + base_url = representation.get("BaseURL", "") + profile["initUrl"] = resolve_url(mpd_url, base_url) + + # Store initialization range if available + if "Initialization" in segment_base_data: + init_range = segment_base_data["Initialization"].get("@range") + if init_range: + profile["initRange"] = init_range + + # For SegmentList profiles, we also need to set initUrl even when not parsing segments + segment_list_data = representation.get("SegmentList") or adaptation.get("SegmentList") + if segment_list_data and "initUrl" not in profile: + if "Initialization" in segment_list_data: + init_data = segment_list_data["Initialization"] + if "@sourceURL" in init_data: + init_url = init_data["@sourceURL"] + profile["initUrl"] = resolve_url(mpd_url, init_url) + elif "@range" in init_data: + base_url = representation.get("BaseURL", "") + profile["initUrl"] = resolve_url(mpd_url, base_url) + profile["initRange"] = init_data["@range"] + if parse_segment_profile_id is None or profile["id"] != parse_segment_profile_id: return profile - item = adaptation.get("SegmentTemplate") or representation.get("SegmentTemplate") - if item: - profile["segments"] = parse_segment_template(parsed_dict, item, profile, source) + # Parse segments based on the addressing scheme used + segment_template = adaptation.get("SegmentTemplate") or representation.get("SegmentTemplate") + segment_list = adaptation.get("SegmentList") or representation.get("SegmentList") + + # Get BaseURL from representation (can be relative path like "a/b/c/") + base_url = representation.get("BaseURL", "") + + if segment_template: + profile["segments"] = parse_segment_template(parsed_dict, segment_template, profile, mpd_url, base_url) + elif segment_list: + # Get timescale from SegmentList or default to 1 + timescale = int(segment_list.get("@timescale", 1)) + profile["segments"] = parse_segment_list(adaptation, representation, profile, mpd_url, timescale) else: - profile["segments"] = parse_segment_base(representation, profile, source) + profile["segments"] = parse_segment_base(representation, profile, mpd_url) return profile @@ -290,7 +357,9 @@ def _get_key(adaptation: dict, representation: dict, key: str) -> Optional[str]: return representation.get(key, adaptation.get(key, None)) -def parse_segment_template(parsed_dict: dict, item: dict, profile: dict, source: str) -> List[Dict]: +def parse_segment_template( + parsed_dict: dict, item: dict, profile: dict, mpd_url: str, base_url: str = "" +) -> List[Dict]: """ Parses a segment template and extracts segment information. @@ -298,7 +367,8 @@ def parse_segment_template(parsed_dict: dict, item: dict, profile: dict, source: parsed_dict (dict): The parsed MPD data. item (dict): The segment template data. profile (dict): The profile information. - source (str): The source URL. + mpd_url (str): The URL of the MPD manifest. + base_url (str): The BaseURL from the representation (optional, for per-representation paths). Returns: List[Dict]: The list of parsed segments. @@ -311,20 +381,23 @@ def parse_segment_template(parsed_dict: dict, item: dict, profile: dict, source: media = item["@initialization"] media = media.replace("$RepresentationID$", profile["id"]) media = media.replace("$Bandwidth$", str(profile["bandwidth"])) - if not media.startswith("http"): - media = f"{source}/{media}" - profile["initUrl"] = media + # Combine base_url and media, then resolve against mpd_url + if base_url: + media = base_url + media + profile["initUrl"] = resolve_url(mpd_url, media) # Segments if "SegmentTimeline" in item: - segments.extend(parse_segment_timeline(parsed_dict, item, profile, source, timescale)) + segments.extend(parse_segment_timeline(parsed_dict, item, profile, mpd_url, timescale, base_url)) elif "@duration" in item: - segments.extend(parse_segment_duration(parsed_dict, item, profile, source, timescale)) + segments.extend(parse_segment_duration(parsed_dict, item, profile, mpd_url, timescale, base_url)) return segments -def parse_segment_timeline(parsed_dict: dict, item: dict, profile: dict, source: str, timescale: int) -> List[Dict]: +def parse_segment_timeline( + parsed_dict: dict, item: dict, profile: dict, mpd_url: str, timescale: int, base_url: str = "" +) -> List[Dict]: """ Parses a segment timeline and extracts segment information. @@ -332,8 +405,9 @@ def parse_segment_timeline(parsed_dict: dict, item: dict, profile: dict, source: parsed_dict (dict): The parsed MPD data. item (dict): The segment timeline data. profile (dict): The profile information. - source (str): The source URL. + mpd_url (str): The URL of the MPD manifest. timescale (int): The timescale for the segments. + base_url (str): The BaseURL from the representation (optional, for per-representation paths). Returns: List[Dict]: The list of parsed segments. @@ -347,7 +421,7 @@ def parse_segment_timeline(parsed_dict: dict, item: dict, profile: dict, source: start_number = int(item.get("@startNumber", 1)) segments = [ - create_segment_data(timeline, item, profile, source, timescale) + create_segment_data(timeline, item, profile, mpd_url, timescale, base_url) for timeline in preprocess_timeline(timelines, start_number, period_start, presentation_time_offset, timescale) ] return segments @@ -379,14 +453,14 @@ def preprocess_timeline( for _ in range(repeat + 1): segment_start_time = period_start + timedelta(seconds=(start_time - presentation_time_offset) / timescale) segment_end_time = segment_start_time + timedelta(seconds=duration / timescale) - presentation_time = start_time - presentation_time_offset processed_data.append( { "number": start_number, "start_time": segment_start_time, "end_time": segment_end_time, "duration": duration, - "time": presentation_time, + "time": start_time, + "duration_mpd_timescale": duration, } ) start_time += duration @@ -397,7 +471,9 @@ def preprocess_timeline( return processed_data -def parse_segment_duration(parsed_dict: dict, item: dict, profile: dict, source: str, timescale: int) -> List[Dict]: +def parse_segment_duration( + parsed_dict: dict, item: dict, profile: dict, mpd_url: str, timescale: int, base_url: str = "" +) -> List[Dict]: """ Parses segment duration and extracts segment information. This is used for static or live MPD manifests. @@ -406,8 +482,9 @@ def parse_segment_duration(parsed_dict: dict, item: dict, profile: dict, source: parsed_dict (dict): The parsed MPD data. item (dict): The segment duration data. profile (dict): The profile information. - source (str): The source URL. + mpd_url (str): The URL of the MPD manifest. timescale (int): The timescale for the segments. + base_url (str): The BaseURL from the representation (optional, for per-representation paths). Returns: List[Dict]: The list of parsed segments. @@ -421,7 +498,7 @@ def parse_segment_duration(parsed_dict: dict, item: dict, profile: dict, source: else: segments = generate_vod_segments(profile, duration, timescale, start_number) - return [create_segment_data(seg, item, profile, source, timescale) for seg in segments] + return [create_segment_data(seg, item, profile, mpd_url, timescale, base_url) for seg in segments] def generate_live_segments(parsed_dict: dict, segment_duration_sec: float, start_number: int) -> List[Dict]: @@ -480,7 +557,9 @@ def generate_vod_segments(profile: dict, duration: int, timescale: int, start_nu return [{"number": start_number + i, "duration": duration / timescale} for i in range(segment_count)] -def create_segment_data(segment: Dict, item: dict, profile: dict, source: str, timescale: Optional[int] = None) -> Dict: +def create_segment_data( + segment: Dict, item: dict, profile: dict, mpd_url: str, timescale: Optional[int] = None, base_url: str = "" +) -> Dict: """ Creates segment data based on the segment information. This includes the segment URL and metadata. @@ -488,8 +567,9 @@ def create_segment_data(segment: Dict, item: dict, profile: dict, source: str, t segment (Dict): The segment information. item (dict): The segment template data. profile (dict): The profile information. - source (str): The source URL. + mpd_url (str): The URL of the MPD manifest. timescale (int, optional): The timescale for the segments. Defaults to None. + base_url (str): The BaseURL from the representation (optional, for per-representation paths). Returns: Dict: The created segment data. @@ -503,8 +583,10 @@ def create_segment_data(segment: Dict, item: dict, profile: dict, source: str, t if "time" in segment and timescale is not None: media = media.replace("$Time$", str(int(segment["time"]))) - if not media.startswith("http"): - media = f"{source}/{media}" + # Combine base_url and media, then resolve against mpd_url + if base_url: + media = base_url + media + media = resolve_url(mpd_url, media) segment_data = { "type": "segment", @@ -528,7 +610,8 @@ def create_segment_data(segment: Dict, item: dict, profile: dict, source: str, t } ) elif "start_time" in segment and "duration" in segment: - duration_seconds = segment["duration"] / timescale + # duration here is in timescale units (from timeline segments) + duration_seconds = segment["duration"] / timescale if timescale else segment["duration"] segment_data.update( { "start_time": segment["start_time"], @@ -537,43 +620,144 @@ def create_segment_data(segment: Dict, item: dict, profile: dict, source: str, t "program_date_time": segment["start_time"].isoformat() + "Z", } ) - elif "duration" in segment and timescale is not None: - # Convert duration from timescale units to seconds - segment_data["extinf"] = segment["duration"] / timescale elif "duration" in segment: - # If no timescale is provided, assume duration is already in seconds + # duration from generate_vod_segments and generate_live_segments is already in seconds segment_data["extinf"] = segment["duration"] return segment_data -def parse_segment_base(representation: dict, profile: dict, source: str) -> List[Dict]: +def parse_segment_list( + adaptation: dict, representation: dict, profile: dict, mpd_url: str, timescale: int +) -> List[Dict]: """ - Parses segment base information and extracts segment data. This is used for single-segment representations. + Parses SegmentList element with explicit SegmentURL entries. + + SegmentList MPDs explicitly list each segment URL, unlike SegmentTemplate which uses + URL patterns. This is less common but used by some packagers. Args: + adaptation (dict): The adaptation set data. representation (dict): The representation data. - source (str): The source URL. + profile (dict): The profile information. + mpd_url (str): The URL of the MPD manifest. + timescale (int): The timescale for duration calculations. Returns: List[Dict]: The list of parsed segments. """ - segment = representation["SegmentBase"] - start, end = map(int, segment["@indexRange"].split("-")) - if "Initialization" in segment: - start, _ = map(int, segment["Initialization"]["@range"].split("-")) - - # Set initUrl for SegmentBase - if not representation['BaseURL'].startswith("http"): - profile["initUrl"] = f"{source}/{representation['BaseURL']}" - else: - profile["initUrl"] = representation['BaseURL'] + # SegmentList can be at AdaptationSet or Representation level + segment_list = representation.get("SegmentList") or adaptation.get("SegmentList", {}) + segments = [] + # Handle Initialization element + if "Initialization" in segment_list: + init_data = segment_list["Initialization"] + if "@sourceURL" in init_data: + init_url = init_data["@sourceURL"] + profile["initUrl"] = resolve_url(mpd_url, init_url) + elif "@range" in init_data: + # Initialization by byte range on the BaseURL + base_url = representation.get("BaseURL", "") + profile["initUrl"] = resolve_url(mpd_url, base_url) + profile["initRange"] = init_data["@range"] + + # Get segment duration from SegmentList attributes + duration = int(segment_list.get("@duration", 0)) + list_timescale = int(segment_list.get("@timescale", timescale or 1)) + segment_duration_sec = duration / list_timescale if list_timescale else 0 + + # Parse SegmentURL elements + segment_urls = segment_list.get("SegmentURL", []) + if not isinstance(segment_urls, list): + segment_urls = [segment_urls] + + for i, seg_url in enumerate(segment_urls): + if seg_url is None: + continue + + # Get media URL - can be @media attribute or use BaseURL with @mediaRange + media_url = seg_url.get("@media", "") + media_range = seg_url.get("@mediaRange") + + if media_url: + media_url = resolve_url(mpd_url, media_url) + else: + # Use BaseURL with byte range + base_url = representation.get("BaseURL", "") + media_url = resolve_url(mpd_url, base_url) + + segment_data = { + "type": "segment", + "media": media_url, + "number": i + 1, + "extinf": segment_duration_sec if segment_duration_sec > 0 else 1.0, + } + + # Include media range if specified + if media_range: + segment_data["mediaRange"] = media_range + + segments.append(segment_data) + + return segments + + +def parse_segment_base(representation: dict, profile: dict, mpd_url: str) -> List[Dict]: + """ + Parses segment base information and extracts segment data. This is used for single-segment representations + (SegmentBase MPDs, typically GPAC-generated on-demand profiles). + + For SegmentBase, the entire media file is treated as a single segment. The initialization data + is specified by the Initialization element's range, and the segment index (SIDX) is at indexRange. + + Args: + representation (dict): The representation data. + profile (dict): The profile information. + mpd_url (str): The URL of the MPD manifest. + + Returns: + List[Dict]: The list of parsed segments. + """ + segment = representation.get("SegmentBase", {}) + base_url = representation.get("BaseURL", "") + + # Build the full media URL + media_url = resolve_url(mpd_url, base_url) + + # Set initUrl for SegmentBase - this is the URL with the initialization range + # The initialization segment contains codec/track info needed before playing media + profile["initUrl"] = media_url + + # For SegmentBase, we need to specify byte ranges for init and media segments + init_range = None + if "Initialization" in segment: + init_range = segment["Initialization"].get("@range") + + # Store initialization range in profile for segment endpoint to use + if init_range: + profile["initRange"] = init_range + + # Get the index range which points to SIDX box + index_range = segment.get("@indexRange", "") + + # Calculate total duration from profile's mediaPresentationDuration + total_duration = profile.get("mediaPresentationDuration") + if isinstance(total_duration, str): + total_duration = parse_duration(total_duration) + elif total_duration is None: + total_duration = 0 + + # For SegmentBase, we return a single segment representing the entire media + # The media URL is the same as initUrl but will be accessed with different byte ranges return [ { "type": "segment", - "range": f"{start}-{end}", - "media": f"{source}/{representation['BaseURL']}", + "media": media_url, + "number": 1, + "extinf": total_duration if total_duration > 0 else 1.0, + "indexRange": index_range, + "initRange": init_range, } ] diff --git a/mediaflow_proxy/utils/packed.py b/mediaflow_proxy/utils/packed.py index a4eb748..2e36670 100644 --- a/mediaflow_proxy/utils/packed.py +++ b/mediaflow_proxy/utils/packed.py @@ -1,5 +1,5 @@ -#Adapted for use in MediaFlowProxy from: -#https://github.com/einars/js-beautify/blob/master/python/jsbeautifier/unpackers/packer.py +# Adapted for use in MediaFlowProxy from: +# https://github.com/einars/js-beautify/blob/master/python/jsbeautifier/unpackers/packer.py # Unpacker for Dean Edward's p.a.c.k.e.r, a part of javascript beautifier # by Einar Lielmanis <einar@beautifier.io> # @@ -21,8 +21,6 @@ import logging logger = logging.getLogger(__name__) - - def detect(source): if "eval(function(p,a,c,k,e,d)" in source: mystr = "smth" @@ -71,9 +69,7 @@ def _filterargs(source): raise UnpackingError("Corrupted p.a.c.k.e.r. data.") # could not find a satisfying regex - raise UnpackingError( - "Could not make sense of p.a.c.k.e.r data (unexpected code structure)" - ) + raise UnpackingError("Could not make sense of p.a.c.k.e.r data (unexpected code structure)") def _replacestrings(source): @@ -88,7 +84,7 @@ def _replacestrings(source): for index, value in enumerate(lookup): source = source.replace(variable % index, '"%s"' % value) return source[startpoint:] - return source + return source class Unbaser(object): @@ -97,10 +93,7 @@ class Unbaser(object): ALPHABET = { 62: "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ", - 95: ( - " !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ" - "[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~" - ), + 95: (" !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~"), } def __init__(self, base): @@ -118,9 +111,7 @@ class Unbaser(object): else: # Build conversion dictionary cache try: - self.dictionary = dict( - (cipher, index) for index, cipher in enumerate(self.ALPHABET[base]) - ) + self.dictionary = dict((cipher, index) for index, cipher in enumerate(self.ALPHABET[base])) except KeyError: raise TypeError("Unsupported base encoding.") @@ -135,6 +126,8 @@ class Unbaser(object): for index, cipher in enumerate(string[::-1]): ret += (self.base**index) * self.dictionary[cipher] return ret + + class UnpackingError(Exception): """Badly packed source or general error. Argument is a meaningful description.""" @@ -142,11 +135,10 @@ class UnpackingError(Exception): pass - async def eval_solver(self, url: str, headers: dict[str, str] | None, patterns: list[str]) -> str: try: response = await self._make_request(url, headers=headers) - soup = BeautifulSoup(response.text, "lxml",parse_only=SoupStrainer("script")) + soup = BeautifulSoup(response.text, "lxml", parse_only=SoupStrainer("script")) script_all = soup.find_all("script") for i in script_all: if detect(i.text): @@ -162,4 +154,4 @@ async def eval_solver(self, url: str, headers: dict[str, str] | None, patterns: raise UnpackingError("No p.a.c.k.e.d JS found or no pattern matched.") except Exception as e: logger.exception("Eval solver error for %s", url) - raise UnpackingError("Error in eval_solver") from e \ No newline at end of file + raise UnpackingError("Error in eval_solver") from e diff --git a/mediaflow_proxy/utils/python_aes.py b/mediaflow_proxy/utils/python_aes.py index 4fc20b7..7906122 100644 --- a/mediaflow_proxy/utils/python_aes.py +++ b/mediaflow_proxy/utils/python_aes.py @@ -8,7 +8,7 @@ from .aes import AES from .rijndael import Rijndael from .cryptomath import bytesToNumber, numberToByteArray -__all__ = ['new', 'Python_AES'] +__all__ = ["new", "Python_AES"] def new(key, mode, IV): @@ -37,22 +37,21 @@ class Python_AES(AES): plaintextBytes = bytearray(plaintext) chainBytes = self.IV[:] - #CBC Mode: For each block... - for x in range(len(plaintextBytes)//16): - - #XOR with the chaining block - blockBytes = plaintextBytes[x*16 : (x*16)+16] + # CBC Mode: For each block... + for x in range(len(plaintextBytes) // 16): + # XOR with the chaining block + blockBytes = plaintextBytes[x * 16 : (x * 16) + 16] for y in range(16): blockBytes[y] ^= chainBytes[y] - #Encrypt it + # Encrypt it encryptedBytes = self.rijndael.encrypt(blockBytes) - #Overwrite the input with the output + # Overwrite the input with the output for y in range(16): - plaintextBytes[(x*16)+y] = encryptedBytes[y] + plaintextBytes[(x * 16) + y] = encryptedBytes[y] - #Set the next chaining block + # Set the next chaining block chainBytes = encryptedBytes self.IV = chainBytes[:] @@ -64,19 +63,18 @@ class Python_AES(AES): ciphertextBytes = ciphertext[:] chainBytes = self.IV[:] - #CBC Mode: For each block... - for x in range(len(ciphertextBytes)//16): - - #Decrypt it - blockBytes = ciphertextBytes[x*16 : (x*16)+16] + # CBC Mode: For each block... + for x in range(len(ciphertextBytes) // 16): + # Decrypt it + blockBytes = ciphertextBytes[x * 16 : (x * 16) + 16] decryptedBytes = self.rijndael.decrypt(blockBytes) - #XOR with the chaining block and overwrite the input with output + # XOR with the chaining block and overwrite the input with output for y in range(16): decryptedBytes[y] ^= chainBytes[y] - ciphertextBytes[(x*16)+y] = decryptedBytes[y] + ciphertextBytes[(x * 16) + y] = decryptedBytes[y] - #Set the next chaining block + # Set the next chaining block chainBytes = blockBytes self.IV = chainBytes[:] @@ -89,7 +87,7 @@ class Python_AES_CTR(AES): self.rijndael = Rijndael(key, 16) self.IV = IV self._counter_bytes = 16 - len(self.IV) - self._counter = self.IV + bytearray(b'\x00' * self._counter_bytes) + self._counter = self.IV + bytearray(b"\x00" * self._counter_bytes) @property def counter(self): @@ -102,9 +100,9 @@ class Python_AES_CTR(AES): def _counter_update(self): counter_int = bytesToNumber(self._counter) + 1 self._counter = numberToByteArray(counter_int, 16) - if self._counter_bytes > 0 and \ - self._counter[-self._counter_bytes:] == \ - bytearray(b'\xff' * self._counter_bytes): + if self._counter_bytes > 0 and self._counter[-self._counter_bytes :] == bytearray( + b"\xff" * self._counter_bytes + ): raise OverflowError("CTR counter overflowed") def encrypt(self, plaintext): diff --git a/mediaflow_proxy/utils/rate_limit_handlers.py b/mediaflow_proxy/utils/rate_limit_handlers.py new file mode 100644 index 0000000..6ada572 --- /dev/null +++ b/mediaflow_proxy/utils/rate_limit_handlers.py @@ -0,0 +1,204 @@ +""" +Rate limit handlers for host-specific rate limiting strategies. + +This module provides handler classes that implement specific rate limiting +logic for different streaming hosts (e.g., Vidoza's aggressive 509 rate limiting). + +Similar pattern to stream_transformers.py but for rate limiting behavior. +""" + +import logging +from typing import Optional +from urllib.parse import urlparse + +logger = logging.getLogger(__name__) + + +class RateLimitHandler: + """ + Base class for rate limit handlers. + + Subclasses should override properties to customize rate limiting behavior. + """ + + @property + def cooldown_seconds(self) -> int: + """ + Duration in seconds to wait between upstream connections. + Default: 0 (no cooldown, allow immediate requests) + """ + return 0 + + @property + def use_head_cache(self) -> bool: + """ + Whether to cache HEAD responses to avoid upstream calls. + Default: False + """ + return False + + @property + def use_stream_gate(self) -> bool: + """ + Whether to use distributed locking to serialize requests. + Default: False + """ + return False + + @property + def exclusive_stream(self) -> bool: + """ + If True, the stream gate is held for the ENTIRE duration of the stream. + This prevents any concurrent connections to the same URL. + Required for hosts that 509 on ANY concurrent streams. + Default: False (gate released after headers received) + """ + return False + + @property + def retry_after_seconds(self) -> int: + """ + Value for Retry-After header when returning 503. + Default: 2 + """ + return 2 + + +class VidozaRateLimitHandler(RateLimitHandler): + """ + Rate limit handler for Vidoza CDN. + + Vidoza aggressively rate-limits (509) if ANY concurrent connections exist + to the same URL from the same IP. This handler: + - Uses EXCLUSIVE stream gate: only ONE stream at a time (gate held during entire stream) + - Caches HEAD responses to serve repeated probes without connections + - ExoPlayer/clients must wait for the current stream to finish before starting a new one + + WARNING: This means only one client can actively stream at a time. Other clients will + wait (up to timeout) and eventually get 503 if the current stream is too long. + """ + + @property + def cooldown_seconds(self) -> int: + return 0 # No cooldown needed - we use exclusive streaming instead + + @property + def use_head_cache(self) -> bool: + return True + + @property + def use_stream_gate(self) -> bool: + return True + + @property + def exclusive_stream(self) -> bool: + """ + If True, the stream gate is held for the ENTIRE duration of the stream, + not just at the start. This prevents any concurrent connections. + Required for hosts like Vidoza that 509 on ANY concurrent connections. + """ + return True + + @property + def retry_after_seconds(self) -> int: + return 5 + + +class AggressiveRateLimitHandler(RateLimitHandler): + """ + Generic aggressive rate limit handler for hosts with strict rate limiting. + + Use this for hosts that show similar behavior to Vidoza but may have + different thresholds. + """ + + @property + def cooldown_seconds(self) -> int: + return 3 + + @property + def use_head_cache(self) -> bool: + return True + + @property + def use_stream_gate(self) -> bool: + return True + + @property + def retry_after_seconds(self) -> int: + return 2 + + +# Registry of available rate limit handlers by ID +RATE_LIMIT_HANDLER_REGISTRY: dict[str, type[RateLimitHandler]] = { + "vidoza": VidozaRateLimitHandler, + "aggressive": AggressiveRateLimitHandler, +} + +# Auto-detection: hostname patterns to handler IDs +# These patterns are checked against the video URL hostname +# +# NOTE: Vidoza CDN DOES rate limit concurrent connections from the same IP. +# When multiple clients request through the proxy, all requests come from +# the proxy's IP, triggering Vidoza's rate limit (509 errors). +# Stream-level rate limiting serializes requests to avoid this. +# +HOST_PATTERN_TO_HANDLER: dict[str, str] = { + "vidoza.net": "vidoza", + "vidoza.org": "vidoza", + # Add more patterns as needed for hosts that rate-limit CDN streaming: + # "example-cdn.com": "aggressive", +} + + +def get_rate_limit_handler( + handler_id: Optional[str] = None, + video_url: Optional[str] = None, +) -> RateLimitHandler: + """ + Get a rate limit handler instance. + + Priority: + 1. Explicit handler_id if provided + 2. Auto-detect from video_url hostname + 3. Default (no rate limiting) + + Args: + handler_id: Explicit handler identifier (e.g., "vidoza", "aggressive") + video_url: Video URL for auto-detection based on hostname + + Returns: + A rate limit handler instance. Returns base RateLimitHandler (no-op) if + no handler specified and no auto-detection match. + """ + # 1. Explicit handler ID + if handler_id: + handler_class = RATE_LIMIT_HANDLER_REGISTRY.get(handler_id) + if handler_class: + logger.debug(f"Using explicit rate limit handler: {handler_id}") + return handler_class() + else: + logger.warning(f"Unknown rate limit handler ID: {handler_id}") + + # 2. Auto-detect from URL hostname + if video_url: + try: + hostname = urlparse(video_url).hostname or "" + # Check each pattern + for pattern, detected_handler_id in HOST_PATTERN_TO_HANDLER.items(): + if pattern in hostname: + handler_class = RATE_LIMIT_HANDLER_REGISTRY.get(detected_handler_id) + if handler_class: + logger.info(f"[RateLimit] Auto-detected handler '{detected_handler_id}' for host: {hostname}") + return handler_class() + logger.debug(f"[RateLimit] No handler matched for hostname: {hostname}") + except Exception as e: + logger.warning(f"[RateLimit] Error during auto-detection: {e}") + + # 3. Default: no rate limiting + return RateLimitHandler() + + +def get_available_handlers() -> list[str]: + """Get list of available rate limit handler IDs.""" + return list(RATE_LIMIT_HANDLER_REGISTRY.keys()) diff --git a/mediaflow_proxy/utils/redis_utils.py b/mediaflow_proxy/utils/redis_utils.py new file mode 100644 index 0000000..a112c2d --- /dev/null +++ b/mediaflow_proxy/utils/redis_utils.py @@ -0,0 +1,861 @@ +""" +Redis utilities for cross-worker coordination and caching. + +Provides: +- Distributed locking (stream gating, generic locks) +- Shared caching (HEAD responses, extractors, MPD, segments, init segments) +- In-flight request deduplication +- Cooldown/throttle tracking + +All caches are shared across all uvicorn workers via Redis. + +IMPORTANT: Redis is OPTIONAL. If settings.redis_url is None, all functions +gracefully degrade: +- Locks always succeed immediately (no cross-worker coordination) +- Cache operations return None/False (no shared caching) +- Cooldowns always allow (no rate limiting) + +This allows single-worker deployments to work without Redis. +""" + +import asyncio +import hashlib +import json +import logging +import time +from typing import Optional + +from mediaflow_proxy.configs import settings + +logger = logging.getLogger(__name__) + +# ============================================================================= +# Redis Clients (Lazy Singletons) +# ============================================================================= +# Two clients: one for text/JSON (decode_responses=True), one for binary data + +_redis_client = None +_redis_binary_client = None +_redis_available: Optional[bool] = None # None = not checked yet + + +def is_redis_configured() -> bool: + """Check if Redis URL is configured in settings.""" + return settings.redis_url is not None and settings.redis_url.strip() != "" + + +async def is_redis_available() -> bool: + """ + Check if Redis is configured and reachable. + + Caches the result after first successful/failed connection attempt. + """ + global _redis_available + + if not is_redis_configured(): + return False + + if _redis_available is not None: + return _redis_available + + # Try to connect + try: + import redis.asyncio as redis + + client = redis.from_url( + settings.redis_url, + decode_responses=True, + socket_connect_timeout=2, + socket_timeout=2, + ) + await client.ping() + await client.aclose() + _redis_available = True + logger.info(f"Redis is available: {settings.redis_url}") + except Exception as e: + _redis_available = False + logger.warning(f"Redis not available (features will be disabled): {e}") + + return _redis_available + + +async def get_redis(): + """ + Get or create the Redis connection pool for text/JSON data (lazy singleton). + + The connection pool is shared across all async tasks in a single worker. + Each worker process has its own pool, but Redis itself coordinates across workers. + + Returns None if Redis is not configured or not available. + """ + global _redis_client + + if not is_redis_configured(): + return None + + if _redis_client is None: + import redis.asyncio as redis + + _redis_client = redis.from_url( + settings.redis_url, + decode_responses=True, + socket_connect_timeout=5, + socket_timeout=5, + ) + # Test connection + try: + await _redis_client.ping() + logger.info(f"Redis connected (text): {settings.redis_url}") + except Exception as e: + logger.error(f"Redis connection failed: {e}") + _redis_client = None + return None + return _redis_client + + +async def get_redis_binary(): + """ + Get or create the Redis connection pool for binary data (lazy singleton). + + Used for caching segments and init segments without base64 encoding overhead. + + Returns None if Redis is not configured or not available. + """ + global _redis_binary_client + + if not is_redis_configured(): + return None + + if _redis_binary_client is None: + import redis.asyncio as redis + + _redis_binary_client = redis.from_url( + settings.redis_url, + decode_responses=False, # Keep bytes as-is + socket_connect_timeout=5, + socket_timeout=5, + ) + # Test connection + try: + await _redis_binary_client.ping() + logger.info(f"Redis connected (binary): {settings.redis_url}") + except Exception as e: + logger.error(f"Redis binary connection failed: {e}") + _redis_binary_client = None + return None + return _redis_binary_client + + +async def close_redis(): + """Close all Redis connection pools (call on shutdown).""" + global _redis_client, _redis_binary_client, _redis_available + if _redis_client is not None: + await _redis_client.aclose() + _redis_client = None + if _redis_binary_client is not None: + await _redis_binary_client.aclose() + _redis_binary_client = None + _redis_available = None + logger.info("Redis connections closed") + + +# ============================================================================= +# Instance Namespace Helper +# ============================================================================= +# Some cached data is bound to the outgoing IP of the pod that produced it +# (e.g. extractor results resolved via the pod's egress IP). Sharing these +# entries across pods in a multi-instance deployment causes other pods to serve +# stale/wrong URLs. +# +# Set CACHE_NAMESPACE (env: CACHE_NAMESPACE) to a unique value per pod (e.g. +# pod name, hostname, or any discriminator). Instance-scoped keys are then +# stored under "<namespace>:<original_key>", while fully-shared keys (MPD, +# init segments, media segments, locks, stream gates) remain unchanged. + + +def make_instance_key(key: str) -> str: + """Prefix *key* with the configured instance namespace. + + Use this for cache/coordination keys that must NOT be shared across pods + because the underlying data is specific to a pod's outgoing IP (e.g. + extractor results). Common content (MPD, init/media segments) should + never be namespaced. + + If ``settings.cache_namespace`` is not set the key is returned unchanged, + so single-instance deployments are unaffected. + """ + ns = settings.cache_namespace + return f"{ns}:{key}" if ns else key + + +# ============================================================================= +# Stream Gate (Distributed Lock) +# ============================================================================= +# Serializes upstream connection handshakes per-URL across all workers. +# Uses SET NX EX for atomic acquire with auto-expiry. + +GATE_PREFIX = "mfp:stream_gate:" +GATE_TTL = 15 # seconds - auto-expire if worker crashes mid-request + + +def _gate_key(url: str) -> str: + """Generate Redis key for a stream gate.""" + url_hash = hashlib.md5(url.encode()).hexdigest() + return f"{GATE_PREFIX}{url_hash}" + + +async def acquire_stream_gate(url: str, timeout: float = 15.0) -> bool: + """ + Try to acquire a per-URL stream gate (distributed lock). + + Only one worker across all processes can hold the gate for a given URL. + The gate auto-expires after GATE_TTL seconds to prevent deadlocks. + + If Redis is not available, always returns True (no coordination). + + Args: + url: The upstream URL to gate + timeout: Maximum time to wait for the gate (seconds) + + Returns: + True if gate acquired (or Redis unavailable), False if timeout + """ + r = await get_redis() + if r is None: + # No Redis - no cross-worker coordination, always allow + return True + + key = _gate_key(url) + deadline = time.time() + timeout + + while time.time() < deadline: + # SET NX EX is atomic: only succeeds if key doesn't exist + if await r.set(key, "1", nx=True, ex=GATE_TTL): + logger.debug(f"[Redis] Acquired stream gate: {key[:50]}...") + return True + # Another worker holds the gate, wait and retry + await asyncio.sleep(0.05) # 50ms poll interval + + logger.warning(f"[Redis] Gate acquisition timeout ({timeout}s): {key[:50]}...") + return False + + +async def release_stream_gate(url: str): + """ + Release a per-URL stream gate. + + Safe to call even if gate wasn't acquired or already expired. + No-op if Redis is not available. + """ + r = await get_redis() + if r is None: + return + + key = _gate_key(url) + await r.delete(key) + logger.debug(f"[Redis] Released stream gate: {key[:50]}...") + + +async def extend_stream_gate(url: str, ttl: int = GATE_TTL): + """ + Extend the TTL of a stream gate to keep it held during long streams. + + Should be called periodically (e.g., every 10s) while streaming. + No-op if Redis is not available or gate doesn't exist. + """ + r = await get_redis() + if r is None: + return + + key = _gate_key(url) + await r.expire(key, ttl) + logger.debug(f"[Redis] Extended stream gate TTL ({ttl}s): {key[:50]}...") + + +async def is_stream_gate_held(url: str) -> bool: + """Check if a stream gate is currently held. Returns False if Redis unavailable.""" + r = await get_redis() + if r is None: + return False + + key = _gate_key(url) + return await r.exists(key) > 0 + + +# ============================================================================= +# HEAD Response Cache +# ============================================================================= +# Caches upstream response headers so repeated HEAD probes (e.g., from ExoPlayer) +# can be served without any upstream connection. Shared across all workers. + +HEAD_CACHE_PREFIX = "mfp:head_cache:" +HEAD_CACHE_TTL = 60 # seconds - Vidoza CDN URLs typically expire in minutes + + +def _head_cache_key(url: str) -> str: + """Generate Redis key for HEAD cache entry.""" + url_hash = hashlib.md5(url.encode()).hexdigest() + return f"{HEAD_CACHE_PREFIX}{url_hash}" + + +async def get_cached_head(url: str) -> Optional[dict]: + """ + Get cached HEAD response metadata for a URL. + + Args: + url: The upstream URL + + Returns: + Dict with 'headers' and 'status' keys, or None if not cached (or Redis unavailable) + """ + r = await get_redis() + if r is None: + return None + + key = _head_cache_key(url) + data = await r.get(key) + if data: + logger.debug(f"[Redis] HEAD cache hit: {key[:50]}...") + return json.loads(data) + return None + + +async def set_cached_head(url: str, headers: dict, status: int): + """ + Cache HEAD response metadata for a URL. + + No-op if Redis is not available. + + Args: + url: The upstream URL + headers: Response headers dict (will be JSON serialized) + status: HTTP status code (e.g., 200, 206) + """ + r = await get_redis() + if r is None: + return + + key = _head_cache_key(url) + # Only cache headers that are useful for HEAD responses + # Filter to avoid caching large or irrelevant headers + cached_headers = {} + for k, v in headers.items(): + k_lower = k.lower() + if k_lower in ( + "content-type", + "content-length", + "accept-ranges", + "content-range", + "etag", + "last-modified", + "cache-control", + ): + cached_headers[k_lower] = v + + payload = json.dumps({"headers": cached_headers, "status": status}) + await r.set(key, payload, ex=HEAD_CACHE_TTL) + logger.debug(f"[Redis] HEAD cache set ({HEAD_CACHE_TTL}s TTL): {key[:50]}...") + + +# ============================================================================= +# Generic Distributed Lock +# ============================================================================= +# For cross-worker coordination (e.g., segment downloads, prebuffering) + +LOCK_PREFIX = "mfp:lock:" +DEFAULT_LOCK_TTL = 30 # seconds + + +def _lock_key(key: str) -> str: + """Generate Redis key for a lock.""" + key_hash = hashlib.md5(key.encode()).hexdigest() + return f"{LOCK_PREFIX}{key_hash}" + + +async def acquire_lock(key: str, ttl: int = DEFAULT_LOCK_TTL, timeout: float = 30.0) -> bool: + """ + Acquire a distributed lock. + + If Redis is not available, always returns True (no coordination). + + Args: + key: The lock identifier + ttl: Lock auto-expiry time in seconds (prevents deadlocks) + timeout: Maximum time to wait for the lock + + Returns: + True if lock acquired (or Redis unavailable), False if timeout + """ + r = await get_redis() + if r is None: + return True # No Redis - no coordination + + lock_key = _lock_key(key) + deadline = time.time() + timeout + + while time.time() < deadline: + if await r.set(lock_key, "1", nx=True, ex=ttl): + logger.debug(f"[Redis] Acquired lock: {key[:60]}...") + return True + await asyncio.sleep(0.05) + + logger.warning(f"[Redis] Lock timeout ({timeout}s): {key[:60]}...") + return False + + +async def release_lock(key: str): + """Release a distributed lock. No-op if Redis unavailable.""" + r = await get_redis() + if r is None: + return + + lock_key = _lock_key(key) + await r.delete(lock_key) + logger.debug(f"[Redis] Released lock: {key[:60]}...") + + +# ============================================================================= +# Extractor Cache +# ============================================================================= +# Caches extractor results (JSON) across all workers + +EXTRACTOR_CACHE_PREFIX = "mfp:extractor:" +EXTRACTOR_CACHE_TTL = 300 # 5 minutes + + +def _extractor_key(key: str) -> str: + """Generate Redis key for extractor cache.""" + key_hash = hashlib.md5(key.encode()).hexdigest() + return f"{EXTRACTOR_CACHE_PREFIX}{key_hash}" + + +async def get_cached_extractor(key: str) -> Optional[dict]: + """Get cached extractor result. Returns None if Redis unavailable.""" + r = await get_redis() + if r is None: + return None + + redis_key = _extractor_key(key) + data = await r.get(redis_key) + if data: + logger.debug(f"[Redis] Extractor cache hit: {key[:60]}...") + return json.loads(data) + return None + + +async def set_cached_extractor(key: str, data: dict, ttl: int = EXTRACTOR_CACHE_TTL): + """Cache extractor result. No-op if Redis unavailable.""" + r = await get_redis() + if r is None: + return + + redis_key = _extractor_key(key) + await r.set(redis_key, json.dumps(data), ex=ttl) + logger.debug(f"[Redis] Extractor cache set ({ttl}s TTL): {key[:60]}...") + + +# ============================================================================= +# MPD Cache +# ============================================================================= +# Caches parsed MPD manifests (JSON) across all workers + +MPD_CACHE_PREFIX = "mfp:mpd:" +DEFAULT_MPD_CACHE_TTL = 60 # 1 minute + + +def _mpd_key(key: str) -> str: + """Generate Redis key for MPD cache.""" + key_hash = hashlib.md5(key.encode()).hexdigest() + return f"{MPD_CACHE_PREFIX}{key_hash}" + + +async def get_cached_mpd(key: str) -> Optional[dict]: + """Get cached MPD manifest. Returns None if Redis unavailable.""" + r = await get_redis() + if r is None: + return None + + redis_key = _mpd_key(key) + data = await r.get(redis_key) + if data: + logger.debug(f"[Redis] MPD cache hit: {key[:60]}...") + return json.loads(data) + return None + + +async def set_cached_mpd(key: str, data: dict, ttl: int | float = DEFAULT_MPD_CACHE_TTL): + """Cache MPD manifest. No-op if Redis unavailable.""" + r = await get_redis() + if r is None: + return + + redis_key = _mpd_key(key) + # Ensure TTL is an integer (Redis requires int for ex parameter) + ttl_int = max(1, int(ttl)) + await r.set(redis_key, json.dumps(data), ex=ttl_int) + logger.debug(f"[Redis] MPD cache set ({ttl_int}s TTL): {key[:60]}...") + + +# ============================================================================= +# Segment Cache (Binary) +# ============================================================================= +# Caches HLS/DASH segments across all workers + +SEGMENT_CACHE_PREFIX = b"mfp:segment:" +DEFAULT_SEGMENT_CACHE_TTL = 60 # 1 minute + + +def _segment_key(url: str) -> bytes: + """Generate Redis key for segment cache.""" + url_hash = hashlib.md5(url.encode()).hexdigest() + return SEGMENT_CACHE_PREFIX + url_hash.encode() + + +async def get_cached_segment(url: str) -> Optional[bytes]: + """Get cached segment data. Returns None if Redis unavailable.""" + r = await get_redis_binary() + if r is None: + return None + + key = _segment_key(url) + data = await r.get(key) + if data: + logger.debug(f"[Redis] Segment cache hit: {url[:60]}...") + return data + + +async def set_cached_segment(url: str, data: bytes, ttl: int = DEFAULT_SEGMENT_CACHE_TTL): + """Cache segment data. No-op if Redis unavailable.""" + r = await get_redis_binary() + if r is None: + return + + key = _segment_key(url) + await r.set(key, data, ex=ttl) + logger.debug(f"[Redis] Segment cache set ({ttl}s TTL, {len(data)} bytes): {url[:60]}...") + + +# ============================================================================= +# Init Segment Cache (Binary) +# ============================================================================= +# Caches initialization segments across all workers + +INIT_CACHE_PREFIX = b"mfp:init:" +DEFAULT_INIT_CACHE_TTL = 3600 # 1 hour + + +def _init_key(url: str) -> bytes: + """Generate Redis key for init segment cache.""" + url_hash = hashlib.md5(url.encode()).hexdigest() + return INIT_CACHE_PREFIX + url_hash.encode() + + +async def get_cached_init_segment(url: str) -> Optional[bytes]: + """Get cached init segment data. Returns None if Redis unavailable.""" + r = await get_redis_binary() + if r is None: + return None + + key = _init_key(url) + data = await r.get(key) + if data: + logger.debug(f"[Redis] Init segment cache hit: {url[:60]}...") + return data + + +async def set_cached_init_segment(url: str, data: bytes, ttl: int = DEFAULT_INIT_CACHE_TTL): + """Cache init segment data. No-op if Redis unavailable.""" + r = await get_redis_binary() + if r is None: + return + + key = _init_key(url) + await r.set(key, data, ex=ttl) + logger.debug(f"[Redis] Init segment cache set ({ttl}s TTL, {len(data)} bytes): {url[:60]}...") + + +# ============================================================================= +# Processed Init Segment Cache (Binary) +# ============================================================================= +# Caches DRM-stripped/processed init segments across all workers + +PROCESSED_INIT_CACHE_PREFIX = b"mfp:processed_init:" +DEFAULT_PROCESSED_INIT_TTL = 3600 # 1 hour + + +def _processed_init_key(key: str) -> bytes: + """Generate Redis key for processed init segment cache.""" + key_hash = hashlib.md5(key.encode()).hexdigest() + return PROCESSED_INIT_CACHE_PREFIX + key_hash.encode() + + +async def get_cached_processed_init(key: str) -> Optional[bytes]: + """Get cached processed init segment data. Returns None if Redis unavailable.""" + r = await get_redis_binary() + if r is None: + return None + + redis_key = _processed_init_key(key) + data = await r.get(redis_key) + if data: + logger.debug(f"[Redis] Processed init cache hit: {key[:60]}...") + return data + + +async def set_cached_processed_init(key: str, data: bytes, ttl: int = DEFAULT_PROCESSED_INIT_TTL): + """Cache processed init segment data. No-op if Redis unavailable.""" + r = await get_redis_binary() + if r is None: + return + + redis_key = _processed_init_key(key) + await r.set(redis_key, data, ex=ttl) + logger.debug(f"[Redis] Processed init cache set ({ttl}s TTL, {len(data)} bytes): {key[:60]}...") + + +# ============================================================================= +# In-flight Request Tracking +# ============================================================================= +# Prevents duplicate upstream requests across all workers + +INFLIGHT_PREFIX = "mfp:inflight:" +DEFAULT_INFLIGHT_TTL = 60 # seconds + + +def _inflight_key(key: str) -> str: + """Generate Redis key for in-flight tracking.""" + key_hash = hashlib.md5(key.encode()).hexdigest() + return f"{INFLIGHT_PREFIX}{key_hash}" + + +async def mark_inflight(key: str, ttl: int = DEFAULT_INFLIGHT_TTL) -> bool: + """ + Mark a request as in-flight (being processed by some worker). + + If Redis is not available, always returns True (this worker is "first"). + + Args: + key: The request identifier + ttl: Auto-expiry time in seconds + + Returns: + True if this call marked it (first worker), False if already in-flight + """ + r = await get_redis() + if r is None: + return True # No Redis - always proceed + + inflight_key = _inflight_key(key) + result = await r.set(inflight_key, "1", nx=True, ex=ttl) + if result: + logger.debug(f"[Redis] Marked in-flight: {key[:60]}...") + return bool(result) + + +async def is_inflight(key: str) -> bool: + """Check if a request is currently in-flight. Returns False if Redis unavailable.""" + r = await get_redis() + if r is None: + return False + + inflight_key = _inflight_key(key) + return await r.exists(inflight_key) > 0 + + +async def clear_inflight(key: str): + """Clear in-flight marker (call when request completes). No-op if Redis unavailable.""" + r = await get_redis() + if r is None: + return + + inflight_key = _inflight_key(key) + await r.delete(inflight_key) + logger.debug(f"[Redis] Cleared in-flight: {key[:60]}...") + + +async def wait_for_completion(key: str, timeout: float = 30.0, poll_interval: float = 0.1) -> bool: + """ + Wait for an in-flight request to complete. + + If Redis is not available, returns True immediately. + + Args: + key: The request identifier + timeout: Maximum time to wait + poll_interval: Time between checks + + Returns: + True if completed (inflight marker gone), False if timeout + """ + r = await get_redis() + if r is None: + return True # No Redis - don't wait + + deadline = time.time() + timeout + while time.time() < deadline: + if not await is_inflight(key): + return True + await asyncio.sleep(poll_interval) + return False + + +# ============================================================================= +# Cooldown/Throttle Tracking +# ============================================================================= +# Prevents rapid repeated operations (e.g., background refresh throttling) + +COOLDOWN_PREFIX = "mfp:cooldown:" + + +def _cooldown_key(key: str) -> str: + """Generate Redis key for cooldown tracking.""" + key_hash = hashlib.md5(key.encode()).hexdigest() + return f"{COOLDOWN_PREFIX}{key_hash}" + + +async def check_and_set_cooldown(key: str, cooldown_seconds: int) -> bool: + """ + Check if cooldown has elapsed and set new cooldown if so. + + If Redis is not available, always returns True (no rate limiting). + + Args: + key: The cooldown identifier + cooldown_seconds: Duration of the cooldown period + + Returns: + True if cooldown elapsed (and new cooldown set), False if still in cooldown + """ + r = await get_redis() + if r is None: + return True # No Redis - no rate limiting + + cooldown_key = _cooldown_key(key) + # SET NX EX: only succeeds if key doesn't exist + result = await r.set(cooldown_key, "1", nx=True, ex=cooldown_seconds) + if result: + logger.debug(f"[Redis] Cooldown set ({cooldown_seconds}s): {key[:60]}...") + return True + return False + + +async def is_in_cooldown(key: str) -> bool: + """Check if currently in cooldown period. Returns False if Redis unavailable.""" + r = await get_redis() + if r is None: + return False + + cooldown_key = _cooldown_key(key) + return await r.exists(cooldown_key) > 0 + + +# ============================================================================= +# HLS Transcode Session (Cross-Worker) +# ============================================================================= +# Per-segment HLS transcode caching. +# Each segment is independently transcoded and cached. Segment output metadata +# (video/audio DTS, sequence number) is stored so consecutive segments can +# maintain timeline continuity without a persistent pipeline. + +HLS_SEG_PREFIX = b"mfp:hls_seg:" +HLS_INIT_PREFIX = b"mfp:hls_init:" +HLS_SEG_META_PREFIX = "mfp:hls_smeta:" + +HLS_SEG_TTL = 60 # 60 s -- short-lived; only for immediate retry/re-request +HLS_INIT_TTL = 3600 # 1 hour -- stable for the viewing session +HLS_SEG_META_TTL = 3600 # 1 hour -- needed for next-segment continuity + + +def _hls_seg_key(cache_key: str, seg_index: int) -> bytes: + return HLS_SEG_PREFIX + f"{cache_key}:{seg_index}".encode() + + +def _hls_init_key(cache_key: str) -> bytes: + return HLS_INIT_PREFIX + cache_key.encode() + + +def _hls_seg_meta_key(cache_key: str, seg_index: int) -> str: + return f"{HLS_SEG_META_PREFIX}{cache_key}:{seg_index}" + + +async def hls_get_segment(cache_key: str, seg_index: int) -> Optional[bytes]: + """Get a cached HLS segment from Redis. Returns None if unavailable.""" + r = await get_redis_binary() + if r is None: + return None + try: + return await r.get(_hls_seg_key(cache_key, seg_index)) + except Exception: + return None + + +async def hls_set_segment(cache_key: str, seg_index: int, data: bytes) -> None: + """Store an HLS segment in Redis with short TTL. No-op if Redis unavailable.""" + r = await get_redis_binary() + if r is None: + return + try: + await r.set(_hls_seg_key(cache_key, seg_index), data, ex=HLS_SEG_TTL) + except Exception: + logger.debug("[Redis] Failed to cache HLS segment %d", seg_index) + + +async def hls_get_init(cache_key: str) -> Optional[bytes]: + """Get the cached HLS init segment from Redis.""" + r = await get_redis_binary() + if r is None: + return None + try: + return await r.get(_hls_init_key(cache_key)) + except Exception: + return None + + +async def hls_set_init(cache_key: str, data: bytes) -> None: + """Store the HLS init segment in Redis.""" + r = await get_redis_binary() + if r is None: + return + try: + await r.set(_hls_init_key(cache_key), data, ex=HLS_INIT_TTL) + except Exception: + logger.debug("[Redis] Failed to cache HLS init segment") + + +async def hls_get_segment_meta(cache_key: str, seg_index: int) -> Optional[dict]: + """ + Get per-segment output metadata from Redis. + + Returns a dict with keys like ``video_dts_ms``, ``audio_dts_ms``, + ``sequence_number``, or None if unavailable. + """ + r = await get_redis() + if r is None: + return None + try: + raw = await r.get(_hls_seg_meta_key(cache_key, seg_index)) + if raw: + return json.loads(raw) + except Exception: + pass + return None + + +async def hls_set_segment_meta(cache_key: str, seg_index: int, meta: dict) -> None: + """ + Store per-segment output metadata in Redis. + + ``meta`` should contain keys like ``video_dts_ms``, ``audio_dts_ms``, + ``sequence_number`` so the next segment can continue the timeline. + """ + r = await get_redis() + if r is None: + return + try: + await r.set( + _hls_seg_meta_key(cache_key, seg_index), + json.dumps(meta), + ex=HLS_SEG_META_TTL, + ) + except Exception: + logger.debug("[Redis] Failed to set HLS segment meta %d", seg_index) diff --git a/mediaflow_proxy/utils/rijndael.py b/mediaflow_proxy/utils/rijndael.py index 000eba6..56c0adf 100644 --- a/mediaflow_proxy/utils/rijndael.py +++ b/mediaflow_proxy/utils/rijndael.py @@ -36,873 +36,3677 @@ import sys # code, in which case it can be made public domain by # deleting all the comments and renaming all the variables -shifts = [[[0, 0], [1, 3], [2, 2], [3, 1]], - [[0, 0], [1, 5], [2, 4], [3, 3]], - [[0, 0], [1, 7], [3, 5], [4, 4]]] +shifts = [[[0, 0], [1, 3], [2, 2], [3, 1]], [[0, 0], [1, 5], [2, 4], [3, 3]], [[0, 0], [1, 7], [3, 5], [4, 4]]] # [keysize][block_size] -num_rounds = {16: {16: 10, 24: 12, 32: 14}, - 24: {16: 12, 24: 12, 32: 14}, - 32: {16: 14, 24: 14, 32: 14}} +num_rounds = {16: {16: 10, 24: 12, 32: 14}, 24: {16: 12, 24: 12, 32: 14}, 32: {16: 14, 24: 14, 32: 14}} # see unit_tests/test_tlslite_utils_rijndael.py for algorithm used to # calculate S, Si, T, U and rcon arrays # S box -S = (99, 124, 119, 123, 242, 107, 111, 197, - 48, 1, 103, 43, 254, 215, 171, 118, - 202, 130, 201, 125, 250, 89, 71, 240, - 173, 212, 162, 175, 156, 164, 114, 192, - 183, 253, 147, 38, 54, 63, 247, 204, - 52, 165, 229, 241, 113, 216, 49, 21, - 4, 199, 35, 195, 24, 150, 5, 154, - 7, 18, 128, 226, 235, 39, 178, 117, - 9, 131, 44, 26, 27, 110, 90, 160, - 82, 59, 214, 179, 41, 227, 47, 132, - 83, 209, 0, 237, 32, 252, 177, 91, - 106, 203, 190, 57, 74, 76, 88, 207, - 208, 239, 170, 251, 67, 77, 51, 133, - 69, 249, 2, 127, 80, 60, 159, 168, - 81, 163, 64, 143, 146, 157, 56, 245, - 188, 182, 218, 33, 16, 255, 243, 210, - 205, 12, 19, 236, 95, 151, 68, 23, - 196, 167, 126, 61, 100, 93, 25, 115, - 96, 129, 79, 220, 34, 42, 144, 136, - 70, 238, 184, 20, 222, 94, 11, 219, - 224, 50, 58, 10, 73, 6, 36, 92, - 194, 211, 172, 98, 145, 149, 228, 121, - 231, 200, 55, 109, 141, 213, 78, 169, - 108, 86, 244, 234, 101, 122, 174, 8, - 186, 120, 37, 46, 28, 166, 180, 198, - 232, 221, 116, 31, 75, 189, 139, 138, - 112, 62, 181, 102, 72, 3, 246, 14, - 97, 53, 87, 185, 134, 193, 29, 158, - 225, 248, 152, 17, 105, 217, 142, 148, - 155, 30, 135, 233, 206, 85, 40, 223, - 140, 161, 137, 13, 191, 230, 66, 104, - 65, 153, 45, 15, 176, 84, 187, 22) +S = ( + 99, + 124, + 119, + 123, + 242, + 107, + 111, + 197, + 48, + 1, + 103, + 43, + 254, + 215, + 171, + 118, + 202, + 130, + 201, + 125, + 250, + 89, + 71, + 240, + 173, + 212, + 162, + 175, + 156, + 164, + 114, + 192, + 183, + 253, + 147, + 38, + 54, + 63, + 247, + 204, + 52, + 165, + 229, + 241, + 113, + 216, + 49, + 21, + 4, + 199, + 35, + 195, + 24, + 150, + 5, + 154, + 7, + 18, + 128, + 226, + 235, + 39, + 178, + 117, + 9, + 131, + 44, + 26, + 27, + 110, + 90, + 160, + 82, + 59, + 214, + 179, + 41, + 227, + 47, + 132, + 83, + 209, + 0, + 237, + 32, + 252, + 177, + 91, + 106, + 203, + 190, + 57, + 74, + 76, + 88, + 207, + 208, + 239, + 170, + 251, + 67, + 77, + 51, + 133, + 69, + 249, + 2, + 127, + 80, + 60, + 159, + 168, + 81, + 163, + 64, + 143, + 146, + 157, + 56, + 245, + 188, + 182, + 218, + 33, + 16, + 255, + 243, + 210, + 205, + 12, + 19, + 236, + 95, + 151, + 68, + 23, + 196, + 167, + 126, + 61, + 100, + 93, + 25, + 115, + 96, + 129, + 79, + 220, + 34, + 42, + 144, + 136, + 70, + 238, + 184, + 20, + 222, + 94, + 11, + 219, + 224, + 50, + 58, + 10, + 73, + 6, + 36, + 92, + 194, + 211, + 172, + 98, + 145, + 149, + 228, + 121, + 231, + 200, + 55, + 109, + 141, + 213, + 78, + 169, + 108, + 86, + 244, + 234, + 101, + 122, + 174, + 8, + 186, + 120, + 37, + 46, + 28, + 166, + 180, + 198, + 232, + 221, + 116, + 31, + 75, + 189, + 139, + 138, + 112, + 62, + 181, + 102, + 72, + 3, + 246, + 14, + 97, + 53, + 87, + 185, + 134, + 193, + 29, + 158, + 225, + 248, + 152, + 17, + 105, + 217, + 142, + 148, + 155, + 30, + 135, + 233, + 206, + 85, + 40, + 223, + 140, + 161, + 137, + 13, + 191, + 230, + 66, + 104, + 65, + 153, + 45, + 15, + 176, + 84, + 187, + 22, +) # inverse of S box -Si = (82, 9, 106, 213, 48, 54, 165, 56, - 191, 64, 163, 158, 129, 243, 215, 251, - 124, 227, 57, 130, 155, 47, 255, 135, - 52, 142, 67, 68, 196, 222, 233, 203, - 84, 123, 148, 50, 166, 194, 35, 61, - 238, 76, 149, 11, 66, 250, 195, 78, - 8, 46, 161, 102, 40, 217, 36, 178, - 118, 91, 162, 73, 109, 139, 209, 37, - 114, 248, 246, 100, 134, 104, 152, 22, - 212, 164, 92, 204, 93, 101, 182, 146, - 108, 112, 72, 80, 253, 237, 185, 218, - 94, 21, 70, 87, 167, 141, 157, 132, - 144, 216, 171, 0, 140, 188, 211, 10, - 247, 228, 88, 5, 184, 179, 69, 6, - 208, 44, 30, 143, 202, 63, 15, 2, - 193, 175, 189, 3, 1, 19, 138, 107, - 58, 145, 17, 65, 79, 103, 220, 234, - 151, 242, 207, 206, 240, 180, 230, 115, - 150, 172, 116, 34, 231, 173, 53, 133, - 226, 249, 55, 232, 28, 117, 223, 110, - 71, 241, 26, 113, 29, 41, 197, 137, - 111, 183, 98, 14, 170, 24, 190, 27, - 252, 86, 62, 75, 198, 210, 121, 32, - 154, 219, 192, 254, 120, 205, 90, 244, - 31, 221, 168, 51, 136, 7, 199, 49, - 177, 18, 16, 89, 39, 128, 236, 95, - 96, 81, 127, 169, 25, 181, 74, 13, - 45, 229, 122, 159, 147, 201, 156, 239, - 160, 224, 59, 77, 174, 42, 245, 176, - 200, 235, 187, 60, 131, 83, 153, 97, - 23, 43, 4, 126, 186, 119, 214, 38, - 225, 105, 20, 99, 85, 33, 12, 125) +Si = ( + 82, + 9, + 106, + 213, + 48, + 54, + 165, + 56, + 191, + 64, + 163, + 158, + 129, + 243, + 215, + 251, + 124, + 227, + 57, + 130, + 155, + 47, + 255, + 135, + 52, + 142, + 67, + 68, + 196, + 222, + 233, + 203, + 84, + 123, + 148, + 50, + 166, + 194, + 35, + 61, + 238, + 76, + 149, + 11, + 66, + 250, + 195, + 78, + 8, + 46, + 161, + 102, + 40, + 217, + 36, + 178, + 118, + 91, + 162, + 73, + 109, + 139, + 209, + 37, + 114, + 248, + 246, + 100, + 134, + 104, + 152, + 22, + 212, + 164, + 92, + 204, + 93, + 101, + 182, + 146, + 108, + 112, + 72, + 80, + 253, + 237, + 185, + 218, + 94, + 21, + 70, + 87, + 167, + 141, + 157, + 132, + 144, + 216, + 171, + 0, + 140, + 188, + 211, + 10, + 247, + 228, + 88, + 5, + 184, + 179, + 69, + 6, + 208, + 44, + 30, + 143, + 202, + 63, + 15, + 2, + 193, + 175, + 189, + 3, + 1, + 19, + 138, + 107, + 58, + 145, + 17, + 65, + 79, + 103, + 220, + 234, + 151, + 242, + 207, + 206, + 240, + 180, + 230, + 115, + 150, + 172, + 116, + 34, + 231, + 173, + 53, + 133, + 226, + 249, + 55, + 232, + 28, + 117, + 223, + 110, + 71, + 241, + 26, + 113, + 29, + 41, + 197, + 137, + 111, + 183, + 98, + 14, + 170, + 24, + 190, + 27, + 252, + 86, + 62, + 75, + 198, + 210, + 121, + 32, + 154, + 219, + 192, + 254, + 120, + 205, + 90, + 244, + 31, + 221, + 168, + 51, + 136, + 7, + 199, + 49, + 177, + 18, + 16, + 89, + 39, + 128, + 236, + 95, + 96, + 81, + 127, + 169, + 25, + 181, + 74, + 13, + 45, + 229, + 122, + 159, + 147, + 201, + 156, + 239, + 160, + 224, + 59, + 77, + 174, + 42, + 245, + 176, + 200, + 235, + 187, + 60, + 131, + 83, + 153, + 97, + 23, + 43, + 4, + 126, + 186, + 119, + 214, + 38, + 225, + 105, + 20, + 99, + 85, + 33, + 12, + 125, +) -T1 = (0xc66363a5, 0xf87c7c84, 0xee777799, 0xf67b7b8d, - 0xfff2f20d, 0xd66b6bbd, 0xde6f6fb1, 0x91c5c554, - 0x60303050, 0x2010103, 0xce6767a9, 0x562b2b7d, - 0xe7fefe19, 0xb5d7d762, 0x4dababe6, 0xec76769a, - 0x8fcaca45, 0x1f82829d, 0x89c9c940, 0xfa7d7d87, - 0xeffafa15, 0xb25959eb, 0x8e4747c9, 0xfbf0f00b, - 0x41adadec, 0xb3d4d467, 0x5fa2a2fd, 0x45afafea, - 0x239c9cbf, 0x53a4a4f7, 0xe4727296, 0x9bc0c05b, - 0x75b7b7c2, 0xe1fdfd1c, 0x3d9393ae, 0x4c26266a, - 0x6c36365a, 0x7e3f3f41, 0xf5f7f702, 0x83cccc4f, - 0x6834345c, 0x51a5a5f4, 0xd1e5e534, 0xf9f1f108, - 0xe2717193, 0xabd8d873, 0x62313153, 0x2a15153f, - 0x804040c, 0x95c7c752, 0x46232365, 0x9dc3c35e, - 0x30181828, 0x379696a1, 0xa05050f, 0x2f9a9ab5, - 0xe070709, 0x24121236, 0x1b80809b, 0xdfe2e23d, - 0xcdebeb26, 0x4e272769, 0x7fb2b2cd, 0xea75759f, - 0x1209091b, 0x1d83839e, 0x582c2c74, 0x341a1a2e, - 0x361b1b2d, 0xdc6e6eb2, 0xb45a5aee, 0x5ba0a0fb, - 0xa45252f6, 0x763b3b4d, 0xb7d6d661, 0x7db3b3ce, - 0x5229297b, 0xdde3e33e, 0x5e2f2f71, 0x13848497, - 0xa65353f5, 0xb9d1d168, 0x0, 0xc1eded2c, - 0x40202060, 0xe3fcfc1f, 0x79b1b1c8, 0xb65b5bed, - 0xd46a6abe, 0x8dcbcb46, 0x67bebed9, 0x7239394b, - 0x944a4ade, 0x984c4cd4, 0xb05858e8, 0x85cfcf4a, - 0xbbd0d06b, 0xc5efef2a, 0x4faaaae5, 0xedfbfb16, - 0x864343c5, 0x9a4d4dd7, 0x66333355, 0x11858594, - 0x8a4545cf, 0xe9f9f910, 0x4020206, 0xfe7f7f81, - 0xa05050f0, 0x783c3c44, 0x259f9fba, 0x4ba8a8e3, - 0xa25151f3, 0x5da3a3fe, 0x804040c0, 0x58f8f8a, - 0x3f9292ad, 0x219d9dbc, 0x70383848, 0xf1f5f504, - 0x63bcbcdf, 0x77b6b6c1, 0xafdada75, 0x42212163, - 0x20101030, 0xe5ffff1a, 0xfdf3f30e, 0xbfd2d26d, - 0x81cdcd4c, 0x180c0c14, 0x26131335, 0xc3ecec2f, - 0xbe5f5fe1, 0x359797a2, 0x884444cc, 0x2e171739, - 0x93c4c457, 0x55a7a7f2, 0xfc7e7e82, 0x7a3d3d47, - 0xc86464ac, 0xba5d5de7, 0x3219192b, 0xe6737395, - 0xc06060a0, 0x19818198, 0x9e4f4fd1, 0xa3dcdc7f, - 0x44222266, 0x542a2a7e, 0x3b9090ab, 0xb888883, - 0x8c4646ca, 0xc7eeee29, 0x6bb8b8d3, 0x2814143c, - 0xa7dede79, 0xbc5e5ee2, 0x160b0b1d, 0xaddbdb76, - 0xdbe0e03b, 0x64323256, 0x743a3a4e, 0x140a0a1e, - 0x924949db, 0xc06060a, 0x4824246c, 0xb85c5ce4, - 0x9fc2c25d, 0xbdd3d36e, 0x43acacef, 0xc46262a6, - 0x399191a8, 0x319595a4, 0xd3e4e437, 0xf279798b, - 0xd5e7e732, 0x8bc8c843, 0x6e373759, 0xda6d6db7, - 0x18d8d8c, 0xb1d5d564, 0x9c4e4ed2, 0x49a9a9e0, - 0xd86c6cb4, 0xac5656fa, 0xf3f4f407, 0xcfeaea25, - 0xca6565af, 0xf47a7a8e, 0x47aeaee9, 0x10080818, - 0x6fbabad5, 0xf0787888, 0x4a25256f, 0x5c2e2e72, - 0x381c1c24, 0x57a6a6f1, 0x73b4b4c7, 0x97c6c651, - 0xcbe8e823, 0xa1dddd7c, 0xe874749c, 0x3e1f1f21, - 0x964b4bdd, 0x61bdbddc, 0xd8b8b86, 0xf8a8a85, - 0xe0707090, 0x7c3e3e42, 0x71b5b5c4, 0xcc6666aa, - 0x904848d8, 0x6030305, 0xf7f6f601, 0x1c0e0e12, - 0xc26161a3, 0x6a35355f, 0xae5757f9, 0x69b9b9d0, - 0x17868691, 0x99c1c158, 0x3a1d1d27, 0x279e9eb9, - 0xd9e1e138, 0xebf8f813, 0x2b9898b3, 0x22111133, - 0xd26969bb, 0xa9d9d970, 0x78e8e89, 0x339494a7, - 0x2d9b9bb6, 0x3c1e1e22, 0x15878792, 0xc9e9e920, - 0x87cece49, 0xaa5555ff, 0x50282878, 0xa5dfdf7a, - 0x38c8c8f, 0x59a1a1f8, 0x9898980, 0x1a0d0d17, - 0x65bfbfda, 0xd7e6e631, 0x844242c6, 0xd06868b8, - 0x824141c3, 0x299999b0, 0x5a2d2d77, 0x1e0f0f11, - 0x7bb0b0cb, 0xa85454fc, 0x6dbbbbd6, 0x2c16163a) +T1 = ( + 0xC66363A5, + 0xF87C7C84, + 0xEE777799, + 0xF67B7B8D, + 0xFFF2F20D, + 0xD66B6BBD, + 0xDE6F6FB1, + 0x91C5C554, + 0x60303050, + 0x2010103, + 0xCE6767A9, + 0x562B2B7D, + 0xE7FEFE19, + 0xB5D7D762, + 0x4DABABE6, + 0xEC76769A, + 0x8FCACA45, + 0x1F82829D, + 0x89C9C940, + 0xFA7D7D87, + 0xEFFAFA15, + 0xB25959EB, + 0x8E4747C9, + 0xFBF0F00B, + 0x41ADADEC, + 0xB3D4D467, + 0x5FA2A2FD, + 0x45AFAFEA, + 0x239C9CBF, + 0x53A4A4F7, + 0xE4727296, + 0x9BC0C05B, + 0x75B7B7C2, + 0xE1FDFD1C, + 0x3D9393AE, + 0x4C26266A, + 0x6C36365A, + 0x7E3F3F41, + 0xF5F7F702, + 0x83CCCC4F, + 0x6834345C, + 0x51A5A5F4, + 0xD1E5E534, + 0xF9F1F108, + 0xE2717193, + 0xABD8D873, + 0x62313153, + 0x2A15153F, + 0x804040C, + 0x95C7C752, + 0x46232365, + 0x9DC3C35E, + 0x30181828, + 0x379696A1, + 0xA05050F, + 0x2F9A9AB5, + 0xE070709, + 0x24121236, + 0x1B80809B, + 0xDFE2E23D, + 0xCDEBEB26, + 0x4E272769, + 0x7FB2B2CD, + 0xEA75759F, + 0x1209091B, + 0x1D83839E, + 0x582C2C74, + 0x341A1A2E, + 0x361B1B2D, + 0xDC6E6EB2, + 0xB45A5AEE, + 0x5BA0A0FB, + 0xA45252F6, + 0x763B3B4D, + 0xB7D6D661, + 0x7DB3B3CE, + 0x5229297B, + 0xDDE3E33E, + 0x5E2F2F71, + 0x13848497, + 0xA65353F5, + 0xB9D1D168, + 0x0, + 0xC1EDED2C, + 0x40202060, + 0xE3FCFC1F, + 0x79B1B1C8, + 0xB65B5BED, + 0xD46A6ABE, + 0x8DCBCB46, + 0x67BEBED9, + 0x7239394B, + 0x944A4ADE, + 0x984C4CD4, + 0xB05858E8, + 0x85CFCF4A, + 0xBBD0D06B, + 0xC5EFEF2A, + 0x4FAAAAE5, + 0xEDFBFB16, + 0x864343C5, + 0x9A4D4DD7, + 0x66333355, + 0x11858594, + 0x8A4545CF, + 0xE9F9F910, + 0x4020206, + 0xFE7F7F81, + 0xA05050F0, + 0x783C3C44, + 0x259F9FBA, + 0x4BA8A8E3, + 0xA25151F3, + 0x5DA3A3FE, + 0x804040C0, + 0x58F8F8A, + 0x3F9292AD, + 0x219D9DBC, + 0x70383848, + 0xF1F5F504, + 0x63BCBCDF, + 0x77B6B6C1, + 0xAFDADA75, + 0x42212163, + 0x20101030, + 0xE5FFFF1A, + 0xFDF3F30E, + 0xBFD2D26D, + 0x81CDCD4C, + 0x180C0C14, + 0x26131335, + 0xC3ECEC2F, + 0xBE5F5FE1, + 0x359797A2, + 0x884444CC, + 0x2E171739, + 0x93C4C457, + 0x55A7A7F2, + 0xFC7E7E82, + 0x7A3D3D47, + 0xC86464AC, + 0xBA5D5DE7, + 0x3219192B, + 0xE6737395, + 0xC06060A0, + 0x19818198, + 0x9E4F4FD1, + 0xA3DCDC7F, + 0x44222266, + 0x542A2A7E, + 0x3B9090AB, + 0xB888883, + 0x8C4646CA, + 0xC7EEEE29, + 0x6BB8B8D3, + 0x2814143C, + 0xA7DEDE79, + 0xBC5E5EE2, + 0x160B0B1D, + 0xADDBDB76, + 0xDBE0E03B, + 0x64323256, + 0x743A3A4E, + 0x140A0A1E, + 0x924949DB, + 0xC06060A, + 0x4824246C, + 0xB85C5CE4, + 0x9FC2C25D, + 0xBDD3D36E, + 0x43ACACEF, + 0xC46262A6, + 0x399191A8, + 0x319595A4, + 0xD3E4E437, + 0xF279798B, + 0xD5E7E732, + 0x8BC8C843, + 0x6E373759, + 0xDA6D6DB7, + 0x18D8D8C, + 0xB1D5D564, + 0x9C4E4ED2, + 0x49A9A9E0, + 0xD86C6CB4, + 0xAC5656FA, + 0xF3F4F407, + 0xCFEAEA25, + 0xCA6565AF, + 0xF47A7A8E, + 0x47AEAEE9, + 0x10080818, + 0x6FBABAD5, + 0xF0787888, + 0x4A25256F, + 0x5C2E2E72, + 0x381C1C24, + 0x57A6A6F1, + 0x73B4B4C7, + 0x97C6C651, + 0xCBE8E823, + 0xA1DDDD7C, + 0xE874749C, + 0x3E1F1F21, + 0x964B4BDD, + 0x61BDBDDC, + 0xD8B8B86, + 0xF8A8A85, + 0xE0707090, + 0x7C3E3E42, + 0x71B5B5C4, + 0xCC6666AA, + 0x904848D8, + 0x6030305, + 0xF7F6F601, + 0x1C0E0E12, + 0xC26161A3, + 0x6A35355F, + 0xAE5757F9, + 0x69B9B9D0, + 0x17868691, + 0x99C1C158, + 0x3A1D1D27, + 0x279E9EB9, + 0xD9E1E138, + 0xEBF8F813, + 0x2B9898B3, + 0x22111133, + 0xD26969BB, + 0xA9D9D970, + 0x78E8E89, + 0x339494A7, + 0x2D9B9BB6, + 0x3C1E1E22, + 0x15878792, + 0xC9E9E920, + 0x87CECE49, + 0xAA5555FF, + 0x50282878, + 0xA5DFDF7A, + 0x38C8C8F, + 0x59A1A1F8, + 0x9898980, + 0x1A0D0D17, + 0x65BFBFDA, + 0xD7E6E631, + 0x844242C6, + 0xD06868B8, + 0x824141C3, + 0x299999B0, + 0x5A2D2D77, + 0x1E0F0F11, + 0x7BB0B0CB, + 0xA85454FC, + 0x6DBBBBD6, + 0x2C16163A, +) -T2 = (0xa5c66363, 0x84f87c7c, 0x99ee7777, 0x8df67b7b, - 0xdfff2f2, 0xbdd66b6b, 0xb1de6f6f, 0x5491c5c5, - 0x50603030, 0x3020101, 0xa9ce6767, 0x7d562b2b, - 0x19e7fefe, 0x62b5d7d7, 0xe64dabab, 0x9aec7676, - 0x458fcaca, 0x9d1f8282, 0x4089c9c9, 0x87fa7d7d, - 0x15effafa, 0xebb25959, 0xc98e4747, 0xbfbf0f0, - 0xec41adad, 0x67b3d4d4, 0xfd5fa2a2, 0xea45afaf, - 0xbf239c9c, 0xf753a4a4, 0x96e47272, 0x5b9bc0c0, - 0xc275b7b7, 0x1ce1fdfd, 0xae3d9393, 0x6a4c2626, - 0x5a6c3636, 0x417e3f3f, 0x2f5f7f7, 0x4f83cccc, - 0x5c683434, 0xf451a5a5, 0x34d1e5e5, 0x8f9f1f1, - 0x93e27171, 0x73abd8d8, 0x53623131, 0x3f2a1515, - 0xc080404, 0x5295c7c7, 0x65462323, 0x5e9dc3c3, - 0x28301818, 0xa1379696, 0xf0a0505, 0xb52f9a9a, - 0x90e0707, 0x36241212, 0x9b1b8080, 0x3ddfe2e2, - 0x26cdebeb, 0x694e2727, 0xcd7fb2b2, 0x9fea7575, - 0x1b120909, 0x9e1d8383, 0x74582c2c, 0x2e341a1a, - 0x2d361b1b, 0xb2dc6e6e, 0xeeb45a5a, 0xfb5ba0a0, - 0xf6a45252, 0x4d763b3b, 0x61b7d6d6, 0xce7db3b3, - 0x7b522929, 0x3edde3e3, 0x715e2f2f, 0x97138484, - 0xf5a65353, 0x68b9d1d1, 0x0, 0x2cc1eded, - 0x60402020, 0x1fe3fcfc, 0xc879b1b1, 0xedb65b5b, - 0xbed46a6a, 0x468dcbcb, 0xd967bebe, 0x4b723939, - 0xde944a4a, 0xd4984c4c, 0xe8b05858, 0x4a85cfcf, - 0x6bbbd0d0, 0x2ac5efef, 0xe54faaaa, 0x16edfbfb, - 0xc5864343, 0xd79a4d4d, 0x55663333, 0x94118585, - 0xcf8a4545, 0x10e9f9f9, 0x6040202, 0x81fe7f7f, - 0xf0a05050, 0x44783c3c, 0xba259f9f, 0xe34ba8a8, - 0xf3a25151, 0xfe5da3a3, 0xc0804040, 0x8a058f8f, - 0xad3f9292, 0xbc219d9d, 0x48703838, 0x4f1f5f5, - 0xdf63bcbc, 0xc177b6b6, 0x75afdada, 0x63422121, - 0x30201010, 0x1ae5ffff, 0xefdf3f3, 0x6dbfd2d2, - 0x4c81cdcd, 0x14180c0c, 0x35261313, 0x2fc3ecec, - 0xe1be5f5f, 0xa2359797, 0xcc884444, 0x392e1717, - 0x5793c4c4, 0xf255a7a7, 0x82fc7e7e, 0x477a3d3d, - 0xacc86464, 0xe7ba5d5d, 0x2b321919, 0x95e67373, - 0xa0c06060, 0x98198181, 0xd19e4f4f, 0x7fa3dcdc, - 0x66442222, 0x7e542a2a, 0xab3b9090, 0x830b8888, - 0xca8c4646, 0x29c7eeee, 0xd36bb8b8, 0x3c281414, - 0x79a7dede, 0xe2bc5e5e, 0x1d160b0b, 0x76addbdb, - 0x3bdbe0e0, 0x56643232, 0x4e743a3a, 0x1e140a0a, - 0xdb924949, 0xa0c0606, 0x6c482424, 0xe4b85c5c, - 0x5d9fc2c2, 0x6ebdd3d3, 0xef43acac, 0xa6c46262, - 0xa8399191, 0xa4319595, 0x37d3e4e4, 0x8bf27979, - 0x32d5e7e7, 0x438bc8c8, 0x596e3737, 0xb7da6d6d, - 0x8c018d8d, 0x64b1d5d5, 0xd29c4e4e, 0xe049a9a9, - 0xb4d86c6c, 0xfaac5656, 0x7f3f4f4, 0x25cfeaea, - 0xafca6565, 0x8ef47a7a, 0xe947aeae, 0x18100808, - 0xd56fbaba, 0x88f07878, 0x6f4a2525, 0x725c2e2e, - 0x24381c1c, 0xf157a6a6, 0xc773b4b4, 0x5197c6c6, - 0x23cbe8e8, 0x7ca1dddd, 0x9ce87474, 0x213e1f1f, - 0xdd964b4b, 0xdc61bdbd, 0x860d8b8b, 0x850f8a8a, - 0x90e07070, 0x427c3e3e, 0xc471b5b5, 0xaacc6666, - 0xd8904848, 0x5060303, 0x1f7f6f6, 0x121c0e0e, - 0xa3c26161, 0x5f6a3535, 0xf9ae5757, 0xd069b9b9, - 0x91178686, 0x5899c1c1, 0x273a1d1d, 0xb9279e9e, - 0x38d9e1e1, 0x13ebf8f8, 0xb32b9898, 0x33221111, - 0xbbd26969, 0x70a9d9d9, 0x89078e8e, 0xa7339494, - 0xb62d9b9b, 0x223c1e1e, 0x92158787, 0x20c9e9e9, - 0x4987cece, 0xffaa5555, 0x78502828, 0x7aa5dfdf, - 0x8f038c8c, 0xf859a1a1, 0x80098989, 0x171a0d0d, - 0xda65bfbf, 0x31d7e6e6, 0xc6844242, 0xb8d06868, - 0xc3824141, 0xb0299999, 0x775a2d2d, 0x111e0f0f, - 0xcb7bb0b0, 0xfca85454, 0xd66dbbbb, 0x3a2c1616) +T2 = ( + 0xA5C66363, + 0x84F87C7C, + 0x99EE7777, + 0x8DF67B7B, + 0xDFFF2F2, + 0xBDD66B6B, + 0xB1DE6F6F, + 0x5491C5C5, + 0x50603030, + 0x3020101, + 0xA9CE6767, + 0x7D562B2B, + 0x19E7FEFE, + 0x62B5D7D7, + 0xE64DABAB, + 0x9AEC7676, + 0x458FCACA, + 0x9D1F8282, + 0x4089C9C9, + 0x87FA7D7D, + 0x15EFFAFA, + 0xEBB25959, + 0xC98E4747, + 0xBFBF0F0, + 0xEC41ADAD, + 0x67B3D4D4, + 0xFD5FA2A2, + 0xEA45AFAF, + 0xBF239C9C, + 0xF753A4A4, + 0x96E47272, + 0x5B9BC0C0, + 0xC275B7B7, + 0x1CE1FDFD, + 0xAE3D9393, + 0x6A4C2626, + 0x5A6C3636, + 0x417E3F3F, + 0x2F5F7F7, + 0x4F83CCCC, + 0x5C683434, + 0xF451A5A5, + 0x34D1E5E5, + 0x8F9F1F1, + 0x93E27171, + 0x73ABD8D8, + 0x53623131, + 0x3F2A1515, + 0xC080404, + 0x5295C7C7, + 0x65462323, + 0x5E9DC3C3, + 0x28301818, + 0xA1379696, + 0xF0A0505, + 0xB52F9A9A, + 0x90E0707, + 0x36241212, + 0x9B1B8080, + 0x3DDFE2E2, + 0x26CDEBEB, + 0x694E2727, + 0xCD7FB2B2, + 0x9FEA7575, + 0x1B120909, + 0x9E1D8383, + 0x74582C2C, + 0x2E341A1A, + 0x2D361B1B, + 0xB2DC6E6E, + 0xEEB45A5A, + 0xFB5BA0A0, + 0xF6A45252, + 0x4D763B3B, + 0x61B7D6D6, + 0xCE7DB3B3, + 0x7B522929, + 0x3EDDE3E3, + 0x715E2F2F, + 0x97138484, + 0xF5A65353, + 0x68B9D1D1, + 0x0, + 0x2CC1EDED, + 0x60402020, + 0x1FE3FCFC, + 0xC879B1B1, + 0xEDB65B5B, + 0xBED46A6A, + 0x468DCBCB, + 0xD967BEBE, + 0x4B723939, + 0xDE944A4A, + 0xD4984C4C, + 0xE8B05858, + 0x4A85CFCF, + 0x6BBBD0D0, + 0x2AC5EFEF, + 0xE54FAAAA, + 0x16EDFBFB, + 0xC5864343, + 0xD79A4D4D, + 0x55663333, + 0x94118585, + 0xCF8A4545, + 0x10E9F9F9, + 0x6040202, + 0x81FE7F7F, + 0xF0A05050, + 0x44783C3C, + 0xBA259F9F, + 0xE34BA8A8, + 0xF3A25151, + 0xFE5DA3A3, + 0xC0804040, + 0x8A058F8F, + 0xAD3F9292, + 0xBC219D9D, + 0x48703838, + 0x4F1F5F5, + 0xDF63BCBC, + 0xC177B6B6, + 0x75AFDADA, + 0x63422121, + 0x30201010, + 0x1AE5FFFF, + 0xEFDF3F3, + 0x6DBFD2D2, + 0x4C81CDCD, + 0x14180C0C, + 0x35261313, + 0x2FC3ECEC, + 0xE1BE5F5F, + 0xA2359797, + 0xCC884444, + 0x392E1717, + 0x5793C4C4, + 0xF255A7A7, + 0x82FC7E7E, + 0x477A3D3D, + 0xACC86464, + 0xE7BA5D5D, + 0x2B321919, + 0x95E67373, + 0xA0C06060, + 0x98198181, + 0xD19E4F4F, + 0x7FA3DCDC, + 0x66442222, + 0x7E542A2A, + 0xAB3B9090, + 0x830B8888, + 0xCA8C4646, + 0x29C7EEEE, + 0xD36BB8B8, + 0x3C281414, + 0x79A7DEDE, + 0xE2BC5E5E, + 0x1D160B0B, + 0x76ADDBDB, + 0x3BDBE0E0, + 0x56643232, + 0x4E743A3A, + 0x1E140A0A, + 0xDB924949, + 0xA0C0606, + 0x6C482424, + 0xE4B85C5C, + 0x5D9FC2C2, + 0x6EBDD3D3, + 0xEF43ACAC, + 0xA6C46262, + 0xA8399191, + 0xA4319595, + 0x37D3E4E4, + 0x8BF27979, + 0x32D5E7E7, + 0x438BC8C8, + 0x596E3737, + 0xB7DA6D6D, + 0x8C018D8D, + 0x64B1D5D5, + 0xD29C4E4E, + 0xE049A9A9, + 0xB4D86C6C, + 0xFAAC5656, + 0x7F3F4F4, + 0x25CFEAEA, + 0xAFCA6565, + 0x8EF47A7A, + 0xE947AEAE, + 0x18100808, + 0xD56FBABA, + 0x88F07878, + 0x6F4A2525, + 0x725C2E2E, + 0x24381C1C, + 0xF157A6A6, + 0xC773B4B4, + 0x5197C6C6, + 0x23CBE8E8, + 0x7CA1DDDD, + 0x9CE87474, + 0x213E1F1F, + 0xDD964B4B, + 0xDC61BDBD, + 0x860D8B8B, + 0x850F8A8A, + 0x90E07070, + 0x427C3E3E, + 0xC471B5B5, + 0xAACC6666, + 0xD8904848, + 0x5060303, + 0x1F7F6F6, + 0x121C0E0E, + 0xA3C26161, + 0x5F6A3535, + 0xF9AE5757, + 0xD069B9B9, + 0x91178686, + 0x5899C1C1, + 0x273A1D1D, + 0xB9279E9E, + 0x38D9E1E1, + 0x13EBF8F8, + 0xB32B9898, + 0x33221111, + 0xBBD26969, + 0x70A9D9D9, + 0x89078E8E, + 0xA7339494, + 0xB62D9B9B, + 0x223C1E1E, + 0x92158787, + 0x20C9E9E9, + 0x4987CECE, + 0xFFAA5555, + 0x78502828, + 0x7AA5DFDF, + 0x8F038C8C, + 0xF859A1A1, + 0x80098989, + 0x171A0D0D, + 0xDA65BFBF, + 0x31D7E6E6, + 0xC6844242, + 0xB8D06868, + 0xC3824141, + 0xB0299999, + 0x775A2D2D, + 0x111E0F0F, + 0xCB7BB0B0, + 0xFCA85454, + 0xD66DBBBB, + 0x3A2C1616, +) -T3 = (0x63a5c663, 0x7c84f87c, 0x7799ee77, 0x7b8df67b, - 0xf20dfff2, 0x6bbdd66b, 0x6fb1de6f, 0xc55491c5, - 0x30506030, 0x1030201, 0x67a9ce67, 0x2b7d562b, - 0xfe19e7fe, 0xd762b5d7, 0xabe64dab, 0x769aec76, - 0xca458fca, 0x829d1f82, 0xc94089c9, 0x7d87fa7d, - 0xfa15effa, 0x59ebb259, 0x47c98e47, 0xf00bfbf0, - 0xadec41ad, 0xd467b3d4, 0xa2fd5fa2, 0xafea45af, - 0x9cbf239c, 0xa4f753a4, 0x7296e472, 0xc05b9bc0, - 0xb7c275b7, 0xfd1ce1fd, 0x93ae3d93, 0x266a4c26, - 0x365a6c36, 0x3f417e3f, 0xf702f5f7, 0xcc4f83cc, - 0x345c6834, 0xa5f451a5, 0xe534d1e5, 0xf108f9f1, - 0x7193e271, 0xd873abd8, 0x31536231, 0x153f2a15, - 0x40c0804, 0xc75295c7, 0x23654623, 0xc35e9dc3, - 0x18283018, 0x96a13796, 0x50f0a05, 0x9ab52f9a, - 0x7090e07, 0x12362412, 0x809b1b80, 0xe23ddfe2, - 0xeb26cdeb, 0x27694e27, 0xb2cd7fb2, 0x759fea75, - 0x91b1209, 0x839e1d83, 0x2c74582c, 0x1a2e341a, - 0x1b2d361b, 0x6eb2dc6e, 0x5aeeb45a, 0xa0fb5ba0, - 0x52f6a452, 0x3b4d763b, 0xd661b7d6, 0xb3ce7db3, - 0x297b5229, 0xe33edde3, 0x2f715e2f, 0x84971384, - 0x53f5a653, 0xd168b9d1, 0x0, 0xed2cc1ed, - 0x20604020, 0xfc1fe3fc, 0xb1c879b1, 0x5bedb65b, - 0x6abed46a, 0xcb468dcb, 0xbed967be, 0x394b7239, - 0x4ade944a, 0x4cd4984c, 0x58e8b058, 0xcf4a85cf, - 0xd06bbbd0, 0xef2ac5ef, 0xaae54faa, 0xfb16edfb, - 0x43c58643, 0x4dd79a4d, 0x33556633, 0x85941185, - 0x45cf8a45, 0xf910e9f9, 0x2060402, 0x7f81fe7f, - 0x50f0a050, 0x3c44783c, 0x9fba259f, 0xa8e34ba8, - 0x51f3a251, 0xa3fe5da3, 0x40c08040, 0x8f8a058f, - 0x92ad3f92, 0x9dbc219d, 0x38487038, 0xf504f1f5, - 0xbcdf63bc, 0xb6c177b6, 0xda75afda, 0x21634221, - 0x10302010, 0xff1ae5ff, 0xf30efdf3, 0xd26dbfd2, - 0xcd4c81cd, 0xc14180c, 0x13352613, 0xec2fc3ec, - 0x5fe1be5f, 0x97a23597, 0x44cc8844, 0x17392e17, - 0xc45793c4, 0xa7f255a7, 0x7e82fc7e, 0x3d477a3d, - 0x64acc864, 0x5de7ba5d, 0x192b3219, 0x7395e673, - 0x60a0c060, 0x81981981, 0x4fd19e4f, 0xdc7fa3dc, - 0x22664422, 0x2a7e542a, 0x90ab3b90, 0x88830b88, - 0x46ca8c46, 0xee29c7ee, 0xb8d36bb8, 0x143c2814, - 0xde79a7de, 0x5ee2bc5e, 0xb1d160b, 0xdb76addb, - 0xe03bdbe0, 0x32566432, 0x3a4e743a, 0xa1e140a, - 0x49db9249, 0x60a0c06, 0x246c4824, 0x5ce4b85c, - 0xc25d9fc2, 0xd36ebdd3, 0xacef43ac, 0x62a6c462, - 0x91a83991, 0x95a43195, 0xe437d3e4, 0x798bf279, - 0xe732d5e7, 0xc8438bc8, 0x37596e37, 0x6db7da6d, - 0x8d8c018d, 0xd564b1d5, 0x4ed29c4e, 0xa9e049a9, - 0x6cb4d86c, 0x56faac56, 0xf407f3f4, 0xea25cfea, - 0x65afca65, 0x7a8ef47a, 0xaee947ae, 0x8181008, - 0xbad56fba, 0x7888f078, 0x256f4a25, 0x2e725c2e, - 0x1c24381c, 0xa6f157a6, 0xb4c773b4, 0xc65197c6, - 0xe823cbe8, 0xdd7ca1dd, 0x749ce874, 0x1f213e1f, - 0x4bdd964b, 0xbddc61bd, 0x8b860d8b, 0x8a850f8a, - 0x7090e070, 0x3e427c3e, 0xb5c471b5, 0x66aacc66, - 0x48d89048, 0x3050603, 0xf601f7f6, 0xe121c0e, - 0x61a3c261, 0x355f6a35, 0x57f9ae57, 0xb9d069b9, - 0x86911786, 0xc15899c1, 0x1d273a1d, 0x9eb9279e, - 0xe138d9e1, 0xf813ebf8, 0x98b32b98, 0x11332211, - 0x69bbd269, 0xd970a9d9, 0x8e89078e, 0x94a73394, - 0x9bb62d9b, 0x1e223c1e, 0x87921587, 0xe920c9e9, - 0xce4987ce, 0x55ffaa55, 0x28785028, 0xdf7aa5df, - 0x8c8f038c, 0xa1f859a1, 0x89800989, 0xd171a0d, - 0xbfda65bf, 0xe631d7e6, 0x42c68442, 0x68b8d068, - 0x41c38241, 0x99b02999, 0x2d775a2d, 0xf111e0f, - 0xb0cb7bb0, 0x54fca854, 0xbbd66dbb, 0x163a2c16) +T3 = ( + 0x63A5C663, + 0x7C84F87C, + 0x7799EE77, + 0x7B8DF67B, + 0xF20DFFF2, + 0x6BBDD66B, + 0x6FB1DE6F, + 0xC55491C5, + 0x30506030, + 0x1030201, + 0x67A9CE67, + 0x2B7D562B, + 0xFE19E7FE, + 0xD762B5D7, + 0xABE64DAB, + 0x769AEC76, + 0xCA458FCA, + 0x829D1F82, + 0xC94089C9, + 0x7D87FA7D, + 0xFA15EFFA, + 0x59EBB259, + 0x47C98E47, + 0xF00BFBF0, + 0xADEC41AD, + 0xD467B3D4, + 0xA2FD5FA2, + 0xAFEA45AF, + 0x9CBF239C, + 0xA4F753A4, + 0x7296E472, + 0xC05B9BC0, + 0xB7C275B7, + 0xFD1CE1FD, + 0x93AE3D93, + 0x266A4C26, + 0x365A6C36, + 0x3F417E3F, + 0xF702F5F7, + 0xCC4F83CC, + 0x345C6834, + 0xA5F451A5, + 0xE534D1E5, + 0xF108F9F1, + 0x7193E271, + 0xD873ABD8, + 0x31536231, + 0x153F2A15, + 0x40C0804, + 0xC75295C7, + 0x23654623, + 0xC35E9DC3, + 0x18283018, + 0x96A13796, + 0x50F0A05, + 0x9AB52F9A, + 0x7090E07, + 0x12362412, + 0x809B1B80, + 0xE23DDFE2, + 0xEB26CDEB, + 0x27694E27, + 0xB2CD7FB2, + 0x759FEA75, + 0x91B1209, + 0x839E1D83, + 0x2C74582C, + 0x1A2E341A, + 0x1B2D361B, + 0x6EB2DC6E, + 0x5AEEB45A, + 0xA0FB5BA0, + 0x52F6A452, + 0x3B4D763B, + 0xD661B7D6, + 0xB3CE7DB3, + 0x297B5229, + 0xE33EDDE3, + 0x2F715E2F, + 0x84971384, + 0x53F5A653, + 0xD168B9D1, + 0x0, + 0xED2CC1ED, + 0x20604020, + 0xFC1FE3FC, + 0xB1C879B1, + 0x5BEDB65B, + 0x6ABED46A, + 0xCB468DCB, + 0xBED967BE, + 0x394B7239, + 0x4ADE944A, + 0x4CD4984C, + 0x58E8B058, + 0xCF4A85CF, + 0xD06BBBD0, + 0xEF2AC5EF, + 0xAAE54FAA, + 0xFB16EDFB, + 0x43C58643, + 0x4DD79A4D, + 0x33556633, + 0x85941185, + 0x45CF8A45, + 0xF910E9F9, + 0x2060402, + 0x7F81FE7F, + 0x50F0A050, + 0x3C44783C, + 0x9FBA259F, + 0xA8E34BA8, + 0x51F3A251, + 0xA3FE5DA3, + 0x40C08040, + 0x8F8A058F, + 0x92AD3F92, + 0x9DBC219D, + 0x38487038, + 0xF504F1F5, + 0xBCDF63BC, + 0xB6C177B6, + 0xDA75AFDA, + 0x21634221, + 0x10302010, + 0xFF1AE5FF, + 0xF30EFDF3, + 0xD26DBFD2, + 0xCD4C81CD, + 0xC14180C, + 0x13352613, + 0xEC2FC3EC, + 0x5FE1BE5F, + 0x97A23597, + 0x44CC8844, + 0x17392E17, + 0xC45793C4, + 0xA7F255A7, + 0x7E82FC7E, + 0x3D477A3D, + 0x64ACC864, + 0x5DE7BA5D, + 0x192B3219, + 0x7395E673, + 0x60A0C060, + 0x81981981, + 0x4FD19E4F, + 0xDC7FA3DC, + 0x22664422, + 0x2A7E542A, + 0x90AB3B90, + 0x88830B88, + 0x46CA8C46, + 0xEE29C7EE, + 0xB8D36BB8, + 0x143C2814, + 0xDE79A7DE, + 0x5EE2BC5E, + 0xB1D160B, + 0xDB76ADDB, + 0xE03BDBE0, + 0x32566432, + 0x3A4E743A, + 0xA1E140A, + 0x49DB9249, + 0x60A0C06, + 0x246C4824, + 0x5CE4B85C, + 0xC25D9FC2, + 0xD36EBDD3, + 0xACEF43AC, + 0x62A6C462, + 0x91A83991, + 0x95A43195, + 0xE437D3E4, + 0x798BF279, + 0xE732D5E7, + 0xC8438BC8, + 0x37596E37, + 0x6DB7DA6D, + 0x8D8C018D, + 0xD564B1D5, + 0x4ED29C4E, + 0xA9E049A9, + 0x6CB4D86C, + 0x56FAAC56, + 0xF407F3F4, + 0xEA25CFEA, + 0x65AFCA65, + 0x7A8EF47A, + 0xAEE947AE, + 0x8181008, + 0xBAD56FBA, + 0x7888F078, + 0x256F4A25, + 0x2E725C2E, + 0x1C24381C, + 0xA6F157A6, + 0xB4C773B4, + 0xC65197C6, + 0xE823CBE8, + 0xDD7CA1DD, + 0x749CE874, + 0x1F213E1F, + 0x4BDD964B, + 0xBDDC61BD, + 0x8B860D8B, + 0x8A850F8A, + 0x7090E070, + 0x3E427C3E, + 0xB5C471B5, + 0x66AACC66, + 0x48D89048, + 0x3050603, + 0xF601F7F6, + 0xE121C0E, + 0x61A3C261, + 0x355F6A35, + 0x57F9AE57, + 0xB9D069B9, + 0x86911786, + 0xC15899C1, + 0x1D273A1D, + 0x9EB9279E, + 0xE138D9E1, + 0xF813EBF8, + 0x98B32B98, + 0x11332211, + 0x69BBD269, + 0xD970A9D9, + 0x8E89078E, + 0x94A73394, + 0x9BB62D9B, + 0x1E223C1E, + 0x87921587, + 0xE920C9E9, + 0xCE4987CE, + 0x55FFAA55, + 0x28785028, + 0xDF7AA5DF, + 0x8C8F038C, + 0xA1F859A1, + 0x89800989, + 0xD171A0D, + 0xBFDA65BF, + 0xE631D7E6, + 0x42C68442, + 0x68B8D068, + 0x41C38241, + 0x99B02999, + 0x2D775A2D, + 0xF111E0F, + 0xB0CB7BB0, + 0x54FCA854, + 0xBBD66DBB, + 0x163A2C16, +) -T4 = (0x6363a5c6, 0x7c7c84f8, 0x777799ee, 0x7b7b8df6, - 0xf2f20dff, 0x6b6bbdd6, 0x6f6fb1de, 0xc5c55491, - 0x30305060, 0x1010302, 0x6767a9ce, 0x2b2b7d56, - 0xfefe19e7, 0xd7d762b5, 0xababe64d, 0x76769aec, - 0xcaca458f, 0x82829d1f, 0xc9c94089, 0x7d7d87fa, - 0xfafa15ef, 0x5959ebb2, 0x4747c98e, 0xf0f00bfb, - 0xadadec41, 0xd4d467b3, 0xa2a2fd5f, 0xafafea45, - 0x9c9cbf23, 0xa4a4f753, 0x727296e4, 0xc0c05b9b, - 0xb7b7c275, 0xfdfd1ce1, 0x9393ae3d, 0x26266a4c, - 0x36365a6c, 0x3f3f417e, 0xf7f702f5, 0xcccc4f83, - 0x34345c68, 0xa5a5f451, 0xe5e534d1, 0xf1f108f9, - 0x717193e2, 0xd8d873ab, 0x31315362, 0x15153f2a, - 0x4040c08, 0xc7c75295, 0x23236546, 0xc3c35e9d, - 0x18182830, 0x9696a137, 0x5050f0a, 0x9a9ab52f, - 0x707090e, 0x12123624, 0x80809b1b, 0xe2e23ddf, - 0xebeb26cd, 0x2727694e, 0xb2b2cd7f, 0x75759fea, - 0x9091b12, 0x83839e1d, 0x2c2c7458, 0x1a1a2e34, - 0x1b1b2d36, 0x6e6eb2dc, 0x5a5aeeb4, 0xa0a0fb5b, - 0x5252f6a4, 0x3b3b4d76, 0xd6d661b7, 0xb3b3ce7d, - 0x29297b52, 0xe3e33edd, 0x2f2f715e, 0x84849713, - 0x5353f5a6, 0xd1d168b9, 0x0, 0xeded2cc1, - 0x20206040, 0xfcfc1fe3, 0xb1b1c879, 0x5b5bedb6, - 0x6a6abed4, 0xcbcb468d, 0xbebed967, 0x39394b72, - 0x4a4ade94, 0x4c4cd498, 0x5858e8b0, 0xcfcf4a85, - 0xd0d06bbb, 0xefef2ac5, 0xaaaae54f, 0xfbfb16ed, - 0x4343c586, 0x4d4dd79a, 0x33335566, 0x85859411, - 0x4545cf8a, 0xf9f910e9, 0x2020604, 0x7f7f81fe, - 0x5050f0a0, 0x3c3c4478, 0x9f9fba25, 0xa8a8e34b, - 0x5151f3a2, 0xa3a3fe5d, 0x4040c080, 0x8f8f8a05, - 0x9292ad3f, 0x9d9dbc21, 0x38384870, 0xf5f504f1, - 0xbcbcdf63, 0xb6b6c177, 0xdada75af, 0x21216342, - 0x10103020, 0xffff1ae5, 0xf3f30efd, 0xd2d26dbf, - 0xcdcd4c81, 0xc0c1418, 0x13133526, 0xecec2fc3, - 0x5f5fe1be, 0x9797a235, 0x4444cc88, 0x1717392e, - 0xc4c45793, 0xa7a7f255, 0x7e7e82fc, 0x3d3d477a, - 0x6464acc8, 0x5d5de7ba, 0x19192b32, 0x737395e6, - 0x6060a0c0, 0x81819819, 0x4f4fd19e, 0xdcdc7fa3, - 0x22226644, 0x2a2a7e54, 0x9090ab3b, 0x8888830b, - 0x4646ca8c, 0xeeee29c7, 0xb8b8d36b, 0x14143c28, - 0xdede79a7, 0x5e5ee2bc, 0xb0b1d16, 0xdbdb76ad, - 0xe0e03bdb, 0x32325664, 0x3a3a4e74, 0xa0a1e14, - 0x4949db92, 0x6060a0c, 0x24246c48, 0x5c5ce4b8, - 0xc2c25d9f, 0xd3d36ebd, 0xacacef43, 0x6262a6c4, - 0x9191a839, 0x9595a431, 0xe4e437d3, 0x79798bf2, - 0xe7e732d5, 0xc8c8438b, 0x3737596e, 0x6d6db7da, - 0x8d8d8c01, 0xd5d564b1, 0x4e4ed29c, 0xa9a9e049, - 0x6c6cb4d8, 0x5656faac, 0xf4f407f3, 0xeaea25cf, - 0x6565afca, 0x7a7a8ef4, 0xaeaee947, 0x8081810, - 0xbabad56f, 0x787888f0, 0x25256f4a, 0x2e2e725c, - 0x1c1c2438, 0xa6a6f157, 0xb4b4c773, 0xc6c65197, - 0xe8e823cb, 0xdddd7ca1, 0x74749ce8, 0x1f1f213e, - 0x4b4bdd96, 0xbdbddc61, 0x8b8b860d, 0x8a8a850f, - 0x707090e0, 0x3e3e427c, 0xb5b5c471, 0x6666aacc, - 0x4848d890, 0x3030506, 0xf6f601f7, 0xe0e121c, - 0x6161a3c2, 0x35355f6a, 0x5757f9ae, 0xb9b9d069, - 0x86869117, 0xc1c15899, 0x1d1d273a, 0x9e9eb927, - 0xe1e138d9, 0xf8f813eb, 0x9898b32b, 0x11113322, - 0x6969bbd2, 0xd9d970a9, 0x8e8e8907, 0x9494a733, - 0x9b9bb62d, 0x1e1e223c, 0x87879215, 0xe9e920c9, - 0xcece4987, 0x5555ffaa, 0x28287850, 0xdfdf7aa5, - 0x8c8c8f03, 0xa1a1f859, 0x89898009, 0xd0d171a, - 0xbfbfda65, 0xe6e631d7, 0x4242c684, 0x6868b8d0, - 0x4141c382, 0x9999b029, 0x2d2d775a, 0xf0f111e, - 0xb0b0cb7b, 0x5454fca8, 0xbbbbd66d, 0x16163a2c) +T4 = ( + 0x6363A5C6, + 0x7C7C84F8, + 0x777799EE, + 0x7B7B8DF6, + 0xF2F20DFF, + 0x6B6BBDD6, + 0x6F6FB1DE, + 0xC5C55491, + 0x30305060, + 0x1010302, + 0x6767A9CE, + 0x2B2B7D56, + 0xFEFE19E7, + 0xD7D762B5, + 0xABABE64D, + 0x76769AEC, + 0xCACA458F, + 0x82829D1F, + 0xC9C94089, + 0x7D7D87FA, + 0xFAFA15EF, + 0x5959EBB2, + 0x4747C98E, + 0xF0F00BFB, + 0xADADEC41, + 0xD4D467B3, + 0xA2A2FD5F, + 0xAFAFEA45, + 0x9C9CBF23, + 0xA4A4F753, + 0x727296E4, + 0xC0C05B9B, + 0xB7B7C275, + 0xFDFD1CE1, + 0x9393AE3D, + 0x26266A4C, + 0x36365A6C, + 0x3F3F417E, + 0xF7F702F5, + 0xCCCC4F83, + 0x34345C68, + 0xA5A5F451, + 0xE5E534D1, + 0xF1F108F9, + 0x717193E2, + 0xD8D873AB, + 0x31315362, + 0x15153F2A, + 0x4040C08, + 0xC7C75295, + 0x23236546, + 0xC3C35E9D, + 0x18182830, + 0x9696A137, + 0x5050F0A, + 0x9A9AB52F, + 0x707090E, + 0x12123624, + 0x80809B1B, + 0xE2E23DDF, + 0xEBEB26CD, + 0x2727694E, + 0xB2B2CD7F, + 0x75759FEA, + 0x9091B12, + 0x83839E1D, + 0x2C2C7458, + 0x1A1A2E34, + 0x1B1B2D36, + 0x6E6EB2DC, + 0x5A5AEEB4, + 0xA0A0FB5B, + 0x5252F6A4, + 0x3B3B4D76, + 0xD6D661B7, + 0xB3B3CE7D, + 0x29297B52, + 0xE3E33EDD, + 0x2F2F715E, + 0x84849713, + 0x5353F5A6, + 0xD1D168B9, + 0x0, + 0xEDED2CC1, + 0x20206040, + 0xFCFC1FE3, + 0xB1B1C879, + 0x5B5BEDB6, + 0x6A6ABED4, + 0xCBCB468D, + 0xBEBED967, + 0x39394B72, + 0x4A4ADE94, + 0x4C4CD498, + 0x5858E8B0, + 0xCFCF4A85, + 0xD0D06BBB, + 0xEFEF2AC5, + 0xAAAAE54F, + 0xFBFB16ED, + 0x4343C586, + 0x4D4DD79A, + 0x33335566, + 0x85859411, + 0x4545CF8A, + 0xF9F910E9, + 0x2020604, + 0x7F7F81FE, + 0x5050F0A0, + 0x3C3C4478, + 0x9F9FBA25, + 0xA8A8E34B, + 0x5151F3A2, + 0xA3A3FE5D, + 0x4040C080, + 0x8F8F8A05, + 0x9292AD3F, + 0x9D9DBC21, + 0x38384870, + 0xF5F504F1, + 0xBCBCDF63, + 0xB6B6C177, + 0xDADA75AF, + 0x21216342, + 0x10103020, + 0xFFFF1AE5, + 0xF3F30EFD, + 0xD2D26DBF, + 0xCDCD4C81, + 0xC0C1418, + 0x13133526, + 0xECEC2FC3, + 0x5F5FE1BE, + 0x9797A235, + 0x4444CC88, + 0x1717392E, + 0xC4C45793, + 0xA7A7F255, + 0x7E7E82FC, + 0x3D3D477A, + 0x6464ACC8, + 0x5D5DE7BA, + 0x19192B32, + 0x737395E6, + 0x6060A0C0, + 0x81819819, + 0x4F4FD19E, + 0xDCDC7FA3, + 0x22226644, + 0x2A2A7E54, + 0x9090AB3B, + 0x8888830B, + 0x4646CA8C, + 0xEEEE29C7, + 0xB8B8D36B, + 0x14143C28, + 0xDEDE79A7, + 0x5E5EE2BC, + 0xB0B1D16, + 0xDBDB76AD, + 0xE0E03BDB, + 0x32325664, + 0x3A3A4E74, + 0xA0A1E14, + 0x4949DB92, + 0x6060A0C, + 0x24246C48, + 0x5C5CE4B8, + 0xC2C25D9F, + 0xD3D36EBD, + 0xACACEF43, + 0x6262A6C4, + 0x9191A839, + 0x9595A431, + 0xE4E437D3, + 0x79798BF2, + 0xE7E732D5, + 0xC8C8438B, + 0x3737596E, + 0x6D6DB7DA, + 0x8D8D8C01, + 0xD5D564B1, + 0x4E4ED29C, + 0xA9A9E049, + 0x6C6CB4D8, + 0x5656FAAC, + 0xF4F407F3, + 0xEAEA25CF, + 0x6565AFCA, + 0x7A7A8EF4, + 0xAEAEE947, + 0x8081810, + 0xBABAD56F, + 0x787888F0, + 0x25256F4A, + 0x2E2E725C, + 0x1C1C2438, + 0xA6A6F157, + 0xB4B4C773, + 0xC6C65197, + 0xE8E823CB, + 0xDDDD7CA1, + 0x74749CE8, + 0x1F1F213E, + 0x4B4BDD96, + 0xBDBDDC61, + 0x8B8B860D, + 0x8A8A850F, + 0x707090E0, + 0x3E3E427C, + 0xB5B5C471, + 0x6666AACC, + 0x4848D890, + 0x3030506, + 0xF6F601F7, + 0xE0E121C, + 0x6161A3C2, + 0x35355F6A, + 0x5757F9AE, + 0xB9B9D069, + 0x86869117, + 0xC1C15899, + 0x1D1D273A, + 0x9E9EB927, + 0xE1E138D9, + 0xF8F813EB, + 0x9898B32B, + 0x11113322, + 0x6969BBD2, + 0xD9D970A9, + 0x8E8E8907, + 0x9494A733, + 0x9B9BB62D, + 0x1E1E223C, + 0x87879215, + 0xE9E920C9, + 0xCECE4987, + 0x5555FFAA, + 0x28287850, + 0xDFDF7AA5, + 0x8C8C8F03, + 0xA1A1F859, + 0x89898009, + 0xD0D171A, + 0xBFBFDA65, + 0xE6E631D7, + 0x4242C684, + 0x6868B8D0, + 0x4141C382, + 0x9999B029, + 0x2D2D775A, + 0xF0F111E, + 0xB0B0CB7B, + 0x5454FCA8, + 0xBBBBD66D, + 0x16163A2C, +) -T5 = (0x51f4a750, 0x7e416553, 0x1a17a4c3, 0x3a275e96, - 0x3bab6bcb, 0x1f9d45f1, 0xacfa58ab, 0x4be30393, - 0x2030fa55, 0xad766df6, 0x88cc7691, 0xf5024c25, - 0x4fe5d7fc, 0xc52acbd7, 0x26354480, 0xb562a38f, - 0xdeb15a49, 0x25ba1b67, 0x45ea0e98, 0x5dfec0e1, - 0xc32f7502, 0x814cf012, 0x8d4697a3, 0x6bd3f9c6, - 0x38f5fe7, 0x15929c95, 0xbf6d7aeb, 0x955259da, - 0xd4be832d, 0x587421d3, 0x49e06929, 0x8ec9c844, - 0x75c2896a, 0xf48e7978, 0x99583e6b, 0x27b971dd, - 0xbee14fb6, 0xf088ad17, 0xc920ac66, 0x7dce3ab4, - 0x63df4a18, 0xe51a3182, 0x97513360, 0x62537f45, - 0xb16477e0, 0xbb6bae84, 0xfe81a01c, 0xf9082b94, - 0x70486858, 0x8f45fd19, 0x94de6c87, 0x527bf8b7, - 0xab73d323, 0x724b02e2, 0xe31f8f57, 0x6655ab2a, - 0xb2eb2807, 0x2fb5c203, 0x86c57b9a, 0xd33708a5, - 0x302887f2, 0x23bfa5b2, 0x2036aba, 0xed16825c, - 0x8acf1c2b, 0xa779b492, 0xf307f2f0, 0x4e69e2a1, - 0x65daf4cd, 0x605bed5, 0xd134621f, 0xc4a6fe8a, - 0x342e539d, 0xa2f355a0, 0x58ae132, 0xa4f6eb75, - 0xb83ec39, 0x4060efaa, 0x5e719f06, 0xbd6e1051, - 0x3e218af9, 0x96dd063d, 0xdd3e05ae, 0x4de6bd46, - 0x91548db5, 0x71c45d05, 0x406d46f, 0x605015ff, - 0x1998fb24, 0xd6bde997, 0x894043cc, 0x67d99e77, - 0xb0e842bd, 0x7898b88, 0xe7195b38, 0x79c8eedb, - 0xa17c0a47, 0x7c420fe9, 0xf8841ec9, 0x0, - 0x9808683, 0x322bed48, 0x1e1170ac, 0x6c5a724e, - 0xfd0efffb, 0xf853856, 0x3daed51e, 0x362d3927, - 0xa0fd964, 0x685ca621, 0x9b5b54d1, 0x24362e3a, - 0xc0a67b1, 0x9357e70f, 0xb4ee96d2, 0x1b9b919e, - 0x80c0c54f, 0x61dc20a2, 0x5a774b69, 0x1c121a16, - 0xe293ba0a, 0xc0a02ae5, 0x3c22e043, 0x121b171d, - 0xe090d0b, 0xf28bc7ad, 0x2db6a8b9, 0x141ea9c8, - 0x57f11985, 0xaf75074c, 0xee99ddbb, 0xa37f60fd, - 0xf701269f, 0x5c72f5bc, 0x44663bc5, 0x5bfb7e34, - 0x8b432976, 0xcb23c6dc, 0xb6edfc68, 0xb8e4f163, - 0xd731dcca, 0x42638510, 0x13972240, 0x84c61120, - 0x854a247d, 0xd2bb3df8, 0xaef93211, 0xc729a16d, - 0x1d9e2f4b, 0xdcb230f3, 0xd8652ec, 0x77c1e3d0, - 0x2bb3166c, 0xa970b999, 0x119448fa, 0x47e96422, - 0xa8fc8cc4, 0xa0f03f1a, 0x567d2cd8, 0x223390ef, - 0x87494ec7, 0xd938d1c1, 0x8ccaa2fe, 0x98d40b36, - 0xa6f581cf, 0xa57ade28, 0xdab78e26, 0x3fadbfa4, - 0x2c3a9de4, 0x5078920d, 0x6a5fcc9b, 0x547e4662, - 0xf68d13c2, 0x90d8b8e8, 0x2e39f75e, 0x82c3aff5, - 0x9f5d80be, 0x69d0937c, 0x6fd52da9, 0xcf2512b3, - 0xc8ac993b, 0x10187da7, 0xe89c636e, 0xdb3bbb7b, - 0xcd267809, 0x6e5918f4, 0xec9ab701, 0x834f9aa8, - 0xe6956e65, 0xaaffe67e, 0x21bccf08, 0xef15e8e6, - 0xbae79bd9, 0x4a6f36ce, 0xea9f09d4, 0x29b07cd6, - 0x31a4b2af, 0x2a3f2331, 0xc6a59430, 0x35a266c0, - 0x744ebc37, 0xfc82caa6, 0xe090d0b0, 0x33a7d815, - 0xf104984a, 0x41ecdaf7, 0x7fcd500e, 0x1791f62f, - 0x764dd68d, 0x43efb04d, 0xccaa4d54, 0xe49604df, - 0x9ed1b5e3, 0x4c6a881b, 0xc12c1fb8, 0x4665517f, - 0x9d5eea04, 0x18c355d, 0xfa877473, 0xfb0b412e, - 0xb3671d5a, 0x92dbd252, 0xe9105633, 0x6dd64713, - 0x9ad7618c, 0x37a10c7a, 0x59f8148e, 0xeb133c89, - 0xcea927ee, 0xb761c935, 0xe11ce5ed, 0x7a47b13c, - 0x9cd2df59, 0x55f2733f, 0x1814ce79, 0x73c737bf, - 0x53f7cdea, 0x5ffdaa5b, 0xdf3d6f14, 0x7844db86, - 0xcaaff381, 0xb968c43e, 0x3824342c, 0xc2a3405f, - 0x161dc372, 0xbce2250c, 0x283c498b, 0xff0d9541, - 0x39a80171, 0x80cb3de, 0xd8b4e49c, 0x6456c190, - 0x7bcb8461, 0xd532b670, 0x486c5c74, 0xd0b85742) +T5 = ( + 0x51F4A750, + 0x7E416553, + 0x1A17A4C3, + 0x3A275E96, + 0x3BAB6BCB, + 0x1F9D45F1, + 0xACFA58AB, + 0x4BE30393, + 0x2030FA55, + 0xAD766DF6, + 0x88CC7691, + 0xF5024C25, + 0x4FE5D7FC, + 0xC52ACBD7, + 0x26354480, + 0xB562A38F, + 0xDEB15A49, + 0x25BA1B67, + 0x45EA0E98, + 0x5DFEC0E1, + 0xC32F7502, + 0x814CF012, + 0x8D4697A3, + 0x6BD3F9C6, + 0x38F5FE7, + 0x15929C95, + 0xBF6D7AEB, + 0x955259DA, + 0xD4BE832D, + 0x587421D3, + 0x49E06929, + 0x8EC9C844, + 0x75C2896A, + 0xF48E7978, + 0x99583E6B, + 0x27B971DD, + 0xBEE14FB6, + 0xF088AD17, + 0xC920AC66, + 0x7DCE3AB4, + 0x63DF4A18, + 0xE51A3182, + 0x97513360, + 0x62537F45, + 0xB16477E0, + 0xBB6BAE84, + 0xFE81A01C, + 0xF9082B94, + 0x70486858, + 0x8F45FD19, + 0x94DE6C87, + 0x527BF8B7, + 0xAB73D323, + 0x724B02E2, + 0xE31F8F57, + 0x6655AB2A, + 0xB2EB2807, + 0x2FB5C203, + 0x86C57B9A, + 0xD33708A5, + 0x302887F2, + 0x23BFA5B2, + 0x2036ABA, + 0xED16825C, + 0x8ACF1C2B, + 0xA779B492, + 0xF307F2F0, + 0x4E69E2A1, + 0x65DAF4CD, + 0x605BED5, + 0xD134621F, + 0xC4A6FE8A, + 0x342E539D, + 0xA2F355A0, + 0x58AE132, + 0xA4F6EB75, + 0xB83EC39, + 0x4060EFAA, + 0x5E719F06, + 0xBD6E1051, + 0x3E218AF9, + 0x96DD063D, + 0xDD3E05AE, + 0x4DE6BD46, + 0x91548DB5, + 0x71C45D05, + 0x406D46F, + 0x605015FF, + 0x1998FB24, + 0xD6BDE997, + 0x894043CC, + 0x67D99E77, + 0xB0E842BD, + 0x7898B88, + 0xE7195B38, + 0x79C8EEDB, + 0xA17C0A47, + 0x7C420FE9, + 0xF8841EC9, + 0x0, + 0x9808683, + 0x322BED48, + 0x1E1170AC, + 0x6C5A724E, + 0xFD0EFFFB, + 0xF853856, + 0x3DAED51E, + 0x362D3927, + 0xA0FD964, + 0x685CA621, + 0x9B5B54D1, + 0x24362E3A, + 0xC0A67B1, + 0x9357E70F, + 0xB4EE96D2, + 0x1B9B919E, + 0x80C0C54F, + 0x61DC20A2, + 0x5A774B69, + 0x1C121A16, + 0xE293BA0A, + 0xC0A02AE5, + 0x3C22E043, + 0x121B171D, + 0xE090D0B, + 0xF28BC7AD, + 0x2DB6A8B9, + 0x141EA9C8, + 0x57F11985, + 0xAF75074C, + 0xEE99DDBB, + 0xA37F60FD, + 0xF701269F, + 0x5C72F5BC, + 0x44663BC5, + 0x5BFB7E34, + 0x8B432976, + 0xCB23C6DC, + 0xB6EDFC68, + 0xB8E4F163, + 0xD731DCCA, + 0x42638510, + 0x13972240, + 0x84C61120, + 0x854A247D, + 0xD2BB3DF8, + 0xAEF93211, + 0xC729A16D, + 0x1D9E2F4B, + 0xDCB230F3, + 0xD8652EC, + 0x77C1E3D0, + 0x2BB3166C, + 0xA970B999, + 0x119448FA, + 0x47E96422, + 0xA8FC8CC4, + 0xA0F03F1A, + 0x567D2CD8, + 0x223390EF, + 0x87494EC7, + 0xD938D1C1, + 0x8CCAA2FE, + 0x98D40B36, + 0xA6F581CF, + 0xA57ADE28, + 0xDAB78E26, + 0x3FADBFA4, + 0x2C3A9DE4, + 0x5078920D, + 0x6A5FCC9B, + 0x547E4662, + 0xF68D13C2, + 0x90D8B8E8, + 0x2E39F75E, + 0x82C3AFF5, + 0x9F5D80BE, + 0x69D0937C, + 0x6FD52DA9, + 0xCF2512B3, + 0xC8AC993B, + 0x10187DA7, + 0xE89C636E, + 0xDB3BBB7B, + 0xCD267809, + 0x6E5918F4, + 0xEC9AB701, + 0x834F9AA8, + 0xE6956E65, + 0xAAFFE67E, + 0x21BCCF08, + 0xEF15E8E6, + 0xBAE79BD9, + 0x4A6F36CE, + 0xEA9F09D4, + 0x29B07CD6, + 0x31A4B2AF, + 0x2A3F2331, + 0xC6A59430, + 0x35A266C0, + 0x744EBC37, + 0xFC82CAA6, + 0xE090D0B0, + 0x33A7D815, + 0xF104984A, + 0x41ECDAF7, + 0x7FCD500E, + 0x1791F62F, + 0x764DD68D, + 0x43EFB04D, + 0xCCAA4D54, + 0xE49604DF, + 0x9ED1B5E3, + 0x4C6A881B, + 0xC12C1FB8, + 0x4665517F, + 0x9D5EEA04, + 0x18C355D, + 0xFA877473, + 0xFB0B412E, + 0xB3671D5A, + 0x92DBD252, + 0xE9105633, + 0x6DD64713, + 0x9AD7618C, + 0x37A10C7A, + 0x59F8148E, + 0xEB133C89, + 0xCEA927EE, + 0xB761C935, + 0xE11CE5ED, + 0x7A47B13C, + 0x9CD2DF59, + 0x55F2733F, + 0x1814CE79, + 0x73C737BF, + 0x53F7CDEA, + 0x5FFDAA5B, + 0xDF3D6F14, + 0x7844DB86, + 0xCAAFF381, + 0xB968C43E, + 0x3824342C, + 0xC2A3405F, + 0x161DC372, + 0xBCE2250C, + 0x283C498B, + 0xFF0D9541, + 0x39A80171, + 0x80CB3DE, + 0xD8B4E49C, + 0x6456C190, + 0x7BCB8461, + 0xD532B670, + 0x486C5C74, + 0xD0B85742, +) -T6 = (0x5051f4a7, 0x537e4165, 0xc31a17a4, 0x963a275e, - 0xcb3bab6b, 0xf11f9d45, 0xabacfa58, 0x934be303, - 0x552030fa, 0xf6ad766d, 0x9188cc76, 0x25f5024c, - 0xfc4fe5d7, 0xd7c52acb, 0x80263544, 0x8fb562a3, - 0x49deb15a, 0x6725ba1b, 0x9845ea0e, 0xe15dfec0, - 0x2c32f75, 0x12814cf0, 0xa38d4697, 0xc66bd3f9, - 0xe7038f5f, 0x9515929c, 0xebbf6d7a, 0xda955259, - 0x2dd4be83, 0xd3587421, 0x2949e069, 0x448ec9c8, - 0x6a75c289, 0x78f48e79, 0x6b99583e, 0xdd27b971, - 0xb6bee14f, 0x17f088ad, 0x66c920ac, 0xb47dce3a, - 0x1863df4a, 0x82e51a31, 0x60975133, 0x4562537f, - 0xe0b16477, 0x84bb6bae, 0x1cfe81a0, 0x94f9082b, - 0x58704868, 0x198f45fd, 0x8794de6c, 0xb7527bf8, - 0x23ab73d3, 0xe2724b02, 0x57e31f8f, 0x2a6655ab, - 0x7b2eb28, 0x32fb5c2, 0x9a86c57b, 0xa5d33708, - 0xf2302887, 0xb223bfa5, 0xba02036a, 0x5ced1682, - 0x2b8acf1c, 0x92a779b4, 0xf0f307f2, 0xa14e69e2, - 0xcd65daf4, 0xd50605be, 0x1fd13462, 0x8ac4a6fe, - 0x9d342e53, 0xa0a2f355, 0x32058ae1, 0x75a4f6eb, - 0x390b83ec, 0xaa4060ef, 0x65e719f, 0x51bd6e10, - 0xf93e218a, 0x3d96dd06, 0xaedd3e05, 0x464de6bd, - 0xb591548d, 0x571c45d, 0x6f0406d4, 0xff605015, - 0x241998fb, 0x97d6bde9, 0xcc894043, 0x7767d99e, - 0xbdb0e842, 0x8807898b, 0x38e7195b, 0xdb79c8ee, - 0x47a17c0a, 0xe97c420f, 0xc9f8841e, 0x0, - 0x83098086, 0x48322bed, 0xac1e1170, 0x4e6c5a72, - 0xfbfd0eff, 0x560f8538, 0x1e3daed5, 0x27362d39, - 0x640a0fd9, 0x21685ca6, 0xd19b5b54, 0x3a24362e, - 0xb10c0a67, 0xf9357e7, 0xd2b4ee96, 0x9e1b9b91, - 0x4f80c0c5, 0xa261dc20, 0x695a774b, 0x161c121a, - 0xae293ba, 0xe5c0a02a, 0x433c22e0, 0x1d121b17, - 0xb0e090d, 0xadf28bc7, 0xb92db6a8, 0xc8141ea9, - 0x8557f119, 0x4caf7507, 0xbbee99dd, 0xfda37f60, - 0x9ff70126, 0xbc5c72f5, 0xc544663b, 0x345bfb7e, - 0x768b4329, 0xdccb23c6, 0x68b6edfc, 0x63b8e4f1, - 0xcad731dc, 0x10426385, 0x40139722, 0x2084c611, - 0x7d854a24, 0xf8d2bb3d, 0x11aef932, 0x6dc729a1, - 0x4b1d9e2f, 0xf3dcb230, 0xec0d8652, 0xd077c1e3, - 0x6c2bb316, 0x99a970b9, 0xfa119448, 0x2247e964, - 0xc4a8fc8c, 0x1aa0f03f, 0xd8567d2c, 0xef223390, - 0xc787494e, 0xc1d938d1, 0xfe8ccaa2, 0x3698d40b, - 0xcfa6f581, 0x28a57ade, 0x26dab78e, 0xa43fadbf, - 0xe42c3a9d, 0xd507892, 0x9b6a5fcc, 0x62547e46, - 0xc2f68d13, 0xe890d8b8, 0x5e2e39f7, 0xf582c3af, - 0xbe9f5d80, 0x7c69d093, 0xa96fd52d, 0xb3cf2512, - 0x3bc8ac99, 0xa710187d, 0x6ee89c63, 0x7bdb3bbb, - 0x9cd2678, 0xf46e5918, 0x1ec9ab7, 0xa8834f9a, - 0x65e6956e, 0x7eaaffe6, 0x821bccf, 0xe6ef15e8, - 0xd9bae79b, 0xce4a6f36, 0xd4ea9f09, 0xd629b07c, - 0xaf31a4b2, 0x312a3f23, 0x30c6a594, 0xc035a266, - 0x37744ebc, 0xa6fc82ca, 0xb0e090d0, 0x1533a7d8, - 0x4af10498, 0xf741ecda, 0xe7fcd50, 0x2f1791f6, - 0x8d764dd6, 0x4d43efb0, 0x54ccaa4d, 0xdfe49604, - 0xe39ed1b5, 0x1b4c6a88, 0xb8c12c1f, 0x7f466551, - 0x49d5eea, 0x5d018c35, 0x73fa8774, 0x2efb0b41, - 0x5ab3671d, 0x5292dbd2, 0x33e91056, 0x136dd647, - 0x8c9ad761, 0x7a37a10c, 0x8e59f814, 0x89eb133c, - 0xeecea927, 0x35b761c9, 0xede11ce5, 0x3c7a47b1, - 0x599cd2df, 0x3f55f273, 0x791814ce, 0xbf73c737, - 0xea53f7cd, 0x5b5ffdaa, 0x14df3d6f, 0x867844db, - 0x81caaff3, 0x3eb968c4, 0x2c382434, 0x5fc2a340, - 0x72161dc3, 0xcbce225, 0x8b283c49, 0x41ff0d95, - 0x7139a801, 0xde080cb3, 0x9cd8b4e4, 0x906456c1, - 0x617bcb84, 0x70d532b6, 0x74486c5c, 0x42d0b857) +T6 = ( + 0x5051F4A7, + 0x537E4165, + 0xC31A17A4, + 0x963A275E, + 0xCB3BAB6B, + 0xF11F9D45, + 0xABACFA58, + 0x934BE303, + 0x552030FA, + 0xF6AD766D, + 0x9188CC76, + 0x25F5024C, + 0xFC4FE5D7, + 0xD7C52ACB, + 0x80263544, + 0x8FB562A3, + 0x49DEB15A, + 0x6725BA1B, + 0x9845EA0E, + 0xE15DFEC0, + 0x2C32F75, + 0x12814CF0, + 0xA38D4697, + 0xC66BD3F9, + 0xE7038F5F, + 0x9515929C, + 0xEBBF6D7A, + 0xDA955259, + 0x2DD4BE83, + 0xD3587421, + 0x2949E069, + 0x448EC9C8, + 0x6A75C289, + 0x78F48E79, + 0x6B99583E, + 0xDD27B971, + 0xB6BEE14F, + 0x17F088AD, + 0x66C920AC, + 0xB47DCE3A, + 0x1863DF4A, + 0x82E51A31, + 0x60975133, + 0x4562537F, + 0xE0B16477, + 0x84BB6BAE, + 0x1CFE81A0, + 0x94F9082B, + 0x58704868, + 0x198F45FD, + 0x8794DE6C, + 0xB7527BF8, + 0x23AB73D3, + 0xE2724B02, + 0x57E31F8F, + 0x2A6655AB, + 0x7B2EB28, + 0x32FB5C2, + 0x9A86C57B, + 0xA5D33708, + 0xF2302887, + 0xB223BFA5, + 0xBA02036A, + 0x5CED1682, + 0x2B8ACF1C, + 0x92A779B4, + 0xF0F307F2, + 0xA14E69E2, + 0xCD65DAF4, + 0xD50605BE, + 0x1FD13462, + 0x8AC4A6FE, + 0x9D342E53, + 0xA0A2F355, + 0x32058AE1, + 0x75A4F6EB, + 0x390B83EC, + 0xAA4060EF, + 0x65E719F, + 0x51BD6E10, + 0xF93E218A, + 0x3D96DD06, + 0xAEDD3E05, + 0x464DE6BD, + 0xB591548D, + 0x571C45D, + 0x6F0406D4, + 0xFF605015, + 0x241998FB, + 0x97D6BDE9, + 0xCC894043, + 0x7767D99E, + 0xBDB0E842, + 0x8807898B, + 0x38E7195B, + 0xDB79C8EE, + 0x47A17C0A, + 0xE97C420F, + 0xC9F8841E, + 0x0, + 0x83098086, + 0x48322BED, + 0xAC1E1170, + 0x4E6C5A72, + 0xFBFD0EFF, + 0x560F8538, + 0x1E3DAED5, + 0x27362D39, + 0x640A0FD9, + 0x21685CA6, + 0xD19B5B54, + 0x3A24362E, + 0xB10C0A67, + 0xF9357E7, + 0xD2B4EE96, + 0x9E1B9B91, + 0x4F80C0C5, + 0xA261DC20, + 0x695A774B, + 0x161C121A, + 0xAE293BA, + 0xE5C0A02A, + 0x433C22E0, + 0x1D121B17, + 0xB0E090D, + 0xADF28BC7, + 0xB92DB6A8, + 0xC8141EA9, + 0x8557F119, + 0x4CAF7507, + 0xBBEE99DD, + 0xFDA37F60, + 0x9FF70126, + 0xBC5C72F5, + 0xC544663B, + 0x345BFB7E, + 0x768B4329, + 0xDCCB23C6, + 0x68B6EDFC, + 0x63B8E4F1, + 0xCAD731DC, + 0x10426385, + 0x40139722, + 0x2084C611, + 0x7D854A24, + 0xF8D2BB3D, + 0x11AEF932, + 0x6DC729A1, + 0x4B1D9E2F, + 0xF3DCB230, + 0xEC0D8652, + 0xD077C1E3, + 0x6C2BB316, + 0x99A970B9, + 0xFA119448, + 0x2247E964, + 0xC4A8FC8C, + 0x1AA0F03F, + 0xD8567D2C, + 0xEF223390, + 0xC787494E, + 0xC1D938D1, + 0xFE8CCAA2, + 0x3698D40B, + 0xCFA6F581, + 0x28A57ADE, + 0x26DAB78E, + 0xA43FADBF, + 0xE42C3A9D, + 0xD507892, + 0x9B6A5FCC, + 0x62547E46, + 0xC2F68D13, + 0xE890D8B8, + 0x5E2E39F7, + 0xF582C3AF, + 0xBE9F5D80, + 0x7C69D093, + 0xA96FD52D, + 0xB3CF2512, + 0x3BC8AC99, + 0xA710187D, + 0x6EE89C63, + 0x7BDB3BBB, + 0x9CD2678, + 0xF46E5918, + 0x1EC9AB7, + 0xA8834F9A, + 0x65E6956E, + 0x7EAAFFE6, + 0x821BCCF, + 0xE6EF15E8, + 0xD9BAE79B, + 0xCE4A6F36, + 0xD4EA9F09, + 0xD629B07C, + 0xAF31A4B2, + 0x312A3F23, + 0x30C6A594, + 0xC035A266, + 0x37744EBC, + 0xA6FC82CA, + 0xB0E090D0, + 0x1533A7D8, + 0x4AF10498, + 0xF741ECDA, + 0xE7FCD50, + 0x2F1791F6, + 0x8D764DD6, + 0x4D43EFB0, + 0x54CCAA4D, + 0xDFE49604, + 0xE39ED1B5, + 0x1B4C6A88, + 0xB8C12C1F, + 0x7F466551, + 0x49D5EEA, + 0x5D018C35, + 0x73FA8774, + 0x2EFB0B41, + 0x5AB3671D, + 0x5292DBD2, + 0x33E91056, + 0x136DD647, + 0x8C9AD761, + 0x7A37A10C, + 0x8E59F814, + 0x89EB133C, + 0xEECEA927, + 0x35B761C9, + 0xEDE11CE5, + 0x3C7A47B1, + 0x599CD2DF, + 0x3F55F273, + 0x791814CE, + 0xBF73C737, + 0xEA53F7CD, + 0x5B5FFDAA, + 0x14DF3D6F, + 0x867844DB, + 0x81CAAFF3, + 0x3EB968C4, + 0x2C382434, + 0x5FC2A340, + 0x72161DC3, + 0xCBCE225, + 0x8B283C49, + 0x41FF0D95, + 0x7139A801, + 0xDE080CB3, + 0x9CD8B4E4, + 0x906456C1, + 0x617BCB84, + 0x70D532B6, + 0x74486C5C, + 0x42D0B857, +) -T7 = (0xa75051f4, 0x65537e41, 0xa4c31a17, 0x5e963a27, - 0x6bcb3bab, 0x45f11f9d, 0x58abacfa, 0x3934be3, - 0xfa552030, 0x6df6ad76, 0x769188cc, 0x4c25f502, - 0xd7fc4fe5, 0xcbd7c52a, 0x44802635, 0xa38fb562, - 0x5a49deb1, 0x1b6725ba, 0xe9845ea, 0xc0e15dfe, - 0x7502c32f, 0xf012814c, 0x97a38d46, 0xf9c66bd3, - 0x5fe7038f, 0x9c951592, 0x7aebbf6d, 0x59da9552, - 0x832dd4be, 0x21d35874, 0x692949e0, 0xc8448ec9, - 0x896a75c2, 0x7978f48e, 0x3e6b9958, 0x71dd27b9, - 0x4fb6bee1, 0xad17f088, 0xac66c920, 0x3ab47dce, - 0x4a1863df, 0x3182e51a, 0x33609751, 0x7f456253, - 0x77e0b164, 0xae84bb6b, 0xa01cfe81, 0x2b94f908, - 0x68587048, 0xfd198f45, 0x6c8794de, 0xf8b7527b, - 0xd323ab73, 0x2e2724b, 0x8f57e31f, 0xab2a6655, - 0x2807b2eb, 0xc2032fb5, 0x7b9a86c5, 0x8a5d337, - 0x87f23028, 0xa5b223bf, 0x6aba0203, 0x825ced16, - 0x1c2b8acf, 0xb492a779, 0xf2f0f307, 0xe2a14e69, - 0xf4cd65da, 0xbed50605, 0x621fd134, 0xfe8ac4a6, - 0x539d342e, 0x55a0a2f3, 0xe132058a, 0xeb75a4f6, - 0xec390b83, 0xefaa4060, 0x9f065e71, 0x1051bd6e, - 0x8af93e21, 0x63d96dd, 0x5aedd3e, 0xbd464de6, - 0x8db59154, 0x5d0571c4, 0xd46f0406, 0x15ff6050, - 0xfb241998, 0xe997d6bd, 0x43cc8940, 0x9e7767d9, - 0x42bdb0e8, 0x8b880789, 0x5b38e719, 0xeedb79c8, - 0xa47a17c, 0xfe97c42, 0x1ec9f884, 0x0, - 0x86830980, 0xed48322b, 0x70ac1e11, 0x724e6c5a, - 0xfffbfd0e, 0x38560f85, 0xd51e3dae, 0x3927362d, - 0xd9640a0f, 0xa621685c, 0x54d19b5b, 0x2e3a2436, - 0x67b10c0a, 0xe70f9357, 0x96d2b4ee, 0x919e1b9b, - 0xc54f80c0, 0x20a261dc, 0x4b695a77, 0x1a161c12, - 0xba0ae293, 0x2ae5c0a0, 0xe0433c22, 0x171d121b, - 0xd0b0e09, 0xc7adf28b, 0xa8b92db6, 0xa9c8141e, - 0x198557f1, 0x74caf75, 0xddbbee99, 0x60fda37f, - 0x269ff701, 0xf5bc5c72, 0x3bc54466, 0x7e345bfb, - 0x29768b43, 0xc6dccb23, 0xfc68b6ed, 0xf163b8e4, - 0xdccad731, 0x85104263, 0x22401397, 0x112084c6, - 0x247d854a, 0x3df8d2bb, 0x3211aef9, 0xa16dc729, - 0x2f4b1d9e, 0x30f3dcb2, 0x52ec0d86, 0xe3d077c1, - 0x166c2bb3, 0xb999a970, 0x48fa1194, 0x642247e9, - 0x8cc4a8fc, 0x3f1aa0f0, 0x2cd8567d, 0x90ef2233, - 0x4ec78749, 0xd1c1d938, 0xa2fe8cca, 0xb3698d4, - 0x81cfa6f5, 0xde28a57a, 0x8e26dab7, 0xbfa43fad, - 0x9de42c3a, 0x920d5078, 0xcc9b6a5f, 0x4662547e, - 0x13c2f68d, 0xb8e890d8, 0xf75e2e39, 0xaff582c3, - 0x80be9f5d, 0x937c69d0, 0x2da96fd5, 0x12b3cf25, - 0x993bc8ac, 0x7da71018, 0x636ee89c, 0xbb7bdb3b, - 0x7809cd26, 0x18f46e59, 0xb701ec9a, 0x9aa8834f, - 0x6e65e695, 0xe67eaaff, 0xcf0821bc, 0xe8e6ef15, - 0x9bd9bae7, 0x36ce4a6f, 0x9d4ea9f, 0x7cd629b0, - 0xb2af31a4, 0x23312a3f, 0x9430c6a5, 0x66c035a2, - 0xbc37744e, 0xcaa6fc82, 0xd0b0e090, 0xd81533a7, - 0x984af104, 0xdaf741ec, 0x500e7fcd, 0xf62f1791, - 0xd68d764d, 0xb04d43ef, 0x4d54ccaa, 0x4dfe496, - 0xb5e39ed1, 0x881b4c6a, 0x1fb8c12c, 0x517f4665, - 0xea049d5e, 0x355d018c, 0x7473fa87, 0x412efb0b, - 0x1d5ab367, 0xd25292db, 0x5633e910, 0x47136dd6, - 0x618c9ad7, 0xc7a37a1, 0x148e59f8, 0x3c89eb13, - 0x27eecea9, 0xc935b761, 0xe5ede11c, 0xb13c7a47, - 0xdf599cd2, 0x733f55f2, 0xce791814, 0x37bf73c7, - 0xcdea53f7, 0xaa5b5ffd, 0x6f14df3d, 0xdb867844, - 0xf381caaf, 0xc43eb968, 0x342c3824, 0x405fc2a3, - 0xc372161d, 0x250cbce2, 0x498b283c, 0x9541ff0d, - 0x17139a8, 0xb3de080c, 0xe49cd8b4, 0xc1906456, - 0x84617bcb, 0xb670d532, 0x5c74486c, 0x5742d0b8) +T7 = ( + 0xA75051F4, + 0x65537E41, + 0xA4C31A17, + 0x5E963A27, + 0x6BCB3BAB, + 0x45F11F9D, + 0x58ABACFA, + 0x3934BE3, + 0xFA552030, + 0x6DF6AD76, + 0x769188CC, + 0x4C25F502, + 0xD7FC4FE5, + 0xCBD7C52A, + 0x44802635, + 0xA38FB562, + 0x5A49DEB1, + 0x1B6725BA, + 0xE9845EA, + 0xC0E15DFE, + 0x7502C32F, + 0xF012814C, + 0x97A38D46, + 0xF9C66BD3, + 0x5FE7038F, + 0x9C951592, + 0x7AEBBF6D, + 0x59DA9552, + 0x832DD4BE, + 0x21D35874, + 0x692949E0, + 0xC8448EC9, + 0x896A75C2, + 0x7978F48E, + 0x3E6B9958, + 0x71DD27B9, + 0x4FB6BEE1, + 0xAD17F088, + 0xAC66C920, + 0x3AB47DCE, + 0x4A1863DF, + 0x3182E51A, + 0x33609751, + 0x7F456253, + 0x77E0B164, + 0xAE84BB6B, + 0xA01CFE81, + 0x2B94F908, + 0x68587048, + 0xFD198F45, + 0x6C8794DE, + 0xF8B7527B, + 0xD323AB73, + 0x2E2724B, + 0x8F57E31F, + 0xAB2A6655, + 0x2807B2EB, + 0xC2032FB5, + 0x7B9A86C5, + 0x8A5D337, + 0x87F23028, + 0xA5B223BF, + 0x6ABA0203, + 0x825CED16, + 0x1C2B8ACF, + 0xB492A779, + 0xF2F0F307, + 0xE2A14E69, + 0xF4CD65DA, + 0xBED50605, + 0x621FD134, + 0xFE8AC4A6, + 0x539D342E, + 0x55A0A2F3, + 0xE132058A, + 0xEB75A4F6, + 0xEC390B83, + 0xEFAA4060, + 0x9F065E71, + 0x1051BD6E, + 0x8AF93E21, + 0x63D96DD, + 0x5AEDD3E, + 0xBD464DE6, + 0x8DB59154, + 0x5D0571C4, + 0xD46F0406, + 0x15FF6050, + 0xFB241998, + 0xE997D6BD, + 0x43CC8940, + 0x9E7767D9, + 0x42BDB0E8, + 0x8B880789, + 0x5B38E719, + 0xEEDB79C8, + 0xA47A17C, + 0xFE97C42, + 0x1EC9F884, + 0x0, + 0x86830980, + 0xED48322B, + 0x70AC1E11, + 0x724E6C5A, + 0xFFFBFD0E, + 0x38560F85, + 0xD51E3DAE, + 0x3927362D, + 0xD9640A0F, + 0xA621685C, + 0x54D19B5B, + 0x2E3A2436, + 0x67B10C0A, + 0xE70F9357, + 0x96D2B4EE, + 0x919E1B9B, + 0xC54F80C0, + 0x20A261DC, + 0x4B695A77, + 0x1A161C12, + 0xBA0AE293, + 0x2AE5C0A0, + 0xE0433C22, + 0x171D121B, + 0xD0B0E09, + 0xC7ADF28B, + 0xA8B92DB6, + 0xA9C8141E, + 0x198557F1, + 0x74CAF75, + 0xDDBBEE99, + 0x60FDA37F, + 0x269FF701, + 0xF5BC5C72, + 0x3BC54466, + 0x7E345BFB, + 0x29768B43, + 0xC6DCCB23, + 0xFC68B6ED, + 0xF163B8E4, + 0xDCCAD731, + 0x85104263, + 0x22401397, + 0x112084C6, + 0x247D854A, + 0x3DF8D2BB, + 0x3211AEF9, + 0xA16DC729, + 0x2F4B1D9E, + 0x30F3DCB2, + 0x52EC0D86, + 0xE3D077C1, + 0x166C2BB3, + 0xB999A970, + 0x48FA1194, + 0x642247E9, + 0x8CC4A8FC, + 0x3F1AA0F0, + 0x2CD8567D, + 0x90EF2233, + 0x4EC78749, + 0xD1C1D938, + 0xA2FE8CCA, + 0xB3698D4, + 0x81CFA6F5, + 0xDE28A57A, + 0x8E26DAB7, + 0xBFA43FAD, + 0x9DE42C3A, + 0x920D5078, + 0xCC9B6A5F, + 0x4662547E, + 0x13C2F68D, + 0xB8E890D8, + 0xF75E2E39, + 0xAFF582C3, + 0x80BE9F5D, + 0x937C69D0, + 0x2DA96FD5, + 0x12B3CF25, + 0x993BC8AC, + 0x7DA71018, + 0x636EE89C, + 0xBB7BDB3B, + 0x7809CD26, + 0x18F46E59, + 0xB701EC9A, + 0x9AA8834F, + 0x6E65E695, + 0xE67EAAFF, + 0xCF0821BC, + 0xE8E6EF15, + 0x9BD9BAE7, + 0x36CE4A6F, + 0x9D4EA9F, + 0x7CD629B0, + 0xB2AF31A4, + 0x23312A3F, + 0x9430C6A5, + 0x66C035A2, + 0xBC37744E, + 0xCAA6FC82, + 0xD0B0E090, + 0xD81533A7, + 0x984AF104, + 0xDAF741EC, + 0x500E7FCD, + 0xF62F1791, + 0xD68D764D, + 0xB04D43EF, + 0x4D54CCAA, + 0x4DFE496, + 0xB5E39ED1, + 0x881B4C6A, + 0x1FB8C12C, + 0x517F4665, + 0xEA049D5E, + 0x355D018C, + 0x7473FA87, + 0x412EFB0B, + 0x1D5AB367, + 0xD25292DB, + 0x5633E910, + 0x47136DD6, + 0x618C9AD7, + 0xC7A37A1, + 0x148E59F8, + 0x3C89EB13, + 0x27EECEA9, + 0xC935B761, + 0xE5EDE11C, + 0xB13C7A47, + 0xDF599CD2, + 0x733F55F2, + 0xCE791814, + 0x37BF73C7, + 0xCDEA53F7, + 0xAA5B5FFD, + 0x6F14DF3D, + 0xDB867844, + 0xF381CAAF, + 0xC43EB968, + 0x342C3824, + 0x405FC2A3, + 0xC372161D, + 0x250CBCE2, + 0x498B283C, + 0x9541FF0D, + 0x17139A8, + 0xB3DE080C, + 0xE49CD8B4, + 0xC1906456, + 0x84617BCB, + 0xB670D532, + 0x5C74486C, + 0x5742D0B8, +) -T8 = (0xf4a75051, 0x4165537e, 0x17a4c31a, 0x275e963a, - 0xab6bcb3b, 0x9d45f11f, 0xfa58abac, 0xe303934b, - 0x30fa5520, 0x766df6ad, 0xcc769188, 0x24c25f5, - 0xe5d7fc4f, 0x2acbd7c5, 0x35448026, 0x62a38fb5, - 0xb15a49de, 0xba1b6725, 0xea0e9845, 0xfec0e15d, - 0x2f7502c3, 0x4cf01281, 0x4697a38d, 0xd3f9c66b, - 0x8f5fe703, 0x929c9515, 0x6d7aebbf, 0x5259da95, - 0xbe832dd4, 0x7421d358, 0xe0692949, 0xc9c8448e, - 0xc2896a75, 0x8e7978f4, 0x583e6b99, 0xb971dd27, - 0xe14fb6be, 0x88ad17f0, 0x20ac66c9, 0xce3ab47d, - 0xdf4a1863, 0x1a3182e5, 0x51336097, 0x537f4562, - 0x6477e0b1, 0x6bae84bb, 0x81a01cfe, 0x82b94f9, - 0x48685870, 0x45fd198f, 0xde6c8794, 0x7bf8b752, - 0x73d323ab, 0x4b02e272, 0x1f8f57e3, 0x55ab2a66, - 0xeb2807b2, 0xb5c2032f, 0xc57b9a86, 0x3708a5d3, - 0x2887f230, 0xbfa5b223, 0x36aba02, 0x16825ced, - 0xcf1c2b8a, 0x79b492a7, 0x7f2f0f3, 0x69e2a14e, - 0xdaf4cd65, 0x5bed506, 0x34621fd1, 0xa6fe8ac4, - 0x2e539d34, 0xf355a0a2, 0x8ae13205, 0xf6eb75a4, - 0x83ec390b, 0x60efaa40, 0x719f065e, 0x6e1051bd, - 0x218af93e, 0xdd063d96, 0x3e05aedd, 0xe6bd464d, - 0x548db591, 0xc45d0571, 0x6d46f04, 0x5015ff60, - 0x98fb2419, 0xbde997d6, 0x4043cc89, 0xd99e7767, - 0xe842bdb0, 0x898b8807, 0x195b38e7, 0xc8eedb79, - 0x7c0a47a1, 0x420fe97c, 0x841ec9f8, 0x0, - 0x80868309, 0x2bed4832, 0x1170ac1e, 0x5a724e6c, - 0xefffbfd, 0x8538560f, 0xaed51e3d, 0x2d392736, - 0xfd9640a, 0x5ca62168, 0x5b54d19b, 0x362e3a24, - 0xa67b10c, 0x57e70f93, 0xee96d2b4, 0x9b919e1b, - 0xc0c54f80, 0xdc20a261, 0x774b695a, 0x121a161c, - 0x93ba0ae2, 0xa02ae5c0, 0x22e0433c, 0x1b171d12, - 0x90d0b0e, 0x8bc7adf2, 0xb6a8b92d, 0x1ea9c814, - 0xf1198557, 0x75074caf, 0x99ddbbee, 0x7f60fda3, - 0x1269ff7, 0x72f5bc5c, 0x663bc544, 0xfb7e345b, - 0x4329768b, 0x23c6dccb, 0xedfc68b6, 0xe4f163b8, - 0x31dccad7, 0x63851042, 0x97224013, 0xc6112084, - 0x4a247d85, 0xbb3df8d2, 0xf93211ae, 0x29a16dc7, - 0x9e2f4b1d, 0xb230f3dc, 0x8652ec0d, 0xc1e3d077, - 0xb3166c2b, 0x70b999a9, 0x9448fa11, 0xe9642247, - 0xfc8cc4a8, 0xf03f1aa0, 0x7d2cd856, 0x3390ef22, - 0x494ec787, 0x38d1c1d9, 0xcaa2fe8c, 0xd40b3698, - 0xf581cfa6, 0x7ade28a5, 0xb78e26da, 0xadbfa43f, - 0x3a9de42c, 0x78920d50, 0x5fcc9b6a, 0x7e466254, - 0x8d13c2f6, 0xd8b8e890, 0x39f75e2e, 0xc3aff582, - 0x5d80be9f, 0xd0937c69, 0xd52da96f, 0x2512b3cf, - 0xac993bc8, 0x187da710, 0x9c636ee8, 0x3bbb7bdb, - 0x267809cd, 0x5918f46e, 0x9ab701ec, 0x4f9aa883, - 0x956e65e6, 0xffe67eaa, 0xbccf0821, 0x15e8e6ef, - 0xe79bd9ba, 0x6f36ce4a, 0x9f09d4ea, 0xb07cd629, - 0xa4b2af31, 0x3f23312a, 0xa59430c6, 0xa266c035, - 0x4ebc3774, 0x82caa6fc, 0x90d0b0e0, 0xa7d81533, - 0x4984af1, 0xecdaf741, 0xcd500e7f, 0x91f62f17, - 0x4dd68d76, 0xefb04d43, 0xaa4d54cc, 0x9604dfe4, - 0xd1b5e39e, 0x6a881b4c, 0x2c1fb8c1, 0x65517f46, - 0x5eea049d, 0x8c355d01, 0x877473fa, 0xb412efb, - 0x671d5ab3, 0xdbd25292, 0x105633e9, 0xd647136d, - 0xd7618c9a, 0xa10c7a37, 0xf8148e59, 0x133c89eb, - 0xa927eece, 0x61c935b7, 0x1ce5ede1, 0x47b13c7a, - 0xd2df599c, 0xf2733f55, 0x14ce7918, 0xc737bf73, - 0xf7cdea53, 0xfdaa5b5f, 0x3d6f14df, 0x44db8678, - 0xaff381ca, 0x68c43eb9, 0x24342c38, 0xa3405fc2, - 0x1dc37216, 0xe2250cbc, 0x3c498b28, 0xd9541ff, - 0xa8017139, 0xcb3de08, 0xb4e49cd8, 0x56c19064, - 0xcb84617b, 0x32b670d5, 0x6c5c7448, 0xb85742d0) +T8 = ( + 0xF4A75051, + 0x4165537E, + 0x17A4C31A, + 0x275E963A, + 0xAB6BCB3B, + 0x9D45F11F, + 0xFA58ABAC, + 0xE303934B, + 0x30FA5520, + 0x766DF6AD, + 0xCC769188, + 0x24C25F5, + 0xE5D7FC4F, + 0x2ACBD7C5, + 0x35448026, + 0x62A38FB5, + 0xB15A49DE, + 0xBA1B6725, + 0xEA0E9845, + 0xFEC0E15D, + 0x2F7502C3, + 0x4CF01281, + 0x4697A38D, + 0xD3F9C66B, + 0x8F5FE703, + 0x929C9515, + 0x6D7AEBBF, + 0x5259DA95, + 0xBE832DD4, + 0x7421D358, + 0xE0692949, + 0xC9C8448E, + 0xC2896A75, + 0x8E7978F4, + 0x583E6B99, + 0xB971DD27, + 0xE14FB6BE, + 0x88AD17F0, + 0x20AC66C9, + 0xCE3AB47D, + 0xDF4A1863, + 0x1A3182E5, + 0x51336097, + 0x537F4562, + 0x6477E0B1, + 0x6BAE84BB, + 0x81A01CFE, + 0x82B94F9, + 0x48685870, + 0x45FD198F, + 0xDE6C8794, + 0x7BF8B752, + 0x73D323AB, + 0x4B02E272, + 0x1F8F57E3, + 0x55AB2A66, + 0xEB2807B2, + 0xB5C2032F, + 0xC57B9A86, + 0x3708A5D3, + 0x2887F230, + 0xBFA5B223, + 0x36ABA02, + 0x16825CED, + 0xCF1C2B8A, + 0x79B492A7, + 0x7F2F0F3, + 0x69E2A14E, + 0xDAF4CD65, + 0x5BED506, + 0x34621FD1, + 0xA6FE8AC4, + 0x2E539D34, + 0xF355A0A2, + 0x8AE13205, + 0xF6EB75A4, + 0x83EC390B, + 0x60EFAA40, + 0x719F065E, + 0x6E1051BD, + 0x218AF93E, + 0xDD063D96, + 0x3E05AEDD, + 0xE6BD464D, + 0x548DB591, + 0xC45D0571, + 0x6D46F04, + 0x5015FF60, + 0x98FB2419, + 0xBDE997D6, + 0x4043CC89, + 0xD99E7767, + 0xE842BDB0, + 0x898B8807, + 0x195B38E7, + 0xC8EEDB79, + 0x7C0A47A1, + 0x420FE97C, + 0x841EC9F8, + 0x0, + 0x80868309, + 0x2BED4832, + 0x1170AC1E, + 0x5A724E6C, + 0xEFFFBFD, + 0x8538560F, + 0xAED51E3D, + 0x2D392736, + 0xFD9640A, + 0x5CA62168, + 0x5B54D19B, + 0x362E3A24, + 0xA67B10C, + 0x57E70F93, + 0xEE96D2B4, + 0x9B919E1B, + 0xC0C54F80, + 0xDC20A261, + 0x774B695A, + 0x121A161C, + 0x93BA0AE2, + 0xA02AE5C0, + 0x22E0433C, + 0x1B171D12, + 0x90D0B0E, + 0x8BC7ADF2, + 0xB6A8B92D, + 0x1EA9C814, + 0xF1198557, + 0x75074CAF, + 0x99DDBBEE, + 0x7F60FDA3, + 0x1269FF7, + 0x72F5BC5C, + 0x663BC544, + 0xFB7E345B, + 0x4329768B, + 0x23C6DCCB, + 0xEDFC68B6, + 0xE4F163B8, + 0x31DCCAD7, + 0x63851042, + 0x97224013, + 0xC6112084, + 0x4A247D85, + 0xBB3DF8D2, + 0xF93211AE, + 0x29A16DC7, + 0x9E2F4B1D, + 0xB230F3DC, + 0x8652EC0D, + 0xC1E3D077, + 0xB3166C2B, + 0x70B999A9, + 0x9448FA11, + 0xE9642247, + 0xFC8CC4A8, + 0xF03F1AA0, + 0x7D2CD856, + 0x3390EF22, + 0x494EC787, + 0x38D1C1D9, + 0xCAA2FE8C, + 0xD40B3698, + 0xF581CFA6, + 0x7ADE28A5, + 0xB78E26DA, + 0xADBFA43F, + 0x3A9DE42C, + 0x78920D50, + 0x5FCC9B6A, + 0x7E466254, + 0x8D13C2F6, + 0xD8B8E890, + 0x39F75E2E, + 0xC3AFF582, + 0x5D80BE9F, + 0xD0937C69, + 0xD52DA96F, + 0x2512B3CF, + 0xAC993BC8, + 0x187DA710, + 0x9C636EE8, + 0x3BBB7BDB, + 0x267809CD, + 0x5918F46E, + 0x9AB701EC, + 0x4F9AA883, + 0x956E65E6, + 0xFFE67EAA, + 0xBCCF0821, + 0x15E8E6EF, + 0xE79BD9BA, + 0x6F36CE4A, + 0x9F09D4EA, + 0xB07CD629, + 0xA4B2AF31, + 0x3F23312A, + 0xA59430C6, + 0xA266C035, + 0x4EBC3774, + 0x82CAA6FC, + 0x90D0B0E0, + 0xA7D81533, + 0x4984AF1, + 0xECDAF741, + 0xCD500E7F, + 0x91F62F17, + 0x4DD68D76, + 0xEFB04D43, + 0xAA4D54CC, + 0x9604DFE4, + 0xD1B5E39E, + 0x6A881B4C, + 0x2C1FB8C1, + 0x65517F46, + 0x5EEA049D, + 0x8C355D01, + 0x877473FA, + 0xB412EFB, + 0x671D5AB3, + 0xDBD25292, + 0x105633E9, + 0xD647136D, + 0xD7618C9A, + 0xA10C7A37, + 0xF8148E59, + 0x133C89EB, + 0xA927EECE, + 0x61C935B7, + 0x1CE5EDE1, + 0x47B13C7A, + 0xD2DF599C, + 0xF2733F55, + 0x14CE7918, + 0xC737BF73, + 0xF7CDEA53, + 0xFDAA5B5F, + 0x3D6F14DF, + 0x44DB8678, + 0xAFF381CA, + 0x68C43EB9, + 0x24342C38, + 0xA3405FC2, + 0x1DC37216, + 0xE2250CBC, + 0x3C498B28, + 0xD9541FF, + 0xA8017139, + 0xCB3DE08, + 0xB4E49CD8, + 0x56C19064, + 0xCB84617B, + 0x32B670D5, + 0x6C5C7448, + 0xB85742D0, +) -U1 = (0x0, 0xe090d0b, 0x1c121a16, 0x121b171d, - 0x3824342c, 0x362d3927, 0x24362e3a, 0x2a3f2331, - 0x70486858, 0x7e416553, 0x6c5a724e, 0x62537f45, - 0x486c5c74, 0x4665517f, 0x547e4662, 0x5a774b69, - 0xe090d0b0, 0xee99ddbb, 0xfc82caa6, 0xf28bc7ad, - 0xd8b4e49c, 0xd6bde997, 0xc4a6fe8a, 0xcaaff381, - 0x90d8b8e8, 0x9ed1b5e3, 0x8ccaa2fe, 0x82c3aff5, - 0xa8fc8cc4, 0xa6f581cf, 0xb4ee96d2, 0xbae79bd9, - 0xdb3bbb7b, 0xd532b670, 0xc729a16d, 0xc920ac66, - 0xe31f8f57, 0xed16825c, 0xff0d9541, 0xf104984a, - 0xab73d323, 0xa57ade28, 0xb761c935, 0xb968c43e, - 0x9357e70f, 0x9d5eea04, 0x8f45fd19, 0x814cf012, - 0x3bab6bcb, 0x35a266c0, 0x27b971dd, 0x29b07cd6, - 0x38f5fe7, 0xd8652ec, 0x1f9d45f1, 0x119448fa, - 0x4be30393, 0x45ea0e98, 0x57f11985, 0x59f8148e, - 0x73c737bf, 0x7dce3ab4, 0x6fd52da9, 0x61dc20a2, - 0xad766df6, 0xa37f60fd, 0xb16477e0, 0xbf6d7aeb, - 0x955259da, 0x9b5b54d1, 0x894043cc, 0x87494ec7, - 0xdd3e05ae, 0xd33708a5, 0xc12c1fb8, 0xcf2512b3, - 0xe51a3182, 0xeb133c89, 0xf9082b94, 0xf701269f, - 0x4de6bd46, 0x43efb04d, 0x51f4a750, 0x5ffdaa5b, - 0x75c2896a, 0x7bcb8461, 0x69d0937c, 0x67d99e77, - 0x3daed51e, 0x33a7d815, 0x21bccf08, 0x2fb5c203, - 0x58ae132, 0xb83ec39, 0x1998fb24, 0x1791f62f, - 0x764dd68d, 0x7844db86, 0x6a5fcc9b, 0x6456c190, - 0x4e69e2a1, 0x4060efaa, 0x527bf8b7, 0x5c72f5bc, - 0x605bed5, 0x80cb3de, 0x1a17a4c3, 0x141ea9c8, - 0x3e218af9, 0x302887f2, 0x223390ef, 0x2c3a9de4, - 0x96dd063d, 0x98d40b36, 0x8acf1c2b, 0x84c61120, - 0xaef93211, 0xa0f03f1a, 0xb2eb2807, 0xbce2250c, - 0xe6956e65, 0xe89c636e, 0xfa877473, 0xf48e7978, - 0xdeb15a49, 0xd0b85742, 0xc2a3405f, 0xccaa4d54, - 0x41ecdaf7, 0x4fe5d7fc, 0x5dfec0e1, 0x53f7cdea, - 0x79c8eedb, 0x77c1e3d0, 0x65daf4cd, 0x6bd3f9c6, - 0x31a4b2af, 0x3fadbfa4, 0x2db6a8b9, 0x23bfa5b2, - 0x9808683, 0x7898b88, 0x15929c95, 0x1b9b919e, - 0xa17c0a47, 0xaf75074c, 0xbd6e1051, 0xb3671d5a, - 0x99583e6b, 0x97513360, 0x854a247d, 0x8b432976, - 0xd134621f, 0xdf3d6f14, 0xcd267809, 0xc32f7502, - 0xe9105633, 0xe7195b38, 0xf5024c25, 0xfb0b412e, - 0x9ad7618c, 0x94de6c87, 0x86c57b9a, 0x88cc7691, - 0xa2f355a0, 0xacfa58ab, 0xbee14fb6, 0xb0e842bd, - 0xea9f09d4, 0xe49604df, 0xf68d13c2, 0xf8841ec9, - 0xd2bb3df8, 0xdcb230f3, 0xcea927ee, 0xc0a02ae5, - 0x7a47b13c, 0x744ebc37, 0x6655ab2a, 0x685ca621, - 0x42638510, 0x4c6a881b, 0x5e719f06, 0x5078920d, - 0xa0fd964, 0x406d46f, 0x161dc372, 0x1814ce79, - 0x322bed48, 0x3c22e043, 0x2e39f75e, 0x2030fa55, - 0xec9ab701, 0xe293ba0a, 0xf088ad17, 0xfe81a01c, - 0xd4be832d, 0xdab78e26, 0xc8ac993b, 0xc6a59430, - 0x9cd2df59, 0x92dbd252, 0x80c0c54f, 0x8ec9c844, - 0xa4f6eb75, 0xaaffe67e, 0xb8e4f163, 0xb6edfc68, - 0xc0a67b1, 0x2036aba, 0x10187da7, 0x1e1170ac, - 0x342e539d, 0x3a275e96, 0x283c498b, 0x26354480, - 0x7c420fe9, 0x724b02e2, 0x605015ff, 0x6e5918f4, - 0x44663bc5, 0x4a6f36ce, 0x587421d3, 0x567d2cd8, - 0x37a10c7a, 0x39a80171, 0x2bb3166c, 0x25ba1b67, - 0xf853856, 0x18c355d, 0x13972240, 0x1d9e2f4b, - 0x47e96422, 0x49e06929, 0x5bfb7e34, 0x55f2733f, - 0x7fcd500e, 0x71c45d05, 0x63df4a18, 0x6dd64713, - 0xd731dcca, 0xd938d1c1, 0xcb23c6dc, 0xc52acbd7, - 0xef15e8e6, 0xe11ce5ed, 0xf307f2f0, 0xfd0efffb, - 0xa779b492, 0xa970b999, 0xbb6bae84, 0xb562a38f, - 0x9f5d80be, 0x91548db5, 0x834f9aa8, 0x8d4697a3) +U1 = ( + 0x0, + 0xE090D0B, + 0x1C121A16, + 0x121B171D, + 0x3824342C, + 0x362D3927, + 0x24362E3A, + 0x2A3F2331, + 0x70486858, + 0x7E416553, + 0x6C5A724E, + 0x62537F45, + 0x486C5C74, + 0x4665517F, + 0x547E4662, + 0x5A774B69, + 0xE090D0B0, + 0xEE99DDBB, + 0xFC82CAA6, + 0xF28BC7AD, + 0xD8B4E49C, + 0xD6BDE997, + 0xC4A6FE8A, + 0xCAAFF381, + 0x90D8B8E8, + 0x9ED1B5E3, + 0x8CCAA2FE, + 0x82C3AFF5, + 0xA8FC8CC4, + 0xA6F581CF, + 0xB4EE96D2, + 0xBAE79BD9, + 0xDB3BBB7B, + 0xD532B670, + 0xC729A16D, + 0xC920AC66, + 0xE31F8F57, + 0xED16825C, + 0xFF0D9541, + 0xF104984A, + 0xAB73D323, + 0xA57ADE28, + 0xB761C935, + 0xB968C43E, + 0x9357E70F, + 0x9D5EEA04, + 0x8F45FD19, + 0x814CF012, + 0x3BAB6BCB, + 0x35A266C0, + 0x27B971DD, + 0x29B07CD6, + 0x38F5FE7, + 0xD8652EC, + 0x1F9D45F1, + 0x119448FA, + 0x4BE30393, + 0x45EA0E98, + 0x57F11985, + 0x59F8148E, + 0x73C737BF, + 0x7DCE3AB4, + 0x6FD52DA9, + 0x61DC20A2, + 0xAD766DF6, + 0xA37F60FD, + 0xB16477E0, + 0xBF6D7AEB, + 0x955259DA, + 0x9B5B54D1, + 0x894043CC, + 0x87494EC7, + 0xDD3E05AE, + 0xD33708A5, + 0xC12C1FB8, + 0xCF2512B3, + 0xE51A3182, + 0xEB133C89, + 0xF9082B94, + 0xF701269F, + 0x4DE6BD46, + 0x43EFB04D, + 0x51F4A750, + 0x5FFDAA5B, + 0x75C2896A, + 0x7BCB8461, + 0x69D0937C, + 0x67D99E77, + 0x3DAED51E, + 0x33A7D815, + 0x21BCCF08, + 0x2FB5C203, + 0x58AE132, + 0xB83EC39, + 0x1998FB24, + 0x1791F62F, + 0x764DD68D, + 0x7844DB86, + 0x6A5FCC9B, + 0x6456C190, + 0x4E69E2A1, + 0x4060EFAA, + 0x527BF8B7, + 0x5C72F5BC, + 0x605BED5, + 0x80CB3DE, + 0x1A17A4C3, + 0x141EA9C8, + 0x3E218AF9, + 0x302887F2, + 0x223390EF, + 0x2C3A9DE4, + 0x96DD063D, + 0x98D40B36, + 0x8ACF1C2B, + 0x84C61120, + 0xAEF93211, + 0xA0F03F1A, + 0xB2EB2807, + 0xBCE2250C, + 0xE6956E65, + 0xE89C636E, + 0xFA877473, + 0xF48E7978, + 0xDEB15A49, + 0xD0B85742, + 0xC2A3405F, + 0xCCAA4D54, + 0x41ECDAF7, + 0x4FE5D7FC, + 0x5DFEC0E1, + 0x53F7CDEA, + 0x79C8EEDB, + 0x77C1E3D0, + 0x65DAF4CD, + 0x6BD3F9C6, + 0x31A4B2AF, + 0x3FADBFA4, + 0x2DB6A8B9, + 0x23BFA5B2, + 0x9808683, + 0x7898B88, + 0x15929C95, + 0x1B9B919E, + 0xA17C0A47, + 0xAF75074C, + 0xBD6E1051, + 0xB3671D5A, + 0x99583E6B, + 0x97513360, + 0x854A247D, + 0x8B432976, + 0xD134621F, + 0xDF3D6F14, + 0xCD267809, + 0xC32F7502, + 0xE9105633, + 0xE7195B38, + 0xF5024C25, + 0xFB0B412E, + 0x9AD7618C, + 0x94DE6C87, + 0x86C57B9A, + 0x88CC7691, + 0xA2F355A0, + 0xACFA58AB, + 0xBEE14FB6, + 0xB0E842BD, + 0xEA9F09D4, + 0xE49604DF, + 0xF68D13C2, + 0xF8841EC9, + 0xD2BB3DF8, + 0xDCB230F3, + 0xCEA927EE, + 0xC0A02AE5, + 0x7A47B13C, + 0x744EBC37, + 0x6655AB2A, + 0x685CA621, + 0x42638510, + 0x4C6A881B, + 0x5E719F06, + 0x5078920D, + 0xA0FD964, + 0x406D46F, + 0x161DC372, + 0x1814CE79, + 0x322BED48, + 0x3C22E043, + 0x2E39F75E, + 0x2030FA55, + 0xEC9AB701, + 0xE293BA0A, + 0xF088AD17, + 0xFE81A01C, + 0xD4BE832D, + 0xDAB78E26, + 0xC8AC993B, + 0xC6A59430, + 0x9CD2DF59, + 0x92DBD252, + 0x80C0C54F, + 0x8EC9C844, + 0xA4F6EB75, + 0xAAFFE67E, + 0xB8E4F163, + 0xB6EDFC68, + 0xC0A67B1, + 0x2036ABA, + 0x10187DA7, + 0x1E1170AC, + 0x342E539D, + 0x3A275E96, + 0x283C498B, + 0x26354480, + 0x7C420FE9, + 0x724B02E2, + 0x605015FF, + 0x6E5918F4, + 0x44663BC5, + 0x4A6F36CE, + 0x587421D3, + 0x567D2CD8, + 0x37A10C7A, + 0x39A80171, + 0x2BB3166C, + 0x25BA1B67, + 0xF853856, + 0x18C355D, + 0x13972240, + 0x1D9E2F4B, + 0x47E96422, + 0x49E06929, + 0x5BFB7E34, + 0x55F2733F, + 0x7FCD500E, + 0x71C45D05, + 0x63DF4A18, + 0x6DD64713, + 0xD731DCCA, + 0xD938D1C1, + 0xCB23C6DC, + 0xC52ACBD7, + 0xEF15E8E6, + 0xE11CE5ED, + 0xF307F2F0, + 0xFD0EFFFB, + 0xA779B492, + 0xA970B999, + 0xBB6BAE84, + 0xB562A38F, + 0x9F5D80BE, + 0x91548DB5, + 0x834F9AA8, + 0x8D4697A3, +) -U2 = (0x0, 0xb0e090d, 0x161c121a, 0x1d121b17, - 0x2c382434, 0x27362d39, 0x3a24362e, 0x312a3f23, - 0x58704868, 0x537e4165, 0x4e6c5a72, 0x4562537f, - 0x74486c5c, 0x7f466551, 0x62547e46, 0x695a774b, - 0xb0e090d0, 0xbbee99dd, 0xa6fc82ca, 0xadf28bc7, - 0x9cd8b4e4, 0x97d6bde9, 0x8ac4a6fe, 0x81caaff3, - 0xe890d8b8, 0xe39ed1b5, 0xfe8ccaa2, 0xf582c3af, - 0xc4a8fc8c, 0xcfa6f581, 0xd2b4ee96, 0xd9bae79b, - 0x7bdb3bbb, 0x70d532b6, 0x6dc729a1, 0x66c920ac, - 0x57e31f8f, 0x5ced1682, 0x41ff0d95, 0x4af10498, - 0x23ab73d3, 0x28a57ade, 0x35b761c9, 0x3eb968c4, - 0xf9357e7, 0x49d5eea, 0x198f45fd, 0x12814cf0, - 0xcb3bab6b, 0xc035a266, 0xdd27b971, 0xd629b07c, - 0xe7038f5f, 0xec0d8652, 0xf11f9d45, 0xfa119448, - 0x934be303, 0x9845ea0e, 0x8557f119, 0x8e59f814, - 0xbf73c737, 0xb47dce3a, 0xa96fd52d, 0xa261dc20, - 0xf6ad766d, 0xfda37f60, 0xe0b16477, 0xebbf6d7a, - 0xda955259, 0xd19b5b54, 0xcc894043, 0xc787494e, - 0xaedd3e05, 0xa5d33708, 0xb8c12c1f, 0xb3cf2512, - 0x82e51a31, 0x89eb133c, 0x94f9082b, 0x9ff70126, - 0x464de6bd, 0x4d43efb0, 0x5051f4a7, 0x5b5ffdaa, - 0x6a75c289, 0x617bcb84, 0x7c69d093, 0x7767d99e, - 0x1e3daed5, 0x1533a7d8, 0x821bccf, 0x32fb5c2, - 0x32058ae1, 0x390b83ec, 0x241998fb, 0x2f1791f6, - 0x8d764dd6, 0x867844db, 0x9b6a5fcc, 0x906456c1, - 0xa14e69e2, 0xaa4060ef, 0xb7527bf8, 0xbc5c72f5, - 0xd50605be, 0xde080cb3, 0xc31a17a4, 0xc8141ea9, - 0xf93e218a, 0xf2302887, 0xef223390, 0xe42c3a9d, - 0x3d96dd06, 0x3698d40b, 0x2b8acf1c, 0x2084c611, - 0x11aef932, 0x1aa0f03f, 0x7b2eb28, 0xcbce225, - 0x65e6956e, 0x6ee89c63, 0x73fa8774, 0x78f48e79, - 0x49deb15a, 0x42d0b857, 0x5fc2a340, 0x54ccaa4d, - 0xf741ecda, 0xfc4fe5d7, 0xe15dfec0, 0xea53f7cd, - 0xdb79c8ee, 0xd077c1e3, 0xcd65daf4, 0xc66bd3f9, - 0xaf31a4b2, 0xa43fadbf, 0xb92db6a8, 0xb223bfa5, - 0x83098086, 0x8807898b, 0x9515929c, 0x9e1b9b91, - 0x47a17c0a, 0x4caf7507, 0x51bd6e10, 0x5ab3671d, - 0x6b99583e, 0x60975133, 0x7d854a24, 0x768b4329, - 0x1fd13462, 0x14df3d6f, 0x9cd2678, 0x2c32f75, - 0x33e91056, 0x38e7195b, 0x25f5024c, 0x2efb0b41, - 0x8c9ad761, 0x8794de6c, 0x9a86c57b, 0x9188cc76, - 0xa0a2f355, 0xabacfa58, 0xb6bee14f, 0xbdb0e842, - 0xd4ea9f09, 0xdfe49604, 0xc2f68d13, 0xc9f8841e, - 0xf8d2bb3d, 0xf3dcb230, 0xeecea927, 0xe5c0a02a, - 0x3c7a47b1, 0x37744ebc, 0x2a6655ab, 0x21685ca6, - 0x10426385, 0x1b4c6a88, 0x65e719f, 0xd507892, - 0x640a0fd9, 0x6f0406d4, 0x72161dc3, 0x791814ce, - 0x48322bed, 0x433c22e0, 0x5e2e39f7, 0x552030fa, - 0x1ec9ab7, 0xae293ba, 0x17f088ad, 0x1cfe81a0, - 0x2dd4be83, 0x26dab78e, 0x3bc8ac99, 0x30c6a594, - 0x599cd2df, 0x5292dbd2, 0x4f80c0c5, 0x448ec9c8, - 0x75a4f6eb, 0x7eaaffe6, 0x63b8e4f1, 0x68b6edfc, - 0xb10c0a67, 0xba02036a, 0xa710187d, 0xac1e1170, - 0x9d342e53, 0x963a275e, 0x8b283c49, 0x80263544, - 0xe97c420f, 0xe2724b02, 0xff605015, 0xf46e5918, - 0xc544663b, 0xce4a6f36, 0xd3587421, 0xd8567d2c, - 0x7a37a10c, 0x7139a801, 0x6c2bb316, 0x6725ba1b, - 0x560f8538, 0x5d018c35, 0x40139722, 0x4b1d9e2f, - 0x2247e964, 0x2949e069, 0x345bfb7e, 0x3f55f273, - 0xe7fcd50, 0x571c45d, 0x1863df4a, 0x136dd647, - 0xcad731dc, 0xc1d938d1, 0xdccb23c6, 0xd7c52acb, - 0xe6ef15e8, 0xede11ce5, 0xf0f307f2, 0xfbfd0eff, - 0x92a779b4, 0x99a970b9, 0x84bb6bae, 0x8fb562a3, - 0xbe9f5d80, 0xb591548d, 0xa8834f9a, 0xa38d4697) +U2 = ( + 0x0, + 0xB0E090D, + 0x161C121A, + 0x1D121B17, + 0x2C382434, + 0x27362D39, + 0x3A24362E, + 0x312A3F23, + 0x58704868, + 0x537E4165, + 0x4E6C5A72, + 0x4562537F, + 0x74486C5C, + 0x7F466551, + 0x62547E46, + 0x695A774B, + 0xB0E090D0, + 0xBBEE99DD, + 0xA6FC82CA, + 0xADF28BC7, + 0x9CD8B4E4, + 0x97D6BDE9, + 0x8AC4A6FE, + 0x81CAAFF3, + 0xE890D8B8, + 0xE39ED1B5, + 0xFE8CCAA2, + 0xF582C3AF, + 0xC4A8FC8C, + 0xCFA6F581, + 0xD2B4EE96, + 0xD9BAE79B, + 0x7BDB3BBB, + 0x70D532B6, + 0x6DC729A1, + 0x66C920AC, + 0x57E31F8F, + 0x5CED1682, + 0x41FF0D95, + 0x4AF10498, + 0x23AB73D3, + 0x28A57ADE, + 0x35B761C9, + 0x3EB968C4, + 0xF9357E7, + 0x49D5EEA, + 0x198F45FD, + 0x12814CF0, + 0xCB3BAB6B, + 0xC035A266, + 0xDD27B971, + 0xD629B07C, + 0xE7038F5F, + 0xEC0D8652, + 0xF11F9D45, + 0xFA119448, + 0x934BE303, + 0x9845EA0E, + 0x8557F119, + 0x8E59F814, + 0xBF73C737, + 0xB47DCE3A, + 0xA96FD52D, + 0xA261DC20, + 0xF6AD766D, + 0xFDA37F60, + 0xE0B16477, + 0xEBBF6D7A, + 0xDA955259, + 0xD19B5B54, + 0xCC894043, + 0xC787494E, + 0xAEDD3E05, + 0xA5D33708, + 0xB8C12C1F, + 0xB3CF2512, + 0x82E51A31, + 0x89EB133C, + 0x94F9082B, + 0x9FF70126, + 0x464DE6BD, + 0x4D43EFB0, + 0x5051F4A7, + 0x5B5FFDAA, + 0x6A75C289, + 0x617BCB84, + 0x7C69D093, + 0x7767D99E, + 0x1E3DAED5, + 0x1533A7D8, + 0x821BCCF, + 0x32FB5C2, + 0x32058AE1, + 0x390B83EC, + 0x241998FB, + 0x2F1791F6, + 0x8D764DD6, + 0x867844DB, + 0x9B6A5FCC, + 0x906456C1, + 0xA14E69E2, + 0xAA4060EF, + 0xB7527BF8, + 0xBC5C72F5, + 0xD50605BE, + 0xDE080CB3, + 0xC31A17A4, + 0xC8141EA9, + 0xF93E218A, + 0xF2302887, + 0xEF223390, + 0xE42C3A9D, + 0x3D96DD06, + 0x3698D40B, + 0x2B8ACF1C, + 0x2084C611, + 0x11AEF932, + 0x1AA0F03F, + 0x7B2EB28, + 0xCBCE225, + 0x65E6956E, + 0x6EE89C63, + 0x73FA8774, + 0x78F48E79, + 0x49DEB15A, + 0x42D0B857, + 0x5FC2A340, + 0x54CCAA4D, + 0xF741ECDA, + 0xFC4FE5D7, + 0xE15DFEC0, + 0xEA53F7CD, + 0xDB79C8EE, + 0xD077C1E3, + 0xCD65DAF4, + 0xC66BD3F9, + 0xAF31A4B2, + 0xA43FADBF, + 0xB92DB6A8, + 0xB223BFA5, + 0x83098086, + 0x8807898B, + 0x9515929C, + 0x9E1B9B91, + 0x47A17C0A, + 0x4CAF7507, + 0x51BD6E10, + 0x5AB3671D, + 0x6B99583E, + 0x60975133, + 0x7D854A24, + 0x768B4329, + 0x1FD13462, + 0x14DF3D6F, + 0x9CD2678, + 0x2C32F75, + 0x33E91056, + 0x38E7195B, + 0x25F5024C, + 0x2EFB0B41, + 0x8C9AD761, + 0x8794DE6C, + 0x9A86C57B, + 0x9188CC76, + 0xA0A2F355, + 0xABACFA58, + 0xB6BEE14F, + 0xBDB0E842, + 0xD4EA9F09, + 0xDFE49604, + 0xC2F68D13, + 0xC9F8841E, + 0xF8D2BB3D, + 0xF3DCB230, + 0xEECEA927, + 0xE5C0A02A, + 0x3C7A47B1, + 0x37744EBC, + 0x2A6655AB, + 0x21685CA6, + 0x10426385, + 0x1B4C6A88, + 0x65E719F, + 0xD507892, + 0x640A0FD9, + 0x6F0406D4, + 0x72161DC3, + 0x791814CE, + 0x48322BED, + 0x433C22E0, + 0x5E2E39F7, + 0x552030FA, + 0x1EC9AB7, + 0xAE293BA, + 0x17F088AD, + 0x1CFE81A0, + 0x2DD4BE83, + 0x26DAB78E, + 0x3BC8AC99, + 0x30C6A594, + 0x599CD2DF, + 0x5292DBD2, + 0x4F80C0C5, + 0x448EC9C8, + 0x75A4F6EB, + 0x7EAAFFE6, + 0x63B8E4F1, + 0x68B6EDFC, + 0xB10C0A67, + 0xBA02036A, + 0xA710187D, + 0xAC1E1170, + 0x9D342E53, + 0x963A275E, + 0x8B283C49, + 0x80263544, + 0xE97C420F, + 0xE2724B02, + 0xFF605015, + 0xF46E5918, + 0xC544663B, + 0xCE4A6F36, + 0xD3587421, + 0xD8567D2C, + 0x7A37A10C, + 0x7139A801, + 0x6C2BB316, + 0x6725BA1B, + 0x560F8538, + 0x5D018C35, + 0x40139722, + 0x4B1D9E2F, + 0x2247E964, + 0x2949E069, + 0x345BFB7E, + 0x3F55F273, + 0xE7FCD50, + 0x571C45D, + 0x1863DF4A, + 0x136DD647, + 0xCAD731DC, + 0xC1D938D1, + 0xDCCB23C6, + 0xD7C52ACB, + 0xE6EF15E8, + 0xEDE11CE5, + 0xF0F307F2, + 0xFBFD0EFF, + 0x92A779B4, + 0x99A970B9, + 0x84BB6BAE, + 0x8FB562A3, + 0xBE9F5D80, + 0xB591548D, + 0xA8834F9A, + 0xA38D4697, +) -U3 = (0x0, 0xd0b0e09, 0x1a161c12, 0x171d121b, - 0x342c3824, 0x3927362d, 0x2e3a2436, 0x23312a3f, - 0x68587048, 0x65537e41, 0x724e6c5a, 0x7f456253, - 0x5c74486c, 0x517f4665, 0x4662547e, 0x4b695a77, - 0xd0b0e090, 0xddbbee99, 0xcaa6fc82, 0xc7adf28b, - 0xe49cd8b4, 0xe997d6bd, 0xfe8ac4a6, 0xf381caaf, - 0xb8e890d8, 0xb5e39ed1, 0xa2fe8cca, 0xaff582c3, - 0x8cc4a8fc, 0x81cfa6f5, 0x96d2b4ee, 0x9bd9bae7, - 0xbb7bdb3b, 0xb670d532, 0xa16dc729, 0xac66c920, - 0x8f57e31f, 0x825ced16, 0x9541ff0d, 0x984af104, - 0xd323ab73, 0xde28a57a, 0xc935b761, 0xc43eb968, - 0xe70f9357, 0xea049d5e, 0xfd198f45, 0xf012814c, - 0x6bcb3bab, 0x66c035a2, 0x71dd27b9, 0x7cd629b0, - 0x5fe7038f, 0x52ec0d86, 0x45f11f9d, 0x48fa1194, - 0x3934be3, 0xe9845ea, 0x198557f1, 0x148e59f8, - 0x37bf73c7, 0x3ab47dce, 0x2da96fd5, 0x20a261dc, - 0x6df6ad76, 0x60fda37f, 0x77e0b164, 0x7aebbf6d, - 0x59da9552, 0x54d19b5b, 0x43cc8940, 0x4ec78749, - 0x5aedd3e, 0x8a5d337, 0x1fb8c12c, 0x12b3cf25, - 0x3182e51a, 0x3c89eb13, 0x2b94f908, 0x269ff701, - 0xbd464de6, 0xb04d43ef, 0xa75051f4, 0xaa5b5ffd, - 0x896a75c2, 0x84617bcb, 0x937c69d0, 0x9e7767d9, - 0xd51e3dae, 0xd81533a7, 0xcf0821bc, 0xc2032fb5, - 0xe132058a, 0xec390b83, 0xfb241998, 0xf62f1791, - 0xd68d764d, 0xdb867844, 0xcc9b6a5f, 0xc1906456, - 0xe2a14e69, 0xefaa4060, 0xf8b7527b, 0xf5bc5c72, - 0xbed50605, 0xb3de080c, 0xa4c31a17, 0xa9c8141e, - 0x8af93e21, 0x87f23028, 0x90ef2233, 0x9de42c3a, - 0x63d96dd, 0xb3698d4, 0x1c2b8acf, 0x112084c6, - 0x3211aef9, 0x3f1aa0f0, 0x2807b2eb, 0x250cbce2, - 0x6e65e695, 0x636ee89c, 0x7473fa87, 0x7978f48e, - 0x5a49deb1, 0x5742d0b8, 0x405fc2a3, 0x4d54ccaa, - 0xdaf741ec, 0xd7fc4fe5, 0xc0e15dfe, 0xcdea53f7, - 0xeedb79c8, 0xe3d077c1, 0xf4cd65da, 0xf9c66bd3, - 0xb2af31a4, 0xbfa43fad, 0xa8b92db6, 0xa5b223bf, - 0x86830980, 0x8b880789, 0x9c951592, 0x919e1b9b, - 0xa47a17c, 0x74caf75, 0x1051bd6e, 0x1d5ab367, - 0x3e6b9958, 0x33609751, 0x247d854a, 0x29768b43, - 0x621fd134, 0x6f14df3d, 0x7809cd26, 0x7502c32f, - 0x5633e910, 0x5b38e719, 0x4c25f502, 0x412efb0b, - 0x618c9ad7, 0x6c8794de, 0x7b9a86c5, 0x769188cc, - 0x55a0a2f3, 0x58abacfa, 0x4fb6bee1, 0x42bdb0e8, - 0x9d4ea9f, 0x4dfe496, 0x13c2f68d, 0x1ec9f884, - 0x3df8d2bb, 0x30f3dcb2, 0x27eecea9, 0x2ae5c0a0, - 0xb13c7a47, 0xbc37744e, 0xab2a6655, 0xa621685c, - 0x85104263, 0x881b4c6a, 0x9f065e71, 0x920d5078, - 0xd9640a0f, 0xd46f0406, 0xc372161d, 0xce791814, - 0xed48322b, 0xe0433c22, 0xf75e2e39, 0xfa552030, - 0xb701ec9a, 0xba0ae293, 0xad17f088, 0xa01cfe81, - 0x832dd4be, 0x8e26dab7, 0x993bc8ac, 0x9430c6a5, - 0xdf599cd2, 0xd25292db, 0xc54f80c0, 0xc8448ec9, - 0xeb75a4f6, 0xe67eaaff, 0xf163b8e4, 0xfc68b6ed, - 0x67b10c0a, 0x6aba0203, 0x7da71018, 0x70ac1e11, - 0x539d342e, 0x5e963a27, 0x498b283c, 0x44802635, - 0xfe97c42, 0x2e2724b, 0x15ff6050, 0x18f46e59, - 0x3bc54466, 0x36ce4a6f, 0x21d35874, 0x2cd8567d, - 0xc7a37a1, 0x17139a8, 0x166c2bb3, 0x1b6725ba, - 0x38560f85, 0x355d018c, 0x22401397, 0x2f4b1d9e, - 0x642247e9, 0x692949e0, 0x7e345bfb, 0x733f55f2, - 0x500e7fcd, 0x5d0571c4, 0x4a1863df, 0x47136dd6, - 0xdccad731, 0xd1c1d938, 0xc6dccb23, 0xcbd7c52a, - 0xe8e6ef15, 0xe5ede11c, 0xf2f0f307, 0xfffbfd0e, - 0xb492a779, 0xb999a970, 0xae84bb6b, 0xa38fb562, - 0x80be9f5d, 0x8db59154, 0x9aa8834f, 0x97a38d46) +U3 = ( + 0x0, + 0xD0B0E09, + 0x1A161C12, + 0x171D121B, + 0x342C3824, + 0x3927362D, + 0x2E3A2436, + 0x23312A3F, + 0x68587048, + 0x65537E41, + 0x724E6C5A, + 0x7F456253, + 0x5C74486C, + 0x517F4665, + 0x4662547E, + 0x4B695A77, + 0xD0B0E090, + 0xDDBBEE99, + 0xCAA6FC82, + 0xC7ADF28B, + 0xE49CD8B4, + 0xE997D6BD, + 0xFE8AC4A6, + 0xF381CAAF, + 0xB8E890D8, + 0xB5E39ED1, + 0xA2FE8CCA, + 0xAFF582C3, + 0x8CC4A8FC, + 0x81CFA6F5, + 0x96D2B4EE, + 0x9BD9BAE7, + 0xBB7BDB3B, + 0xB670D532, + 0xA16DC729, + 0xAC66C920, + 0x8F57E31F, + 0x825CED16, + 0x9541FF0D, + 0x984AF104, + 0xD323AB73, + 0xDE28A57A, + 0xC935B761, + 0xC43EB968, + 0xE70F9357, + 0xEA049D5E, + 0xFD198F45, + 0xF012814C, + 0x6BCB3BAB, + 0x66C035A2, + 0x71DD27B9, + 0x7CD629B0, + 0x5FE7038F, + 0x52EC0D86, + 0x45F11F9D, + 0x48FA1194, + 0x3934BE3, + 0xE9845EA, + 0x198557F1, + 0x148E59F8, + 0x37BF73C7, + 0x3AB47DCE, + 0x2DA96FD5, + 0x20A261DC, + 0x6DF6AD76, + 0x60FDA37F, + 0x77E0B164, + 0x7AEBBF6D, + 0x59DA9552, + 0x54D19B5B, + 0x43CC8940, + 0x4EC78749, + 0x5AEDD3E, + 0x8A5D337, + 0x1FB8C12C, + 0x12B3CF25, + 0x3182E51A, + 0x3C89EB13, + 0x2B94F908, + 0x269FF701, + 0xBD464DE6, + 0xB04D43EF, + 0xA75051F4, + 0xAA5B5FFD, + 0x896A75C2, + 0x84617BCB, + 0x937C69D0, + 0x9E7767D9, + 0xD51E3DAE, + 0xD81533A7, + 0xCF0821BC, + 0xC2032FB5, + 0xE132058A, + 0xEC390B83, + 0xFB241998, + 0xF62F1791, + 0xD68D764D, + 0xDB867844, + 0xCC9B6A5F, + 0xC1906456, + 0xE2A14E69, + 0xEFAA4060, + 0xF8B7527B, + 0xF5BC5C72, + 0xBED50605, + 0xB3DE080C, + 0xA4C31A17, + 0xA9C8141E, + 0x8AF93E21, + 0x87F23028, + 0x90EF2233, + 0x9DE42C3A, + 0x63D96DD, + 0xB3698D4, + 0x1C2B8ACF, + 0x112084C6, + 0x3211AEF9, + 0x3F1AA0F0, + 0x2807B2EB, + 0x250CBCE2, + 0x6E65E695, + 0x636EE89C, + 0x7473FA87, + 0x7978F48E, + 0x5A49DEB1, + 0x5742D0B8, + 0x405FC2A3, + 0x4D54CCAA, + 0xDAF741EC, + 0xD7FC4FE5, + 0xC0E15DFE, + 0xCDEA53F7, + 0xEEDB79C8, + 0xE3D077C1, + 0xF4CD65DA, + 0xF9C66BD3, + 0xB2AF31A4, + 0xBFA43FAD, + 0xA8B92DB6, + 0xA5B223BF, + 0x86830980, + 0x8B880789, + 0x9C951592, + 0x919E1B9B, + 0xA47A17C, + 0x74CAF75, + 0x1051BD6E, + 0x1D5AB367, + 0x3E6B9958, + 0x33609751, + 0x247D854A, + 0x29768B43, + 0x621FD134, + 0x6F14DF3D, + 0x7809CD26, + 0x7502C32F, + 0x5633E910, + 0x5B38E719, + 0x4C25F502, + 0x412EFB0B, + 0x618C9AD7, + 0x6C8794DE, + 0x7B9A86C5, + 0x769188CC, + 0x55A0A2F3, + 0x58ABACFA, + 0x4FB6BEE1, + 0x42BDB0E8, + 0x9D4EA9F, + 0x4DFE496, + 0x13C2F68D, + 0x1EC9F884, + 0x3DF8D2BB, + 0x30F3DCB2, + 0x27EECEA9, + 0x2AE5C0A0, + 0xB13C7A47, + 0xBC37744E, + 0xAB2A6655, + 0xA621685C, + 0x85104263, + 0x881B4C6A, + 0x9F065E71, + 0x920D5078, + 0xD9640A0F, + 0xD46F0406, + 0xC372161D, + 0xCE791814, + 0xED48322B, + 0xE0433C22, + 0xF75E2E39, + 0xFA552030, + 0xB701EC9A, + 0xBA0AE293, + 0xAD17F088, + 0xA01CFE81, + 0x832DD4BE, + 0x8E26DAB7, + 0x993BC8AC, + 0x9430C6A5, + 0xDF599CD2, + 0xD25292DB, + 0xC54F80C0, + 0xC8448EC9, + 0xEB75A4F6, + 0xE67EAAFF, + 0xF163B8E4, + 0xFC68B6ED, + 0x67B10C0A, + 0x6ABA0203, + 0x7DA71018, + 0x70AC1E11, + 0x539D342E, + 0x5E963A27, + 0x498B283C, + 0x44802635, + 0xFE97C42, + 0x2E2724B, + 0x15FF6050, + 0x18F46E59, + 0x3BC54466, + 0x36CE4A6F, + 0x21D35874, + 0x2CD8567D, + 0xC7A37A1, + 0x17139A8, + 0x166C2BB3, + 0x1B6725BA, + 0x38560F85, + 0x355D018C, + 0x22401397, + 0x2F4B1D9E, + 0x642247E9, + 0x692949E0, + 0x7E345BFB, + 0x733F55F2, + 0x500E7FCD, + 0x5D0571C4, + 0x4A1863DF, + 0x47136DD6, + 0xDCCAD731, + 0xD1C1D938, + 0xC6DCCB23, + 0xCBD7C52A, + 0xE8E6EF15, + 0xE5EDE11C, + 0xF2F0F307, + 0xFFFBFD0E, + 0xB492A779, + 0xB999A970, + 0xAE84BB6B, + 0xA38FB562, + 0x80BE9F5D, + 0x8DB59154, + 0x9AA8834F, + 0x97A38D46, +) -U4 = (0x0, 0x90d0b0e, 0x121a161c, 0x1b171d12, - 0x24342c38, 0x2d392736, 0x362e3a24, 0x3f23312a, - 0x48685870, 0x4165537e, 0x5a724e6c, 0x537f4562, - 0x6c5c7448, 0x65517f46, 0x7e466254, 0x774b695a, - 0x90d0b0e0, 0x99ddbbee, 0x82caa6fc, 0x8bc7adf2, - 0xb4e49cd8, 0xbde997d6, 0xa6fe8ac4, 0xaff381ca, - 0xd8b8e890, 0xd1b5e39e, 0xcaa2fe8c, 0xc3aff582, - 0xfc8cc4a8, 0xf581cfa6, 0xee96d2b4, 0xe79bd9ba, - 0x3bbb7bdb, 0x32b670d5, 0x29a16dc7, 0x20ac66c9, - 0x1f8f57e3, 0x16825ced, 0xd9541ff, 0x4984af1, - 0x73d323ab, 0x7ade28a5, 0x61c935b7, 0x68c43eb9, - 0x57e70f93, 0x5eea049d, 0x45fd198f, 0x4cf01281, - 0xab6bcb3b, 0xa266c035, 0xb971dd27, 0xb07cd629, - 0x8f5fe703, 0x8652ec0d, 0x9d45f11f, 0x9448fa11, - 0xe303934b, 0xea0e9845, 0xf1198557, 0xf8148e59, - 0xc737bf73, 0xce3ab47d, 0xd52da96f, 0xdc20a261, - 0x766df6ad, 0x7f60fda3, 0x6477e0b1, 0x6d7aebbf, - 0x5259da95, 0x5b54d19b, 0x4043cc89, 0x494ec787, - 0x3e05aedd, 0x3708a5d3, 0x2c1fb8c1, 0x2512b3cf, - 0x1a3182e5, 0x133c89eb, 0x82b94f9, 0x1269ff7, - 0xe6bd464d, 0xefb04d43, 0xf4a75051, 0xfdaa5b5f, - 0xc2896a75, 0xcb84617b, 0xd0937c69, 0xd99e7767, - 0xaed51e3d, 0xa7d81533, 0xbccf0821, 0xb5c2032f, - 0x8ae13205, 0x83ec390b, 0x98fb2419, 0x91f62f17, - 0x4dd68d76, 0x44db8678, 0x5fcc9b6a, 0x56c19064, - 0x69e2a14e, 0x60efaa40, 0x7bf8b752, 0x72f5bc5c, - 0x5bed506, 0xcb3de08, 0x17a4c31a, 0x1ea9c814, - 0x218af93e, 0x2887f230, 0x3390ef22, 0x3a9de42c, - 0xdd063d96, 0xd40b3698, 0xcf1c2b8a, 0xc6112084, - 0xf93211ae, 0xf03f1aa0, 0xeb2807b2, 0xe2250cbc, - 0x956e65e6, 0x9c636ee8, 0x877473fa, 0x8e7978f4, - 0xb15a49de, 0xb85742d0, 0xa3405fc2, 0xaa4d54cc, - 0xecdaf741, 0xe5d7fc4f, 0xfec0e15d, 0xf7cdea53, - 0xc8eedb79, 0xc1e3d077, 0xdaf4cd65, 0xd3f9c66b, - 0xa4b2af31, 0xadbfa43f, 0xb6a8b92d, 0xbfa5b223, - 0x80868309, 0x898b8807, 0x929c9515, 0x9b919e1b, - 0x7c0a47a1, 0x75074caf, 0x6e1051bd, 0x671d5ab3, - 0x583e6b99, 0x51336097, 0x4a247d85, 0x4329768b, - 0x34621fd1, 0x3d6f14df, 0x267809cd, 0x2f7502c3, - 0x105633e9, 0x195b38e7, 0x24c25f5, 0xb412efb, - 0xd7618c9a, 0xde6c8794, 0xc57b9a86, 0xcc769188, - 0xf355a0a2, 0xfa58abac, 0xe14fb6be, 0xe842bdb0, - 0x9f09d4ea, 0x9604dfe4, 0x8d13c2f6, 0x841ec9f8, - 0xbb3df8d2, 0xb230f3dc, 0xa927eece, 0xa02ae5c0, - 0x47b13c7a, 0x4ebc3774, 0x55ab2a66, 0x5ca62168, - 0x63851042, 0x6a881b4c, 0x719f065e, 0x78920d50, - 0xfd9640a, 0x6d46f04, 0x1dc37216, 0x14ce7918, - 0x2bed4832, 0x22e0433c, 0x39f75e2e, 0x30fa5520, - 0x9ab701ec, 0x93ba0ae2, 0x88ad17f0, 0x81a01cfe, - 0xbe832dd4, 0xb78e26da, 0xac993bc8, 0xa59430c6, - 0xd2df599c, 0xdbd25292, 0xc0c54f80, 0xc9c8448e, - 0xf6eb75a4, 0xffe67eaa, 0xe4f163b8, 0xedfc68b6, - 0xa67b10c, 0x36aba02, 0x187da710, 0x1170ac1e, - 0x2e539d34, 0x275e963a, 0x3c498b28, 0x35448026, - 0x420fe97c, 0x4b02e272, 0x5015ff60, 0x5918f46e, - 0x663bc544, 0x6f36ce4a, 0x7421d358, 0x7d2cd856, - 0xa10c7a37, 0xa8017139, 0xb3166c2b, 0xba1b6725, - 0x8538560f, 0x8c355d01, 0x97224013, 0x9e2f4b1d, - 0xe9642247, 0xe0692949, 0xfb7e345b, 0xf2733f55, - 0xcd500e7f, 0xc45d0571, 0xdf4a1863, 0xd647136d, - 0x31dccad7, 0x38d1c1d9, 0x23c6dccb, 0x2acbd7c5, - 0x15e8e6ef, 0x1ce5ede1, 0x7f2f0f3, 0xefffbfd, - 0x79b492a7, 0x70b999a9, 0x6bae84bb, 0x62a38fb5, - 0x5d80be9f, 0x548db591, 0x4f9aa883, 0x4697a38d) +U4 = ( + 0x0, + 0x90D0B0E, + 0x121A161C, + 0x1B171D12, + 0x24342C38, + 0x2D392736, + 0x362E3A24, + 0x3F23312A, + 0x48685870, + 0x4165537E, + 0x5A724E6C, + 0x537F4562, + 0x6C5C7448, + 0x65517F46, + 0x7E466254, + 0x774B695A, + 0x90D0B0E0, + 0x99DDBBEE, + 0x82CAA6FC, + 0x8BC7ADF2, + 0xB4E49CD8, + 0xBDE997D6, + 0xA6FE8AC4, + 0xAFF381CA, + 0xD8B8E890, + 0xD1B5E39E, + 0xCAA2FE8C, + 0xC3AFF582, + 0xFC8CC4A8, + 0xF581CFA6, + 0xEE96D2B4, + 0xE79BD9BA, + 0x3BBB7BDB, + 0x32B670D5, + 0x29A16DC7, + 0x20AC66C9, + 0x1F8F57E3, + 0x16825CED, + 0xD9541FF, + 0x4984AF1, + 0x73D323AB, + 0x7ADE28A5, + 0x61C935B7, + 0x68C43EB9, + 0x57E70F93, + 0x5EEA049D, + 0x45FD198F, + 0x4CF01281, + 0xAB6BCB3B, + 0xA266C035, + 0xB971DD27, + 0xB07CD629, + 0x8F5FE703, + 0x8652EC0D, + 0x9D45F11F, + 0x9448FA11, + 0xE303934B, + 0xEA0E9845, + 0xF1198557, + 0xF8148E59, + 0xC737BF73, + 0xCE3AB47D, + 0xD52DA96F, + 0xDC20A261, + 0x766DF6AD, + 0x7F60FDA3, + 0x6477E0B1, + 0x6D7AEBBF, + 0x5259DA95, + 0x5B54D19B, + 0x4043CC89, + 0x494EC787, + 0x3E05AEDD, + 0x3708A5D3, + 0x2C1FB8C1, + 0x2512B3CF, + 0x1A3182E5, + 0x133C89EB, + 0x82B94F9, + 0x1269FF7, + 0xE6BD464D, + 0xEFB04D43, + 0xF4A75051, + 0xFDAA5B5F, + 0xC2896A75, + 0xCB84617B, + 0xD0937C69, + 0xD99E7767, + 0xAED51E3D, + 0xA7D81533, + 0xBCCF0821, + 0xB5C2032F, + 0x8AE13205, + 0x83EC390B, + 0x98FB2419, + 0x91F62F17, + 0x4DD68D76, + 0x44DB8678, + 0x5FCC9B6A, + 0x56C19064, + 0x69E2A14E, + 0x60EFAA40, + 0x7BF8B752, + 0x72F5BC5C, + 0x5BED506, + 0xCB3DE08, + 0x17A4C31A, + 0x1EA9C814, + 0x218AF93E, + 0x2887F230, + 0x3390EF22, + 0x3A9DE42C, + 0xDD063D96, + 0xD40B3698, + 0xCF1C2B8A, + 0xC6112084, + 0xF93211AE, + 0xF03F1AA0, + 0xEB2807B2, + 0xE2250CBC, + 0x956E65E6, + 0x9C636EE8, + 0x877473FA, + 0x8E7978F4, + 0xB15A49DE, + 0xB85742D0, + 0xA3405FC2, + 0xAA4D54CC, + 0xECDAF741, + 0xE5D7FC4F, + 0xFEC0E15D, + 0xF7CDEA53, + 0xC8EEDB79, + 0xC1E3D077, + 0xDAF4CD65, + 0xD3F9C66B, + 0xA4B2AF31, + 0xADBFA43F, + 0xB6A8B92D, + 0xBFA5B223, + 0x80868309, + 0x898B8807, + 0x929C9515, + 0x9B919E1B, + 0x7C0A47A1, + 0x75074CAF, + 0x6E1051BD, + 0x671D5AB3, + 0x583E6B99, + 0x51336097, + 0x4A247D85, + 0x4329768B, + 0x34621FD1, + 0x3D6F14DF, + 0x267809CD, + 0x2F7502C3, + 0x105633E9, + 0x195B38E7, + 0x24C25F5, + 0xB412EFB, + 0xD7618C9A, + 0xDE6C8794, + 0xC57B9A86, + 0xCC769188, + 0xF355A0A2, + 0xFA58ABAC, + 0xE14FB6BE, + 0xE842BDB0, + 0x9F09D4EA, + 0x9604DFE4, + 0x8D13C2F6, + 0x841EC9F8, + 0xBB3DF8D2, + 0xB230F3DC, + 0xA927EECE, + 0xA02AE5C0, + 0x47B13C7A, + 0x4EBC3774, + 0x55AB2A66, + 0x5CA62168, + 0x63851042, + 0x6A881B4C, + 0x719F065E, + 0x78920D50, + 0xFD9640A, + 0x6D46F04, + 0x1DC37216, + 0x14CE7918, + 0x2BED4832, + 0x22E0433C, + 0x39F75E2E, + 0x30FA5520, + 0x9AB701EC, + 0x93BA0AE2, + 0x88AD17F0, + 0x81A01CFE, + 0xBE832DD4, + 0xB78E26DA, + 0xAC993BC8, + 0xA59430C6, + 0xD2DF599C, + 0xDBD25292, + 0xC0C54F80, + 0xC9C8448E, + 0xF6EB75A4, + 0xFFE67EAA, + 0xE4F163B8, + 0xEDFC68B6, + 0xA67B10C, + 0x36ABA02, + 0x187DA710, + 0x1170AC1E, + 0x2E539D34, + 0x275E963A, + 0x3C498B28, + 0x35448026, + 0x420FE97C, + 0x4B02E272, + 0x5015FF60, + 0x5918F46E, + 0x663BC544, + 0x6F36CE4A, + 0x7421D358, + 0x7D2CD856, + 0xA10C7A37, + 0xA8017139, + 0xB3166C2B, + 0xBA1B6725, + 0x8538560F, + 0x8C355D01, + 0x97224013, + 0x9E2F4B1D, + 0xE9642247, + 0xE0692949, + 0xFB7E345B, + 0xF2733F55, + 0xCD500E7F, + 0xC45D0571, + 0xDF4A1863, + 0xD647136D, + 0x31DCCAD7, + 0x38D1C1D9, + 0x23C6DCCB, + 0x2ACBD7C5, + 0x15E8E6EF, + 0x1CE5EDE1, + 0x7F2F0F3, + 0xEFFFBFD, + 0x79B492A7, + 0x70B999A9, + 0x6BAE84BB, + 0x62A38FB5, + 0x5D80BE9F, + 0x548DB591, + 0x4F9AA883, + 0x4697A38D, +) -rcon = (0x1, 0x2, 0x4, 0x8, 0x10, 0x20, 0x40, 0x80, - 0x1b, 0x36, 0x6c, 0xd8, 0xab, 0x4d, 0x9a, 0x2f, - 0x5e, 0xbc, 0x63, 0xc6, 0x97, 0x35, 0x6a, 0xd4, - 0xb3, 0x7d, 0xfa, 0xef, 0xc5, 0x91) +rcon = ( + 0x1, + 0x2, + 0x4, + 0x8, + 0x10, + 0x20, + 0x40, + 0x80, + 0x1B, + 0x36, + 0x6C, + 0xD8, + 0xAB, + 0x4D, + 0x9A, + 0x2F, + 0x5E, + 0xBC, + 0x63, + 0xC6, + 0x97, + 0x35, + 0x6A, + 0xD4, + 0xB3, + 0x7D, + 0xFA, + 0xEF, + 0xC5, + 0x91, +) -@deprecated_class_name('rijndael') +@deprecated_class_name("rijndael") class Rijndael(object): """ Implementation of the AES (formely known as Rijndael) block cipher. @@ -921,12 +3725,13 @@ class Rijndael(object): :ival list Ke: key schedule for encryption :ival list Kd: key schedule for decryption """ + def __init__(self, key, block_size=16): """Initialise the object, derive keys for encryption and decryption.""" if block_size != 16 and block_size != 24 and block_size != 32: - raise ValueError('Invalid block size: ' + str(block_size)) + raise ValueError("Invalid block size: " + str(block_size)) if len(key) != 16 and len(key) != 24 and len(key) != 32: - raise ValueError('Invalid key size: ' + str(len(key))) + raise ValueError("Invalid key size: " + str(len(key))) self.block_size = block_size ROUNDS = num_rounds[len(key)][block_size] @@ -943,15 +3748,14 @@ class Rijndael(object): if sys.version_info < (3, 0): for i in range(0, KC): tk.append( - (ord(key[i * 4]) << 24) | (ord(key[i * 4 + 1]) << 16) - | (ord(key[i * 4 + 2]) << 8) | ord(key[i * 4 + 3]) + (ord(key[i * 4]) << 24) + | (ord(key[i * 4 + 1]) << 16) + | (ord(key[i * 4 + 2]) << 8) + | ord(key[i * 4 + 3]) ) else: for i in range(0, KC): - tk.append( - (key[i * 4] << 24) | (key[i * 4 + 1] << 16) - | (key[i * 4 + 2] << 8) | key[i * 4 + 3] - ) + tk.append((key[i * 4] << 24) | (key[i * 4 + 1] << 16) | (key[i * 4 + 2] << 8) | key[i * 4 + 3]) # copy values into round key arrays t = 0 @@ -966,25 +3770,29 @@ class Rijndael(object): while t < ROUND_KEY_COUNT: # extrapolate using phi (the round key evolution function) tt = tk[KC - 1] - tk[0] ^= (S[(tt >> 16) & 0xFF] & 0xFF) << 24 ^ \ - (S[(tt >> 8) & 0xFF] & 0xFF) << 16 ^ \ - (S[ tt & 0xFF] & 0xFF) << 8 ^ \ - (S[(tt >> 24) & 0xFF] & 0xFF) ^ \ - (rcon[rconpointer] & 0xFF) << 24 + tk[0] ^= ( + (S[(tt >> 16) & 0xFF] & 0xFF) << 24 + ^ (S[(tt >> 8) & 0xFF] & 0xFF) << 16 + ^ (S[tt & 0xFF] & 0xFF) << 8 + ^ (S[(tt >> 24) & 0xFF] & 0xFF) + ^ (rcon[rconpointer] & 0xFF) << 24 + ) rconpointer += 1 if KC != 8: for i in range(1, KC): - tk[i] ^= tk[i-1] + tk[i] ^= tk[i - 1] else: for i in range(1, KC // 2): - tk[i] ^= tk[i-1] + tk[i] ^= tk[i - 1] tt = tk[KC // 2 - 1] - tk[KC // 2] ^= (S[ tt & 0xFF] & 0xFF) ^ \ - (S[(tt >> 8) & 0xFF] & 0xFF) << 8 ^ \ - (S[(tt >> 16) & 0xFF] & 0xFF) << 16 ^ \ - (S[(tt >> 24) & 0xFF] & 0xFF) << 24 + tk[KC // 2] ^= ( + (S[tt & 0xFF] & 0xFF) + ^ (S[(tt >> 8) & 0xFF] & 0xFF) << 8 + ^ (S[(tt >> 16) & 0xFF] & 0xFF) << 16 + ^ (S[(tt >> 24) & 0xFF] & 0xFF) << 24 + ) for i in range(KC // 2 + 1, KC): - tk[i] ^= tk[i-1] + tk[i] ^= tk[i - 1] # copy values into round key arrays j = 0 while j < KC and t < ROUND_KEY_COUNT: @@ -996,18 +3804,14 @@ class Rijndael(object): for r in range(1, ROUNDS): for j in range(BC): tt = Kd[r][j] - Kd[r][j] = U1[(tt >> 24) & 0xFF] ^ \ - U2[(tt >> 16) & 0xFF] ^ \ - U3[(tt >> 8) & 0xFF] ^ \ - U4[ tt & 0xFF] + Kd[r][j] = U1[(tt >> 24) & 0xFF] ^ U2[(tt >> 16) & 0xFF] ^ U3[(tt >> 8) & 0xFF] ^ U4[tt & 0xFF] self.Ke = Ke self.Kd = Kd def encrypt(self, plaintext): """Encrypt a single block of plaintext.""" if len(plaintext) != self.block_size: - raise ValueError('wrong block length, expected {0} got {1}' - .format(self.block_size, len(plaintext))) + raise ValueError("wrong block length, expected {0} got {1}".format(self.block_size, len(plaintext))) Ke = self.Ke BC = self.block_size // 4 @@ -1026,33 +3830,34 @@ class Rijndael(object): t = [] # plaintext to ints + key for i in range(BC): - t.append((plaintext[i * 4 ] << 24 | - plaintext[i * 4 + 1] << 16 | - plaintext[i * 4 + 2] << 8 | - plaintext[i * 4 + 3] ) ^ Ke[0][i]) + t.append( + (plaintext[i * 4] << 24 | plaintext[i * 4 + 1] << 16 | plaintext[i * 4 + 2] << 8 | plaintext[i * 4 + 3]) + ^ Ke[0][i] + ) # apply round transforms for r in range(1, ROUNDS): for i in range(BC): - a[i] = (T1[(t[ i ] >> 24) & 0xFF] ^ - T2[(t[(i + s1) % BC] >> 16) & 0xFF] ^ - T3[(t[(i + s2) % BC] >> 8) & 0xFF] ^ - T4[ t[(i + s3) % BC] & 0xFF] ) ^ Ke[r][i] + a[i] = ( + T1[(t[i] >> 24) & 0xFF] + ^ T2[(t[(i + s1) % BC] >> 16) & 0xFF] + ^ T3[(t[(i + s2) % BC] >> 8) & 0xFF] + ^ T4[t[(i + s3) % BC] & 0xFF] + ) ^ Ke[r][i] t = a[:] # last round is special result = [] for i in range(BC): tt = Ke[ROUNDS][i] - result.append((S[(t[ i ] >> 24) & 0xFF] ^ (tt>>24)) & 0xFF) - result.append((S[(t[(i+s1) % BC] >> 16) & 0xFF] ^ (tt>>16)) & 0xFF) - result.append((S[(t[(i+s2) % BC] >> 8) & 0xFF] ^ (tt>> 8)) & 0xFF) - result.append((S[ t[(i+s3) % BC] & 0xFF] ^ tt ) & 0xFF) + result.append((S[(t[i] >> 24) & 0xFF] ^ (tt >> 24)) & 0xFF) + result.append((S[(t[(i + s1) % BC] >> 16) & 0xFF] ^ (tt >> 16)) & 0xFF) + result.append((S[(t[(i + s2) % BC] >> 8) & 0xFF] ^ (tt >> 8)) & 0xFF) + result.append((S[t[(i + s3) % BC] & 0xFF] ^ tt) & 0xFF) return bytearray(result) def decrypt(self, ciphertext): """Decrypt a block of ciphertext.""" if len(ciphertext) != self.block_size: - raise ValueError('wrong block length, expected {0} got {1}' - .format(self.block_size, len(ciphertext))) + raise ValueError("wrong block length, expected {0} got {1}".format(self.block_size, len(ciphertext))) Kd = self.Kd BC = self.block_size // 4 @@ -1071,26 +3876,30 @@ class Rijndael(object): t = [0] * BC # ciphertext to ints + key for i in range(BC): - t[i] = (ciphertext[i * 4 ] << 24 | - ciphertext[i * 4 + 1] << 16 | - ciphertext[i * 4 + 2] << 8 | - ciphertext[i * 4 + 3] ) ^ Kd[0][i] + t[i] = ( + ciphertext[i * 4] << 24 + | ciphertext[i * 4 + 1] << 16 + | ciphertext[i * 4 + 2] << 8 + | ciphertext[i * 4 + 3] + ) ^ Kd[0][i] # apply round transforms for r in range(1, ROUNDS): for i in range(BC): - a[i] = (T5[(t[ i ] >> 24) & 0xFF] ^ - T6[(t[(i + s1) % BC] >> 16) & 0xFF] ^ - T7[(t[(i + s2) % BC] >> 8) & 0xFF] ^ - T8[ t[(i + s3) % BC] & 0xFF] ) ^ Kd[r][i] + a[i] = ( + T5[(t[i] >> 24) & 0xFF] + ^ T6[(t[(i + s1) % BC] >> 16) & 0xFF] + ^ T7[(t[(i + s2) % BC] >> 8) & 0xFF] + ^ T8[t[(i + s3) % BC] & 0xFF] + ) ^ Kd[r][i] t = a[:] # last round is special result = [] for i in range(BC): tt = Kd[ROUNDS][i] - result.append((Si[(t[ i ] >> 24) & 0xFF] ^ (tt>>24)) &0xFF) - result.append((Si[(t[(i+s1) % BC] >> 16) & 0xFF] ^ (tt>>16)) &0xFF) - result.append((Si[(t[(i+s2) % BC] >> 8) & 0xFF] ^ (tt>> 8)) &0xFF) - result.append((Si[ t[(i+s3) % BC] & 0xFF] ^ tt ) &0xFF) + result.append((Si[(t[i] >> 24) & 0xFF] ^ (tt >> 24)) & 0xFF) + result.append((Si[(t[(i + s1) % BC] >> 16) & 0xFF] ^ (tt >> 16)) & 0xFF) + result.append((Si[(t[(i + s2) % BC] >> 8) & 0xFF] ^ (tt >> 8)) & 0xFF) + result.append((Si[t[(i + s3) % BC] & 0xFF] ^ tt) & 0xFF) return bytearray(result) @@ -1104,9 +3913,10 @@ def decrypt(key, block): def test(): def t(kl, bl): - b = 'b' * bl - r = Rijndael('a' * kl, bl) + b = "b" * bl + r = Rijndael("a" * kl, bl) assert r.decrypt(r.encrypt(b)) == b + t(16, 16) t(16, 24) t(16, 32) diff --git a/mediaflow_proxy/utils/stream_transformers.py b/mediaflow_proxy/utils/stream_transformers.py new file mode 100644 index 0000000..da07a0d --- /dev/null +++ b/mediaflow_proxy/utils/stream_transformers.py @@ -0,0 +1,241 @@ +""" +Stream transformers for host-specific content manipulation. + +This module provides transformer classes that can modify streaming content +on-the-fly. Each transformer handles specific content manipulation needs +for different streaming hosts (e.g., PNG wrapper stripping, TS detection). +""" + +import logging +import typing + +logger = logging.getLogger(__name__) + + +class StreamTransformer: + """ + Base class for stream content transformers. + + Subclasses should override the transform method to implement + specific content manipulation logic. + """ + + async def transform(self, chunk_iterator: typing.AsyncIterator[bytes]) -> typing.AsyncGenerator[bytes, None]: + """ + Transform stream chunks. + + Args: + chunk_iterator: Async iterator of raw bytes from upstream. + + Yields: + Transformed bytes chunks. + """ + async for chunk in chunk_iterator: + yield chunk + + +class TSStreamTransformer(StreamTransformer): + """ + Transformer for MPEG-TS streams with obfuscation. + + Handles streams from hosts like TurboVidPlay, StreamWish, and FileMoon + that may have: + - Fake PNG wrapper prepended to video data + - 0xFF padding bytes before actual content + - Need for TS sync byte detection + """ + + # PNG signature and IEND marker for fake PNG header detection + _PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n" + _PNG_IEND_MARKER = b"\x49\x45\x4e\x44\xae\x42\x60\x82" + + # TS packet constants + _TS_SYNC = 0x47 + _TS_PACKET_SIZE = 188 + + # Maximum bytes to buffer before forcing passthrough + _MAX_PREFETCH = 512 * 1024 # 512 KB + + def __init__(self): + self.buffer = bytearray() + self.ts_started = False + self.bytes_stripped = 0 + + @staticmethod + def _find_ts_start(buffer: bytes) -> typing.Optional[int]: + """ + Find MPEG-TS sync byte (0x47) aligned on 188 bytes. + + Args: + buffer: Bytes to search for TS sync pattern. + + Returns: + Offset where TS starts, or None if not found. + """ + TS_SYNC = 0x47 + TS_PACKET = 188 + + max_i = len(buffer) - TS_PACKET + for i in range(max(0, max_i)): + if buffer[i] == TS_SYNC and buffer[i + TS_PACKET] == TS_SYNC: + return i + return None + + def _strip_fake_png_wrapper(self, chunk: bytes) -> bytes: + """ + Strip fake PNG wrapper from chunk data. + + Some streaming services prepend a fake PNG image to video data + to evade detection. This method detects and removes it. + + Args: + chunk: The raw chunk data that may contain a fake PNG header. + + Returns: + The chunk with fake PNG wrapper removed, or original chunk if not present. + """ + if not chunk.startswith(self._PNG_SIGNATURE): + return chunk + + # Find the IEND marker that signals end of PNG data + iend_pos = chunk.find(self._PNG_IEND_MARKER) + if iend_pos == -1: + # IEND not found in this chunk - return as-is to avoid data corruption + logger.debug("PNG signature detected but IEND marker not found in chunk") + return chunk + + # Calculate position after IEND marker + content_start = iend_pos + len(self._PNG_IEND_MARKER) + + # Skip any padding bytes (null or 0xFF) between PNG and actual content + while content_start < len(chunk) and chunk[content_start] in (0x00, 0xFF): + content_start += 1 + + self.bytes_stripped = content_start + logger.debug(f"Stripped {content_start} bytes of fake PNG wrapper from stream") + + return chunk[content_start:] + + async def transform(self, chunk_iterator: typing.AsyncIterator[bytes]) -> typing.AsyncGenerator[bytes, None]: + """ + Transform TS stream by stripping PNG wrapper and finding TS start. + + Args: + chunk_iterator: Async iterator of raw bytes from upstream. + + Yields: + Cleaned TS stream bytes. + """ + async for chunk in chunk_iterator: + if self.ts_started: + # Normal streaming once TS has started + yield chunk + continue + + # Prebuffer phase (until we find TS or pass through) + self.buffer += chunk + + # Fast-path: if it's an m3u8 playlist, don't do TS detection + if len(self.buffer) >= 7 and self.buffer[:7] in (b"#EXTM3U", b"#EXT-X-"): + yield bytes(self.buffer) + self.buffer.clear() + self.ts_started = True + continue + + # Strip fake PNG wrapper if present + if self.buffer.startswith(self._PNG_SIGNATURE): + if self._PNG_IEND_MARKER in self.buffer: + self.buffer = bytearray(self._strip_fake_png_wrapper(bytes(self.buffer))) + + # Skip pure 0xFF padding bytes (TurboVid style) + while self.buffer and self.buffer[0] == 0xFF: + self.buffer.pop(0) + + # Re-check for m3u8 playlist after stripping PNG wrapper and padding + # This handles cases where m3u8 content is wrapped in PNG + if len(self.buffer) >= 7 and self.buffer[:7] in (b"#EXTM3U", b"#EXT-X-"): + logger.debug("Found m3u8 content after stripping wrapper - passing through") + yield bytes(self.buffer) + self.buffer.clear() + self.ts_started = True + continue + + ts_offset = self._find_ts_start(bytes(self.buffer)) + if ts_offset is None: + # Keep buffering until we find TS or hit limit + if len(self.buffer) > self._MAX_PREFETCH: + logger.warning("TS sync not found after large prebuffer, forcing passthrough") + yield bytes(self.buffer) + self.buffer.clear() + self.ts_started = True + continue + + # TS found: emit from ts_offset and switch to pass-through + self.ts_started = True + out = bytes(self.buffer[ts_offset:]) + self.buffer.clear() + + if out: + yield out + + +# Registry of available transformers +TRANSFORMER_REGISTRY: dict[str, type[StreamTransformer]] = { + "ts_stream": TSStreamTransformer, +} + + +def get_transformer(transformer_id: typing.Optional[str]) -> typing.Optional[StreamTransformer]: + """ + Get a transformer instance by ID. + + Args: + transformer_id: The transformer identifier (e.g., "ts_stream"). + + Returns: + A new transformer instance, or None if transformer_id is None or not found. + """ + if transformer_id is None: + return None + + transformer_class = TRANSFORMER_REGISTRY.get(transformer_id) + if transformer_class is None: + logger.warning(f"Unknown transformer ID: {transformer_id}") + return None + + return transformer_class() + + +async def apply_transformer_to_bytes( + data: bytes, + transformer_id: typing.Optional[str], +) -> bytes: + """ + Apply a transformer to already-downloaded bytes data. + + This is useful when serving cached segments that need transformation. + Creates a single-chunk async iterator and collects the transformed output. + + Args: + data: The raw bytes data to transform. + transformer_id: The transformer identifier (e.g., "ts_stream"). + + Returns: + Transformed bytes, or original data if no transformer specified. + """ + if not transformer_id: + return data + + transformer = get_transformer(transformer_id) + if not transformer: + return data + + async def single_chunk_iterator(): + yield data + + # Collect all transformed chunks + result = bytearray() + async for chunk in transformer.transform(single_chunk_iterator()): + result.extend(chunk) + + return bytes(result) diff --git a/mediaflow_proxy/utils/telegram.py b/mediaflow_proxy/utils/telegram.py new file mode 100644 index 0000000..4381875 --- /dev/null +++ b/mediaflow_proxy/utils/telegram.py @@ -0,0 +1,1263 @@ +""" +Telegram MTProto streaming support with parallel chunk downloads. + +This module provides: +- TelegramSessionManager: Manages the Telethon client session +- TelegramMediaRef: Parsed reference to Telegram media (t.me links or file_id) +- ParallelTransferrer: FastTelethon-based parallel chunk downloader for high-speed streaming + +Based on FastTelethon technique from mautrix-telegram for parallel downloads. +""" + +import asyncio +import base64 +import logging +import math +import re +import struct +from dataclasses import dataclass +from io import BytesIO +from typing import AsyncGenerator, Optional, Union +from urllib.parse import urlparse + +from telethon import TelegramClient, utils +from telethon.crypto import AuthKey +from telethon.network import MTProtoSender +from telethon.sessions import StringSession +from telethon.tl.alltlobjects import LAYER +from telethon.tl.functions import InvokeWithLayerRequest +from telethon.tl.functions.auth import ExportAuthorizationRequest, ImportAuthorizationRequest +from telethon.tl.functions.upload import GetFileRequest +from telethon.tl.types import ( + Document, + InputDocumentFileLocation, + InputFileLocation, + InputPeerPhotoFileLocation, + InputPhotoFileLocation, + Message, + MessageMediaDocument, + MessageMediaPhoto, + Photo, +) + +from mediaflow_proxy.configs import settings + +logger = logging.getLogger(__name__) + +# Type aliases for file locations +TypeLocation = Union[ + Document, + InputDocumentFileLocation, + InputPeerPhotoFileLocation, + InputFileLocation, + InputPhotoFileLocation, +] + +# File type IDs for Bot API file_id +FILE_TYPE_THUMBNAIL = 0 +FILE_TYPE_PROFILE_PHOTO = 1 +FILE_TYPE_PHOTO = 2 +FILE_TYPE_VOICE = 3 +FILE_TYPE_VIDEO = 4 +FILE_TYPE_DOCUMENT = 5 +FILE_TYPE_ENCRYPTED = 6 +FILE_TYPE_TEMP = 7 +FILE_TYPE_STICKER = 8 +FILE_TYPE_AUDIO = 9 +FILE_TYPE_ANIMATION = 10 +FILE_TYPE_ENCRYPTED_THUMBNAIL = 11 +FILE_TYPE_WALLPAPER = 12 +FILE_TYPE_VIDEO_NOTE = 13 +FILE_TYPE_SECURE_RAW = 14 +FILE_TYPE_SECURE = 15 +FILE_TYPE_BACKGROUND = 16 +FILE_TYPE_DOCUMENT_AS_FILE = 17 + +# Flags in type_id +TYPE_ID_WEB_LOCATION_FLAG = 1 << 24 +TYPE_ID_FILE_REFERENCE_FLAG = 1 << 25 + + +@dataclass +class DecodedFileId: + """Decoded Bot API file_id structure.""" + + type_id: int + dc_id: int + id: int + access_hash: int + file_reference: bytes = b"" + has_web_location: bool = False + has_reference: bool = False + + +def _decode_telegram_base64(s: str) -> bytes: + """Decode Telegram's URL-safe base64.""" + s = s.replace("-", "+").replace("_", "/") + padding = 4 - len(s) % 4 + if padding != 4: + s += "=" * padding + return base64.b64decode(s) + + +def _rle_decode(data: bytes) -> bytes: + """RLE decode Telegram's file_id encoding.""" + result = bytearray() + i = 0 + while i < len(data): + if data[i] == 0 and i + 1 < len(data): + result.extend(bytes(data[i + 1])) + i += 2 + else: + result.append(data[i]) + i += 1 + return bytes(result) + + +def decode_file_id(file_id: str) -> DecodedFileId: + """ + Decode a Bot API file_id into its components. + + Supports both old and new file_id formats (including version 4 with high sub_versions). + + Args: + file_id: Bot API file_id string + + Returns: + DecodedFileId with parsed components + + Raises: + ValueError: If file_id cannot be decoded + """ + try: + decoded = _decode_telegram_base64(file_id) + data = _rle_decode(decoded) + except Exception as e: + raise ValueError(f"Failed to decode file_id base64: {e}") from e + + if len(data) < 20: + raise ValueError(f"file_id too short: {len(data)} bytes") + + buf = BytesIO(data) + + # Read type_id (4 bytes, little-endian) + type_id_raw = struct.unpack("<i", buf.read(4))[0] + + # Extract flags and actual type + has_web_location = bool(type_id_raw & TYPE_ID_WEB_LOCATION_FLAG) + has_reference = bool(type_id_raw & TYPE_ID_FILE_REFERENCE_FLAG) + type_id = type_id_raw & 0xFFFFFF + + # Read dc_id (4 bytes) + dc_id = struct.unpack("<i", buf.read(4))[0] + + file_reference = b"" + if has_reference: + # Read TL string (length-prefixed) + ref_len_byte = buf.read(1)[0] + if ref_len_byte == 254: + # Long string: next 3 bytes are length + ref_len = struct.unpack("<I", buf.read(3) + b"\x00")[0] + else: + ref_len = ref_len_byte + + file_reference = buf.read(ref_len) + + # Skip padding to 4-byte alignment + total_len = 1 + (3 if ref_len_byte == 254 else 0) + ref_len + padding = total_len % 4 + if padding: + buf.read(4 - padding) + + # Read id and access_hash (8 bytes each) + remaining = buf.read() + if len(remaining) < 16: + raise ValueError(f"file_id remaining data too short: {len(remaining)} bytes") + + id_val = struct.unpack("<q", remaining[0:8])[0] + access_hash = struct.unpack("<q", remaining[8:16])[0] + + return DecodedFileId( + type_id=type_id, + dc_id=dc_id, + id=id_val, + access_hash=access_hash, + file_reference=file_reference, + has_web_location=has_web_location, + has_reference=has_reference, + ) + + +@dataclass +class TelegramMediaRef: + """ + Parsed reference to Telegram media. + + Can be constructed from: + - t.me links: https://t.me/channel/123, https://t.me/c/123456789/456 + - file_id: Direct Telegram file IDs + """ + + chat_id: Optional[Union[int, str]] = None # Channel/group/user ID or username + message_id: Optional[int] = None # Message ID for t.me links + file_id: Optional[str] = None # Direct file reference + + +@dataclass +class MediaInfo: + """Information about a Telegram media file.""" + + file_id: str + file_size: int + mime_type: str + file_name: Optional[str] = None + duration: Optional[int] = None # For video/audio + width: Optional[int] = None # For video/photo + height: Optional[int] = None # For video/photo + dc_id: Optional[int] = None + + +def parse_telegram_url(url: str) -> TelegramMediaRef: + """ + Parse a Telegram URL or file_id into a TelegramMediaRef. + + Supported formats: + - https://t.me/channel/123 (public channel) + - https://t.me/c/123456789/456 (private channel) + - https://t.me/username/123 (user/channel by username) + - file_id (base64-encoded) + + Args: + url: The URL or file_id to parse + + Returns: + TelegramMediaRef with parsed information + """ + if not url: + raise ValueError("URL cannot be empty") + + # Check if it's a t.me link + parsed = urlparse(url) + if parsed.netloc in ("t.me", "telegram.me", "telegram.dog"): + path_parts = parsed.path.strip("/").split("/") + + if len(path_parts) >= 2: + # Format: /c/chat_id/message_id (private channel) + if path_parts[0] == "c" and len(path_parts) >= 3: + try: + # Private channel IDs need -100 prefix + chat_id = int(f"-100{path_parts[1]}") + message_id = int(path_parts[2]) + return TelegramMediaRef(chat_id=chat_id, message_id=message_id) + except ValueError as e: + raise ValueError(f"Invalid private channel URL format: {url}") from e + + # Format: /username/message_id (public channel or user) + else: + try: + username = path_parts[0] + message_id = int(path_parts[1]) + return TelegramMediaRef(chat_id=username, message_id=message_id) + except ValueError as e: + raise ValueError(f"Invalid public channel URL format: {url}") from e + + raise ValueError(f"Invalid Telegram URL format: {url}") + + # Check if it looks like a file_id (base64-like string) + if re.match(r"^[A-Za-z0-9_-]+$", url) and len(url) > 20: + return TelegramMediaRef(file_id=url) + + raise ValueError(f"Unrecognized Telegram URL or file_id format: {url}") + + +@dataclass +class DownloadSender: + """Handles downloading chunks from a single connection.""" + + client: TelegramClient + sender: MTProtoSender + request: GetFileRequest + remaining: int + stride: int + + async def next(self) -> Optional[bytes]: + """Download the next chunk.""" + if not self.remaining: + return None + result = await self.client._call(self.sender, self.request) + self.remaining -= 1 + self.request.offset += self.stride + return result.bytes + + async def disconnect(self) -> None: + """Disconnect this sender gracefully.""" + try: + await self.sender.disconnect() + except Exception: + # Ignore errors during disconnect - connection may already be closed + pass + + +class ParallelTransferrer: + """ + Parallel chunk downloader using multiple DC connections. + + Based on FastTelethon technique from mautrix-telegram. + Creates multiple MTProtoSender connections to the same DC + and downloads different chunks in parallel for maximum speed. + """ + + def __init__(self, client: TelegramClient, dc_id: Optional[int] = None) -> None: + self.client = client + self.loop = client.loop + self.dc_id = dc_id or client.session.dc_id + self.auth_key: Optional[AuthKey] = None if dc_id and client.session.dc_id != dc_id else client.session.auth_key + self.senders: Optional[list[DownloadSender]] = None + + async def _cleanup(self) -> None: + """Clean up all sender connections gracefully.""" + if self.senders: + # Use return_exceptions=True to prevent one failed disconnect from blocking others + await asyncio.gather(*[sender.disconnect() for sender in self.senders], return_exceptions=True) + self.senders = None + + @staticmethod + def _get_connection_count(file_size: int, max_count: int = 20, full_size: int = 100 * 1024 * 1024) -> int: + """ + Calculate optimal number of connections based on file size. + + Small files use fewer connections, large files use more. + """ + if file_size > full_size: + return max_count + return max(1, math.ceil((file_size / full_size) * max_count)) + + async def _create_sender(self) -> MTProtoSender: + """Create a new MTProtoSender connection to the DC.""" + dc = await self.client._get_dc(self.dc_id) + sender = MTProtoSender(self.auth_key, loggers=self.client._log) + await sender.connect( + self.client._connection( + dc.ip_address, + dc.port, + dc.id, + loggers=self.client._log, + proxy=self.client._proxy, + ) + ) + if not self.auth_key: + logger.debug(f"Exporting auth to DC {self.dc_id}") + auth = await self.client(ExportAuthorizationRequest(self.dc_id)) + self.client._init_request.query = ImportAuthorizationRequest(id=auth.id, bytes=auth.bytes) + req = InvokeWithLayerRequest(LAYER, self.client._init_request) + await sender.send(req) + self.auth_key = sender.auth_key + return sender + + async def _create_download_sender( + self, + file: TypeLocation, + index: int, + part_size: int, + stride: int, + part_count: int, + base_offset: int = 0, + ) -> DownloadSender: + """Create a DownloadSender for a specific chunk offset.""" + return DownloadSender( + client=self.client, + sender=await self._create_sender(), + request=GetFileRequest(file, offset=base_offset + index * part_size, limit=part_size), + stride=stride, + remaining=part_count, + ) + + async def _init_download( + self, + connections: int, + file: TypeLocation, + part_count: int, + part_size: int, + base_offset: int = 0, + ) -> None: + """Initialize all download senders.""" + minimum, remainder = divmod(part_count, connections) + + def get_part_count() -> int: + nonlocal remainder + if remainder > 0: + remainder -= 1 + return minimum + 1 + return minimum + + # Create first sender synchronously to handle auth export + self.senders = [ + await self._create_download_sender( + file, 0, part_size, connections * part_size, get_part_count(), base_offset + ), + *await asyncio.gather( + *[ + self._create_download_sender( + file, i, part_size, connections * part_size, get_part_count(), base_offset + ) + for i in range(1, connections) + ] + ), + ] + + async def download( + self, + file: TypeLocation, + file_size: int, + offset: int = 0, + limit: Optional[int] = None, + part_size_kb: Optional[float] = None, + connection_count: Optional[int] = None, + ) -> AsyncGenerator[bytes, None]: + """ + Download file in parallel chunks. + + Args: + file: The file location to download + file_size: Total file size in bytes + offset: Byte offset to start from (for range requests) + limit: Number of bytes to download (None for entire file) + part_size_kb: Chunk size in KB (auto-calculated if None) + connection_count: Number of parallel connections (auto-calculated if None) + + Yields: + Chunks of file data + """ + # Calculate actual range + if limit is None: + limit = file_size - offset + + # Clamp connection count to configured max + max_connections = min(settings.telegram_max_connections, 20) + connection_count = connection_count or self._get_connection_count(limit, max_count=max_connections) + connection_count = min(connection_count, max_connections) + + part_size = int((part_size_kb or utils.get_appropriated_part_size(file_size)) * 1024) + # Round offset down to part boundary + aligned_offset = (offset // part_size) * part_size + skip_bytes = offset - aligned_offset + + part_count = math.ceil((limit + skip_bytes) / part_size) + + logger.debug( + f"Starting parallel download: {connection_count} connections, " + f"{part_size} bytes/part, {part_count} parts, offset={offset}, aligned_offset={aligned_offset}" + ) + + await self._init_download(connection_count, file, part_count, part_size, base_offset=aligned_offset) + + try: + part = 0 + bytes_yielded = 0 + while part < part_count and bytes_yielded < limit: + tasks = [self.loop.create_task(sender.next()) for sender in self.senders] + for task in tasks: + data = await task + if not data: + break + + # Handle offset alignment - skip initial bytes if needed + if skip_bytes > 0: + if len(data) <= skip_bytes: + skip_bytes -= len(data) + part += 1 + continue + data = data[skip_bytes:] + skip_bytes = 0 + + # Handle limit - truncate if we'd exceed + remaining = limit - bytes_yielded + if len(data) > remaining: + data = data[:remaining] + + yield data + bytes_yielded += len(data) + part += 1 + + if bytes_yielded >= limit: + break + + logger.debug("Parallel download finished, cleaning up connections") + finally: + await self._cleanup() + + +class _SingleSenderPool: + """ + Pool of persistent ``MTProtoSender`` connections per DC. + + Instead of creating a new connection for every HLS segment request + (which involves handshake + auth export overhead), this pool maintains + a queue of idle senders per DC. When a caller needs a sender, it + borrows one from the pool (or creates a new one if the pool is empty). + After use, the sender is returned to the pool for reuse. + + Senders that have been idle longer than ``_MAX_IDLE_SECONDS`` are + discarded on checkout. + """ + + _MAX_IDLE_SECONDS = 120.0 # discard senders idle longer than this + + def __init__(self) -> None: + # dc_id -> list of (sender, auth_key, last_used_monotonic) + self._pool: dict[int, list[tuple[MTProtoSender, AuthKey, float]]] = {} + self._lock = asyncio.Lock() + # Cached auth keys per DC -- shared across all senders. + self._auth_keys: dict[int, AuthKey] = {} + + async def acquire( + self, + client: TelegramClient, + dc_id: int, + ) -> tuple[MTProtoSender, AuthKey]: + """ + Borrow a connected ``MTProtoSender`` for *dc_id*. + + Returns an existing idle sender if one is available, otherwise + creates a new one (handling auth export if needed). + """ + import time as _time + + async with self._lock: + bucket = self._pool.get(dc_id, []) + now = _time.monotonic() + # Try to find a live sender + while bucket: + sender, auth_key, last_used = bucket.pop() + idle = now - last_used + if idle > self._MAX_IDLE_SECONDS: + # Stale -- disconnect quietly + logger.debug("[sender_pool] Discarding stale sender for DC %d (idle %.0fs)", dc_id, idle) + try: + await sender.disconnect() + except Exception: + pass + continue + # Check if still connected + if sender.is_connected(): + logger.debug("[sender_pool] Reusing sender for DC %d (idle %.1fs)", dc_id, idle) + return sender, auth_key + else: + logger.debug("[sender_pool] Sender for DC %d disconnected, discarding", dc_id) + try: + await sender.disconnect() + except Exception: + pass + + # No reusable sender -- create a new one + logger.debug("[sender_pool] Creating new sender for DC %d", dc_id) + return await self._create_sender(client, dc_id) + + async def _create_sender( + self, + client: TelegramClient, + dc_id: int, + ) -> tuple[MTProtoSender, AuthKey]: + """Create a new ``MTProtoSender`` with auth export if needed.""" + auth_key = self._auth_keys.get(dc_id) + if auth_key is None and dc_id == client.session.dc_id: + auth_key = client.session.auth_key + + dc = await client._get_dc(dc_id) + sender = MTProtoSender(auth_key, loggers=client._log) + await sender.connect( + client._connection( + dc.ip_address, + dc.port, + dc.id, + loggers=client._log, + proxy=client._proxy, + ) + ) + if not auth_key: + logger.debug("[sender_pool] Exporting auth to DC %d", dc_id) + auth = await client(ExportAuthorizationRequest(dc_id)) + client._init_request.query = ImportAuthorizationRequest(id=auth.id, bytes=auth.bytes) + req = InvokeWithLayerRequest(LAYER, client._init_request) + await sender.send(req) + auth_key = sender.auth_key + self._auth_keys[dc_id] = auth_key + return sender, auth_key + + async def release( + self, + dc_id: int, + sender: MTProtoSender, + auth_key: AuthKey, + ) -> None: + """Return a sender to the pool for reuse.""" + import time as _time + + # Cache auth key + if auth_key is not None: + self._auth_keys[dc_id] = auth_key + + if not sender.is_connected(): + logger.debug("[sender_pool] Sender for DC %d disconnected, not returning to pool", dc_id) + try: + await sender.disconnect() + except Exception: + pass + return + + async with self._lock: + bucket = self._pool.setdefault(dc_id, []) + bucket.append((sender, auth_key, _time.monotonic())) + logger.debug("[sender_pool] Returned sender to pool for DC %d (pool size=%d)", dc_id, len(bucket)) + + async def discard(self, sender: MTProtoSender) -> None: + """Disconnect and discard a sender without returning it to the pool.""" + try: + await sender.disconnect() + except Exception: + pass + + async def close_all(self) -> None: + """Disconnect all pooled senders.""" + async with self._lock: + for dc_id, bucket in self._pool.items(): + for sender, _, _ in bucket: + try: + await sender.disconnect() + except Exception: + pass + bucket.clear() + self._pool.clear() + self._auth_keys.clear() + + +class TelegramSessionManager: + """ + Manages the Telethon client session. + + Features: + - Lazy initialization on first request + - Session persistence via StringSession + - Automatic reconnection on disconnect + - Thread-safe with asyncio lock + - Persistent sender pool for HLS segment downloads + """ + + # Cache TTL for get_media_info results (seconds) + _MEDIA_INFO_CACHE_TTL = 3600 # 1 hour + + def __init__(self): + self._client: Optional[TelegramClient] = None + self._lock = asyncio.Lock() + self._initialized = False + # In-memory cache: key → (MediaInfo, expiry_timestamp) + self._media_info_cache: dict[str, tuple["MediaInfo", float]] = {} + # Persistent sender pool for single-connection downloads (HLS). + self._sender_pool = _SingleSenderPool() + + async def get_client(self) -> TelegramClient: + """ + Get the Telethon client, initializing if needed. + + Returns: + Connected TelegramClient instance + + Raises: + ValueError: If Telegram settings are not configured + RuntimeError: If connection fails + """ + async with self._lock: + if self._client is not None and self._client.is_connected(): + return self._client + + # Validate settings + if not settings.telegram_api_id or not settings.telegram_api_hash: + raise ValueError("Telegram API credentials not configured (telegram_api_id, telegram_api_hash)") + + if not settings.telegram_session_string: + raise ValueError( + "Telegram session string not configured. Generate one using the web UI at /url-generator#telegram" + ) + + logger.info("Initializing Telegram client...") + + # Create client with StringSession (extract raw values from SecretStr) + self._client = TelegramClient( + StringSession(settings.telegram_session_string.get_secret_value()), + settings.telegram_api_id, + settings.telegram_api_hash.get_secret_value(), + request_retries=3, + connection_retries=3, + retry_delay=1, + timeout=settings.telegram_request_timeout, + ) + + await self._client.connect() + + if not await self._client.is_user_authorized(): + raise RuntimeError( + "Telegram session is not authorized. Please regenerate the session string with valid credentials." + ) + + self._initialized = True + logger.info("Telegram client initialized successfully") + return self._client + + async def get_message(self, ref: TelegramMediaRef) -> Message: + """ + Get a message by its reference. + + Args: + ref: TelegramMediaRef with chat_id and message_id + + Returns: + The Message object + + Raises: + ValueError: If reference is incomplete + Various Telegram errors: ChannelPrivateError, MessageIdInvalidError, etc. + """ + if ref.chat_id is None or ref.message_id is None: + raise ValueError("chat_id and message_id are required to fetch a message") + + client = await self.get_client() + messages = await client.get_messages(ref.chat_id, ids=ref.message_id) + + if not messages: + raise ValueError(f"Message {ref.message_id} not found in {ref.chat_id}") + + return messages + + def resolve_file_id(self, file_id: str) -> tuple[Union[Document, Photo], int]: + """ + Resolve a Bot API file_id to a Telethon Document or Photo object. + + Supports both old and new file_id formats by using a custom decoder + that handles all version/sub_version combinations. + + Args: + file_id: Bot API style file_id string + + Returns: + Tuple of (Document or Photo object, dc_id) + + Raises: + ValueError: If file_id is invalid or cannot be decoded + """ + # First try Telethon's built-in resolver (works for older formats) + media = utils.resolve_bot_file_id(file_id) + if media is not None: + if isinstance(media, Document): + return media, media.dc_id + elif isinstance(media, Photo): + return media, media.dc_id + + # Fall back to our custom decoder for newer formats + logger.debug("Telethon couldn't decode file_id, trying custom decoder") + decoded = decode_file_id(file_id) + + # Determine if it's a photo or document based on type_id + if decoded.type_id in (FILE_TYPE_PHOTO, FILE_TYPE_PROFILE_PHOTO, FILE_TYPE_THUMBNAIL): + # Create a Photo object + return Photo( + id=decoded.id, + access_hash=decoded.access_hash, + file_reference=decoded.file_reference, + date=None, + sizes=[], # Empty, we don't have size info from file_id + dc_id=decoded.dc_id, + ), decoded.dc_id + else: + # Create a Document object (video, audio, document, etc.) + return Document( + id=decoded.id, + access_hash=decoded.access_hash, + file_reference=decoded.file_reference, + date=None, + mime_type="", # Unknown from file_id + size=0, # Unknown from file_id + thumbs=None, + dc_id=decoded.dc_id, + attributes=[], + ), decoded.dc_id + + def _media_info_cache_key(self, ref: TelegramMediaRef) -> str: + """Derive an in-memory cache key for a TelegramMediaRef.""" + if ref.file_id and not ref.message_id: + return f"fid:{ref.file_id}" + if ref.chat_id is not None and ref.message_id is not None: + return f"chat:{ref.chat_id}:msg:{ref.message_id}" + return "" + + async def get_media_info(self, ref: TelegramMediaRef, file_size: Optional[int] = None) -> MediaInfo: + """ + Get information about a media file. + + Results are cached in-memory (with TTL) to avoid repeated Telegram API + calls for the same media -- especially important for HLS, where each + sub-request (playlist, init, segments) resolves the same source. + + Args: + ref: TelegramMediaRef pointing to the media + file_size: Optional file size (required for file_id since it's not encoded in the ID) + + Returns: + MediaInfo with file details + """ + # Check in-memory cache first + import time + + ck = self._media_info_cache_key(ref) + if ck: + cached = self._media_info_cache.get(ck) + if cached is not None: + info, expiry = cached + if time.monotonic() < expiry: + return info + else: + del self._media_info_cache[ck] + + info = await self._get_media_info_uncached(ref, file_size) + + # Store in cache + if ck: + self._media_info_cache[ck] = (info, time.monotonic() + self._MEDIA_INFO_CACHE_TTL) + + return info + + async def _get_media_info_uncached( + self, + ref: TelegramMediaRef, + file_size: Optional[int] = None, + ) -> MediaInfo: + """Uncached implementation of get_media_info.""" + # Handle file_id reference + if ref.file_id and not ref.message_id: + media, dc_id = self.resolve_file_id(ref.file_id) + + if isinstance(media, Document): + # Extract attributes + file_name = None + duration = None + width = None + height = None + mime_type = media.mime_type or "application/octet-stream" + + for attr in media.attributes: + attr_dict = attr.to_dict() + if "file_name" in attr_dict: + file_name = attr_dict["file_name"] + if "duration" in attr_dict: + duration = attr_dict["duration"] + if "w" in attr_dict: + width = attr_dict["w"] + if "h" in attr_dict: + height = attr_dict["h"] + + # Determine mime_type from attributes if empty + if mime_type == "application/octet-stream" or not mime_type: + # Infer from document type + for attr in media.attributes: + if hasattr(attr, "voice") and attr.voice: + mime_type = "audio/ogg" + break + elif hasattr(attr, "round_message") and attr.round_message: + mime_type = "video/mp4" + break + elif attr.__class__.__name__ == "DocumentAttributeVideo": + mime_type = "video/mp4" + break + elif attr.__class__.__name__ == "DocumentAttributeAudio": + mime_type = "audio/mpeg" + break + elif attr.__class__.__name__ == "DocumentAttributeSticker": + mime_type = "image/webp" + break + elif attr.__class__.__name__ == "DocumentAttributeAnimated": + mime_type = "application/x-tgsticker" + break + + return MediaInfo( + file_id=ref.file_id, + file_size=file_size or media.size, # Use provided size or 0 from resolved + mime_type=mime_type, + file_name=file_name, + duration=duration, + width=width, + height=height, + dc_id=dc_id, + ) + + elif isinstance(media, Photo): + # Get largest photo size + largest = max(media.sizes, key=lambda s: getattr(s, "size", 0) if hasattr(s, "size") else 0) + + return MediaInfo( + file_id=ref.file_id, + file_size=file_size or getattr(largest, "size", 0), + mime_type="image/jpeg", + width=getattr(largest, "w", None), + height=getattr(largest, "h", None), + dc_id=dc_id, + ) + + raise ValueError(f"Unsupported media type from file_id: {type(media)}") + + # Handle message-based reference + message = await self.get_message(ref) + + if not message.media: + raise ValueError(f"Message {ref.message_id} does not contain media") + + if isinstance(message.media, MessageMediaDocument): + doc = message.media.document + if not isinstance(doc, Document): + raise ValueError("Invalid document in message") + + # Extract attributes + file_name = None + duration = None + width = None + height = None + + for attr in doc.attributes: + attr_dict = attr.to_dict() + if "file_name" in attr_dict: + file_name = attr_dict["file_name"] + if "duration" in attr_dict: + duration = attr_dict["duration"] + if "w" in attr_dict: + width = attr_dict["w"] + if "h" in attr_dict: + height = attr_dict["h"] + + return MediaInfo( + file_id=str(doc.id), + file_size=doc.size, + mime_type=doc.mime_type or "application/octet-stream", + file_name=file_name, + duration=duration, + width=width, + height=height, + dc_id=doc.dc_id, + ) + + elif isinstance(message.media, MessageMediaPhoto): + photo = message.media.photo + if not photo: + raise ValueError("Invalid photo in message") + + # Get largest photo size + largest = max(photo.sizes, key=lambda s: getattr(s, "size", 0) if hasattr(s, "size") else 0) + + return MediaInfo( + file_id=str(photo.id), + file_size=getattr(largest, "size", 0), + mime_type="image/jpeg", + width=getattr(largest, "w", None), + height=getattr(largest, "h", None), + dc_id=photo.dc_id, + ) + + else: + raise ValueError(f"Unsupported media type: {type(message.media)}") + + async def validate_file_access( + self, + ref: TelegramMediaRef, + file_size: Optional[int] = None, + ) -> None: + """ + Validate that the session can access the file before streaming. + + This makes a small test request to verify the file_reference is valid + and the session has access. This should be called before streaming to + avoid mid-stream errors. + + Args: + ref: TelegramMediaRef pointing to the media + file_size: Optional file size for file_id mode + + Raises: + FileReferenceExpiredError: If file_id belongs to different session + Various Telegram errors: For access issues + """ + client = await self.get_client() + + if ref.file_id and not ref.message_id: + media, dc_id = self.resolve_file_id(ref.file_id) + + if isinstance(media, Document): + file_location = InputDocumentFileLocation( + id=media.id, + access_hash=media.access_hash, + file_reference=media.file_reference, + thumb_size="", + ) + elif isinstance(media, Photo): + largest = max(media.sizes, key=lambda s: getattr(s, "size", 0) if hasattr(s, "size") else 0) + file_location = InputPhotoFileLocation( + id=media.id, + access_hash=media.access_hash, + file_reference=media.file_reference, + thumb_size=getattr(largest, "type", "x"), + ) + else: + raise ValueError(f"Unsupported media type from file_id: {type(media)}") + + # Make a small test request to validate access + # Use ParallelTransferrer which handles DC migration properly + transferrer = ParallelTransferrer(client, dc_id) + try: + # Just request a tiny amount to validate - the download method handles DC connections + download_gen = transferrer.download(file_location, file_size or 4096, offset=0, limit=4096) + try: + await download_gen.__anext__() # Get first chunk to validate + except StopAsyncIteration: + pass # Empty file is still valid + finally: + # Properly close the generator + await download_gen.aclose() + logger.debug("[validate_file_access] file_id access validated on DC %d", dc_id) + except Exception as e: + logger.warning(f"[validate_file_access] file_id validation failed: {e}") + raise + finally: + # Clean up transferrer connections + await transferrer._cleanup() + + async def _resolve_file_location( + self, + ref: TelegramMediaRef, + file_size: Optional[int] = None, + ) -> tuple["TypeLocation", int, int]: + """ + Resolve a ``TelegramMediaRef`` into a Telegram file location. + + Returns: + ``(file_location, dc_id, actual_file_size)`` + """ + # Handle file_id reference (no message needed, fast local parse) + if ref.file_id and not ref.message_id: + media, dc_id = self.resolve_file_id(ref.file_id) + + if isinstance(media, Document): + actual_file_size = file_size or media.size + if actual_file_size == 0: + raise ValueError( + "file_size parameter is required when streaming by file_id. " + "The file_id doesn't contain size information." + ) + file_location = InputDocumentFileLocation( + id=media.id, + access_hash=media.access_hash, + file_reference=media.file_reference, + thumb_size="", + ) + return file_location, dc_id, actual_file_size + + elif isinstance(media, Photo): + largest = max(media.sizes, key=lambda s: getattr(s, "size", 0) if hasattr(s, "size") else 0) + actual_file_size = file_size or getattr(largest, "size", 0) + if actual_file_size == 0: + raise ValueError( + "file_size parameter is required when streaming by file_id. " + "The file_id doesn't contain size information." + ) + file_location = InputPhotoFileLocation( + id=media.id, + access_hash=media.access_hash, + file_reference=media.file_reference, + thumb_size=getattr(largest, "type", "x"), + ) + return file_location, dc_id, actual_file_size + + else: + raise ValueError(f"Unsupported media type from file_id: {type(media)}") + + # Handle message-based reference (requires Telegram API call) + message = await self.get_message(ref) + + if not message.media: + raise ValueError(f"Message {ref.message_id} does not contain media") + + if isinstance(message.media, MessageMediaDocument): + doc = message.media.document + if not isinstance(doc, Document): + raise ValueError("Invalid document") + + file_location = InputDocumentFileLocation( + id=doc.id, + access_hash=doc.access_hash, + file_reference=doc.file_reference, + thumb_size="", + ) + return file_location, doc.dc_id, doc.size + + elif isinstance(message.media, MessageMediaPhoto): + photo = message.media.photo + if not photo: + raise ValueError("Invalid photo") + + largest = max(photo.sizes, key=lambda s: getattr(s, "size", 0) if hasattr(s, "size") else 0) + file_location = InputPhotoFileLocation( + id=photo.id, + access_hash=photo.access_hash, + file_reference=photo.file_reference, + thumb_size=getattr(largest, "type", ""), + ) + return file_location, photo.dc_id, getattr(largest, "size", 0) + + else: + raise ValueError(f"Unsupported media type: {type(message.media)}") + + async def stream_media( + self, + ref: TelegramMediaRef, + offset: int = 0, + limit: Optional[int] = None, + file_size: Optional[int] = None, + ) -> AsyncGenerator[bytes, None]: + """ + Stream media content with **parallel** downloads (fast Telethon). + + Creates multiple MTProtoSender connections to the file's DC for + maximum throughput. Best suited for large/full-file downloads + (e.g. the non-transcode ``/proxy/telegram/stream`` endpoint). + + For small byte-range fetches (HLS segments) use + ``stream_media_single`` instead. + + Args: + ref: TelegramMediaRef pointing to the media + offset: Byte offset to start from + limit: Number of bytes to download (None for entire file) + file_size: Optional file size (required for file_id streaming) + + Yields: + Chunks of media data + """ + client = await self.get_client() + file_location, dc_id, actual_file_size = await self._resolve_file_location(ref, file_size) + + transferrer = ParallelTransferrer(client, dc_id) + try: + async for chunk in transferrer.download( + file_location, + actual_file_size, + offset=offset, + limit=limit, + ): + yield chunk + finally: + await transferrer._cleanup() + + async def stream_media_single( + self, + ref: TelegramMediaRef, + offset: int = 0, + limit: Optional[int] = None, + file_size: Optional[int] = None, + ) -> AsyncGenerator[bytes, None]: + """ + Stream media content over a **pooled** single MTProto connection. + + Borrows a persistent ``MTProtoSender`` from ``_SingleSenderPool`` + for the target DC. The sender is returned to the pool after the + download completes so the next request reuses the same TCP + connection (no handshake, no ``ExportAuthorizationRequest``). + + This is ideal for small byte-range fetches (HLS segments, probe + headers) where spinning up connections per request is wasteful. + + Args: + ref: TelegramMediaRef pointing to the media + offset: Byte offset to start from + limit: Number of bytes to download (None for entire file) + file_size: Optional file size (required for file_id streaming) + + Yields: + Chunks of media data + """ + client = await self.get_client() + file_location, dc_id, actual_file_size = await self._resolve_file_location(ref, file_size) + + if offset >= actual_file_size: + return + + if limit is None: + limit = actual_file_size - offset + + part_size = int(utils.get_appropriated_part_size(actual_file_size) * 1024) + aligned_offset = (offset // part_size) * part_size + skip_bytes = offset - aligned_offset + part_count = math.ceil((limit + skip_bytes) / part_size) + + logger.debug( + "[single] DC %d: offset=%d, limit=%d, parts=%d, part_size=%d", + dc_id, + offset, + limit, + part_count, + part_size, + ) + + sender, auth_key = await self._sender_pool.acquire(client, dc_id) + sender_ok = True # track whether to return to pool or discard + + try: + request = GetFileRequest(file_location, offset=aligned_offset, limit=part_size) + bytes_yielded = 0 + + for _ in range(part_count): + if bytes_yielded >= limit: + break + try: + result = await client._call(sender, request) + except Exception: + sender_ok = False + raise + data = result.bytes + if not data: + break + request.offset += part_size + + # Handle offset alignment + if skip_bytes > 0: + if len(data) <= skip_bytes: + skip_bytes -= len(data) + continue + data = data[skip_bytes:] + skip_bytes = 0 + + # Trim to limit + remaining = limit - bytes_yielded + if len(data) > remaining: + data = data[:remaining] + + bytes_yielded += len(data) + yield data + finally: + if sender_ok: + await self._sender_pool.release(dc_id, sender, auth_key) + else: + await self._sender_pool.discard(sender) + + async def close(self) -> None: + """Close the Telegram client connection and pooled senders.""" + await self._sender_pool.close_all() + async with self._lock: + if self._client is not None: + await self._client.disconnect() + self._client = None + self._initialized = False + logger.info("Telegram client disconnected") + + @property + def is_initialized(self) -> bool: + """Check if the client is initialized and connected.""" + return self._initialized and self._client is not None and self._client.is_connected() + + +# Global session manager instance +telegram_manager = TelegramSessionManager() diff --git a/mediaflow_proxy/utils/tlshashlib.py b/mediaflow_proxy/utils/tlshashlib.py index 346f545..491ab7b 100644 --- a/mediaflow_proxy/utils/tlshashlib.py +++ b/mediaflow_proxy/utils/tlshashlib.py @@ -3,13 +3,21 @@ """hashlib that handles FIPS mode.""" -# Because we are extending the hashlib module, we need to import all its -# fields to suppport the same uses -# pylint: disable=unused-wildcard-import, wildcard-import -from hashlib import * -# pylint: enable=unused-wildcard-import, wildcard-import import hashlib +# Re-export commonly used hash constructors +sha1 = hashlib.sha1 +sha224 = hashlib.sha224 +sha256 = hashlib.sha256 +sha384 = hashlib.sha384 +sha512 = hashlib.sha512 +sha3_224 = hashlib.sha3_224 +sha3_256 = hashlib.sha3_256 +sha3_384 = hashlib.sha3_384 +sha3_512 = hashlib.sha3_512 +blake2b = hashlib.blake2b +blake2s = hashlib.blake2s + def _fipsFunction(func, *args, **kwargs): """Make hash function support FIPS mode.""" @@ -19,8 +27,6 @@ def _fipsFunction(func, *args, **kwargs): return func(*args, usedforsecurity=False, **kwargs) -# redefining the function is exactly what we intend to do -# pylint: disable=function-redefined def md5(*args, **kwargs): """MD5 constructor that works in FIPS mode.""" return _fipsFunction(hashlib.md5, *args, **kwargs) @@ -29,4 +35,3 @@ def md5(*args, **kwargs): def new(*args, **kwargs): """General constructor that works in FIPS mode.""" return _fipsFunction(hashlib.new, *args, **kwargs) -# pylint: enable=function-redefined diff --git a/mediaflow_proxy/utils/tlshmac.py b/mediaflow_proxy/utils/tlshmac.py index 789df72..f0f14c9 100644 --- a/mediaflow_proxy/utils/tlshmac.py +++ b/mediaflow_proxy/utils/tlshmac.py @@ -11,17 +11,20 @@ Note that this makes this code FIPS non-compliant! # fields to suppport the same uses from . import tlshashlib from .compat import compatHMAC + try: from hmac import compare_digest + __all__ = ["new", "compare_digest", "HMAC"] except ImportError: __all__ = ["new", "HMAC"] try: from hmac import HMAC, new + # if we can calculate HMAC on MD5, then use the built-in HMAC # implementation - _val = HMAC(b'some key', b'msg', 'md5') + _val = HMAC(b"some key", b"msg", "md5") _val.digest() del _val except Exception: @@ -38,10 +41,10 @@ except Exception: """ self.key = key if digestmod is None: - digestmod = 'md5' + digestmod = "md5" if callable(digestmod): digestmod = digestmod() - if not hasattr(digestmod, 'digest_size'): + if not hasattr(digestmod, "digest_size"): digestmod = tlshashlib.new(digestmod) self.block_size = digestmod.block_size self.digest_size = digestmod.digest_size @@ -51,10 +54,10 @@ except Exception: k_hash.update(compatHMAC(key)) key = k_hash.digest() if len(key) < self.block_size: - key = key + b'\x00' * (self.block_size - len(key)) + key = key + b"\x00" * (self.block_size - len(key)) key = bytearray(key) - ipad = bytearray(b'\x36' * self.block_size) - opad = bytearray(b'\x5c' * self.block_size) + ipad = bytearray(b"\x36" * self.block_size) + opad = bytearray(b"\x5c" * self.block_size) i_key = bytearray(i ^ j for i, j in zip(key, ipad)) self._o_key = bytearray(i ^ j for i, j in zip(key, opad)) self._context = digestmod.copy() @@ -82,7 +85,6 @@ except Exception: new._context = self._context.copy() return new - def new(*args, **kwargs): """General constructor that works in FIPS mode.""" return HMAC(*args, **kwargs) diff --git a/requirements.txt b/requirements.txt index fbea228..2749e03 100644 --- a/requirements.txt +++ b/requirements.txt @@ -18,3 +18,6 @@ cachetools tqdm aiofiles psutil +anyio +telethon +av