├── .dockerignore ├── .github ├── FUNDING.yml ├── dependabot.yml └── workflows │ └── main.yml ├── .gitignore ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── docker-compose.portainer ├── docker-compose.yml ├── mediaflow_proxy ├── __init__.py ├── configs.py ├── const.py ├── drm │ ├── __init__.py │ └── decrypter.py ├── extractors │ ├── __init__.py │ ├── base.py │ ├── dlhd.py │ ├── doodstream.py │ ├── factory.py │ ├── fastream.py │ ├── filelions.py │ ├── filemoon.py │ ├── livetv.py │ ├── lulustream.py │ ├── maxstream.py │ ├── mixdrop.py │ ├── okru.py │ ├── streamtape.py │ ├── supervideo.py │ ├── uqload.py │ ├── vavoo.py │ ├── vixcloud.py │ └── voe.py ├── handlers.py ├── main.py ├── middleware.py ├── mpd_processor.py ├── routes │ ├── __init__.py │ ├── extractor.py │ ├── playlist_builder.py │ ├── proxy.py │ └── speedtest.py ├── schemas.py ├── speedtest │ ├── __init__.py │ ├── models.py │ ├── providers │ │ ├── all_debrid.py │ │ ├── base.py │ │ └── real_debrid.py │ └── service.py ├── static │ ├── index.html │ ├── logo.png │ ├── playlist_builder.html │ ├── speedtest.html │ └── speedtest.js └── utils │ ├── __init__.py │ ├── base64_utils.py │ ├── cache_utils.py │ ├── crypto_utils.py │ ├── dash_prebuffer.py │ ├── hls_prebuffer.py │ ├── hls_utils.py │ ├── http_utils.py │ ├── m3u8_processor.py │ ├── mpd_utils.py │ └── packed.py ├── poetry.lock └── pyproject.toml /.dockerignore: -------------------------------------------------------------------------------- 1 | __pycache__/ 2 | *.py[cod] 3 | *$py.class 4 | 5 | # C extensions 6 | *.so 7 | 8 | # Distribution / packaging 9 | .Python 10 | build/ 11 | develop-eggs/ 12 | dist/ 13 | downloads/ 14 | eggs/ 15 | .eggs/ 16 | lib/ 17 | lib64/ 18 | parts/ 19 | sdist/ 20 | var/ 21 | wheels/ 22 | pip-wheel-metadata/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | .env 30 | .idea/ 31 | *.service 32 | 33 | # Ignore all files under drm folder 34 | mediaflow_proxy/drm/* 35 | 36 | # Unignore specific files 37 | !mediaflow_proxy/drm/__init__.py 38 | !mediaflow_proxy/drm/decrypter.py 39 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | 2 | github: [mhdzumair] 3 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "pip" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | commit-message: 8 | prefix: "dependabot" 9 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: MediaFlow Proxy CI/CD 2 | 3 | on: 4 | release: 5 | types: [ created ] 6 | 7 | jobs: 8 | mediaflow_proxy_docker_build: 9 | runs-on: ubuntu-latest 10 | 11 | steps: 12 | - name: Checkout 13 | uses: actions/checkout@v4 14 | 15 | - name: Set up QEMU 16 | uses: docker/setup-qemu-action@v3 17 | 18 | - name: Set up Docker Buildx 19 | uses: docker/setup-buildx-action@v3 20 | 21 | - name: Login to Docker Hub 22 | uses: docker/login-action@v3 23 | with: 24 | username: mhdzumair 25 | password: ${{ secrets.DOCKERHUB_TOKEN }} 26 | 27 | - name: Build and push 28 | id: docker_build 29 | uses: docker/build-push-action@v5 30 | with: 31 | context: . 32 | file: Dockerfile 33 | platforms: linux/amd64,linux/arm64 34 | push: true 35 | tags: | 36 | mhdzumair/mediaflow-proxy:v${{ github.ref_name }} 37 | mhdzumair/mediaflow-proxy:latest 38 | 39 | - name: Image digest 40 | run: echo ${{ steps.docker_build.outputs.digest }} 41 | 42 | publish_to_pypi: 43 | runs-on: ubuntu-latest 44 | 45 | steps: 46 | - uses: actions/checkout@v4 47 | 48 | - name: Extract version from tag 49 | id: get_version 50 | run: | 51 | # Remove 'v' prefix if present (e.g., v1.2.3 -> 1.2.3) 52 | VERSION=${GITHUB_REF_NAME#v} 53 | echo "version=$VERSION" >> $GITHUB_OUTPUT 54 | echo "Extracted version: $VERSION" 55 | 56 | - name: Update version in pyproject.toml 57 | run: | 58 | # Update the version in pyproject.toml using sed 59 | sed -i 's/^version = ".*"/version = "${{ steps.get_version.outputs.version }}"/' pyproject.toml 60 | echo "Updated pyproject.toml version to: ${{ steps.get_version.outputs.version }}" 61 | # Verify the change 62 | grep '^version = ' pyproject.toml 63 | 64 | - name: Set up Python 65 | uses: actions/setup-python@v5 66 | with: 67 | python-version: '3.13' 68 | 69 | - name: Install dependencies 70 | run: | 71 | pip install poetry 72 | poetry build 73 | 74 | - name: Build and publish 75 | uses: pypa/gh-action-pypi-publish@release/v1 76 | with: 77 | password: ${{ secrets.PYPI_API_TOKEN }} 78 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | 131 | .idea/ 132 | *.service 133 | 134 | # Ignore all files under drm folder 135 | mediaflow_proxy/drm/* 136 | 137 | # Unignore specific files 138 | !mediaflow_proxy/drm/__init__.py 139 | !mediaflow_proxy/drm/decrypter.py 140 | 141 | .vscode/ -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.13.5-slim 2 | 3 | # Set environment variables 4 | ENV PYTHONDONTWRITEBYTECODE="1" 5 | ENV PYTHONUNBUFFERED="1" 6 | ENV PORT="8888" 7 | 8 | # Set work directory 9 | WORKDIR /mediaflow_proxy 10 | 11 | # Create a non-root user 12 | RUN useradd -m mediaflow_proxy 13 | RUN chown -R mediaflow_proxy:mediaflow_proxy /mediaflow_proxy 14 | 15 | # Set up the PATH to include the user's local bin 16 | ENV PATH="/home/mediaflow_proxy/.local/bin:$PATH" 17 | 18 | # Switch to non-root user 19 | USER mediaflow_proxy 20 | 21 | # Install Poetry 22 | RUN pip install --user --no-cache-dir poetry 23 | 24 | # Copy only requirements to cache them in docker layer 25 | COPY --chown=mediaflow_proxy:mediaflow_proxy pyproject.toml poetry.lock* /mediaflow_proxy/ 26 | 27 | # Project initialization: 28 | RUN poetry config virtualenvs.in-project true \ 29 | && poetry install --no-interaction --no-ansi --no-root --only main 30 | 31 | # Copy project files 32 | COPY --chown=mediaflow_proxy:mediaflow_proxy . /mediaflow_proxy 33 | 34 | # Expose the port the app runs on 35 | EXPOSE 8888 36 | 37 | # Activate virtual environment and run the application with Gunicorn 38 | CMD ["sh", "-c", "exec poetry run gunicorn mediaflow_proxy.main:app -w 4 -k uvicorn.workers.UvicornWorker --bind 0.0.0.0:8888 --timeout 120 --max-requests 500 --max-requests-jitter 200 --access-logfile - --error-logfile - --log-level info --forwarded-allow-ips \"${FORWARDED_ALLOW_IPS:-127.0.0.1}\""] 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) [2024] [Mohamed Zumair] 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Makefile 2 | 3 | # Variables to hold version tags and contributor names 4 | VERSION_OLD ?= 5 | VERSION_NEW ?= 6 | CONTRIBUTORS ?= $(shell git log --pretty=format:'%an' $(VERSION_OLD)..$(VERSION_NEW) | sort | uniq) 7 | 8 | # Claude API settings 9 | CLAUDE_MODEL ?= claude-sonnet-4-20250514 10 | MAX_TOKENS ?= 1024 11 | ANTHROPIC_VERSION ?= 2023-06-01 12 | 13 | .PHONY: generate-notes prompt all 14 | 15 | prompt: 16 | ifndef VERSION_OLD 17 | @echo "Error: VERSION_OLD is not set. Please set it like: make prompt VERSION_OLD=x.x.x VERSION_NEW=y.y.y CONTRIBUTORS='@user1, @user2'" 18 | @exit 1 19 | endif 20 | ifndef VERSION_NEW 21 | @echo "Error: VERSION_NEW is not set. Please set it like: make prompt VERSION_OLD=x.x.x VERSION_NEW=y.y.y CONTRIBUTORS='@user1, @user2'" 22 | @exit 1 23 | endif 24 | @echo "Generate a release note for MediaFlow $(VERSION_NEW) by analyzing the following changes. Organize the release note by importance rather than by commit order. highlight the most significant updates first, and streamline the content to focus on what adds the most value to the user. Ensure to dynamically create sections for New Features & Enhancements, Bug Fixes, and Documentation updates only if relevant based on the types of changes listed. Use emojis relevantly at the start of each item to enhance readability and engagement. Keep the format straightforward & shorter, provide a direct link to the detailed list of changes:\n" 25 | @echo "## 🚀 MediaFlow $(VERSION_NEW) Released\n" 26 | @echo "### Commit Messages and Descriptions:\n" 27 | @git log --pretty=format:'%s%n%b' $(VERSION_OLD)..$(VERSION_NEW) | awk 'BEGIN {RS="\n\n"; FS="\n"} { \ 28 | message = $$1; \ 29 | description = ""; \ 30 | for (i=2; i<=NF; i++) { \ 31 | if ($$i ~ /^\*/) description = description " " $$i "\n"; \ 32 | else if ($$i != "") description = description " " $$i "\n"; \ 33 | } \ 34 | if (message != "") print "- " message; \ 35 | if (description != "") printf "%s", description; \ 36 | }' 37 | @echo "--- \n### 🤝 Contributors: $(CONTRIBUTORS)\n\n### 📄 Full Changelog:\nhttps://github.com/mhdzumair/mediaflow-proxy/compare/$(VERSION_OLD)...$(VERSION_NEW)"; 38 | 39 | generate-notes: 40 | ifndef VERSION_OLD 41 | @echo "Error: VERSION_OLD is not set" 42 | @exit 1 43 | endif 44 | ifndef VERSION_NEW 45 | @echo "Error: VERSION_NEW is not set" 46 | @exit 1 47 | endif 48 | ifndef ANTHROPIC_API_KEY 49 | @echo "Error: ANTHROPIC_API_KEY is not set" 50 | @exit 1 51 | endif 52 | @PROMPT_CONTENT=$$(make prompt VERSION_OLD=$(VERSION_OLD) VERSION_NEW=$(VERSION_NEW) | jq -sRr @json); \ 53 | if [ -z "$$PROMPT_CONTENT" ]; then \ 54 | echo "Failed to generate release notes using Claude AI, prompt content is empty"; \ 55 | exit 1; \ 56 | fi; \ 57 | temp_file=$$(mktemp); \ 58 | curl -s https://api.anthropic.com/v1/messages \ 59 | --header "x-api-key: $(ANTHROPIC_API_KEY)" \ 60 | --header "anthropic-version: $(ANTHROPIC_VERSION)" \ 61 | --header "content-type: application/json" \ 62 | --data "{\"model\":\"$(CLAUDE_MODEL)\",\"max_tokens\":$(MAX_TOKENS),\"messages\":[{\"role\":\"user\",\"content\":$$PROMPT_CONTENT}]}" > $$temp_file; \ 63 | jq -r '.content[] | select(.type=="text") | .text' $$temp_file || { echo "Failed to generate release notes using Claude AI, response: $$(cat $$temp_file)"; rm $$temp_file; exit 1; } ; \ 64 | rm $$temp_file 65 | 66 | all: generate-notes -------------------------------------------------------------------------------- /docker-compose.portainer: -------------------------------------------------------------------------------- 1 | version: "3.9" 2 | 3 | services: 4 | mediaflow_proxy: 5 | container_name: MediaflowProxy 6 | build: . 7 | ports: 8 | - "8888:8888" 9 | environment: 10 | - PYTHONDONTWRITEBYTECODE=1 11 | - PYTHONUNBUFFERED=1 12 | - PORT=8888 13 | env_file: 14 | - stack.env 15 | restart: unless-stopped 16 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | mfp: 3 | build: https://github.com/mhdzumair/mediaflow-proxy.git#main 4 | container_name: mfp 5 | restart: unless-stopped 6 | ports: 7 | - '8888:8888' 8 | environment: 9 | API_PASSWORD: mfp 10 | FORWARDED_ALLOW_IPS: "*" 11 | -------------------------------------------------------------------------------- /mediaflow_proxy/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mhdzumair/mediaflow-proxy/ffac0c2dc12502a64b9bfd4c720ac61a89900a5e/mediaflow_proxy/__init__.py -------------------------------------------------------------------------------- /mediaflow_proxy/configs.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, Literal, Optional, Union 2 | 3 | import httpx 4 | from pydantic import BaseModel, Field 5 | from pydantic_settings import BaseSettings 6 | 7 | 8 | class RouteConfig(BaseModel): 9 | """Configuration for a specific route""" 10 | 11 | proxy: bool = True 12 | proxy_url: Optional[str] = None 13 | verify_ssl: bool = True 14 | 15 | 16 | class TransportConfig(BaseSettings): 17 | """Main proxy configuration""" 18 | 19 | proxy_url: Optional[str] = Field( 20 | None, description="Primary proxy URL. Example: socks5://user:pass@proxy:1080 or http://proxy:8080" 21 | ) 22 | all_proxy: bool = Field(False, description="Enable proxy for all routes by default") 23 | transport_routes: Dict[str, RouteConfig] = Field( 24 | default_factory=dict, description="Pattern-based route configuration" 25 | ) 26 | timeout: int = Field(60, description="Timeout for HTTP requests in seconds") 27 | 28 | def get_mounts( 29 | self, async_http: bool = True 30 | ) -> Dict[str, Optional[Union[httpx.HTTPTransport, httpx.AsyncHTTPTransport]]]: 31 | """ 32 | Get a dictionary of httpx mount points to transport instances. 33 | """ 34 | mounts = {} 35 | transport_cls = httpx.AsyncHTTPTransport if async_http else httpx.HTTPTransport 36 | 37 | # Configure specific routes 38 | for pattern, route in self.transport_routes.items(): 39 | mounts[pattern] = transport_cls( 40 | verify=route.verify_ssl, proxy=route.proxy_url or self.proxy_url if route.proxy else None 41 | ) 42 | 43 | # Hardcoded configuration for jxoplay.xyz domain - SSL verification disabled 44 | mounts["all://jxoplay.xyz"] = transport_cls( 45 | verify=False, proxy=self.proxy_url if self.all_proxy else None 46 | ) 47 | 48 | mounts["all://dlhd.dad"] = transport_cls( 49 | verify=False, proxy=self.proxy_url if self.all_proxy else None 50 | ) 51 | 52 | mounts["all://*.newkso.ru"] = transport_cls( 53 | verify=False, proxy=self.proxy_url if self.all_proxy else None 54 | ) 55 | 56 | # Set default proxy for all routes if enabled 57 | if self.all_proxy: 58 | mounts["all://"] = transport_cls(proxy=self.proxy_url) 59 | 60 | return mounts 61 | 62 | class Config: 63 | env_file = ".env" 64 | extra = "ignore" 65 | 66 | 67 | class Settings(BaseSettings): 68 | api_password: str | None = None # The password for protecting the API endpoints. 69 | log_level: str = "INFO" # The logging level to use. 70 | transport_config: TransportConfig = Field(default_factory=TransportConfig) # Configuration for httpx transport. 71 | enable_streaming_progress: bool = False # Whether to enable streaming progress tracking. 72 | disable_home_page: bool = False # Whether to disable the home page UI. 73 | disable_docs: bool = False # Whether to disable the API documentation (Swagger UI). 74 | disable_speedtest: bool = False # Whether to disable the speedtest UI. 75 | stremio_proxy_url: str | None = None # The Stremio server URL for alternative content proxying. 76 | m3u8_content_routing: Literal["mediaflow", "stremio", "direct"] = ( 77 | "mediaflow" # Routing strategy for M3U8 content URLs: "mediaflow", "stremio", or "direct" 78 | ) 79 | enable_hls_prebuffer: bool = False # Whether to enable HLS pre-buffering for improved streaming performance. 80 | hls_prebuffer_segments: int = 5 # Number of segments to pre-buffer ahead. 81 | hls_prebuffer_cache_size: int = 50 # Maximum number of segments to cache in memory. 82 | hls_prebuffer_max_memory_percent: int = 80 # Maximum percentage of system memory to use for HLS pre-buffer cache. 83 | hls_prebuffer_emergency_threshold: int = 90 # Emergency threshold percentage to trigger aggressive cache cleanup. 84 | enable_dash_prebuffer: bool = False # Whether to enable DASH pre-buffering for improved streaming performance. 85 | dash_prebuffer_segments: int = 5 # Number of segments to pre-buffer ahead. 86 | dash_prebuffer_cache_size: int = 50 # Maximum number of segments to cache in memory. 87 | dash_prebuffer_max_memory_percent: int = 80 # Maximum percentage of system memory to use for DASH pre-buffer cache. 88 | dash_prebuffer_emergency_threshold: int = 90 # Emergency threshold percentage to trigger aggressive cache cleanup. 89 | 90 | user_agent: str = ( 91 | "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. 92 | ) 93 | 94 | class Config: 95 | env_file = ".env" 96 | extra = "ignore" 97 | 98 | 99 | settings = Settings() -------------------------------------------------------------------------------- /mediaflow_proxy/const.py: -------------------------------------------------------------------------------- 1 | SUPPORTED_RESPONSE_HEADERS = [ 2 | "accept-ranges", 3 | "content-type", 4 | "content-length", 5 | "content-range", 6 | "connection", 7 | "transfer-encoding", 8 | "last-modified", 9 | "etag", 10 | "cache-control", 11 | "expires", 12 | ] 13 | 14 | SUPPORTED_REQUEST_HEADERS = [ 15 | "range", 16 | "if-range", 17 | ] 18 | -------------------------------------------------------------------------------- /mediaflow_proxy/drm/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | import tempfile 3 | 4 | 5 | async def create_temp_file(suffix: str, content: bytes = None, prefix: str = None) -> tempfile.NamedTemporaryFile: 6 | temp_file = tempfile.NamedTemporaryFile(delete=False, suffix=suffix, prefix=prefix) 7 | temp_file.delete_file = lambda: os.unlink(temp_file.name) 8 | if content: 9 | temp_file.write(content) 10 | temp_file.close() 11 | return temp_file 12 | -------------------------------------------------------------------------------- /mediaflow_proxy/extractors/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mhdzumair/mediaflow-proxy/ffac0c2dc12502a64b9bfd4c720ac61a89900a5e/mediaflow_proxy/extractors/__init__.py -------------------------------------------------------------------------------- /mediaflow_proxy/extractors/base.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | from typing import Dict, Optional, Any 3 | 4 | import asyncio 5 | import httpx 6 | import logging 7 | 8 | from mediaflow_proxy.configs import settings 9 | from mediaflow_proxy.utils.http_utils import create_httpx_client, DownloadError 10 | 11 | logger = logging.getLogger(__name__) 12 | 13 | 14 | class ExtractorError(Exception): 15 | """Base exception for all extractors.""" 16 | pass 17 | 18 | 19 | class BaseExtractor(ABC): 20 | """Base class for all URL extractors. 21 | 22 | Improvements: 23 | - Built-in retry/backoff for transient network errors 24 | - Configurable timeouts and per-request overrides 25 | - Better logging of non-200 responses and body previews for debugging 26 | """ 27 | 28 | def __init__(self, request_headers: dict): 29 | self.base_headers = { 30 | "user-agent": settings.user_agent, 31 | } 32 | self.mediaflow_endpoint = "proxy_stream_endpoint" 33 | # merge incoming headers (e.g. Accept-Language / Referer) with default base headers 34 | self.base_headers.update(request_headers or {}) 35 | 36 | async def _make_request( 37 | self, 38 | url: str, 39 | method: str = "GET", 40 | headers: Optional[Dict] = None, 41 | timeout: Optional[float] = None, 42 | retries: int = 3, 43 | backoff_factor: float = 0.5, 44 | raise_on_status: bool = True, 45 | **kwargs, 46 | ) -> httpx.Response: 47 | """ 48 | Make HTTP request with retry and timeout support. 49 | 50 | Parameters 51 | ---------- 52 | timeout : float | None 53 | Seconds to wait for the request (applied to httpx.Timeout). Defaults to 15s. 54 | retries : int 55 | Number of attempts for transient errors. 56 | backoff_factor : float 57 | Base for exponential backoff between retries. 58 | raise_on_status : bool 59 | If True, HTTP non-2xx raises DownloadError (preserves status code). 60 | """ 61 | attempt = 0 62 | last_exc = None 63 | 64 | # build request headers merging base and per-request 65 | request_headers = self.base_headers.copy() 66 | if headers: 67 | request_headers.update(headers) 68 | 69 | timeout_cfg = httpx.Timeout(timeout or 15.0) 70 | 71 | while attempt < retries: 72 | try: 73 | async with create_httpx_client(timeout=timeout_cfg) as client: 74 | response = await client.request( 75 | method, 76 | url, 77 | headers=request_headers, 78 | **kwargs, 79 | ) 80 | 81 | if raise_on_status: 82 | try: 83 | response.raise_for_status() 84 | except httpx.HTTPStatusError as e: 85 | # Provide a short body preview for debugging 86 | body_preview = "" 87 | try: 88 | body_preview = e.response.text[:500] 89 | except Exception: 90 | body_preview = "" 91 | logger.debug( 92 | "HTTPStatusError for %s (status=%s) -- body preview: %s", 93 | url, 94 | e.response.status_code, 95 | body_preview, 96 | ) 97 | raise DownloadError(e.response.status_code, f"HTTP error {e.response.status_code} while requesting {url}") 98 | return response 99 | 100 | except DownloadError: 101 | # Do not retry on explicit HTTP status errors (they are intentional) 102 | raise 103 | except (httpx.ReadTimeout, httpx.ConnectTimeout, httpx.NetworkError, httpx.TransportError) as e: 104 | # Transient network error — retry with backoff 105 | last_exc = e 106 | attempt += 1 107 | sleep_for = backoff_factor * (2 ** (attempt - 1)) 108 | logger.warning("Transient network error (attempt %s/%s) for %s: %s — retrying in %.1fs", 109 | attempt, retries, url, e, sleep_for) 110 | await asyncio.sleep(sleep_for) 111 | continue 112 | except Exception as e: 113 | # Unexpected exception — wrap as ExtractorError to keep interface consistent 114 | logger.exception("Unhandled exception while requesting %s: %s", url, e) 115 | raise ExtractorError(f"Request failed for URL {url}: {str(e)}") 116 | 117 | logger.error("All retries failed for %s: %s", url, last_exc) 118 | raise ExtractorError(f"Request failed for URL {url}: {str(last_exc)}") 119 | 120 | @abstractmethod 121 | async def extract(self, url: str, **kwargs) -> Dict[str, Any]: 122 | """Extract final URL and required headers.""" 123 | pass 124 | -------------------------------------------------------------------------------- /mediaflow_proxy/extractors/doodstream.py: -------------------------------------------------------------------------------- 1 | import re 2 | import time 3 | from typing import Dict 4 | 5 | from mediaflow_proxy.extractors.base import BaseExtractor, ExtractorError 6 | 7 | 8 | class DoodStreamExtractor(BaseExtractor): 9 | """DoodStream URL extractor.""" 10 | 11 | def __init__(self, request_headers: dict): 12 | super().__init__(request_headers) 13 | self.base_url = "https://d000d.com" 14 | 15 | async def extract(self, url: str, **kwargs) -> Dict[str, str]: 16 | """Extract DoodStream URL.""" 17 | response = await self._make_request(url) 18 | 19 | # Extract URL pattern 20 | pattern = r"(\/pass_md5\/.*?)'.*(\?token=.*?expiry=)" 21 | match = re.search(pattern, response.text, re.DOTALL) 22 | if not match: 23 | raise ExtractorError("Failed to extract URL pattern") 24 | 25 | # Build final URL 26 | pass_url = f"{self.base_url}{match[1]}" 27 | referer = f"{self.base_url}/" 28 | headers = {"range": "bytes=0-", "referer": referer} 29 | 30 | response = await self._make_request(pass_url, headers=headers) 31 | timestamp = str(int(time.time())) 32 | final_url = f"{response.text}123456789{match[2]}{timestamp}" 33 | 34 | self.base_headers["referer"] = referer 35 | return { 36 | "destination_url": final_url, 37 | "request_headers": self.base_headers, 38 | "mediaflow_endpoint": self.mediaflow_endpoint, 39 | } 40 | -------------------------------------------------------------------------------- /mediaflow_proxy/extractors/factory.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, Type 2 | 3 | from mediaflow_proxy.extractors.base import BaseExtractor, ExtractorError 4 | from mediaflow_proxy.extractors.dlhd import DLHDExtractor 5 | from mediaflow_proxy.extractors.doodstream import DoodStreamExtractor 6 | from mediaflow_proxy.extractors.filelions import FileLionsExtractor 7 | from mediaflow_proxy.extractors.filemoon import FileMoonExtractor 8 | from mediaflow_proxy.extractors.livetv import LiveTVExtractor 9 | from mediaflow_proxy.extractors.lulustream import LuluStreamExtractor 10 | from mediaflow_proxy.extractors.maxstream import MaxstreamExtractor 11 | from mediaflow_proxy.extractors.mixdrop import MixdropExtractor 12 | from mediaflow_proxy.extractors.okru import OkruExtractor 13 | from mediaflow_proxy.extractors.streamtape import StreamtapeExtractor 14 | from mediaflow_proxy.extractors.supervideo import SupervideoExtractor 15 | from mediaflow_proxy.extractors.uqload import UqloadExtractor 16 | from mediaflow_proxy.extractors.vavoo import VavooExtractor 17 | from mediaflow_proxy.extractors.vixcloud import VixCloudExtractor 18 | from mediaflow_proxy.extractors.fastream import FastreamExtractor 19 | from mediaflow_proxy.extractors.voe import VoeExtractor 20 | 21 | 22 | class ExtractorFactory: 23 | """Factory for creating URL extractors.""" 24 | 25 | _extractors: Dict[str, Type[BaseExtractor]] = { 26 | "Doodstream": DoodStreamExtractor, 27 | "FileLions": FileLionsExtractor, 28 | "FileMoon": FileMoonExtractor, 29 | "Uqload": UqloadExtractor, 30 | "Mixdrop": MixdropExtractor, 31 | "Streamtape": StreamtapeExtractor, 32 | "Supervideo": SupervideoExtractor, 33 | "VixCloud": VixCloudExtractor, 34 | "Okru": OkruExtractor, 35 | "Maxstream": MaxstreamExtractor, 36 | "LiveTV": LiveTVExtractor, 37 | "LuluStream": LuluStreamExtractor, 38 | "DLHD": DLHDExtractor, 39 | "Vavoo": VavooExtractor, 40 | "Fastream": FastreamExtractor, 41 | "Voe": VoeExtractor 42 | } 43 | 44 | @classmethod 45 | def get_extractor(cls, host: str, request_headers: dict) -> BaseExtractor: 46 | """Get appropriate extractor instance for the given host.""" 47 | extractor_class = cls._extractors.get(host) 48 | if not extractor_class: 49 | raise ExtractorError(f"Unsupported host: {host}") 50 | return extractor_class(request_headers) 51 | -------------------------------------------------------------------------------- /mediaflow_proxy/extractors/fastream.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, Any 2 | 3 | from mediaflow_proxy.extractors.base import BaseExtractor 4 | from mediaflow_proxy.utils.packed import eval_solver 5 | 6 | 7 | 8 | 9 | class FastreamExtractor(BaseExtractor): 10 | """Fastream URL extractor.""" 11 | def __init__(self, *args, **kwargs): 12 | super().__init__(*args, **kwargs) 13 | self.mediaflow_endpoint = "hls_manifest_proxy" 14 | 15 | async def extract(self, url: str, **kwargs) -> Dict[str, Any]: 16 | 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'} 17 | patterns = [r'file:"(.*?)"'] 18 | 19 | final_url = await eval_solver(self, url, headers, patterns) 20 | 21 | self.base_headers["referer"] = f'https://{url.replace("https://","").split("/")[0]}/' 22 | self.base_headers["origin"] = f'https://{url.replace("https://","").split("/")[0]}' 23 | self.base_headers['Accept-Language'] = 'en-US,en;q=0.5' 24 | self.base_headers['Accept'] = '*/*' 25 | self.base_headers['user-agent'] = 'Mozilla/5.0 (X11; Linux x86_64; rv:138.0) Gecko/20100101 Firefox/138.0' 26 | 27 | return { 28 | "destination_url": final_url, 29 | "request_headers": self.base_headers, 30 | "mediaflow_endpoint": self.mediaflow_endpoint, 31 | } 32 | -------------------------------------------------------------------------------- /mediaflow_proxy/extractors/filelions.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, Any 2 | 3 | from mediaflow_proxy.extractors.base import BaseExtractor 4 | from mediaflow_proxy.utils.packed import eval_solver 5 | 6 | class FileLionsExtractor(BaseExtractor): 7 | def __init__(self, *args, **kwargs): 8 | super().__init__(*args, **kwargs) 9 | self.mediaflow_endpoint = "hls_manifest_proxy" 10 | 11 | async def extract(self, url: str, **kwargs) -> Dict[str, Any]: 12 | headers = {} 13 | patterns = [ # See https://github.com/Gujal00/ResolveURL/blob/master/script.module.resolveurl/lib/resolveurl/plugins/filelions.py 14 | r'''sources:\s*\[{file:\s*["'](?P[^"']+)''', 15 | r'''["']hls[24]["']:\s*["'](?P[^"']+)''' 16 | ] 17 | 18 | final_url = await eval_solver(self, url, headers, patterns) 19 | 20 | self.base_headers["referer"] = url 21 | return { 22 | "destination_url": final_url, 23 | "request_headers": self.base_headers, 24 | "mediaflow_endpoint": self.mediaflow_endpoint, 25 | } 26 | -------------------------------------------------------------------------------- /mediaflow_proxy/extractors/filemoon.py: -------------------------------------------------------------------------------- 1 | import re 2 | from typing import Dict, Any 3 | 4 | from mediaflow_proxy.extractors.base import BaseExtractor, ExtractorError 5 | from mediaflow_proxy.utils.packed import eval_solver 6 | 7 | 8 | class FileMoonExtractor(BaseExtractor): 9 | def __init__(self, *args, **kwargs): 10 | super().__init__(*args, **kwargs) 11 | self.mediaflow_endpoint = "hls_manifest_proxy" 12 | 13 | async def extract(self, url: str, **kwargs) -> Dict[str, Any]: 14 | response = await self._make_request(url) 15 | 16 | pattern = r"iframe.*?src=[\"'](.*?)[\"']" 17 | match = re.search(pattern, response.text, re.DOTALL) 18 | if not match: 19 | raise ExtractorError("Failed to extract iframe URL") 20 | iframe_url = match.group(1) 21 | 22 | headers = {'Referer': url} 23 | patterns = [r'file:"(.*?)"'] 24 | 25 | final_url = await eval_solver(self, iframe_url, headers, patterns) 26 | 27 | self.base_headers["referer"] = url 28 | return { 29 | "destination_url": final_url, 30 | "request_headers": self.base_headers, 31 | "mediaflow_endpoint": self.mediaflow_endpoint, 32 | } 33 | -------------------------------------------------------------------------------- /mediaflow_proxy/extractors/livetv.py: -------------------------------------------------------------------------------- 1 | import re 2 | from typing import Dict, Tuple, Optional 3 | from urllib.parse import urljoin, urlparse, unquote 4 | 5 | from httpx import Response 6 | 7 | from mediaflow_proxy.extractors.base import BaseExtractor, ExtractorError 8 | 9 | 10 | class LiveTVExtractor(BaseExtractor): 11 | """LiveTV URL extractor for both M3U8 and MPD streams.""" 12 | 13 | def __init__(self, request_headers: dict): 14 | super().__init__(request_headers) 15 | # Default to HLS proxy endpoint, will be updated based on stream type 16 | self.mediaflow_endpoint = "hls_manifest_proxy" 17 | 18 | # Patterns for stream URL extraction 19 | self.fallback_pattern = re.compile( 20 | r"source: [\'\"](.*?)[\'\"]\s*,\s*[\s\S]*?mimeType: [\'\"](application/x-mpegURL|application/vnd\.apple\.mpegURL|application/dash\+xml)[\'\"]", 21 | re.IGNORECASE, 22 | ) 23 | self.any_m3u8_pattern = re.compile( 24 | r'["\']?(https?://.*?\.m3u8(?:\?[^"\']*)?)["\']?', 25 | re.IGNORECASE, 26 | ) 27 | 28 | async def extract(self, url: str, stream_title: str = None, **kwargs) -> Dict[str, str]: 29 | """Extract LiveTV URL and required headers. 30 | 31 | Args: 32 | url: The channel page URL 33 | stream_title: Optional stream title to filter specific stream 34 | 35 | Returns: 36 | Tuple[str, Dict[str, str]]: Stream URL and required headers 37 | """ 38 | try: 39 | # Get the channel page 40 | response = await self._make_request(url) 41 | self.base_headers["referer"] = urljoin(url, "/") 42 | 43 | # Extract player API details 44 | player_api_base, method = await self._extract_player_api_base(response.text) 45 | if not player_api_base: 46 | raise ExtractorError("Failed to extract player API URL") 47 | 48 | # Get player options 49 | options_data = await self._get_player_options(response.text) 50 | if not options_data: 51 | raise ExtractorError("No player options found") 52 | 53 | # Process player options to find matching stream 54 | for option in options_data: 55 | current_title = option.get("title") 56 | if stream_title and current_title != stream_title: 57 | continue 58 | 59 | # Get stream URL based on player option 60 | stream_data = await self._process_player_option( 61 | player_api_base, method, option.get("post"), option.get("nume"), option.get("type") 62 | ) 63 | 64 | if stream_data: 65 | stream_url = stream_data.get("url") 66 | if not stream_url: 67 | continue 68 | 69 | response = { 70 | "destination_url": stream_url, 71 | "request_headers": self.base_headers, 72 | "mediaflow_endpoint": self.mediaflow_endpoint, 73 | } 74 | 75 | # Set endpoint based on stream type 76 | if stream_data.get("type") == "mpd": 77 | if stream_data.get("drm_key_id") and stream_data.get("drm_key"): 78 | response.update( 79 | { 80 | "query_params": { 81 | "key_id": stream_data["drm_key_id"], 82 | "key": stream_data["drm_key"], 83 | }, 84 | "mediaflow_endpoint": "mpd_manifest_proxy", 85 | } 86 | ) 87 | 88 | return response 89 | 90 | raise ExtractorError("No valid stream found") 91 | 92 | except Exception as e: 93 | raise ExtractorError(f"Extraction failed: {str(e)}") 94 | 95 | async def _extract_player_api_base(self, html_content: str) -> Tuple[Optional[str], Optional[str]]: 96 | """Extract player API base URL and method.""" 97 | admin_ajax_pattern = r'"player_api"\s*:\s*"([^"]+)".*?"play_method"\s*:\s*"([^"]+)"' 98 | match = re.search(admin_ajax_pattern, html_content) 99 | if not match: 100 | return None, None 101 | url = match.group(1).replace("\\/", "/") 102 | method = match.group(2) 103 | if method == "wp_json": 104 | return url, method 105 | url = urljoin(url, "/wp-admin/admin-ajax.php") 106 | return url, method 107 | 108 | async def _get_player_options(self, html_content: str) -> list: 109 | """Extract player options from HTML content.""" 110 | pattern = r']*class=["\']dooplay_player_option["\'][^>]*data-type=["\']([^"\']*)["\'][^>]*data-post=["\']([^"\']*)["\'][^>]*data-nume=["\']([^"\']*)["\'][^>]*>.*?([^<]*)' 111 | matches = re.finditer(pattern, html_content, re.DOTALL) 112 | return [ 113 | {"type": match.group(1), "post": match.group(2), "nume": match.group(3), "title": match.group(4).strip()} 114 | for match in matches 115 | ] 116 | 117 | async def _process_player_option(self, api_base: str, method: str, post: str, nume: str, type_: str) -> Dict: 118 | """Process player option to get stream URL.""" 119 | if method == "wp_json": 120 | api_url = f"{api_base}{post}/{type_}/{nume}" 121 | response = await self._make_request(api_url) 122 | else: 123 | form_data = {"action": "doo_player_ajax", "post": post, "nume": nume, "type": type_} 124 | response = await self._make_request(api_base, method="POST", data=form_data) 125 | 126 | # Get iframe URL from API response 127 | try: 128 | data = response.json() 129 | iframe_url = urljoin(api_base, data.get("embed_url", "").replace("\\/", "/")) 130 | 131 | # Get stream URL from iframe 132 | iframe_response = await self._make_request(iframe_url) 133 | stream_data = await self._extract_stream_url(iframe_response, iframe_url) 134 | return stream_data 135 | 136 | except Exception as e: 137 | raise ExtractorError(f"Failed to process player option: {str(e)}") 138 | 139 | async def _extract_stream_url(self, iframe_response: Response, iframe_url: str) -> Dict: 140 | """ 141 | Extract final stream URL from iframe content. 142 | """ 143 | try: 144 | # Parse URL components 145 | parsed_url = urlparse(iframe_url) 146 | query_params = dict(param.split("=") for param in parsed_url.query.split("&") if "=" in param) 147 | 148 | # Check if content is already a direct M3U8 stream 149 | content_types = ["application/x-mpegurl", "application/vnd.apple.mpegurl"] 150 | 151 | if any(ext in iframe_response.headers["content-type"] for ext in content_types): 152 | return {"url": iframe_url, "type": "m3u8"} 153 | 154 | stream_data = {} 155 | 156 | # Check for source parameter in URL 157 | if "source" in query_params: 158 | stream_data = { 159 | "url": urljoin(iframe_url, unquote(query_params["source"])), 160 | "type": "m3u8", 161 | } 162 | 163 | # Check for MPD stream with DRM 164 | elif "zy" in query_params and ".mpd``" in query_params["zy"]: 165 | data = query_params["zy"].split("``") 166 | url = data[0] 167 | key_id, key = data[1].split(":") 168 | stream_data = {"url": url, "type": "mpd", "drm_key_id": key_id, "drm_key": key} 169 | 170 | # Check for tamilultra specific format 171 | elif "tamilultra" in iframe_url: 172 | stream_data = {"url": urljoin(iframe_url, parsed_url.query), "type": "m3u8"} 173 | 174 | # Try pattern matching for stream URLs 175 | else: 176 | channel_id = query_params.get("id", [""]) 177 | stream_url = None 178 | 179 | html_content = iframe_response.text 180 | 181 | if channel_id: 182 | # Try channel ID specific pattern 183 | pattern = rf'{re.escape(channel_id)}["\']:\s*{{\s*["\']?url["\']?\s*:\s*["\']([^"\']+)["\']' 184 | match = re.search(pattern, html_content) 185 | if match: 186 | stream_url = match.group(1) 187 | 188 | # Try fallback patterns if channel ID pattern fails 189 | if not stream_url: 190 | for pattern in [self.fallback_pattern, self.any_m3u8_pattern]: 191 | match = pattern.search(html_content) 192 | if match: 193 | stream_url = match.group(1) 194 | break 195 | 196 | if stream_url: 197 | stream_data = {"url": stream_url, "type": "m3u8"} # Default to m3u8, will be updated 198 | 199 | # Check for MPD stream and extract DRM keys 200 | if stream_url.endswith(".mpd"): 201 | stream_data["type"] = "mpd" 202 | drm_data = await self._extract_drm_keys(html_content, channel_id) 203 | if drm_data: 204 | stream_data.update(drm_data) 205 | 206 | # If no stream data found, raise error 207 | if not stream_data: 208 | raise ExtractorError("No valid stream URL found") 209 | 210 | # Update stream type based on URL if not already set 211 | if stream_data.get("type") == "m3u8": 212 | if stream_data["url"].endswith(".mpd"): 213 | stream_data["type"] = "mpd" 214 | elif not any(ext in stream_data["url"] for ext in [".m3u8", ".m3u"]): 215 | stream_data["type"] = "m3u8" # Default to m3u8 if no extension found 216 | 217 | return stream_data 218 | 219 | except Exception as e: 220 | raise ExtractorError(f"Failed to extract stream URL: {str(e)}") 221 | 222 | async def _extract_drm_keys(self, html_content: str, channel_id: str) -> Dict: 223 | """ 224 | Extract DRM keys for MPD streams. 225 | """ 226 | try: 227 | # Pattern for channel entry 228 | channel_pattern = rf'"{re.escape(channel_id)}":\s*{{[^}}]+}}' 229 | channel_match = re.search(channel_pattern, html_content) 230 | 231 | if channel_match: 232 | channel_data = channel_match.group(0) 233 | 234 | # Try clearkeys pattern first 235 | clearkey_pattern = r'["\']?clearkeys["\']?\s*:\s*{\s*["\'](.+?)["\']:\s*["\'](.+?)["\']' 236 | clearkey_match = re.search(clearkey_pattern, channel_data) 237 | 238 | # Try k1/k2 pattern if clearkeys not found 239 | if not clearkey_match: 240 | k1k2_pattern = r'["\']?k1["\']?\s*:\s*["\'](.+?)["\'],\s*["\']?k2["\']?\s*:\s*["\'](.+?)["\']' 241 | k1k2_match = re.search(k1k2_pattern, channel_data) 242 | 243 | if k1k2_match: 244 | return {"drm_key_id": k1k2_match.group(1), "drm_key": k1k2_match.group(2)} 245 | else: 246 | return {"drm_key_id": clearkey_match.group(1), "drm_key": clearkey_match.group(2)} 247 | 248 | return {} 249 | 250 | except Exception: 251 | return {} 252 | -------------------------------------------------------------------------------- /mediaflow_proxy/extractors/lulustream.py: -------------------------------------------------------------------------------- 1 | import re 2 | from typing import Dict, Any 3 | 4 | from mediaflow_proxy.extractors.base import BaseExtractor, ExtractorError 5 | 6 | 7 | class LuluStreamExtractor(BaseExtractor): 8 | def __init__(self, *args, **kwargs): 9 | super().__init__(*args, **kwargs) 10 | self.mediaflow_endpoint = "hls_manifest_proxy" 11 | 12 | async def extract(self, url: str, **kwargs) -> Dict[str, Any]: 13 | response = await self._make_request(url) 14 | 15 | # See https://github.com/Gujal00/ResolveURL/blob/master/script.module.resolveurl/lib/resolveurl/plugins/lulustream.py 16 | pattern = r'''sources:\s*\[{file:\s*["'](?P[^"']+)''' 17 | match = re.search(pattern, response.text, re.DOTALL) 18 | if not match: 19 | raise ExtractorError("Failed to extract source URL") 20 | final_url = match.group(1) 21 | 22 | self.base_headers["referer"] = url 23 | return { 24 | "destination_url": final_url, 25 | "request_headers": self.base_headers, 26 | "mediaflow_endpoint": self.mediaflow_endpoint, 27 | } 28 | -------------------------------------------------------------------------------- /mediaflow_proxy/extractors/maxstream.py: -------------------------------------------------------------------------------- 1 | import re 2 | from typing import Dict, Any 3 | 4 | from bs4 import BeautifulSoup 5 | 6 | from mediaflow_proxy.extractors.base import BaseExtractor, ExtractorError 7 | 8 | 9 | class MaxstreamExtractor(BaseExtractor): 10 | """Maxstream URL extractor.""" 11 | 12 | def __init__(self, *args, **kwargs): 13 | super().__init__(*args, **kwargs) 14 | self.mediaflow_endpoint = "hls_manifest_proxy" 15 | 16 | async def get_uprot(self, link: str): 17 | """Extract MaxStream URL.""" 18 | if "msf" in link: 19 | link = link.replace("msf", "mse") 20 | response = await self._make_request(link) 21 | soup = BeautifulSoup(response.text, "lxml") 22 | maxstream_url = soup.find("a") 23 | maxstream_url = maxstream_url.get("href") 24 | return maxstream_url 25 | 26 | async def extract(self, url: str, **kwargs) -> Dict[str, Any]: 27 | """Extract Maxstream URL.""" 28 | maxstream_url = await self.get_uprot(url) 29 | response = await self._make_request(maxstream_url, headers={"accept-language": "en-US,en;q=0.5"}) 30 | 31 | # Extract and decode URL 32 | match = re.search(r"\}\('(.+)',.+,'(.+)'\.split", response.text) 33 | if not match: 34 | raise ExtractorError("Failed to extract URL components") 35 | 36 | s1 = match.group(2) 37 | # Extract Terms 38 | terms = s1.split("|") 39 | urlset_index = terms.index("urlset") 40 | hls_index = terms.index("hls") 41 | sources_index = terms.index("sources") 42 | result = terms[urlset_index + 1 : hls_index] 43 | reversed_elements = result[::-1] 44 | first_part = terms[hls_index + 1 : sources_index] 45 | reversed_first_part = first_part[::-1] 46 | first_url_part = "" 47 | for first_part in reversed_first_part: 48 | if "0" in first_part: 49 | first_url_part += first_part 50 | else: 51 | first_url_part += first_part + "-" 52 | 53 | base_url = f"https://{first_url_part}.host-cdn.net/hls/" 54 | if len(reversed_elements) == 1: 55 | final_url = base_url + "," + reversed_elements[0] + ".urlset/master.m3u8" 56 | lenght = len(reversed_elements) 57 | i = 1 58 | for element in reversed_elements: 59 | base_url += element + "," 60 | if lenght == i: 61 | base_url += ".urlset/master.m3u8" 62 | else: 63 | i += 1 64 | final_url = base_url 65 | 66 | self.base_headers["referer"] = url 67 | return { 68 | "destination_url": final_url, 69 | "request_headers": self.base_headers, 70 | "mediaflow_endpoint": self.mediaflow_endpoint, 71 | } 72 | -------------------------------------------------------------------------------- /mediaflow_proxy/extractors/mixdrop.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, Any 2 | 3 | from mediaflow_proxy.extractors.base import BaseExtractor, ExtractorError 4 | from mediaflow_proxy.utils.packed import eval_solver 5 | 6 | 7 | class MixdropExtractor(BaseExtractor): 8 | """Mixdrop URL extractor.""" 9 | 10 | async def extract(self, url: str, **kwargs) -> Dict[str, Any]: 11 | """Extract Mixdrop URL.""" 12 | if "club" in url: 13 | url = url.replace("club", "ps").split("/2")[0] 14 | 15 | headers = {"accept-language": "en-US,en;q=0.5"} 16 | patterns = [r'MDCore.wurl ?= ?"(.*?)"'] 17 | 18 | final_url = await eval_solver(self, url, headers, patterns) 19 | 20 | self.base_headers["referer"] = url 21 | return { 22 | "destination_url": final_url, 23 | "request_headers": self.base_headers, 24 | "mediaflow_endpoint": self.mediaflow_endpoint, 25 | } 26 | -------------------------------------------------------------------------------- /mediaflow_proxy/extractors/okru.py: -------------------------------------------------------------------------------- 1 | import json 2 | from typing import Dict, Any 3 | 4 | from bs4 import BeautifulSoup, SoupStrainer 5 | 6 | from mediaflow_proxy.extractors.base import BaseExtractor 7 | 8 | 9 | class OkruExtractor(BaseExtractor): 10 | """Okru URL extractor.""" 11 | 12 | def __init__(self, *args, **kwargs): 13 | super().__init__(*args, **kwargs) 14 | self.mediaflow_endpoint = "hls_manifest_proxy" 15 | 16 | async def extract(self, url: str, **kwargs) -> Dict[str, Any]: 17 | """Extract Okru URL.""" 18 | response = await self._make_request(url) 19 | soup = BeautifulSoup(response.text, "lxml", parse_only=SoupStrainer("div")) 20 | if soup: 21 | div = soup.find("div", {"data-module": "OKVideo"}) 22 | data_options = div.get("data-options") 23 | data = json.loads(data_options) 24 | metadata = json.loads(data["flashvars"]["metadata"]) 25 | final_url = metadata.get("hlsMasterPlaylistUrl") or metadata.get("hlsManifestUrl") 26 | self.base_headers["referer"] = url 27 | return { 28 | "destination_url": final_url, 29 | "request_headers": self.base_headers, 30 | "mediaflow_endpoint": self.mediaflow_endpoint, 31 | } 32 | -------------------------------------------------------------------------------- /mediaflow_proxy/extractors/streamtape.py: -------------------------------------------------------------------------------- 1 | import re 2 | from typing import Dict, Any 3 | from mediaflow_proxy.extractors.base import BaseExtractor, ExtractorError 4 | 5 | 6 | class StreamtapeExtractor(BaseExtractor): 7 | """Streamtape URL extractor.""" 8 | 9 | async def extract(self, url: str, **kwargs) -> Dict[str, Any]: 10 | """Extract Streamtape URL.""" 11 | response = await self._make_request(url) 12 | 13 | # Extract and decode URL 14 | matches = re.findall(r"id=.*?(?=')", response.text) 15 | if not matches: 16 | raise ExtractorError("Failed to extract URL components") 17 | i = 0 18 | for i in range(len(matches)): 19 | if matches[i-1] == matches[i] and "ip=" in matches[i]: 20 | final_url = f"https://streamtape.com/get_video?{matches[i]}" 21 | 22 | self.base_headers["referer"] = url 23 | return { 24 | "destination_url": final_url, 25 | "request_headers": self.base_headers, 26 | "mediaflow_endpoint": self.mediaflow_endpoint, 27 | } 28 | -------------------------------------------------------------------------------- /mediaflow_proxy/extractors/supervideo.py: -------------------------------------------------------------------------------- 1 | import re 2 | from typing import Dict, Any 3 | 4 | from mediaflow_proxy.extractors.base import BaseExtractor 5 | from mediaflow_proxy.utils.packed import eval_solver 6 | 7 | 8 | 9 | 10 | class SupervideoExtractor(BaseExtractor): 11 | """Supervideo URL extractor.""" 12 | def __init__(self, *args, **kwargs): 13 | super().__init__(*args, **kwargs) 14 | self.mediaflow_endpoint = "hls_manifest_proxy" 15 | 16 | async def extract(self, url: str, **kwargs) -> Dict[str, Any]: 17 | 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'} 18 | patterns = [r'file:"(.*?)"'] 19 | 20 | final_url = await eval_solver(self, url, headers, patterns) 21 | 22 | self.base_headers["referer"] = url 23 | return { 24 | "destination_url": final_url, 25 | "request_headers": self.base_headers, 26 | "mediaflow_endpoint": self.mediaflow_endpoint, 27 | } 28 | -------------------------------------------------------------------------------- /mediaflow_proxy/extractors/uqload.py: -------------------------------------------------------------------------------- 1 | import re 2 | from typing import Dict 3 | from urllib.parse import urljoin 4 | 5 | from mediaflow_proxy.extractors.base import BaseExtractor, ExtractorError 6 | 7 | 8 | class UqloadExtractor(BaseExtractor): 9 | """Uqload URL extractor.""" 10 | 11 | async def extract(self, url: str, **kwargs) -> Dict[str, str]: 12 | """Extract Uqload URL.""" 13 | response = await self._make_request(url) 14 | 15 | video_url_match = re.search(r'sources: \["(.*?)"]', response.text) 16 | if not video_url_match: 17 | raise ExtractorError("Failed to extract video URL") 18 | 19 | self.base_headers["referer"] = urljoin(url, "/") 20 | return { 21 | "destination_url": video_url_match.group(1), 22 | "request_headers": self.base_headers, 23 | "mediaflow_endpoint": self.mediaflow_endpoint, 24 | } 25 | -------------------------------------------------------------------------------- /mediaflow_proxy/extractors/vavoo.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import Any, Dict, Optional 3 | from mediaflow_proxy.extractors.base import BaseExtractor, ExtractorError 4 | 5 | logger = logging.getLogger(__name__) 6 | 7 | 8 | class VavooExtractor(BaseExtractor): 9 | """Vavoo URL extractor for resolving vavoo.to links. 10 | 11 | Features: 12 | - Uses BaseExtractor's retry/timeouts 13 | - Improved headers to mimic Android okhttp client 14 | - Robust JSON handling and logging 15 | """ 16 | 17 | def __init__(self, request_headers: dict): 18 | super().__init__(request_headers) 19 | self.mediaflow_endpoint = "proxy_stream_endpoint" 20 | 21 | async def get_auth_signature(self) -> Optional[str]: 22 | """Get authentication signature for Vavoo API (async).""" 23 | headers = { 24 | "user-agent": "okhttp/4.11.0", 25 | "accept": "application/json", 26 | "content-type": "application/json; charset=utf-8", 27 | "accept-encoding": "gzip", 28 | } 29 | import time 30 | current_time = int(time.time() * 1000) 31 | 32 | data = { 33 | "token": "tosFwQCJMS8qrW_AjLoHPQ41646J5dRNha6ZWHnijoYQQQoADQoXYSo7ki7O5-CsgN4CH0uRk6EEoJ0728ar9scCRQW3ZkbfrPfeCXW2VgopSW2FWDqPOoVYIuVPAOnXCZ5g", 34 | "reason": "app-blur", 35 | "locale": "de", 36 | "theme": "dark", 37 | "metadata": { 38 | "device": { 39 | "type": "Handset", 40 | "brand": "google", 41 | "model": "Pixel", 42 | "name": "sdk_gphone64_arm64", 43 | "uniqueId": "d10e5d99ab665233" 44 | }, 45 | "os": { 46 | "name": "android", 47 | "version": "13" 48 | }, 49 | "app": { 50 | "platform": "android", 51 | "version": "3.1.21" 52 | }, 53 | "version": { 54 | "package": "tv.vavoo.app", 55 | "binary": "3.1.21", 56 | "js": "3.1.21" 57 | }, 58 | }, 59 | "appFocusTime": 0, 60 | "playerActive": False, 61 | "playDuration": 0, 62 | "devMode": False, 63 | "hasAddon": True, 64 | "castConnected": False, 65 | "package": "tv.vavoo.app", 66 | "version": "3.1.21", 67 | "process": "app", 68 | "firstAppStart": current_time, 69 | "lastAppStart": current_time, 70 | "ipLocation": "", 71 | "adblockEnabled": True, 72 | "proxy": { 73 | "supported": ["ss", "openvpn"], 74 | "engine": "ss", 75 | "ssVersion": 1, 76 | "enabled": True, 77 | "autoServer": True, 78 | "id": "de-fra" 79 | }, 80 | "iap": { 81 | "supported": False 82 | } 83 | } 84 | 85 | try: 86 | resp = await self._make_request( 87 | "https://www.vavoo.tv/api/app/ping", 88 | method="POST", 89 | json=data, 90 | headers=headers, 91 | timeout=10, 92 | retries=2, 93 | ) 94 | try: 95 | result = resp.json() 96 | except Exception: 97 | logger.warning("Vavoo ping returned non-json response (status=%s).", resp.status_code) 98 | return None 99 | 100 | addon_sig = result.get("addonSig") if isinstance(result, dict) else None 101 | if addon_sig: 102 | logger.info("Successfully obtained Vavoo authentication signature") 103 | return addon_sig 104 | else: 105 | logger.warning("No addonSig in Vavoo API response: %s", result) 106 | return None 107 | except ExtractorError as e: 108 | logger.warning("Failed to get Vavoo auth signature: %s", e) 109 | return None 110 | 111 | async def extract(self, url: str, **kwargs) -> Dict[str, Any]: 112 | """Extract Vavoo stream URL (async).""" 113 | if "vavoo.to" not in url: 114 | raise ExtractorError("Not a valid Vavoo URL") 115 | 116 | signature = await self.get_auth_signature() 117 | if not signature: 118 | raise ExtractorError("Failed to get Vavoo authentication signature") 119 | 120 | resolved_url = await self._resolve_vavoo_link(url, signature) 121 | if not resolved_url: 122 | raise ExtractorError("Failed to resolve Vavoo URL") 123 | 124 | stream_headers = { 125 | "user-agent": self.base_headers.get("user-agent", "okhttp/4.11.0"), 126 | "referer": "https://vavoo.to/", 127 | } 128 | 129 | return { 130 | "destination_url": resolved_url, 131 | "request_headers": stream_headers, 132 | "mediaflow_endpoint": self.mediaflow_endpoint, 133 | } 134 | 135 | async def _resolve_vavoo_link(self, link: str, signature: str) -> Optional[str]: 136 | """Resolve a Vavoo link using the MediaHubMX API (async).""" 137 | headers = { 138 | "user-agent": "okhttp/4.11.0", 139 | "accept": "application/json", 140 | "content-type": "application/json; charset=utf-8", 141 | "accept-encoding": "gzip", 142 | "mediahubmx-signature": signature 143 | } 144 | data = { 145 | "language": "de", 146 | "region": "AT", 147 | "url": link, 148 | "clientVersion": "3.1.21" 149 | } 150 | try: 151 | logger.info(f"Attempting to resolve Vavoo URL: {link}") 152 | resp = await self._make_request( 153 | "https://vavoo.to/mediahubmx-resolve.json", 154 | method="POST", 155 | json=data, 156 | headers=headers, 157 | timeout=12, 158 | retries=3, 159 | backoff_factor=0.6, 160 | ) 161 | try: 162 | result = resp.json() 163 | except Exception: 164 | logger.warning("Vavoo resolve returned non-json response (status=%s). Body preview: %s", resp.status_code, getattr(resp, "text", "")[:500]) 165 | return None 166 | 167 | logger.debug("Vavoo API response: %s", result) 168 | 169 | # Accept either list or dict with 'url' 170 | if isinstance(result, list) and result and isinstance(result[0], dict) and result[0].get("url"): 171 | resolved_url = result[0]["url"] 172 | logger.info("Successfully resolved Vavoo URL to: %s", resolved_url) 173 | return resolved_url 174 | elif isinstance(result, dict) and result.get("url"): 175 | resolved_url = result["url"] 176 | logger.info("Successfully resolved Vavoo URL to: %s", resolved_url) 177 | return resolved_url 178 | else: 179 | logger.warning("No URL found in Vavoo API response: %s", result) 180 | return None 181 | except ExtractorError as e: 182 | logger.error(f"Vavoo resolution failed for URL {link}: {e}") 183 | raise ExtractorError(f"Vavoo resolution failed: {str(e)}") from e 184 | except Exception as e: 185 | logger.error(f"Unexpected error while resolving Vavoo URL {link}: {e}") 186 | raise ExtractorError(f"Vavoo resolution failed: {str(e)}") from e 187 | -------------------------------------------------------------------------------- /mediaflow_proxy/extractors/vixcloud.py: -------------------------------------------------------------------------------- 1 | import json 2 | import re 3 | from typing import Dict, Any 4 | from urllib.parse import urlparse, parse_qs 5 | 6 | from bs4 import BeautifulSoup, SoupStrainer 7 | 8 | from mediaflow_proxy.extractors.base import BaseExtractor, ExtractorError 9 | 10 | 11 | class VixCloudExtractor(BaseExtractor): 12 | """VixCloud URL extractor.""" 13 | 14 | def __init__(self, *args, **kwargs): 15 | super().__init__(*args, **kwargs) 16 | self.mediaflow_endpoint = "hls_manifest_proxy" 17 | 18 | async def version(self, site_url: str) -> str: 19 | """Get version of VixCloud Parent Site.""" 20 | base_url = f"{site_url}/request-a-title" 21 | response = await self._make_request( 22 | base_url, 23 | headers={ 24 | "Referer": f"{site_url}/", 25 | "Origin": f"{site_url}", 26 | }, 27 | ) 28 | if response.status_code != 200: 29 | raise ExtractorError("Outdated Url") 30 | # Soup the response 31 | soup = BeautifulSoup(response.text, "lxml", parse_only=SoupStrainer("div", {"id": "app"})) 32 | if soup: 33 | # Extract version 34 | try: 35 | data = json.loads(soup.find("div", {"id": "app"}).get("data-page")) 36 | return data["version"] 37 | except (KeyError, json.JSONDecodeError, AttributeError) as e: 38 | raise ExtractorError(f"Failed to parse version: {e}") 39 | 40 | async def extract(self, url: str, **kwargs) -> Dict[str, Any]: 41 | """Extract Vixcloud URL.""" 42 | if "iframe" in url: 43 | site_url = url.split("/iframe")[0] 44 | version = await self.version(site_url) 45 | response = await self._make_request(url, headers={"x-inertia": "true", "x-inertia-version": version}) 46 | soup = BeautifulSoup(response.text, "lxml", parse_only=SoupStrainer("iframe")) 47 | iframe = soup.find("iframe").get("src") 48 | response = await self._make_request(iframe, headers={"x-inertia": "true", "x-inertia-version": version}) 49 | elif "movie" in url or "tv" in url: 50 | response = await self._make_request(url) 51 | 52 | if response.status_code != 200: 53 | raise ExtractorError("Failed to extract URL components, Invalid Request") 54 | soup = BeautifulSoup(response.text, "lxml", parse_only=SoupStrainer("body")) 55 | if soup: 56 | script = soup.find("body").find("script").text 57 | token = re.search(r"'token':\s*'(\w+)'", script).group(1) 58 | expires = re.search(r"'expires':\s*'(\d+)'", script).group(1) 59 | server_url = re.search(r"url:\s*'([^']+)'", script).group(1) 60 | if "?b=1" in server_url: 61 | final_url = f'{server_url}&token={token}&expires={expires}' 62 | else: 63 | final_url = f"{server_url}?token={token}&expires={expires}" 64 | if "window.canPlayFHD = true" in script: 65 | final_url += "&h=1" 66 | self.base_headers["referer"] = url 67 | return { 68 | "destination_url": final_url, 69 | "request_headers": self.base_headers, 70 | "mediaflow_endpoint": self.mediaflow_endpoint, 71 | } 72 | -------------------------------------------------------------------------------- /mediaflow_proxy/extractors/voe.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import re 3 | from typing import Dict, Any 4 | from urllib.parse import urljoin 5 | 6 | from mediaflow_proxy.extractors.base import BaseExtractor, ExtractorError 7 | 8 | 9 | class VoeExtractor(BaseExtractor): 10 | def __init__(self, *args, **kwargs): 11 | super().__init__(*args, **kwargs) 12 | self.mediaflow_endpoint = "hls_manifest_proxy" 13 | 14 | async def extract(self, url: str, redirected: bool = False, **kwargs) -> Dict[str, Any]: 15 | response = await self._make_request(url) 16 | 17 | # See https://github.com/Gujal00/ResolveURL/blob/master/script.module.resolveurl/lib/resolveurl/plugins/voesx.py 18 | redirect_pattern = r'''window\.location\.href\s*=\s*'([^']+)''' 19 | redirect_match = re.search(redirect_pattern, response.text, re.DOTALL) 20 | if redirect_match: 21 | if redirected: 22 | raise ExtractorError("VOE: too many redirects") 23 | 24 | return await self.extract(redirect_match.group(1)) 25 | 26 | code_and_script_pattern = r'json">\["([^"]+)"]\s* Dict[str, Any]: 53 | import json 54 | lut = [''.join([('\\' + x) if x in '.*+?^${}()|[]\\' else x for x in i]) for i in luts[2:-2].split("','")] 55 | txt = '' 56 | for i in ct: 57 | x = ord(i) 58 | if 64 < x < 91: 59 | x = (x - 52) % 26 + 65 60 | elif 96 < x < 123: 61 | x = (x - 84) % 26 + 97 62 | txt += chr(x) 63 | for i in lut: 64 | txt = re.sub(i, '', txt) 65 | ct = base64.b64decode(txt).decode('utf-8') 66 | txt = ''.join([chr(ord(i) - 3) for i in ct]) 67 | txt = base64.b64decode(txt[::-1]).decode('utf-8') 68 | return json.loads(txt) 69 | -------------------------------------------------------------------------------- /mediaflow_proxy/handlers.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import logging 3 | from urllib.parse import urlparse, parse_qs 4 | 5 | import httpx 6 | import tenacity 7 | from fastapi import Request, Response, HTTPException 8 | from starlette.background import BackgroundTask 9 | 10 | from .const import SUPPORTED_RESPONSE_HEADERS 11 | from .mpd_processor import process_manifest, process_playlist, process_segment 12 | from .schemas import HLSManifestParams, MPDManifestParams, MPDPlaylistParams, MPDSegmentParams 13 | from .utils.cache_utils import get_cached_mpd, get_cached_init_segment 14 | from .utils.http_utils import ( 15 | Streamer, 16 | DownloadError, 17 | download_file_with_retry, 18 | request_with_retry, 19 | EnhancedStreamingResponse, 20 | ProxyRequestHeaders, 21 | create_httpx_client, 22 | ) 23 | from .utils.m3u8_processor import M3U8Processor 24 | from .utils.mpd_utils import pad_base64 25 | 26 | logger = logging.getLogger(__name__) 27 | 28 | 29 | async def setup_client_and_streamer() -> tuple[httpx.AsyncClient, Streamer]: 30 | """ 31 | Set up an HTTP client and a streamer. 32 | 33 | Returns: 34 | tuple: An httpx.AsyncClient instance and a Streamer instance. 35 | """ 36 | client = create_httpx_client() 37 | return client, Streamer(client) 38 | 39 | 40 | def handle_exceptions(exception: Exception) -> Response: 41 | """ 42 | Handle exceptions and return appropriate HTTP responses. 43 | 44 | Args: 45 | exception (Exception): The exception that was raised. 46 | 47 | Returns: 48 | Response: An HTTP response corresponding to the exception type. 49 | """ 50 | if isinstance(exception, httpx.HTTPStatusError): 51 | logger.error(f"Upstream service error while handling request: {exception}") 52 | return Response(status_code=exception.response.status_code, content=f"Upstream service error: {exception}") 53 | elif isinstance(exception, DownloadError): 54 | logger.error(f"Error downloading content: {exception}") 55 | return Response(status_code=exception.status_code, content=str(exception)) 56 | elif isinstance(exception, tenacity.RetryError): 57 | return Response(status_code=502, content="Max retries exceeded while downloading content") 58 | else: 59 | logger.exception(f"Internal server error while handling request: {exception}") 60 | return Response(status_code=502, content=f"Internal server error: {exception}") 61 | 62 | 63 | async def handle_hls_stream_proxy( 64 | request: Request, hls_params: HLSManifestParams, proxy_headers: ProxyRequestHeaders 65 | ) -> Response: 66 | """ 67 | Handle HLS stream proxy requests. 68 | 69 | This function processes HLS manifest files and streams content based on the request parameters. 70 | 71 | Args: 72 | request (Request): The incoming FastAPI request object. 73 | hls_params (HLSManifestParams): Parameters for the HLS manifest. 74 | proxy_headers (ProxyRequestHeaders): Headers to be used in the proxy request. 75 | 76 | Returns: 77 | Union[Response, EnhancedStreamingResponse]: Either a processed m3u8 playlist or a streaming response. 78 | """ 79 | _, streamer = await setup_client_and_streamer() 80 | # Handle range requests 81 | content_range = proxy_headers.request.get("range", "bytes=0-") 82 | if "nan" in content_range.casefold(): 83 | # Handle invalid range requests "bytes=NaN-NaN" 84 | raise HTTPException(status_code=416, detail="Invalid Range Header") 85 | proxy_headers.request.update({"range": content_range}) 86 | 87 | try: 88 | # Auto-detect and resolve Vavoo links 89 | if "vavoo.to" in hls_params.destination: 90 | try: 91 | from mediaflow_proxy.extractors.vavoo import VavooExtractor 92 | vavoo_extractor = VavooExtractor(proxy_headers.request) 93 | resolved_data = await vavoo_extractor.extract(hls_params.destination) 94 | resolved_url = resolved_data["destination_url"] 95 | logger.info(f"Auto-resolved Vavoo URL: {hls_params.destination} -> {resolved_url}") 96 | # Update destination with resolved URL 97 | hls_params.destination = resolved_url 98 | except Exception as e: 99 | logger.warning(f"Failed to auto-resolve Vavoo URL: {e}") 100 | # Continue with original URL if resolution fails 101 | 102 | # If force_playlist_proxy is enabled, skip detection and directly process as m3u8 103 | if hls_params.force_playlist_proxy: 104 | return await fetch_and_process_m3u8( 105 | streamer, hls_params.destination, proxy_headers, request, 106 | hls_params.key_url, hls_params.force_playlist_proxy, hls_params.key_only_proxy, hls_params.no_proxy 107 | ) 108 | 109 | parsed_url = urlparse(hls_params.destination) 110 | # Check if the URL is a valid m3u8 playlist or m3u file 111 | if parsed_url.path.endswith((".m3u", ".m3u8", ".m3u_plus")) or parse_qs(parsed_url.query).get("type", [""])[ 112 | 0 113 | ] in ["m3u", "m3u8", "m3u_plus"]: 114 | return await fetch_and_process_m3u8( 115 | streamer, hls_params.destination, proxy_headers, request, 116 | hls_params.key_url, hls_params.force_playlist_proxy, hls_params.key_only_proxy, hls_params.no_proxy 117 | ) 118 | 119 | # Create initial streaming response to check content type 120 | await streamer.create_streaming_response(hls_params.destination, proxy_headers.request) 121 | response_headers = prepare_response_headers(streamer.response.headers, proxy_headers.response) 122 | 123 | if "mpegurl" in response_headers.get("content-type", "").lower(): 124 | return await fetch_and_process_m3u8( 125 | streamer, hls_params.destination, proxy_headers, request, 126 | hls_params.key_url, hls_params.force_playlist_proxy, hls_params.key_only_proxy, hls_params.no_proxy 127 | ) 128 | 129 | return EnhancedStreamingResponse( 130 | streamer.stream_content(), 131 | status_code=streamer.response.status_code, 132 | headers=response_headers, 133 | background=BackgroundTask(streamer.close), 134 | ) 135 | except Exception as e: 136 | await streamer.close() 137 | return handle_exceptions(e) 138 | 139 | 140 | async def handle_stream_request( 141 | method: str, 142 | video_url: str, 143 | proxy_headers: ProxyRequestHeaders, 144 | ) -> Response: 145 | """ 146 | Handle general stream requests. 147 | 148 | This function processes both HEAD and GET requests for video streams. 149 | 150 | Args: 151 | method (str): The HTTP method (e.g., 'GET' or 'HEAD'). 152 | video_url (str): The URL of the video to stream. 153 | proxy_headers (ProxyRequestHeaders): Headers to be used in the proxy request. 154 | 155 | Returns: 156 | Union[Response, EnhancedStreamingResponse]: Either a HEAD response with headers or a streaming response. 157 | """ 158 | client, streamer = await setup_client_and_streamer() 159 | 160 | try: 161 | # Auto-detect and resolve Vavoo links 162 | if "vavoo.to" in video_url: 163 | try: 164 | from mediaflow_proxy.extractors.vavoo import VavooExtractor 165 | vavoo_extractor = VavooExtractor(proxy_headers.request) 166 | resolved_data = await vavoo_extractor.extract(video_url) 167 | resolved_url = resolved_data["destination_url"] 168 | logger.info(f"Auto-resolved Vavoo URL: {video_url} -> {resolved_url}") 169 | # Update video_url with resolved URL 170 | video_url = resolved_url 171 | except Exception as e: 172 | logger.warning(f"Failed to auto-resolve Vavoo URL: {e}") 173 | # Continue with original URL if resolution fails 174 | 175 | await streamer.create_streaming_response(video_url, proxy_headers.request) 176 | response_headers = prepare_response_headers(streamer.response.headers, proxy_headers.response) 177 | 178 | if method == "HEAD": 179 | # For HEAD requests, just return the headers without streaming content 180 | await streamer.close() 181 | return Response(headers=response_headers, status_code=streamer.response.status_code) 182 | else: 183 | # For GET requests, return the streaming response 184 | return EnhancedStreamingResponse( 185 | streamer.stream_content(), 186 | headers=response_headers, 187 | status_code=streamer.response.status_code, 188 | background=BackgroundTask(streamer.close), 189 | ) 190 | except Exception as e: 191 | await streamer.close() 192 | return handle_exceptions(e) 193 | 194 | 195 | def prepare_response_headers(original_headers, proxy_response_headers) -> dict: 196 | """ 197 | Prepare response headers for the proxy response. 198 | 199 | This function filters the original headers, ensures proper transfer encoding, 200 | and merges them with the proxy response headers. 201 | 202 | Args: 203 | original_headers (httpx.Headers): The original headers from the upstream response. 204 | proxy_response_headers (dict): Additional headers to be included in the proxy response. 205 | 206 | Returns: 207 | dict: The prepared headers for the proxy response. 208 | """ 209 | response_headers = {k: v for k, v in original_headers.multi_items() if k in SUPPORTED_RESPONSE_HEADERS} 210 | response_headers.update(proxy_response_headers) 211 | return response_headers 212 | 213 | 214 | async def proxy_stream(method: str, destination: str, proxy_headers: ProxyRequestHeaders): 215 | """ 216 | Proxies the stream request to the given video URL. 217 | 218 | Args: 219 | method (str): The HTTP method (e.g., GET, HEAD). 220 | destination (str): The URL of the stream to be proxied. 221 | proxy_headers (ProxyRequestHeaders): The headers to include in the request. 222 | 223 | Returns: 224 | Response: The HTTP response with the streamed content. 225 | """ 226 | return await handle_stream_request(method, destination, proxy_headers) 227 | 228 | 229 | async def fetch_and_process_m3u8( 230 | streamer: Streamer, 231 | url: str, 232 | proxy_headers: ProxyRequestHeaders, 233 | request: Request, 234 | key_url: str = None, 235 | force_playlist_proxy: bool = None, 236 | key_only_proxy: bool = False, 237 | no_proxy: bool = False 238 | ): 239 | """ 240 | Fetches and processes the m3u8 playlist on-the-fly, converting it to an HLS playlist. 241 | 242 | Args: 243 | streamer (Streamer): The HTTP client to use for streaming. 244 | url (str): The URL of the m3u8 playlist. 245 | proxy_headers (ProxyRequestHeaders): The headers to include in the request. 246 | request (Request): The incoming HTTP request. 247 | key_url (str, optional): The HLS Key URL to replace the original key URL. Defaults to None. 248 | force_playlist_proxy (bool, optional): Force all playlist URLs to be proxied through MediaFlow. Defaults to None. 249 | key_only_proxy (bool, optional): Only proxy the key URL, leaving segment URLs direct. Defaults to False. 250 | no_proxy (bool, optional): If True, returns the manifest without proxying any URLs. Defaults to False. 251 | 252 | Returns: 253 | Response: The HTTP response with the processed m3u8 playlist. 254 | """ 255 | try: 256 | # Create streaming response if not already created 257 | if not streamer.response: 258 | await streamer.create_streaming_response(url, proxy_headers.request) 259 | 260 | # Initialize processor and response headers 261 | processor = M3U8Processor(request, key_url, force_playlist_proxy, key_only_proxy, no_proxy) 262 | response_headers = { 263 | "content-disposition": "inline", 264 | "accept-ranges": "none", 265 | "content-type": "application/vnd.apple.mpegurl", 266 | } 267 | response_headers.update(proxy_headers.response) 268 | 269 | # Create streaming response with on-the-fly processing 270 | return EnhancedStreamingResponse( 271 | processor.process_m3u8_streaming(streamer.stream_content(), str(streamer.response.url)), 272 | headers=response_headers, 273 | background=BackgroundTask(streamer.close), 274 | ) 275 | except Exception as e: 276 | await streamer.close() 277 | return handle_exceptions(e) 278 | 279 | 280 | async def handle_drm_key_data(key_id, key, drm_info): 281 | """ 282 | Handles the DRM key data, retrieving the key ID and key from the DRM info if not provided. 283 | 284 | Args: 285 | key_id (str): The DRM key ID. 286 | key (str): The DRM key. 287 | drm_info (dict): The DRM information from the MPD manifest. 288 | 289 | Returns: 290 | tuple: The key ID and key. 291 | """ 292 | if drm_info and not drm_info.get("isDrmProtected"): 293 | return None, None 294 | 295 | if not key_id or not key: 296 | if "keyId" in drm_info and "key" in drm_info: 297 | key_id = drm_info["keyId"] 298 | key = drm_info["key"] 299 | elif "laUrl" in drm_info and "keyId" in drm_info: 300 | raise HTTPException(status_code=400, detail="LA URL is not supported yet") 301 | else: 302 | raise HTTPException( 303 | status_code=400, detail="Unable to determine key_id and key, and they were not provided" 304 | ) 305 | 306 | return key_id, key 307 | 308 | 309 | async def get_manifest( 310 | request: Request, 311 | manifest_params: MPDManifestParams, 312 | proxy_headers: ProxyRequestHeaders, 313 | ): 314 | """ 315 | Retrieves and processes the MPD manifest, converting it to an HLS manifest. 316 | 317 | Args: 318 | request (Request): The incoming HTTP request. 319 | manifest_params (MPDManifestParams): The parameters for the manifest request. 320 | proxy_headers (ProxyRequestHeaders): The headers to include in the request. 321 | 322 | Returns: 323 | Response: The HTTP response with the HLS manifest. 324 | """ 325 | try: 326 | mpd_dict = await get_cached_mpd( 327 | manifest_params.destination, 328 | headers=proxy_headers.request, 329 | parse_drm=not manifest_params.key_id and not manifest_params.key, 330 | ) 331 | except DownloadError as e: 332 | raise HTTPException(status_code=e.status_code, detail=f"Failed to download MPD: {e.message}") 333 | drm_info = mpd_dict.get("drmInfo", {}) 334 | 335 | if drm_info and not drm_info.get("isDrmProtected"): 336 | # For non-DRM protected MPD, we still create an HLS manifest 337 | return await process_manifest(request, mpd_dict, proxy_headers, None, None) 338 | 339 | key_id, key = await handle_drm_key_data(manifest_params.key_id, manifest_params.key, drm_info) 340 | 341 | # check if the provided key_id and key are valid 342 | if key_id and len(key_id) != 32: 343 | key_id = base64.urlsafe_b64decode(pad_base64(key_id)).hex() 344 | if key and len(key) != 32: 345 | key = base64.urlsafe_b64decode(pad_base64(key)).hex() 346 | 347 | return await process_manifest(request, mpd_dict, proxy_headers, key_id, key) 348 | 349 | 350 | async def get_playlist( 351 | request: Request, 352 | playlist_params: MPDPlaylistParams, 353 | proxy_headers: ProxyRequestHeaders, 354 | ): 355 | """ 356 | Retrieves and processes the MPD manifest, converting it to an HLS playlist for a specific profile. 357 | 358 | Args: 359 | request (Request): The incoming HTTP request. 360 | playlist_params (MPDPlaylistParams): The parameters for the playlist request. 361 | proxy_headers (ProxyRequestHeaders): The headers to include in the request. 362 | 363 | Returns: 364 | Response: The HTTP response with the HLS playlist. 365 | """ 366 | try: 367 | mpd_dict = await get_cached_mpd( 368 | playlist_params.destination, 369 | headers=proxy_headers.request, 370 | parse_drm=not playlist_params.key_id and not playlist_params.key, 371 | parse_segment_profile_id=playlist_params.profile_id, 372 | ) 373 | except DownloadError as e: 374 | raise HTTPException(status_code=e.status_code, detail=f"Failed to download MPD: {e.message}") 375 | return await process_playlist(request, mpd_dict, playlist_params.profile_id, proxy_headers) 376 | 377 | 378 | async def get_segment( 379 | segment_params: MPDSegmentParams, 380 | proxy_headers: ProxyRequestHeaders, 381 | ): 382 | """ 383 | Retrieves and processes a media segment, decrypting it if necessary. 384 | 385 | Args: 386 | segment_params (MPDSegmentParams): The parameters for the segment request. 387 | proxy_headers (ProxyRequestHeaders): The headers to include in the request. 388 | 389 | Returns: 390 | Response: The HTTP response with the processed segment. 391 | """ 392 | try: 393 | init_content = await get_cached_init_segment(segment_params.init_url, proxy_headers.request) 394 | segment_content = await download_file_with_retry(segment_params.segment_url, proxy_headers.request) 395 | except Exception as e: 396 | return handle_exceptions(e) 397 | 398 | return await process_segment( 399 | init_content, 400 | segment_content, 401 | segment_params.mime_type, 402 | proxy_headers, 403 | segment_params.key_id, 404 | segment_params.key, 405 | ) 406 | 407 | 408 | async def get_public_ip(): 409 | """ 410 | Retrieves the public IP address of the MediaFlow proxy. 411 | 412 | Returns: 413 | Response: The HTTP response with the public IP address. 414 | """ 415 | ip_address_data = await request_with_retry("GET", "https://api.ipify.org?format=json", {}) 416 | return ip_address_data.json() 417 | -------------------------------------------------------------------------------- /mediaflow_proxy/main.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | from importlib import resources 4 | 5 | from fastapi import FastAPI, Depends, Security, HTTPException 6 | from fastapi.security import APIKeyQuery, APIKeyHeader 7 | from starlette.middleware.cors import CORSMiddleware 8 | from starlette.responses import RedirectResponse 9 | from starlette.staticfiles import StaticFiles 10 | 11 | from mediaflow_proxy.configs import settings 12 | from mediaflow_proxy.middleware import UIAccessControlMiddleware 13 | from mediaflow_proxy.routes import proxy_router, extractor_router, speedtest_router, playlist_builder_router 14 | from mediaflow_proxy.schemas import GenerateUrlRequest, GenerateMultiUrlRequest, MultiUrlRequestItem 15 | from mediaflow_proxy.utils.crypto_utils import EncryptionHandler, EncryptionMiddleware 16 | from mediaflow_proxy.utils.http_utils import encode_mediaflow_proxy_url 17 | from mediaflow_proxy.utils.base64_utils import encode_url_to_base64, decode_base64_url, is_base64_url 18 | 19 | logging.basicConfig(level=settings.log_level, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s") 20 | app = FastAPI() 21 | api_password_query = APIKeyQuery(name="api_password", auto_error=False) 22 | api_password_header = APIKeyHeader(name="api_password", auto_error=False) 23 | app.add_middleware( 24 | CORSMiddleware, 25 | allow_origins=["*"], 26 | allow_credentials=True, 27 | allow_methods=["*"], 28 | allow_headers=["*"], 29 | ) 30 | app.add_middleware(EncryptionMiddleware) 31 | app.add_middleware(UIAccessControlMiddleware) 32 | 33 | 34 | async def verify_api_key(api_key: str = Security(api_password_query), api_key_alt: str = Security(api_password_header)): 35 | """ 36 | Verifies the API key for the request. 37 | 38 | Args: 39 | api_key (str): The API key to validate. 40 | api_key_alt (str): The alternative API key to validate. 41 | 42 | Raises: 43 | HTTPException: If the API key is invalid. 44 | """ 45 | if not settings.api_password: 46 | return 47 | 48 | if api_key == settings.api_password or api_key_alt == settings.api_password: 49 | return 50 | 51 | raise HTTPException(status_code=403, detail="Could not validate credentials") 52 | 53 | 54 | @app.get("/health") 55 | async def health_check(): 56 | return {"status": "healthy"} 57 | 58 | 59 | @app.get("/favicon.ico") 60 | async def get_favicon(): 61 | return RedirectResponse(url="/logo.png") 62 | 63 | 64 | @app.get("/speedtest") 65 | async def show_speedtest_page(): 66 | return RedirectResponse(url="/speedtest.html") 67 | 68 | 69 | @app.post( 70 | "/generate_encrypted_or_encoded_url", 71 | description="Generate a single encoded URL", 72 | response_description="Returns a single encoded URL", 73 | deprecated=True, 74 | tags=["url"], 75 | ) 76 | async def generate_encrypted_or_encoded_url( 77 | request: GenerateUrlRequest, 78 | ): 79 | """ 80 | Generate a single encoded URL based on the provided request. 81 | """ 82 | return {"encoded_url": (await generate_url(request))["url"]} 83 | 84 | 85 | @app.post( 86 | "/generate_url", 87 | description="Generate a single encoded URL", 88 | response_description="Returns a single encoded URL", 89 | tags=["url"], 90 | ) 91 | async def generate_url(request: GenerateUrlRequest): 92 | """Generate a single encoded URL based on the provided request.""" 93 | encryption_handler = EncryptionHandler(request.api_password) if request.api_password else None 94 | 95 | # Ensure api_password is in query_params if provided 96 | query_params = request.query_params.copy() 97 | if "api_password" not in query_params and request.api_password: 98 | query_params["api_password"] = request.api_password 99 | 100 | # Convert IP to string if provided 101 | ip_str = str(request.ip) if request.ip else None 102 | 103 | # Handle base64 encoding of destination URL if requested 104 | destination_url = request.destination_url 105 | if request.base64_encode_destination and destination_url: 106 | destination_url = encode_url_to_base64(destination_url) 107 | 108 | encoded_url = encode_mediaflow_proxy_url( 109 | mediaflow_proxy_url=request.mediaflow_proxy_url, 110 | endpoint=request.endpoint, 111 | destination_url=destination_url, 112 | query_params=query_params, 113 | request_headers=request.request_headers, 114 | response_headers=request.response_headers, 115 | encryption_handler=encryption_handler, 116 | expiration=request.expiration, 117 | ip=ip_str, 118 | filename=request.filename, 119 | ) 120 | 121 | return {"url": encoded_url} 122 | 123 | 124 | @app.post( 125 | "/generate_urls", 126 | description="Generate multiple encoded URLs with shared common parameters", 127 | response_description="Returns a list of encoded URLs", 128 | tags=["url"], 129 | ) 130 | async def generate_urls(request: GenerateMultiUrlRequest): 131 | """Generate multiple encoded URLs with shared common parameters.""" 132 | # Set up encryption handler if password is provided 133 | encryption_handler = EncryptionHandler(request.api_password) if request.api_password else None 134 | 135 | # Convert IP to string if provided 136 | ip_str = str(request.ip) if request.ip else None 137 | 138 | async def _process_url_item( 139 | url_item: MultiUrlRequestItem, 140 | ) -> str: 141 | """Process a single URL item with common parameters and return the encoded URL.""" 142 | query_params = url_item.query_params.copy() 143 | if "api_password" not in query_params and request.api_password: 144 | query_params["api_password"] = request.api_password 145 | 146 | # Generate the encoded URL 147 | return encode_mediaflow_proxy_url( 148 | mediaflow_proxy_url=request.mediaflow_proxy_url, 149 | endpoint=url_item.endpoint, 150 | destination_url=url_item.destination_url, 151 | query_params=query_params, 152 | request_headers=url_item.request_headers, 153 | response_headers=url_item.response_headers, 154 | encryption_handler=encryption_handler, 155 | expiration=request.expiration, 156 | ip=ip_str, 157 | filename=url_item.filename, 158 | ) 159 | 160 | tasks = [_process_url_item(url_item) for url_item in request.urls] 161 | encoded_urls = await asyncio.gather(*tasks) 162 | return {"urls": encoded_urls} 163 | 164 | 165 | @app.post( 166 | "/base64/encode", 167 | description="Encode a URL to base64 format", 168 | response_description="Returns the base64 encoded URL", 169 | tags=["base64"], 170 | ) 171 | async def encode_url_base64(url: str): 172 | """ 173 | Encode a URL to base64 format. 174 | 175 | Args: 176 | url (str): The URL to encode. 177 | 178 | Returns: 179 | dict: A dictionary containing the encoded URL. 180 | """ 181 | try: 182 | encoded_url = encode_url_to_base64(url) 183 | return {"encoded_url": encoded_url, "original_url": url} 184 | except Exception as e: 185 | raise HTTPException(status_code=400, detail=f"Failed to encode URL: {str(e)}") 186 | 187 | 188 | @app.post( 189 | "/base64/decode", 190 | description="Decode a base64 encoded URL", 191 | response_description="Returns the decoded URL", 192 | tags=["base64"], 193 | ) 194 | async def decode_url_base64(encoded_url: str): 195 | """ 196 | Decode a base64 encoded URL. 197 | 198 | Args: 199 | encoded_url (str): The base64 encoded URL to decode. 200 | 201 | Returns: 202 | dict: A dictionary containing the decoded URL. 203 | """ 204 | decoded_url = decode_base64_url(encoded_url) 205 | if decoded_url is None: 206 | raise HTTPException(status_code=400, detail="Invalid base64 encoded URL") 207 | 208 | return {"decoded_url": decoded_url, "encoded_url": encoded_url} 209 | 210 | 211 | @app.get( 212 | "/base64/check", 213 | description="Check if a string appears to be a base64 encoded URL", 214 | response_description="Returns whether the string is likely base64 encoded", 215 | tags=["base64"], 216 | ) 217 | async def check_base64_url(url: str): 218 | """ 219 | Check if a string appears to be a base64 encoded URL. 220 | 221 | Args: 222 | url (str): The string to check. 223 | 224 | Returns: 225 | dict: A dictionary indicating if the string is likely base64 encoded. 226 | """ 227 | is_base64 = is_base64_url(url) 228 | result = {"url": url, "is_base64": is_base64} 229 | 230 | if is_base64: 231 | decoded_url = decode_base64_url(url) 232 | if decoded_url: 233 | result["decoded_url"] = decoded_url 234 | 235 | return result 236 | 237 | 238 | app.include_router(proxy_router, prefix="/proxy", tags=["proxy"], dependencies=[Depends(verify_api_key)]) 239 | app.include_router(extractor_router, prefix="/extractor", tags=["extractors"], dependencies=[Depends(verify_api_key)]) 240 | app.include_router(speedtest_router, prefix="/speedtest", tags=["speedtest"], dependencies=[Depends(verify_api_key)]) 241 | app.include_router(playlist_builder_router, prefix="/playlist", tags=["playlist"]) 242 | 243 | static_path = resources.files("mediaflow_proxy").joinpath("static") 244 | app.mount("/", StaticFiles(directory=str(static_path), html=True), name="static") 245 | 246 | 247 | def run(): 248 | import uvicorn 249 | 250 | uvicorn.run(app, host="0.0.0.0", port=8888, log_level="info", workers=3) 251 | 252 | 253 | if __name__ == "__main__": 254 | run() 255 | -------------------------------------------------------------------------------- /mediaflow_proxy/middleware.py: -------------------------------------------------------------------------------- 1 | from fastapi import Request, Response 2 | from starlette.middleware.base import BaseHTTPMiddleware 3 | 4 | from mediaflow_proxy.configs import settings 5 | 6 | 7 | class UIAccessControlMiddleware(BaseHTTPMiddleware): 8 | """Middleware that controls access to UI components based on settings.""" 9 | 10 | async def dispatch(self, request: Request, call_next): 11 | path = request.url.path 12 | 13 | # Block access to home page 14 | if settings.disable_home_page and (path == "/" or path == "/index.html"): 15 | return Response(status_code=403, content="Forbidden") 16 | 17 | # Block access to API docs 18 | if settings.disable_docs and (path == "/docs" or path == "/redoc" or path.startswith("/openapi")): 19 | return Response(status_code=403, content="Forbidden") 20 | 21 | # Block access to speedtest UI 22 | if settings.disable_speedtest and path.startswith("/speedtest"): 23 | return Response(status_code=403, content="Forbidden") 24 | 25 | return await call_next(request) 26 | -------------------------------------------------------------------------------- /mediaflow_proxy/mpd_processor.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | import math 4 | import time 5 | 6 | from fastapi import Request, Response, HTTPException 7 | 8 | from mediaflow_proxy.drm.decrypter import decrypt_segment 9 | from mediaflow_proxy.utils.crypto_utils import encryption_handler 10 | from mediaflow_proxy.utils.http_utils import encode_mediaflow_proxy_url, get_original_scheme, ProxyRequestHeaders 11 | from mediaflow_proxy.utils.dash_prebuffer import dash_prebuffer 12 | from mediaflow_proxy.configs import settings 13 | 14 | logger = logging.getLogger(__name__) 15 | 16 | 17 | async def process_manifest( 18 | request: Request, mpd_dict: dict, proxy_headers: ProxyRequestHeaders, key_id: str = None, key: str = None 19 | ) -> Response: 20 | """ 21 | Processes the MPD manifest and converts it to an HLS manifest. 22 | 23 | Args: 24 | request (Request): The incoming HTTP request. 25 | mpd_dict (dict): The MPD manifest data. 26 | proxy_headers (ProxyRequestHeaders): The headers to include in the request. 27 | key_id (str, optional): The DRM key ID. Defaults to None. 28 | key (str, optional): The DRM key. Defaults to None. 29 | 30 | Returns: 31 | Response: The HLS manifest as an HTTP response. 32 | """ 33 | hls_content = build_hls(mpd_dict, request, key_id, key) 34 | 35 | # Start DASH pre-buffering in background if enabled 36 | if settings.enable_dash_prebuffer: 37 | # Extract headers for pre-buffering 38 | headers = {} 39 | for key, value in request.query_params.items(): 40 | if key.startswith("h_"): 41 | headers[key[2:]] = value 42 | 43 | # Get the original MPD URL from the request 44 | mpd_url = request.query_params.get("d", "") 45 | if mpd_url: 46 | # Start pre-buffering in background 47 | asyncio.create_task( 48 | dash_prebuffer.prebuffer_dash_manifest(mpd_url, headers) 49 | ) 50 | 51 | return Response(content=hls_content, media_type="application/vnd.apple.mpegurl", headers=proxy_headers.response) 52 | 53 | 54 | async def process_playlist( 55 | request: Request, mpd_dict: dict, profile_id: str, proxy_headers: ProxyRequestHeaders 56 | ) -> Response: 57 | """ 58 | Processes the MPD manifest and converts it to an HLS playlist for a specific profile. 59 | 60 | Args: 61 | request (Request): The incoming HTTP request. 62 | mpd_dict (dict): The MPD manifest data. 63 | profile_id (str): The profile ID to generate the playlist for. 64 | proxy_headers (ProxyRequestHeaders): The headers to include in the request. 65 | 66 | Returns: 67 | Response: The HLS playlist as an HTTP response. 68 | 69 | Raises: 70 | HTTPException: If the profile is not found in the MPD manifest. 71 | """ 72 | matching_profiles = [p for p in mpd_dict["profiles"] if p["id"] == profile_id] 73 | if not matching_profiles: 74 | raise HTTPException(status_code=404, detail="Profile not found") 75 | 76 | hls_content = build_hls_playlist(mpd_dict, matching_profiles, request) 77 | return Response(content=hls_content, media_type="application/vnd.apple.mpegurl", headers=proxy_headers.response) 78 | 79 | 80 | async def process_segment( 81 | init_content: bytes, 82 | segment_content: bytes, 83 | mimetype: str, 84 | proxy_headers: ProxyRequestHeaders, 85 | key_id: str = None, 86 | key: str = None, 87 | ) -> Response: 88 | """ 89 | Processes and decrypts a media segment. 90 | 91 | Args: 92 | init_content (bytes): The initialization segment content. 93 | segment_content (bytes): The media segment content. 94 | mimetype (str): The MIME type of the segment. 95 | proxy_headers (ProxyRequestHeaders): The headers to include in the request. 96 | key_id (str, optional): The DRM key ID. Defaults to None. 97 | key (str, optional): The DRM key. Defaults to None. 98 | 99 | Returns: 100 | Response: The decrypted segment as an HTTP response. 101 | """ 102 | if key_id and key: 103 | # For DRM protected content 104 | now = time.time() 105 | decrypted_content = decrypt_segment(init_content, segment_content, key_id, key) 106 | logger.info(f"Decryption of {mimetype} segment took {time.time() - now:.4f} seconds") 107 | else: 108 | # For non-DRM protected content, we just concatenate init and segment content 109 | decrypted_content = init_content + segment_content 110 | 111 | return Response(content=decrypted_content, media_type=mimetype, headers=proxy_headers.response) 112 | 113 | 114 | def build_hls(mpd_dict: dict, request: Request, key_id: str = None, key: str = None) -> str: 115 | """ 116 | Builds an HLS manifest from the MPD manifest. 117 | 118 | Args: 119 | mpd_dict (dict): The MPD manifest data. 120 | request (Request): The incoming HTTP request. 121 | key_id (str, optional): The DRM key ID. Defaults to None. 122 | key (str, optional): The DRM key. Defaults to None. 123 | 124 | Returns: 125 | str: The HLS manifest as a string. 126 | """ 127 | hls = ["#EXTM3U", "#EXT-X-VERSION:6"] 128 | query_params = dict(request.query_params) 129 | has_encrypted = query_params.pop("has_encrypted", False) 130 | 131 | video_profiles = {} 132 | audio_profiles = {} 133 | 134 | # Get the base URL for the playlist_endpoint endpoint 135 | proxy_url = request.url_for("playlist_endpoint") 136 | proxy_url = str(proxy_url.replace(scheme=get_original_scheme(request))) 137 | 138 | for profile in mpd_dict["profiles"]: 139 | query_params.update({"profile_id": profile["id"], "key_id": key_id or "", "key": key or ""}) 140 | playlist_url = encode_mediaflow_proxy_url( 141 | proxy_url, 142 | query_params=query_params, 143 | encryption_handler=encryption_handler if has_encrypted else None, 144 | ) 145 | 146 | if "video" in profile["mimeType"]: 147 | video_profiles[profile["id"]] = (profile, playlist_url) 148 | elif "audio" in profile["mimeType"]: 149 | audio_profiles[profile["id"]] = (profile, playlist_url) 150 | 151 | # Add audio streams 152 | for i, (profile, playlist_url) in enumerate(audio_profiles.values()): 153 | is_default = "YES" if i == 0 else "NO" # Set the first audio track as default 154 | hls.append( 155 | f'#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="audio",NAME="{profile["id"]}",DEFAULT={is_default},AUTOSELECT={is_default},LANGUAGE="{profile.get("lang", "und")}",URI="{playlist_url}"' 156 | ) 157 | 158 | # Add video streams 159 | for profile, playlist_url in video_profiles.values(): 160 | # Only add AUDIO attribute if there are audio profiles available 161 | audio_attr = ',AUDIO="audio"' if audio_profiles else "" 162 | hls.append( 163 | f'#EXT-X-STREAM-INF:BANDWIDTH={profile["bandwidth"]},RESOLUTION={profile["width"]}x{profile["height"]},CODECS="{profile["codecs"]}",FRAME-RATE={profile["frameRate"]}{audio_attr}' 164 | ) 165 | hls.append(playlist_url) 166 | 167 | return "\n".join(hls) 168 | 169 | 170 | def build_hls_playlist(mpd_dict: dict, profiles: list[dict], request: Request) -> str: 171 | """ 172 | Builds an HLS playlist from the MPD manifest for specific profiles. 173 | 174 | Args: 175 | mpd_dict (dict): The MPD manifest data. 176 | profiles (list[dict]): The profiles to include in the playlist. 177 | request (Request): The incoming HTTP request. 178 | 179 | Returns: 180 | str: The HLS playlist as a string. 181 | """ 182 | hls = ["#EXTM3U", "#EXT-X-VERSION:6"] 183 | 184 | added_segments = 0 185 | 186 | proxy_url = request.url_for("segment_endpoint") 187 | proxy_url = str(proxy_url.replace(scheme=get_original_scheme(request))) 188 | 189 | for index, profile in enumerate(profiles): 190 | segments = profile["segments"] 191 | if not segments: 192 | logger.warning(f"No segments found for profile {profile['id']}") 193 | continue 194 | 195 | # Add headers for only the first profile 196 | if index == 0: 197 | first_segment = segments[0] 198 | extinf_values = [f["extinf"] for f in segments if "extinf" in f] 199 | target_duration = math.ceil(max(extinf_values)) if extinf_values else 3 200 | 201 | # Calculate media sequence using adaptive logic for different MPD types 202 | mpd_start_number = profile.get("segment_template_start_number") 203 | if mpd_start_number and mpd_start_number >= 1000: 204 | # Amazon-style: Use absolute segment numbering 205 | sequence = first_segment.get("number", mpd_start_number) 206 | else: 207 | # Sky-style: Use time-based calculation if available 208 | time_val = first_segment.get("time") 209 | duration_val = first_segment.get("duration_mpd_timescale") 210 | if time_val is not None and duration_val and duration_val > 0: 211 | calculated_sequence = math.floor(time_val / duration_val) 212 | # For live streams with very large sequence numbers, use modulo to keep reasonable range 213 | if mpd_dict.get("isLive", False) and calculated_sequence > 100000: 214 | sequence = calculated_sequence % 100000 215 | else: 216 | sequence = calculated_sequence 217 | else: 218 | sequence = first_segment.get("number", 1) 219 | 220 | hls.extend( 221 | [ 222 | f"#EXT-X-TARGETDURATION:{target_duration}", 223 | f"#EXT-X-MEDIA-SEQUENCE:{sequence}", 224 | ] 225 | ) 226 | if mpd_dict["isLive"]: 227 | hls.append("#EXT-X-PLAYLIST-TYPE:EVENT") 228 | else: 229 | hls.append("#EXT-X-PLAYLIST-TYPE:VOD") 230 | 231 | init_url = profile["initUrl"] 232 | 233 | query_params = dict(request.query_params) 234 | query_params.pop("profile_id", None) 235 | query_params.pop("d", None) 236 | has_encrypted = query_params.pop("has_encrypted", False) 237 | 238 | for segment in segments: 239 | hls.append(f'#EXTINF:{segment["extinf"]:.3f},') 240 | query_params.update( 241 | {"init_url": init_url, "segment_url": segment["media"], "mime_type": profile["mimeType"]} 242 | ) 243 | hls.append( 244 | encode_mediaflow_proxy_url( 245 | proxy_url, 246 | query_params=query_params, 247 | encryption_handler=encryption_handler if has_encrypted else None, 248 | ) 249 | ) 250 | added_segments += 1 251 | 252 | if not mpd_dict["isLive"]: 253 | hls.append("#EXT-X-ENDLIST") 254 | 255 | logger.info(f"Added {added_segments} segments to HLS playlist") 256 | return "\n".join(hls) 257 | -------------------------------------------------------------------------------- /mediaflow_proxy/routes/__init__.py: -------------------------------------------------------------------------------- 1 | from .proxy import proxy_router 2 | from .extractor import extractor_router 3 | from .speedtest import speedtest_router 4 | from .playlist_builder import playlist_builder_router 5 | 6 | __all__ = ["proxy_router", "extractor_router", "speedtest_router", "playlist_builder_router"] 7 | -------------------------------------------------------------------------------- /mediaflow_proxy/routes/extractor.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import Annotated 3 | 4 | from fastapi import APIRouter, Query, HTTPException, Request, Depends, BackgroundTasks 5 | from fastapi.responses import RedirectResponse 6 | 7 | from mediaflow_proxy.extractors.base import ExtractorError 8 | from mediaflow_proxy.extractors.factory import ExtractorFactory 9 | from mediaflow_proxy.schemas import ExtractorURLParams 10 | from mediaflow_proxy.utils.cache_utils import get_cached_extractor_result, set_cache_extractor_result 11 | from mediaflow_proxy.utils.http_utils import ( 12 | DownloadError, 13 | encode_mediaflow_proxy_url, 14 | get_original_scheme, 15 | ProxyRequestHeaders, 16 | get_proxy_headers, 17 | ) 18 | from mediaflow_proxy.utils.base64_utils import process_potential_base64_url 19 | 20 | extractor_router = APIRouter() 21 | logger = logging.getLogger(__name__) 22 | 23 | async def refresh_extractor_cache(cache_key: str, extractor_params: ExtractorURLParams, proxy_headers: ProxyRequestHeaders): 24 | """Asynchronously refreshes the extractor cache in the background.""" 25 | try: 26 | logger.info(f"Background cache refresh started for key: {cache_key}") 27 | extractor = ExtractorFactory.get_extractor(extractor_params.host, proxy_headers.request) 28 | response = await extractor.extract(extractor_params.destination, **extractor_params.extra_params) 29 | await set_cache_extractor_result(cache_key, response) 30 | logger.info(f"Background cache refresh completed for key: {cache_key}") 31 | except Exception as e: 32 | logger.error(f"Background cache refresh failed for key {cache_key}: {e}") 33 | 34 | 35 | @extractor_router.head("/video") 36 | @extractor_router.get("/video") 37 | async def extract_url( 38 | extractor_params: Annotated[ExtractorURLParams, Query()], 39 | request: Request, 40 | background_tasks: BackgroundTasks, 41 | proxy_headers: Annotated[ProxyRequestHeaders, Depends(get_proxy_headers)], 42 | ): 43 | """Extract clean links from various video hosting services.""" 44 | try: 45 | # Process potential base64 encoded destination URL 46 | processed_destination = process_potential_base64_url(extractor_params.destination) 47 | extractor_params.destination = processed_destination 48 | 49 | cache_key = f"{extractor_params.host}_{extractor_params.model_dump_json()}" 50 | response = await get_cached_extractor_result(cache_key) 51 | 52 | if response: 53 | logger.info(f"Serving from cache for key: {cache_key}") 54 | # Schedule a background task to refresh the cache without blocking the user 55 | background_tasks.add_task(refresh_extractor_cache, cache_key, extractor_params, proxy_headers) 56 | else: 57 | logger.info(f"Cache miss for key: {cache_key}. Fetching fresh data.") 58 | extractor = ExtractorFactory.get_extractor(extractor_params.host, proxy_headers.request) 59 | response = await extractor.extract(extractor_params.destination, **extractor_params.extra_params) 60 | await set_cache_extractor_result(cache_key, response) 61 | 62 | # Ensure the latest request headers are used, even with cached data 63 | if "request_headers" not in response: 64 | response["request_headers"] = {} 65 | response["request_headers"].update(proxy_headers.request) 66 | response["mediaflow_proxy_url"] = str( 67 | request.url_for(response.pop("mediaflow_endpoint")).replace(scheme=get_original_scheme(request)) 68 | ) 69 | response["query_params"] = response.get("query_params", {}) 70 | # Add API password to query params 71 | response["query_params"]["api_password"] = request.query_params.get("api_password") 72 | 73 | if "max_res" in request.query_params: 74 | response["query_params"]["max_res"] = request.query_params.get("max_res") 75 | 76 | if "no_proxy" in request.query_params: 77 | response["query_params"]["no_proxy"] = request.query_params.get("no_proxy") 78 | 79 | if extractor_params.redirect_stream: 80 | stream_url = encode_mediaflow_proxy_url( 81 | **response, 82 | response_headers=proxy_headers.response, 83 | ) 84 | return RedirectResponse(url=stream_url, status_code=302) 85 | 86 | return response 87 | 88 | except DownloadError as e: 89 | logger.error(f"Extraction failed: {str(e)}") 90 | raise HTTPException(status_code=e.status_code, detail=str(e)) 91 | except ExtractorError as e: 92 | logger.error(f"Extraction failed: {str(e)}") 93 | raise HTTPException(status_code=400, detail=str(e)) 94 | except Exception as e: 95 | logger.exception(f"Extraction failed: {str(e)}") 96 | raise HTTPException(status_code=500, detail=f"Extraction failed: {str(e)}") 97 | -------------------------------------------------------------------------------- /mediaflow_proxy/routes/playlist_builder.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | import urllib.parse 4 | from typing import Iterator, Dict, Optional 5 | from fastapi import APIRouter, Request, HTTPException, Query 6 | from fastapi.responses import StreamingResponse 7 | from starlette.responses import RedirectResponse 8 | import httpx 9 | from mediaflow_proxy.configs import settings 10 | from mediaflow_proxy.utils.http_utils import get_original_scheme 11 | import asyncio 12 | 13 | logger = logging.getLogger(__name__) 14 | playlist_builder_router = APIRouter() 15 | 16 | 17 | def rewrite_m3u_links_streaming(m3u_lines_iterator: Iterator[str], base_url: str, api_password: Optional[str]) -> Iterator[str]: 18 | """ 19 | Riscrive i link da un iteratore di linee M3U secondo le regole specificate, 20 | includendo gli headers da #EXTVLCOPT e #EXTHTTP. Yields rewritten lines. 21 | """ 22 | current_ext_headers: Dict[str, str] = {} # Dizionario per conservare gli headers dalle direttive 23 | 24 | for line_with_newline in m3u_lines_iterator: 25 | line_content = line_with_newline.rstrip('\n') 26 | logical_line = line_content.strip() 27 | 28 | is_header_tag = False 29 | if logical_line.startswith('#EXTVLCOPT:'): 30 | is_header_tag = True 31 | try: 32 | option_str = logical_line.split(':', 1)[1] 33 | if '=' in option_str: 34 | key_vlc, value_vlc = option_str.split('=', 1) 35 | key_vlc = key_vlc.strip() 36 | value_vlc = value_vlc.strip() 37 | 38 | # Gestione speciale per http-header che contiene "Key: Value" 39 | if key_vlc == 'http-header' and ':' in value_vlc: 40 | header_key, header_value = value_vlc.split(':', 1) 41 | header_key = header_key.strip() 42 | header_value = header_value.strip() 43 | current_ext_headers[header_key] = header_value 44 | elif key_vlc.startswith('http-'): 45 | # Gestisce http-user-agent, http-referer etc. 46 | header_key = key_vlc[len('http-'):] 47 | current_ext_headers[header_key] = value_vlc 48 | except Exception as e: 49 | logger.error(f"⚠️ Error parsing #EXTVLCOPT '{logical_line}': {e}") 50 | 51 | elif logical_line.startswith('#EXTHTTP:'): 52 | is_header_tag = True 53 | try: 54 | json_str = logical_line.split(':', 1)[1] 55 | # Sostituisce tutti gli header correnti con quelli del JSON 56 | current_ext_headers = json.loads(json_str) 57 | except Exception as e: 58 | logger.error(f"⚠️ Error parsing #EXTHTTP '{logical_line}': {e}") 59 | current_ext_headers = {} # Resetta in caso di errore 60 | 61 | if is_header_tag: 62 | yield line_with_newline 63 | continue 64 | 65 | if logical_line and not logical_line.startswith('#') and \ 66 | ('http://' in logical_line or 'https://' in logical_line): 67 | 68 | processed_url_content = logical_line 69 | 70 | # Non modificare link pluto.tv 71 | if 'pluto.tv' in logical_line: 72 | processed_url_content = logical_line 73 | elif 'vavoo.to' in logical_line: 74 | encoded_url = urllib.parse.quote(logical_line, safe='') 75 | processed_url_content = f"{base_url}/proxy/hls/manifest.m3u8?d={encoded_url}" 76 | elif 'vixsrc.to' in logical_line: 77 | encoded_url = urllib.parse.quote(logical_line, safe='') 78 | processed_url_content = f"{base_url}/extractor/video?host=VixCloud&redirect_stream=true&d={encoded_url}&max_res=true&no_proxy=true" 79 | elif '.m3u8' in logical_line: 80 | encoded_url = urllib.parse.quote(logical_line, safe='') 81 | processed_url_content = f"{base_url}/proxy/hls/manifest.m3u8?d={encoded_url}" 82 | elif '.mpd' in logical_line: 83 | # Estrai parametri DRM dall'URL MPD se presenti 84 | from urllib.parse import urlparse, parse_qs, urlencode, urlunparse 85 | 86 | # Parse dell'URL per estrarre parametri 87 | parsed_url = urlparse(logical_line) 88 | query_params = parse_qs(parsed_url.query) 89 | 90 | # Estrai key_id e key se presenti 91 | key_id = query_params.get('key_id', [None])[0] 92 | key = query_params.get('key', [None])[0] 93 | 94 | # Rimuovi key_id e key dai parametri originali 95 | clean_params = {k: v for k, v in query_params.items() if k not in ['key_id', 'key']} 96 | 97 | # Ricostruisci l'URL senza i parametri DRM 98 | clean_query = urlencode(clean_params, doseq=True) if clean_params else '' 99 | clean_url = urlunparse(( 100 | parsed_url.scheme, 101 | parsed_url.netloc, 102 | parsed_url.path, 103 | parsed_url.params, 104 | clean_query, 105 | parsed_url.fragment 106 | )) 107 | 108 | # Encode the MPD URL like other URL types 109 | clean_url_for_param = urllib.parse.quote(clean_url, safe='') 110 | 111 | # Costruisci l'URL MediaFlow con parametri DRM separati 112 | processed_url_content = f"{base_url}/proxy/mpd/manifest.m3u8?d={clean_url_for_param}" 113 | 114 | # Aggiungi parametri DRM se presenti 115 | if key_id: 116 | processed_url_content += f"&key_id={key_id}" 117 | if key: 118 | processed_url_content += f"&key={key}" 119 | elif '.php' in logical_line: 120 | encoded_url = urllib.parse.quote(logical_line, safe='') 121 | processed_url_content = f"{base_url}/proxy/hls/manifest.m3u8?d={encoded_url}" 122 | else: 123 | # Per tutti gli altri link senza estensioni specifiche, trattali come .m3u8 con codifica 124 | encoded_url = urllib.parse.quote(logical_line, safe='') 125 | processed_url_content = f"{base_url}/proxy/hls/manifest.m3u8?d={encoded_url}" 126 | 127 | # Applica gli header raccolti prima di api_password 128 | if current_ext_headers: 129 | header_params_str = "".join([f"&h_{urllib.parse.quote(key)}={urllib.parse.quote(value)}" for key, value in current_ext_headers.items()]) 130 | processed_url_content += header_params_str 131 | current_ext_headers = {} 132 | 133 | # Aggiungi api_password sempre alla fine 134 | if api_password: 135 | processed_url_content += f"&api_password={api_password}" 136 | 137 | yield processed_url_content + '\n' 138 | else: 139 | yield line_with_newline 140 | 141 | 142 | async def async_download_m3u_playlist(url: str) -> list[str]: 143 | """Scarica una playlist M3U in modo asincrono e restituisce le righe.""" 144 | headers = { 145 | 'User-Agent': settings.user_agent, 146 | 'Accept': '*/*', 147 | 'Accept-Language': 'en-US,en;q=0.9', 148 | 'Accept-Encoding': 'gzip, deflate', 149 | 'Connection': 'keep-alive' 150 | } 151 | lines = [] 152 | try: 153 | async with httpx.AsyncClient(verify=True, timeout=30) as client: 154 | async with client.stream('GET', url, headers=headers) as response: 155 | response.raise_for_status() 156 | async for line_bytes in response.aiter_lines(): 157 | if isinstance(line_bytes, bytes): 158 | decoded_line = line_bytes.decode('utf-8', errors='replace') 159 | else: 160 | decoded_line = str(line_bytes) 161 | lines.append(decoded_line + '\n' if decoded_line else '') 162 | except Exception as e: 163 | logger.error(f"Error downloading playlist (async): {str(e)}") 164 | raise 165 | return lines 166 | 167 | async def async_generate_combined_playlist(playlist_definitions: list[str], base_url: str, api_password: Optional[str]): 168 | """Genera una playlist combinata da multiple definizioni, scaricando in parallelo.""" 169 | # Prepara gli URL 170 | playlist_urls = [] 171 | for definition in playlist_definitions: 172 | if '&' in definition: 173 | parts = definition.split('&', 1) 174 | playlist_url_str = parts[1] if len(parts) > 1 else parts[0] 175 | else: 176 | playlist_url_str = definition 177 | playlist_urls.append(playlist_url_str) 178 | 179 | # Scarica tutte le playlist in parallelo 180 | results = await asyncio.gather(*[async_download_m3u_playlist(url) for url in playlist_urls], return_exceptions=True) 181 | 182 | first_playlist_header_handled = False 183 | for idx, lines in enumerate(results): 184 | if isinstance(lines, Exception): 185 | yield f"# ERROR processing playlist {playlist_urls[idx]}: {str(lines)}\n" 186 | continue 187 | playlist_lines: list[str] = lines # type: ignore 188 | current_playlist_had_lines = False 189 | first_line_of_this_segment = True 190 | lines_processed_for_current_playlist = 0 191 | rewritten_lines_iter = rewrite_m3u_links_streaming(iter(playlist_lines), base_url, api_password) 192 | for line in rewritten_lines_iter: 193 | current_playlist_had_lines = True 194 | is_extm3u_line = line.strip().startswith('#EXTM3U') 195 | lines_processed_for_current_playlist += 1 196 | if not first_playlist_header_handled: 197 | yield line 198 | if is_extm3u_line: 199 | first_playlist_header_handled = True 200 | else: 201 | if first_line_of_this_segment and is_extm3u_line: 202 | pass 203 | else: 204 | yield line 205 | first_line_of_this_segment = False 206 | if current_playlist_had_lines and not first_playlist_header_handled: 207 | first_playlist_header_handled = True 208 | 209 | 210 | @playlist_builder_router.get("/playlist") 211 | async def proxy_handler( 212 | request: Request, 213 | d: str = Query(..., description="Query string con le definizioni delle playlist", alias="d"), 214 | api_password: Optional[str] = Query(None, description="Password API per MFP"), 215 | ): 216 | """ 217 | Endpoint per il proxy delle playlist M3U con supporto MFP. 218 | 219 | Formato query string: playlist1&url1;playlist2&url2 220 | Esempio: https://mfp.com:pass123&http://provider.com/playlist.m3u 221 | """ 222 | try: 223 | if not d: 224 | raise HTTPException(status_code=400, detail="Query string mancante") 225 | 226 | if not d.strip(): 227 | raise HTTPException(status_code=400, detail="Query string cannot be empty") 228 | 229 | # Validate that we have at least one valid definition 230 | playlist_definitions = [def_.strip() for def_ in d.split(';') if def_.strip()] 231 | if not playlist_definitions: 232 | raise HTTPException(status_code=400, detail="No valid playlist definitions found") 233 | 234 | # Costruisci base_url con lo schema corretto 235 | original_scheme = get_original_scheme(request) 236 | base_url = f"{original_scheme}://{request.url.netloc}" 237 | 238 | # Estrai base_url dalla prima definizione se presente 239 | if playlist_definitions and '&' in playlist_definitions[0]: 240 | parts = playlist_definitions[0].split('&', 1) 241 | if ':' in parts[0] and not parts[0].startswith('http'): 242 | # Estrai base_url dalla prima parte se contiene password 243 | base_url_part = parts[0].rsplit(':', 1)[0] 244 | if base_url_part.startswith('http'): 245 | base_url = base_url_part 246 | 247 | async def generate_response(): 248 | async for line in async_generate_combined_playlist(playlist_definitions, base_url, api_password): 249 | yield line 250 | 251 | return StreamingResponse( 252 | generate_response(), 253 | media_type='application/vnd.apple.mpegurl', 254 | headers={ 255 | 'Content-Disposition': 'attachment; filename="playlist.m3u"', 256 | 'Access-Control-Allow-Origin': '*' 257 | } 258 | ) 259 | 260 | except Exception as e: 261 | logger.error(f"General error in playlist handler: {str(e)}") 262 | raise HTTPException(status_code=500, detail=f"Error: {str(e)}") from e 263 | 264 | 265 | @playlist_builder_router.get("/builder") 266 | async def url_builder(): 267 | """ 268 | Pagina con un'interfaccia per generare l'URL del proxy MFP. 269 | """ 270 | return RedirectResponse(url="/playlist_builder.html") 271 | -------------------------------------------------------------------------------- /mediaflow_proxy/routes/speedtest.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter, HTTPException 2 | from fastapi.responses import RedirectResponse 3 | 4 | from mediaflow_proxy.speedtest.models import ( 5 | BrowserSpeedTestConfig, 6 | BrowserSpeedTestRequest, 7 | ) 8 | from mediaflow_proxy.speedtest.service import SpeedTestService 9 | 10 | speedtest_router = APIRouter() 11 | 12 | # Initialize service 13 | speedtest_service = SpeedTestService() 14 | 15 | 16 | @speedtest_router.get("/", summary="Show browser speed test interface") 17 | async def show_speedtest_page(): 18 | """Return the browser-based speed test HTML interface.""" 19 | return RedirectResponse(url="/speedtest.html") 20 | 21 | 22 | @speedtest_router.post("/config", summary="Get browser speed test configuration") 23 | async def get_browser_speedtest_config( 24 | test_request: BrowserSpeedTestRequest, 25 | ) -> BrowserSpeedTestConfig: 26 | """Get configuration for browser-based speed test.""" 27 | try: 28 | provider_impl = speedtest_service.get_provider(test_request.provider, test_request.api_key) 29 | 30 | # Get test URLs and user info 31 | test_urls, user_info = await provider_impl.get_test_urls() 32 | config = await provider_impl.get_config() 33 | 34 | return BrowserSpeedTestConfig( 35 | provider=test_request.provider, 36 | test_urls=test_urls, 37 | test_duration=config.test_duration, 38 | user_info=user_info, 39 | ) 40 | except Exception as e: 41 | raise HTTPException(status_code=400, detail=str(e)) 42 | -------------------------------------------------------------------------------- /mediaflow_proxy/schemas.py: -------------------------------------------------------------------------------- 1 | import json 2 | from typing import Literal, Dict, Any, Optional 3 | 4 | from pydantic import BaseModel, Field, IPvAnyAddress, ConfigDict, field_validator 5 | 6 | 7 | class GenerateUrlRequest(BaseModel): 8 | mediaflow_proxy_url: str = Field(..., description="The base URL for the mediaflow proxy.") 9 | endpoint: Optional[str] = Field(None, description="The specific endpoint to be appended to the base URL.") 10 | destination_url: Optional[str] = Field( 11 | None, description="The destination URL to which the request will be proxied." 12 | ) 13 | query_params: Optional[dict] = Field( 14 | default_factory=dict, description="Query parameters to be included in the request." 15 | ) 16 | request_headers: Optional[dict] = Field(default_factory=dict, description="Headers to be included in the request.") 17 | response_headers: Optional[dict] = Field( 18 | default_factory=dict, description="Headers to be included in the response." 19 | ) 20 | expiration: Optional[int] = Field( 21 | None, description="Expiration time for the URL in seconds. If not provided, the URL will not expire." 22 | ) 23 | api_password: Optional[str] = Field( 24 | None, description="API password for encryption. If not provided, the URL will only be encoded." 25 | ) 26 | ip: Optional[IPvAnyAddress] = Field(None, description="The IP address to restrict the URL to.") 27 | filename: Optional[str] = Field(None, description="Filename to be preserved for media players like Infuse.") 28 | base64_encode_destination: Optional[bool] = Field( 29 | False, description="Whether to encode the destination URL in base64 format before processing." 30 | ) 31 | 32 | 33 | class MultiUrlRequestItem(BaseModel): 34 | endpoint: Optional[str] = Field(None, description="The specific endpoint to be appended to the base URL.") 35 | destination_url: Optional[str] = Field( 36 | None, description="The destination URL to which the request will be proxied." 37 | ) 38 | query_params: Optional[dict] = Field( 39 | default_factory=dict, description="Query parameters to be included in the request." 40 | ) 41 | request_headers: Optional[dict] = Field(default_factory=dict, description="Headers to be included in the request.") 42 | response_headers: Optional[dict] = Field( 43 | default_factory=dict, description="Headers to be included in the response." 44 | ) 45 | filename: Optional[str] = Field(None, description="Filename to be preserved for media players like Infuse.") 46 | 47 | 48 | class GenerateMultiUrlRequest(BaseModel): 49 | mediaflow_proxy_url: str = Field(..., description="The base URL for the mediaflow proxy.") 50 | api_password: Optional[str] = Field( 51 | None, description="API password for encryption. If not provided, the URL will only be encoded." 52 | ) 53 | expiration: Optional[int] = Field( 54 | None, description="Expiration time for the URL in seconds. If not provided, the URL will not expire." 55 | ) 56 | ip: Optional[IPvAnyAddress] = Field(None, description="The IP address to restrict the URL to.") 57 | urls: list[MultiUrlRequestItem] = Field(..., description="List of URL configurations to generate.") 58 | 59 | 60 | class GenericParams(BaseModel): 61 | model_config = ConfigDict(populate_by_name=True) 62 | 63 | 64 | class HLSManifestParams(GenericParams): 65 | destination: str = Field(..., description="The URL of the HLS manifest.", alias="d") 66 | key_url: Optional[str] = Field( 67 | None, 68 | description="The HLS Key URL to replace the original key URL. Defaults to None. (Useful for bypassing some sneaky protection)", 69 | ) 70 | force_playlist_proxy: Optional[bool] = Field( 71 | None, 72 | description="Force all playlist URLs to be proxied through MediaFlow regardless of m3u8_content_routing setting. Useful for IPTV m3u/m3u_plus formats that don't have clear URL indicators.", 73 | ) 74 | key_only_proxy: Optional[bool] = Field( 75 | False, 76 | description="Only proxy the key URL, leaving segment URLs direct.", 77 | ) 78 | no_proxy: bool = Field( 79 | False, 80 | description="If true, returns the manifest content without proxying any internal URLs (segments, keys, playlists).", 81 | ) 82 | max_res: bool = Field( 83 | False, 84 | description="If true, redirects to the highest resolution stream in the manifest.", 85 | ) 86 | 87 | 88 | class MPDManifestParams(GenericParams): 89 | destination: str = Field(..., description="The URL of the MPD manifest.", alias="d") 90 | key_id: Optional[str] = Field(None, description="The DRM key ID (optional).") 91 | key: Optional[str] = Field(None, description="The DRM key (optional).") 92 | 93 | 94 | class MPDPlaylistParams(GenericParams): 95 | destination: str = Field(..., description="The URL of the MPD manifest.", alias="d") 96 | profile_id: str = Field(..., description="The profile ID to generate the playlist for.") 97 | key_id: Optional[str] = Field(None, description="The DRM key ID (optional).") 98 | key: Optional[str] = Field(None, description="The DRM key (optional).") 99 | 100 | 101 | class MPDSegmentParams(GenericParams): 102 | init_url: str = Field(..., description="The URL of the initialization segment.") 103 | segment_url: str = Field(..., description="The URL of the media segment.") 104 | mime_type: str = Field(..., description="The MIME type of the segment.") 105 | key_id: Optional[str] = Field(None, description="The DRM key ID (optional).") 106 | key: Optional[str] = Field(None, description="The DRM key (optional).") 107 | 108 | 109 | class ExtractorURLParams(GenericParams): 110 | host: Literal[ 111 | "Doodstream", "FileLions", "FileMoon", "Mixdrop", "Uqload", "Streamtape", "Supervideo", "VixCloud", "Okru", "Maxstream", "LiveTV", "LuluStream", "DLHD", "Fastream", "Voe" 112 | ] = Field(..., description="The host to extract the URL from.") 113 | destination: str = Field(..., description="The URL of the stream.", alias="d") 114 | redirect_stream: bool = Field(False, description="Whether to redirect to the stream endpoint automatically.") 115 | extra_params: Dict[str, Any] = Field( 116 | default_factory=dict, 117 | description="Additional parameters required for specific extractors (e.g., stream_title for LiveTV)", 118 | ) 119 | 120 | @field_validator("extra_params", mode="before") 121 | def validate_extra_params(cls, value: Any): 122 | if isinstance(value, str): 123 | return json.loads(value) 124 | return value 125 | -------------------------------------------------------------------------------- /mediaflow_proxy/speedtest/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mhdzumair/mediaflow-proxy/ffac0c2dc12502a64b9bfd4c720ac61a89900a5e/mediaflow_proxy/speedtest/__init__.py -------------------------------------------------------------------------------- /mediaflow_proxy/speedtest/models.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | from typing import Dict, Optional 3 | 4 | from pydantic import BaseModel, HttpUrl 5 | 6 | 7 | class SpeedTestProvider(str, Enum): 8 | REAL_DEBRID = "real_debrid" 9 | ALL_DEBRID = "all_debrid" 10 | 11 | 12 | class ServerInfo(BaseModel): 13 | url: str 14 | name: str 15 | 16 | 17 | class UserInfo(BaseModel): 18 | ip: Optional[str] = None 19 | isp: Optional[str] = None 20 | country: Optional[str] = None 21 | 22 | 23 | class MediaFlowServer(BaseModel): 24 | url: HttpUrl 25 | api_password: Optional[str] = None 26 | name: Optional[str] = None 27 | 28 | 29 | class BrowserSpeedTestConfig(BaseModel): 30 | provider: SpeedTestProvider 31 | test_urls: Dict[str, str] 32 | test_duration: int = 10 33 | user_info: Optional[UserInfo] = None 34 | 35 | 36 | class BrowserSpeedTestRequest(BaseModel): 37 | provider: SpeedTestProvider 38 | api_key: Optional[str] = None 39 | current_api_password: Optional[str] = None 40 | -------------------------------------------------------------------------------- /mediaflow_proxy/speedtest/providers/all_debrid.py: -------------------------------------------------------------------------------- 1 | import random 2 | from typing import Dict, Tuple, Optional 3 | 4 | from mediaflow_proxy.configs import settings 5 | from mediaflow_proxy.speedtest.models import ServerInfo, UserInfo 6 | from mediaflow_proxy.speedtest.providers.base import BaseSpeedTestProvider, SpeedTestProviderConfig 7 | from mediaflow_proxy.utils.http_utils import request_with_retry 8 | 9 | 10 | class SpeedTestError(Exception): 11 | pass 12 | 13 | 14 | class AllDebridSpeedTest(BaseSpeedTestProvider): 15 | """AllDebrid speed test provider implementation.""" 16 | 17 | def __init__(self, api_key: str): 18 | self.api_key = api_key 19 | self.servers: Dict[str, ServerInfo] = {} 20 | 21 | async def get_test_urls(self) -> Tuple[Dict[str, str], Optional[UserInfo]]: 22 | response = await request_with_retry( 23 | "GET", 24 | "https://alldebrid.com/internalapi/v4/speedtest", 25 | headers={"User-Agent": settings.user_agent}, 26 | params={"agent": "service", "version": "1.0-363869a7", "apikey": self.api_key}, 27 | ) 28 | 29 | if response.status_code != 200: 30 | raise SpeedTestError("Failed to fetch AllDebrid servers") 31 | 32 | data = response.json() 33 | if data["status"] != "success": 34 | raise SpeedTestError("AllDebrid API returned error") 35 | 36 | # Create UserInfo 37 | user_info = UserInfo(ip=data["data"]["ip"], isp=data["data"]["isp"], country=data["data"]["country"]) 38 | 39 | # Store server info 40 | self.servers = {server["name"]: ServerInfo(**server) for server in data["data"]["servers"]} 41 | 42 | # Generate URLs with random number 43 | random_number = f"{random.uniform(1, 2):.24f}".replace(".", "") 44 | urls = {name: f"{server.url}/speedtest/{random_number}" for name, server in self.servers.items()} 45 | 46 | return urls, user_info 47 | 48 | async def get_config(self) -> SpeedTestProviderConfig: 49 | urls, _ = await self.get_test_urls() 50 | return SpeedTestProviderConfig(test_duration=10, test_urls=urls) 51 | -------------------------------------------------------------------------------- /mediaflow_proxy/speedtest/providers/base.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | from typing import Dict, Tuple, Optional 3 | from pydantic import BaseModel 4 | 5 | from mediaflow_proxy.speedtest.models import UserInfo 6 | 7 | 8 | class SpeedTestProviderConfig(BaseModel): 9 | test_duration: int = 10 # seconds 10 | test_urls: Dict[str, str] 11 | 12 | 13 | class BaseSpeedTestProvider(ABC): 14 | """Base class for speed test providers.""" 15 | 16 | @abstractmethod 17 | async def get_test_urls(self) -> Tuple[Dict[str, str], Optional[UserInfo]]: 18 | """Get list of test URLs for the provider and optional user info.""" 19 | pass 20 | 21 | @abstractmethod 22 | async def get_config(self) -> SpeedTestProviderConfig: 23 | """Get provider-specific configuration.""" 24 | pass 25 | -------------------------------------------------------------------------------- /mediaflow_proxy/speedtest/providers/real_debrid.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, Tuple, Optional 2 | import random 3 | 4 | from mediaflow_proxy.speedtest.models import UserInfo 5 | from mediaflow_proxy.speedtest.providers.base import BaseSpeedTestProvider, SpeedTestProviderConfig 6 | 7 | 8 | class RealDebridSpeedTest(BaseSpeedTestProvider): 9 | """RealDebrid speed test provider implementation.""" 10 | 11 | async def get_test_urls(self) -> Tuple[Dict[str, str], Optional[UserInfo]]: 12 | urls = { 13 | "AMS": "https://45.download.real-debrid.com/speedtest/testDefault.rar/", 14 | "RBX": "https://rbx.download.real-debrid.com/speedtest/test.rar/", 15 | "LON1": "https://lon1.download.real-debrid.com/speedtest/test.rar/", 16 | "HKG1": "https://hkg1.download.real-debrid.com/speedtest/test.rar/", 17 | "SGP1": "https://sgp1.download.real-debrid.com/speedtest/test.rar/", 18 | "SGPO1": "https://sgpo1.download.real-debrid.com/speedtest/test.rar/", 19 | "TYO1": "https://tyo1.download.real-debrid.com/speedtest/test.rar/", 20 | "LAX1": "https://lax1.download.real-debrid.com/speedtest/test.rar/", 21 | "TLV1": "https://tlv1.download.real-debrid.com/speedtest/test.rar/", 22 | "MUM1": "https://mum1.download.real-debrid.com/speedtest/test.rar/", 23 | "JKT1": "https://jkt1.download.real-debrid.com/speedtest/test.rar/", 24 | "Cloudflare": "https://45.download.real-debrid.cloud/speedtest/testCloudflare.rar/", 25 | } 26 | # Add random number to prevent caching 27 | urls = {location: f"{base_url}{random.uniform(0, 1):.16f}" for location, base_url in urls.items()} 28 | return urls, None 29 | 30 | async def get_config(self) -> SpeedTestProviderConfig: 31 | urls, _ = await self.get_test_urls() 32 | return SpeedTestProviderConfig(test_duration=10, test_urls=urls) 33 | -------------------------------------------------------------------------------- /mediaflow_proxy/speedtest/service.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, Optional, Type 2 | 3 | from .models import SpeedTestProvider 4 | from .providers.all_debrid import AllDebridSpeedTest 5 | from .providers.base import BaseSpeedTestProvider 6 | from .providers.real_debrid import RealDebridSpeedTest 7 | 8 | 9 | class SpeedTestService: 10 | """Service for managing speed test provider configurations.""" 11 | 12 | def __init__(self): 13 | # Provider mapping 14 | self._providers: Dict[SpeedTestProvider, Type[BaseSpeedTestProvider]] = { 15 | SpeedTestProvider.REAL_DEBRID: RealDebridSpeedTest, 16 | SpeedTestProvider.ALL_DEBRID: AllDebridSpeedTest, 17 | } 18 | 19 | def get_provider(self, provider: SpeedTestProvider, api_key: Optional[str] = None) -> BaseSpeedTestProvider: 20 | """Get the appropriate provider implementation.""" 21 | provider_class = self._providers.get(provider) 22 | if not provider_class: 23 | raise ValueError(f"Unsupported provider: {provider}") 24 | 25 | if provider == SpeedTestProvider.ALL_DEBRID and not api_key: 26 | raise ValueError("API key required for AllDebrid") 27 | 28 | return provider_class(api_key) if provider == SpeedTestProvider.ALL_DEBRID else provider_class() 29 | -------------------------------------------------------------------------------- /mediaflow_proxy/static/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | MediaFlow Proxy 7 | 8 | 88 | 89 | 90 |
91 | MediaFlow Proxy Logo 92 |

MediaFlow Proxy

93 |
94 |

A high-performance proxy server for streaming media, supporting HTTP(S), HLS, and MPEG-DASH with real-time DRM decryption.

95 | 96 |

Key Features

97 |
Convert MPEG-DASH streams (DRM-protected and non-protected) to HLS
98 |
Support for Clear Key DRM-protected MPD DASH streams
99 |
Handle both live and video-on-demand (VOD) DASH streams
100 |
Proxy HTTP/HTTPS links with custom headers
101 |
Proxy and modify HLS (M3U8) streams in real-time with custom headers and key URL modifications for bypassing some sneaky restrictions.
102 |
HLS and DASH pre-buffering for improved streaming performance and reduced latency
103 |
Protect against unauthorized access and network bandwidth abuses
104 | 105 |
106 |

🚀 Speed Test Tool

107 |

Test your connection speed with debrid services to optimize your streaming experience:

108 | 111 |

112 | Browser Speed Test: Tests your actual connection speed through MediaFlow proxy vs direct connection with support for multiple servers, interactive charts, and comprehensive analytics. 113 |

114 |
115 | 116 |
117 |

🔗 Playlist Builder

118 |

Create and combine M3U playlists with automatic link rewriting for optimal streaming experience:

119 | 122 |

123 | Playlist Builder: Combine multiple M3U playlists, automatically rewrite links for optimal streaming, and support custom headers for enhanced compatibility. 124 |

125 |
126 | 127 |

Getting Started

128 |

Visit the GitHub repository for installation instructions and documentation.

129 | 130 |

Premium Hosted Service

131 |

For a hassle-free experience, check out premium hosted service on ElfHosted.

132 | 133 |

API Documentation

134 |

Explore the Swagger UI for comprehensive details about the API endpoints and their usage.

135 | 136 | 137 | 138 | -------------------------------------------------------------------------------- /mediaflow_proxy/static/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mhdzumair/mediaflow-proxy/ffac0c2dc12502a64b9bfd4c720ac61a89900a5e/mediaflow_proxy/static/logo.png -------------------------------------------------------------------------------- /mediaflow_proxy/static/playlist_builder.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | MFP Playlist Builder 7 | 23 | 24 | 25 |
26 |

🔗 MFP Playlist Builder

27 | 28 |
29 | 30 | 31 |
32 | 33 |
34 | 35 | 36 |
37 | 38 |

Playlists to Merge

39 |
40 | 41 |
42 | 43 | 44 |
45 | 46 | 47 | 48 |
49 | 50 |
The URL will appear here...
51 | 52 |
53 |
54 | 55 | 56 | 65 | 66 | 131 | 132 | -------------------------------------------------------------------------------- /mediaflow_proxy/utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mhdzumair/mediaflow-proxy/ffac0c2dc12502a64b9bfd4c720ac61a89900a5e/mediaflow_proxy/utils/__init__.py -------------------------------------------------------------------------------- /mediaflow_proxy/utils/base64_utils.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import logging 3 | from typing import Optional 4 | from urllib.parse import urlparse 5 | 6 | logger = logging.getLogger(__name__) 7 | 8 | 9 | def is_base64_url(url: str) -> bool: 10 | """ 11 | Check if a URL appears to be base64 encoded. 12 | 13 | Args: 14 | url (str): The URL to check. 15 | 16 | Returns: 17 | bool: True if the URL appears to be base64 encoded, False otherwise. 18 | """ 19 | # Check if the URL doesn't start with http/https and contains base64-like characters 20 | if url.startswith(('http://', 'https://', 'ftp://', 'ftps://')): 21 | return False 22 | 23 | # Base64 URLs typically contain only alphanumeric characters, +, /, and = 24 | # and don't contain typical URL characters like :// 25 | base64_chars = set('ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=') 26 | url_chars = set(url) 27 | 28 | # If the URL contains characters not in base64 charset, it's likely not base64 29 | if not url_chars.issubset(base64_chars): 30 | return False 31 | 32 | # Additional heuristic: base64 strings are typically longer and don't contain common URL patterns 33 | if len(url) < 10: # Too short to be a meaningful base64 encoded URL 34 | return False 35 | 36 | return True 37 | 38 | 39 | def decode_base64_url(encoded_url: str) -> Optional[str]: 40 | """ 41 | Decode a base64 encoded URL. 42 | 43 | Args: 44 | encoded_url (str): The base64 encoded URL string. 45 | 46 | Returns: 47 | Optional[str]: The decoded URL if successful, None if decoding fails. 48 | """ 49 | try: 50 | # Handle URL-safe base64 encoding (replace - with + and _ with /) 51 | url_safe_encoded = encoded_url.replace('-', '+').replace('_', '/') 52 | 53 | # Add padding if necessary 54 | missing_padding = len(url_safe_encoded) % 4 55 | if missing_padding: 56 | url_safe_encoded += '=' * (4 - missing_padding) 57 | 58 | # Decode the base64 string 59 | decoded_bytes = base64.b64decode(url_safe_encoded) 60 | decoded_url = decoded_bytes.decode('utf-8') 61 | 62 | # Validate that the decoded string is a valid URL 63 | parsed = urlparse(decoded_url) 64 | if parsed.scheme and parsed.netloc: 65 | logger.info(f"Successfully decoded base64 URL: {encoded_url[:50]}... -> {decoded_url}") 66 | return decoded_url 67 | else: 68 | logger.warning(f"Decoded string is not a valid URL: {decoded_url}") 69 | return None 70 | 71 | except (base64.binascii.Error, UnicodeDecodeError, ValueError) as e: 72 | logger.debug(f"Failed to decode base64 URL '{encoded_url[:50]}...': {e}") 73 | return None 74 | 75 | 76 | def encode_url_to_base64(url: str, url_safe: bool = True) -> str: 77 | """ 78 | Encode a URL to base64. 79 | 80 | Args: 81 | url (str): The URL to encode. 82 | url_safe (bool): Whether to use URL-safe base64 encoding (default: True). 83 | 84 | Returns: 85 | str: The base64 encoded URL. 86 | """ 87 | try: 88 | url_bytes = url.encode('utf-8') 89 | if url_safe: 90 | # Use URL-safe base64 encoding (replace + with - and / with _) 91 | encoded = base64.urlsafe_b64encode(url_bytes).decode('utf-8') 92 | # Remove padding for cleaner URLs 93 | encoded = encoded.rstrip('=') 94 | else: 95 | encoded = base64.b64encode(url_bytes).decode('utf-8') 96 | 97 | logger.debug(f"Encoded URL to base64: {url} -> {encoded}") 98 | return encoded 99 | 100 | except Exception as e: 101 | logger.error(f"Failed to encode URL to base64: {e}") 102 | raise 103 | 104 | 105 | def process_potential_base64_url(url: str) -> str: 106 | """ 107 | Process a URL that might be base64 encoded. If it's base64 encoded, decode it. 108 | Otherwise, return the original URL. 109 | 110 | Args: 111 | url (str): The URL to process. 112 | 113 | Returns: 114 | str: The processed URL (decoded if it was base64, original otherwise). 115 | """ 116 | if is_base64_url(url): 117 | decoded_url = decode_base64_url(url) 118 | if decoded_url: 119 | return decoded_url 120 | else: 121 | logger.warning(f"URL appears to be base64 but failed to decode: {url[:50]}...") 122 | 123 | return url -------------------------------------------------------------------------------- /mediaflow_proxy/utils/cache_utils.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import hashlib 3 | import json 4 | import logging 5 | import os 6 | import tempfile 7 | import threading 8 | import time 9 | from collections import OrderedDict 10 | from concurrent.futures import ThreadPoolExecutor 11 | from dataclasses import dataclass 12 | from pathlib import Path 13 | from typing import Optional, Union, Any 14 | 15 | import aiofiles 16 | import aiofiles.os 17 | 18 | from mediaflow_proxy.utils.http_utils import download_file_with_retry, DownloadError 19 | from mediaflow_proxy.utils.mpd_utils import parse_mpd, parse_mpd_dict 20 | 21 | logger = logging.getLogger(__name__) 22 | 23 | 24 | @dataclass 25 | class CacheEntry: 26 | """Represents a cache entry with metadata.""" 27 | 28 | data: bytes 29 | expires_at: float 30 | access_count: int = 0 31 | last_access: float = 0.0 32 | size: int = 0 33 | 34 | 35 | class LRUMemoryCache: 36 | """Thread-safe LRU memory cache with support.""" 37 | 38 | def __init__(self, maxsize: int): 39 | self.maxsize = maxsize 40 | self._cache: OrderedDict[str, CacheEntry] = OrderedDict() 41 | self._lock = threading.Lock() 42 | self._current_size = 0 43 | 44 | def get(self, key: str) -> Optional[CacheEntry]: 45 | with self._lock: 46 | if key in self._cache: 47 | entry = self._cache.pop(key) # Remove and re-insert for LRU 48 | if time.time() < entry.expires_at: 49 | entry.access_count += 1 50 | entry.last_access = time.time() 51 | self._cache[key] = entry 52 | return entry 53 | else: 54 | # Remove expired entry 55 | self._current_size -= entry.size 56 | self._cache.pop(key, None) 57 | return None 58 | 59 | def set(self, key: str, entry: CacheEntry) -> None: 60 | with self._lock: 61 | if key in self._cache: 62 | old_entry = self._cache[key] 63 | self._current_size -= old_entry.size 64 | 65 | # Check if we need to make space 66 | while self._current_size + entry.size > self.maxsize and self._cache: 67 | _, removed_entry = self._cache.popitem(last=False) 68 | self._current_size -= removed_entry.size 69 | 70 | self._cache[key] = entry 71 | self._current_size += entry.size 72 | 73 | def remove(self, key: str) -> None: 74 | with self._lock: 75 | if key in self._cache: 76 | entry = self._cache.pop(key) 77 | self._current_size -= entry.size 78 | 79 | 80 | class HybridCache: 81 | """High-performance hybrid cache combining memory and file storage.""" 82 | 83 | def __init__( 84 | self, 85 | cache_dir_name: str, 86 | ttl: int, 87 | max_memory_size: int = 100 * 1024 * 1024, # 100MB default 88 | executor_workers: int = 4, 89 | ): 90 | self.cache_dir = Path(tempfile.gettempdir()) / cache_dir_name 91 | self.ttl = ttl 92 | self.memory_cache = LRUMemoryCache(maxsize=max_memory_size) 93 | self._executor = ThreadPoolExecutor(max_workers=executor_workers) 94 | self._lock = asyncio.Lock() 95 | 96 | # Initialize cache directories 97 | self._init_cache_dirs() 98 | 99 | def _init_cache_dirs(self): 100 | """Initialize sharded cache directories.""" 101 | os.makedirs(self.cache_dir, exist_ok=True) 102 | 103 | def _get_md5_hash(self, key: str) -> str: 104 | """Get the MD5 hash of a cache key.""" 105 | return hashlib.md5(key.encode()).hexdigest() 106 | 107 | def _get_file_path(self, key: str) -> Path: 108 | """Get the file path for a cache key.""" 109 | return self.cache_dir / key 110 | 111 | async def get(self, key: str, default: Any = None) -> Optional[bytes]: 112 | """ 113 | Get value from cache, trying memory first then file. 114 | 115 | Args: 116 | key: Cache key 117 | default: Default value if key not found 118 | 119 | Returns: 120 | Cached value or default if not found 121 | """ 122 | key = self._get_md5_hash(key) 123 | # Try memory cache first 124 | entry = self.memory_cache.get(key) 125 | if entry is not None: 126 | return entry.data 127 | 128 | # Try file cache 129 | try: 130 | file_path = self._get_file_path(key) 131 | async with aiofiles.open(file_path, "rb") as f: 132 | metadata_size = await f.read(8) 133 | metadata_length = int.from_bytes(metadata_size, "big") 134 | metadata_bytes = await f.read(metadata_length) 135 | metadata = json.loads(metadata_bytes.decode()) 136 | 137 | # Check expiration 138 | if metadata["expires_at"] < time.time(): 139 | await self.delete(key) 140 | return default 141 | 142 | # Read data 143 | data = await f.read() 144 | 145 | # Update memory cache in background 146 | entry = CacheEntry( 147 | data=data, 148 | expires_at=metadata["expires_at"], 149 | access_count=metadata["access_count"] + 1, 150 | last_access=time.time(), 151 | size=len(data), 152 | ) 153 | self.memory_cache.set(key, entry) 154 | 155 | return data 156 | 157 | except FileNotFoundError: 158 | return default 159 | except Exception as e: 160 | logger.error(f"Error reading from cache: {e}") 161 | return default 162 | 163 | async def set(self, key: str, data: Union[bytes, bytearray, memoryview], ttl: Optional[int] = None) -> bool: 164 | """ 165 | Set value in both memory and file cache. 166 | 167 | Args: 168 | key: Cache key 169 | data: Data to cache 170 | ttl: Optional TTL override 171 | 172 | Returns: 173 | bool: Success status 174 | """ 175 | if not isinstance(data, (bytes, bytearray, memoryview)): 176 | raise ValueError("Data must be bytes, bytearray, or memoryview") 177 | 178 | expires_at = time.time() + (ttl or self.ttl) 179 | 180 | # Create cache entry 181 | entry = CacheEntry(data=data, expires_at=expires_at, access_count=0, last_access=time.time(), size=len(data)) 182 | 183 | key = self._get_md5_hash(key) 184 | # Update memory cache 185 | self.memory_cache.set(key, entry) 186 | file_path = self._get_file_path(key) 187 | temp_path = file_path.with_suffix(".tmp") 188 | 189 | # Update file cache 190 | try: 191 | metadata = {"expires_at": expires_at, "access_count": 0, "last_access": time.time()} 192 | metadata_bytes = json.dumps(metadata).encode() 193 | metadata_size = len(metadata_bytes).to_bytes(8, "big") 194 | 195 | async with aiofiles.open(temp_path, "wb") as f: 196 | await f.write(metadata_size) 197 | await f.write(metadata_bytes) 198 | await f.write(data) 199 | 200 | await aiofiles.os.rename(temp_path, file_path) 201 | return True 202 | 203 | except Exception as e: 204 | logger.error(f"Error writing to cache: {e}") 205 | try: 206 | await aiofiles.os.remove(temp_path) 207 | except: 208 | pass 209 | return False 210 | 211 | async def delete(self, key: str) -> bool: 212 | """Delete item from both caches.""" 213 | self.memory_cache.remove(key) 214 | 215 | try: 216 | file_path = self._get_file_path(key) 217 | await aiofiles.os.remove(file_path) 218 | return True 219 | except FileNotFoundError: 220 | return True 221 | except Exception as e: 222 | logger.error(f"Error deleting from cache: {e}") 223 | return False 224 | 225 | 226 | class AsyncMemoryCache: 227 | """Async wrapper around LRUMemoryCache.""" 228 | 229 | def __init__(self, max_memory_size: int): 230 | self.memory_cache = LRUMemoryCache(maxsize=max_memory_size) 231 | 232 | async def get(self, key: str, default: Any = None) -> Optional[bytes]: 233 | """Get value from cache.""" 234 | entry = self.memory_cache.get(key) 235 | return entry.data if entry is not None else default 236 | 237 | async def set(self, key: str, data: Union[bytes, bytearray, memoryview], ttl: Optional[int] = None) -> bool: 238 | """Set value in cache.""" 239 | try: 240 | expires_at = time.time() + (ttl or 3600) # Default 1 hour TTL if not specified 241 | entry = CacheEntry( 242 | data=data, expires_at=expires_at, access_count=0, last_access=time.time(), size=len(data) 243 | ) 244 | self.memory_cache.set(key, entry) 245 | return True 246 | except Exception as e: 247 | logger.error(f"Error setting cache value: {e}") 248 | return False 249 | 250 | async def delete(self, key: str) -> bool: 251 | """Delete item from cache.""" 252 | try: 253 | self.memory_cache.remove(key) 254 | return True 255 | except Exception as e: 256 | logger.error(f"Error deleting from cache: {e}") 257 | return False 258 | 259 | 260 | # Create cache instances 261 | INIT_SEGMENT_CACHE = HybridCache( 262 | cache_dir_name="init_segment_cache", 263 | ttl=3600, # 1 hour 264 | max_memory_size=500 * 1024 * 1024, # 500MB for init segments 265 | ) 266 | 267 | MPD_CACHE = AsyncMemoryCache( 268 | max_memory_size=100 * 1024 * 1024, # 100MB for MPD files 269 | ) 270 | 271 | EXTRACTOR_CACHE = HybridCache( 272 | cache_dir_name="extractor_cache", 273 | ttl=5 * 60, # 5 minutes 274 | max_memory_size=50 * 1024 * 1024, 275 | ) 276 | 277 | 278 | # Specific cache implementations 279 | async def get_cached_init_segment(init_url: str, headers: dict) -> Optional[bytes]: 280 | """Get initialization segment from cache or download it.""" 281 | # Try cache first 282 | cached_data = await INIT_SEGMENT_CACHE.get(init_url) 283 | if cached_data is not None: 284 | return cached_data 285 | 286 | # Download if not cached 287 | try: 288 | init_content = await download_file_with_retry(init_url, headers) 289 | if init_content: 290 | await INIT_SEGMENT_CACHE.set(init_url, init_content) 291 | return init_content 292 | except Exception as e: 293 | logger.error(f"Error downloading init segment: {e}") 294 | return None 295 | 296 | 297 | async def get_cached_mpd( 298 | mpd_url: str, 299 | headers: dict, 300 | parse_drm: bool, 301 | parse_segment_profile_id: Optional[str] = None, 302 | ) -> dict: 303 | """Get MPD from cache or download and parse it.""" 304 | # Try cache first 305 | cached_data = await MPD_CACHE.get(mpd_url) 306 | if cached_data is not None: 307 | try: 308 | mpd_dict = json.loads(cached_data) 309 | return parse_mpd_dict(mpd_dict, mpd_url, parse_drm, parse_segment_profile_id) 310 | except json.JSONDecodeError: 311 | await MPD_CACHE.delete(mpd_url) 312 | 313 | # Download and parse if not cached 314 | try: 315 | mpd_content = await download_file_with_retry(mpd_url, headers) 316 | mpd_dict = parse_mpd(mpd_content) 317 | parsed_dict = parse_mpd_dict(mpd_dict, mpd_url, parse_drm, parse_segment_profile_id) 318 | 319 | # Cache the original MPD dict 320 | await MPD_CACHE.set(mpd_url, json.dumps(mpd_dict).encode(), ttl=parsed_dict.get("minimumUpdatePeriod")) 321 | return parsed_dict 322 | except DownloadError as error: 323 | logger.error(f"Error downloading MPD: {error}") 324 | raise error 325 | except Exception as error: 326 | logger.exception(f"Error processing MPD: {error}") 327 | raise error 328 | 329 | 330 | async def get_cached_extractor_result(key: str) -> Optional[dict]: 331 | """Get extractor result from cache.""" 332 | cached_data = await EXTRACTOR_CACHE.get(key) 333 | if cached_data is not None: 334 | try: 335 | return json.loads(cached_data) 336 | except json.JSONDecodeError: 337 | await EXTRACTOR_CACHE.delete(key) 338 | return None 339 | 340 | 341 | async def set_cache_extractor_result(key: str, result: dict) -> bool: 342 | """Cache extractor result.""" 343 | try: 344 | return await EXTRACTOR_CACHE.set(key, json.dumps(result).encode()) 345 | except Exception as e: 346 | logger.error(f"Error caching extractor result: {e}") 347 | return False 348 | -------------------------------------------------------------------------------- /mediaflow_proxy/utils/crypto_utils.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import json 3 | import logging 4 | import time 5 | import traceback 6 | from typing import Optional 7 | from urllib.parse import urlencode 8 | 9 | from Crypto.Cipher import AES 10 | from Crypto.Random import get_random_bytes 11 | from Crypto.Util.Padding import pad, unpad 12 | from fastapi import HTTPException, Request 13 | from starlette.middleware.base import BaseHTTPMiddleware 14 | from starlette.responses import JSONResponse 15 | 16 | from mediaflow_proxy.configs import settings 17 | 18 | 19 | class EncryptionHandler: 20 | def __init__(self, secret_key: str): 21 | self.secret_key = secret_key.encode("utf-8").ljust(32)[:32] 22 | 23 | def encrypt_data(self, data: dict, expiration: int = None, ip: str = None) -> str: 24 | if expiration: 25 | data["exp"] = int(time.time()) + expiration 26 | if ip: 27 | data["ip"] = ip 28 | json_data = json.dumps(data).encode("utf-8") 29 | iv = get_random_bytes(16) 30 | cipher = AES.new(self.secret_key, AES.MODE_CBC, iv) 31 | encrypted_data = cipher.encrypt(pad(json_data, AES.block_size)) 32 | return base64.urlsafe_b64encode(iv + encrypted_data).decode("utf-8").rstrip("=") 33 | 34 | def decrypt_data(self, token: str, client_ip: str) -> dict: 35 | try: 36 | padding_needed = (4 - len(token) % 4) % 4 37 | encrypted_token_b64_padded = token + ("=" * padding_needed) 38 | encrypted_data = base64.urlsafe_b64decode(encrypted_token_b64_padded.encode("utf-8")) 39 | iv = encrypted_data[:16] 40 | cipher = AES.new(self.secret_key, AES.MODE_CBC, iv) 41 | decrypted_data = unpad(cipher.decrypt(encrypted_data[16:]), AES.block_size) 42 | data = json.loads(decrypted_data) 43 | 44 | if "exp" in data: 45 | if data["exp"] < time.time(): 46 | raise HTTPException(status_code=401, detail="Token has expired") 47 | del data["exp"] # Remove expiration from the data 48 | 49 | if "ip" in data: 50 | if data["ip"] != client_ip: 51 | raise HTTPException(status_code=403, detail="IP address mismatch") 52 | del data["ip"] # Remove IP from the data 53 | 54 | return data 55 | except Exception as e: 56 | raise HTTPException(status_code=401, detail="Invalid or expired token") 57 | 58 | 59 | class EncryptionMiddleware(BaseHTTPMiddleware): 60 | def __init__(self, app): 61 | super().__init__(app) 62 | self.encryption_handler = encryption_handler 63 | 64 | async def dispatch(self, request: Request, call_next): 65 | path = request.url.path 66 | token_marker = "/_token_" 67 | encrypted_token = None 68 | 69 | # Check for token in path 70 | if path.startswith(token_marker) and self.encryption_handler: 71 | try: 72 | # Extract token from the beginning of the path 73 | token_start = len(token_marker) 74 | token_end = path.find("/", token_start) 75 | 76 | if token_end == -1: # No trailing slash 77 | encrypted_token = path[token_start:] 78 | remaining_path = "" 79 | else: 80 | encrypted_token = path[token_start:token_end] 81 | remaining_path = path[token_end:] 82 | 83 | # Modify the path to remove the token part 84 | request.scope["path"] = remaining_path 85 | 86 | # Update the raw path as well 87 | request.scope["raw_path"] = remaining_path.encode() 88 | 89 | except Exception as e: 90 | logging.error(f"Error processing token in path: {str(e)}") 91 | return JSONResponse(content={"error": f"Invalid token in path: {str(e)}"}, status_code=400) 92 | 93 | # Check for token in query parameters (original method) 94 | if not encrypted_token: # Only check if we didn't already find a token in the path 95 | encrypted_token = request.query_params.get("token") 96 | 97 | # Process the token if found (from either source) 98 | if encrypted_token and self.encryption_handler: 99 | try: 100 | client_ip = self.get_client_ip(request) 101 | decrypted_data = self.encryption_handler.decrypt_data(encrypted_token, client_ip) 102 | 103 | # Modify request query parameters with decrypted data 104 | query_params = dict(request.query_params) 105 | if "token" in query_params: 106 | query_params.pop("token") # Remove the encrypted token from query params 107 | 108 | query_params.update(decrypted_data) # Add decrypted data to query params 109 | query_params["has_encrypted"] = True 110 | 111 | # Create a new request scope with updated query parameters 112 | new_query_string = urlencode(query_params) 113 | request.scope["query_string"] = new_query_string.encode() 114 | request._query_params = query_params 115 | 116 | except HTTPException as e: 117 | return JSONResponse(content={"error": str(e.detail)}, status_code=e.status_code) 118 | except Exception as e: 119 | logging.error(f"Error decrypting token: {str(e)}") 120 | return JSONResponse(content={"error": f"Invalid token: {str(e)}"}, status_code=400) 121 | 122 | try: 123 | response = await call_next(request) 124 | except Exception: 125 | exc = traceback.format_exc(chain=False) 126 | logging.error("An error occurred while processing the request, error: %s", exc) 127 | return JSONResponse( 128 | content={"error": "An error occurred while processing the request, check the server for logs"}, 129 | status_code=500, 130 | ) 131 | return response 132 | 133 | @staticmethod 134 | def get_client_ip(request: Request) -> Optional[str]: 135 | """ 136 | Extract the client's real IP address from the request headers or fallback to the client host. 137 | """ 138 | x_forwarded_for = request.headers.get("X-Forwarded-For") 139 | if x_forwarded_for: 140 | # In some cases, this header can contain multiple IPs 141 | # separated by commas. 142 | # The first one is the original client's IP. 143 | return x_forwarded_for.split(",")[0].strip() 144 | # Fallback to X-Real-IP if X-Forwarded-For is not available 145 | x_real_ip = request.headers.get("X-Real-IP") 146 | if x_real_ip: 147 | return x_real_ip 148 | return request.client.host if request.client else "127.0.0.1" 149 | 150 | 151 | encryption_handler = EncryptionHandler(settings.api_password) if settings.api_password else None 152 | -------------------------------------------------------------------------------- /mediaflow_proxy/utils/dash_prebuffer.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import psutil 3 | from typing import Dict, Optional, List 4 | from urllib.parse import urljoin 5 | import xmltodict 6 | from mediaflow_proxy.utils.http_utils import create_httpx_client 7 | from mediaflow_proxy.configs import settings 8 | 9 | logger = logging.getLogger(__name__) 10 | 11 | 12 | class DASHPreBuffer: 13 | """ 14 | Pre-buffer system for DASH streams to reduce latency and improve streaming performance. 15 | """ 16 | 17 | def __init__(self, max_cache_size: Optional[int] = None, prebuffer_segments: Optional[int] = None): 18 | """ 19 | Initialize the DASH pre-buffer system. 20 | 21 | Args: 22 | max_cache_size (int): Maximum number of segments to cache (uses config if None) 23 | prebuffer_segments (int): Number of segments to pre-buffer ahead (uses config if None) 24 | """ 25 | self.max_cache_size = max_cache_size or settings.dash_prebuffer_cache_size 26 | self.prebuffer_segments = prebuffer_segments or settings.dash_prebuffer_segments 27 | self.max_memory_percent = settings.dash_prebuffer_max_memory_percent 28 | self.emergency_threshold = settings.dash_prebuffer_emergency_threshold 29 | 30 | # Cache for different types of DASH content 31 | self.segment_cache: Dict[str, bytes] = {} 32 | self.init_segment_cache: Dict[str, bytes] = {} 33 | self.manifest_cache: Dict[str, dict] = {} 34 | 35 | # Track segment URLs for each adaptation set 36 | self.adaptation_segments: Dict[str, List[str]] = {} 37 | self.client = create_httpx_client() 38 | 39 | def _get_memory_usage_percent(self) -> float: 40 | """ 41 | Get current memory usage percentage. 42 | 43 | Returns: 44 | float: Memory usage percentage 45 | """ 46 | try: 47 | memory = psutil.virtual_memory() 48 | return memory.percent 49 | except Exception as e: 50 | logger.warning(f"Failed to get memory usage: {e}") 51 | return 0.0 52 | 53 | def _check_memory_threshold(self) -> bool: 54 | """ 55 | Check if memory usage exceeds the emergency threshold. 56 | 57 | Returns: 58 | bool: True if emergency cleanup is needed 59 | """ 60 | memory_percent = self._get_memory_usage_percent() 61 | return memory_percent > self.emergency_threshold 62 | 63 | def _emergency_cache_cleanup(self) -> None: 64 | """ 65 | Perform emergency cache cleanup when memory usage is high. 66 | """ 67 | if self._check_memory_threshold(): 68 | logger.warning("Emergency DASH cache cleanup triggered due to high memory usage") 69 | 70 | # Clear 50% of segment cache 71 | segment_cache_size = len(self.segment_cache) 72 | segment_keys_to_remove = list(self.segment_cache.keys())[:segment_cache_size // 2] 73 | for key in segment_keys_to_remove: 74 | del self.segment_cache[key] 75 | 76 | # Clear 50% of init segment cache 77 | init_cache_size = len(self.init_segment_cache) 78 | init_keys_to_remove = list(self.init_segment_cache.keys())[:init_cache_size // 2] 79 | for key in init_keys_to_remove: 80 | del self.init_segment_cache[key] 81 | 82 | logger.info(f"Emergency cleanup removed {len(segment_keys_to_remove)} segments and {len(init_keys_to_remove)} init segments from cache") 83 | 84 | async def prebuffer_dash_manifest(self, mpd_url: str, headers: Dict[str, str]) -> None: 85 | """ 86 | Pre-buffer segments from a DASH manifest. 87 | 88 | Args: 89 | mpd_url (str): URL of the DASH manifest 90 | headers (Dict[str, str]): Headers to use for requests 91 | """ 92 | try: 93 | # Download and parse MPD manifest 94 | response = await self.client.get(mpd_url, headers=headers) 95 | response.raise_for_status() 96 | mpd_content = response.text 97 | 98 | # Parse MPD XML 99 | mpd_dict = xmltodict.parse(mpd_content) 100 | 101 | # Store manifest in cache 102 | self.manifest_cache[mpd_url] = mpd_dict 103 | 104 | # Extract initialization segments and first few segments 105 | await self._extract_and_prebuffer_segments(mpd_dict, mpd_url, headers) 106 | 107 | logger.info(f"Pre-buffered DASH manifest: {mpd_url}") 108 | 109 | except Exception as e: 110 | logger.warning(f"Failed to pre-buffer DASH manifest {mpd_url}: {e}") 111 | 112 | async def _extract_and_prebuffer_segments(self, mpd_dict: dict, base_url: str, headers: Dict[str, str]) -> None: 113 | """ 114 | Extract and pre-buffer segments from MPD manifest. 115 | 116 | Args: 117 | mpd_dict (dict): Parsed MPD manifest 118 | base_url (str): Base URL for resolving relative URLs 119 | headers (Dict[str, str]): Headers to use for requests 120 | """ 121 | try: 122 | # Extract Period and AdaptationSet information 123 | mpd = mpd_dict.get('MPD', {}) 124 | periods = mpd.get('Period', []) 125 | if not isinstance(periods, list): 126 | periods = [periods] 127 | 128 | for period in periods: 129 | adaptation_sets = period.get('AdaptationSet', []) 130 | if not isinstance(adaptation_sets, list): 131 | adaptation_sets = [adaptation_sets] 132 | 133 | for adaptation_set in adaptation_sets: 134 | # Extract initialization segment 135 | init_segment = adaptation_set.get('SegmentTemplate', {}).get('@initialization') 136 | if init_segment: 137 | init_url = urljoin(base_url, init_segment) 138 | await self._download_init_segment(init_url, headers) 139 | 140 | # Extract segment template 141 | segment_template = adaptation_set.get('SegmentTemplate', {}) 142 | if segment_template: 143 | await self._prebuffer_template_segments(segment_template, base_url, headers) 144 | 145 | # Extract segment list 146 | segment_list = adaptation_set.get('SegmentList', {}) 147 | if segment_list: 148 | await self._prebuffer_list_segments(segment_list, base_url, headers) 149 | 150 | except Exception as e: 151 | logger.warning(f"Failed to extract segments from MPD: {e}") 152 | 153 | async def _download_init_segment(self, init_url: str, headers: Dict[str, str]) -> None: 154 | """ 155 | Download and cache initialization segment. 156 | 157 | Args: 158 | init_url (str): URL of the initialization segment 159 | headers (Dict[str, str]): Headers to use for request 160 | """ 161 | try: 162 | # Check memory usage before downloading 163 | memory_percent = self._get_memory_usage_percent() 164 | if memory_percent > self.max_memory_percent: 165 | logger.warning(f"Memory usage {memory_percent}% exceeds limit {self.max_memory_percent}%, skipping init segment download") 166 | return 167 | 168 | response = await self.client.get(init_url, headers=headers) 169 | response.raise_for_status() 170 | 171 | # Cache the init segment 172 | self.init_segment_cache[init_url] = response.content 173 | 174 | # Check for emergency cleanup 175 | if self._check_memory_threshold(): 176 | self._emergency_cache_cleanup() 177 | 178 | logger.debug(f"Cached init segment: {init_url}") 179 | 180 | except Exception as e: 181 | logger.warning(f"Failed to download init segment {init_url}: {e}") 182 | 183 | async def _prebuffer_template_segments(self, segment_template: dict, base_url: str, headers: Dict[str, str]) -> None: 184 | """ 185 | Pre-buffer segments using segment template. 186 | 187 | Args: 188 | segment_template (dict): Segment template from MPD 189 | base_url (str): Base URL for resolving relative URLs 190 | headers (Dict[str, str]): Headers to use for requests 191 | """ 192 | try: 193 | media_template = segment_template.get('@media') 194 | if not media_template: 195 | return 196 | 197 | # Extract template parameters 198 | start_number = int(segment_template.get('@startNumber', 1)) 199 | duration = float(segment_template.get('@duration', 0)) 200 | timescale = float(segment_template.get('@timescale', 1)) 201 | 202 | # Pre-buffer first few segments 203 | for i in range(self.prebuffer_segments): 204 | segment_number = start_number + i 205 | segment_url = media_template.replace('$Number$', str(segment_number)) 206 | full_url = urljoin(base_url, segment_url) 207 | 208 | await self._download_segment(full_url, headers) 209 | 210 | except Exception as e: 211 | logger.warning(f"Failed to pre-buffer template segments: {e}") 212 | 213 | async def _prebuffer_list_segments(self, segment_list: dict, base_url: str, headers: Dict[str, str]) -> None: 214 | """ 215 | Pre-buffer segments from segment list. 216 | 217 | Args: 218 | segment_list (dict): Segment list from MPD 219 | base_url (str): Base URL for resolving relative URLs 220 | headers (Dict[str, str]): Headers to use for requests 221 | """ 222 | try: 223 | segments = segment_list.get('SegmentURL', []) 224 | if not isinstance(segments, list): 225 | segments = [segments] 226 | 227 | # Pre-buffer first few segments 228 | for segment in segments[:self.prebuffer_segments]: 229 | segment_url = segment.get('@src') 230 | if segment_url: 231 | full_url = urljoin(base_url, segment_url) 232 | await self._download_segment(full_url, headers) 233 | 234 | except Exception as e: 235 | logger.warning(f"Failed to pre-buffer list segments: {e}") 236 | 237 | async def _download_segment(self, segment_url: str, headers: Dict[str, str]) -> None: 238 | """ 239 | Download a single segment and cache it. 240 | 241 | Args: 242 | segment_url (str): URL of the segment to download 243 | headers (Dict[str, str]): Headers to use for request 244 | """ 245 | try: 246 | # Check memory usage before downloading 247 | memory_percent = self._get_memory_usage_percent() 248 | if memory_percent > self.max_memory_percent: 249 | logger.warning(f"Memory usage {memory_percent}% exceeds limit {self.max_memory_percent}%, skipping segment download") 250 | return 251 | 252 | response = await self.client.get(segment_url, headers=headers) 253 | response.raise_for_status() 254 | 255 | # Cache the segment 256 | self.segment_cache[segment_url] = response.content 257 | 258 | # Check for emergency cleanup 259 | if self._check_memory_threshold(): 260 | self._emergency_cache_cleanup() 261 | # Maintain cache size 262 | elif len(self.segment_cache) > self.max_cache_size: 263 | # Remove oldest entries (simple FIFO) 264 | oldest_key = next(iter(self.segment_cache)) 265 | del self.segment_cache[oldest_key] 266 | 267 | logger.debug(f"Cached DASH segment: {segment_url}") 268 | 269 | except Exception as e: 270 | logger.warning(f"Failed to download DASH segment {segment_url}: {e}") 271 | 272 | async def get_segment(self, segment_url: str, headers: Dict[str, str]) -> Optional[bytes]: 273 | """ 274 | Get a segment from cache or download it. 275 | 276 | Args: 277 | segment_url (str): URL of the segment 278 | headers (Dict[str, str]): Headers to use for request 279 | 280 | Returns: 281 | Optional[bytes]: Cached segment data or None if not available 282 | """ 283 | # Check segment cache first 284 | if segment_url in self.segment_cache: 285 | logger.debug(f"DASH cache hit for segment: {segment_url}") 286 | return self.segment_cache[segment_url] 287 | 288 | # Check init segment cache 289 | if segment_url in self.init_segment_cache: 290 | logger.debug(f"DASH cache hit for init segment: {segment_url}") 291 | return self.init_segment_cache[segment_url] 292 | 293 | # Check memory usage before downloading 294 | memory_percent = self._get_memory_usage_percent() 295 | if memory_percent > self.max_memory_percent: 296 | logger.warning(f"Memory usage {memory_percent}% exceeds limit {self.max_memory_percent}%, skipping download") 297 | return None 298 | 299 | # Download if not in cache 300 | try: 301 | response = await self.client.get(segment_url, headers=headers) 302 | response.raise_for_status() 303 | segment_data = response.content 304 | 305 | # Determine if it's an init segment or regular segment 306 | if 'init' in segment_url.lower() or segment_url.endswith('.mp4'): 307 | self.init_segment_cache[segment_url] = segment_data 308 | else: 309 | self.segment_cache[segment_url] = segment_data 310 | 311 | # Check for emergency cleanup 312 | if self._check_memory_threshold(): 313 | self._emergency_cache_cleanup() 314 | # Maintain cache size 315 | elif len(self.segment_cache) > self.max_cache_size: 316 | oldest_key = next(iter(self.segment_cache)) 317 | del self.segment_cache[oldest_key] 318 | 319 | logger.debug(f"Downloaded and cached DASH segment: {segment_url}") 320 | return segment_data 321 | 322 | except Exception as e: 323 | logger.warning(f"Failed to get DASH segment {segment_url}: {e}") 324 | return None 325 | 326 | async def get_manifest(self, mpd_url: str, headers: Dict[str, str]) -> Optional[dict]: 327 | """ 328 | Get MPD manifest from cache or download it. 329 | 330 | Args: 331 | mpd_url (str): URL of the MPD manifest 332 | headers (Dict[str, str]): Headers to use for request 333 | 334 | Returns: 335 | Optional[dict]: Cached manifest data or None if not available 336 | """ 337 | # Check cache first 338 | if mpd_url in self.manifest_cache: 339 | logger.debug(f"DASH cache hit for manifest: {mpd_url}") 340 | return self.manifest_cache[mpd_url] 341 | 342 | # Download if not in cache 343 | try: 344 | response = await self.client.get(mpd_url, headers=headers) 345 | response.raise_for_status() 346 | mpd_content = response.text 347 | mpd_dict = xmltodict.parse(mpd_content) 348 | 349 | # Cache the manifest 350 | self.manifest_cache[mpd_url] = mpd_dict 351 | 352 | logger.debug(f"Downloaded and cached DASH manifest: {mpd_url}") 353 | return mpd_dict 354 | 355 | except Exception as e: 356 | logger.warning(f"Failed to get DASH manifest {mpd_url}: {e}") 357 | return None 358 | 359 | def clear_cache(self) -> None: 360 | """Clear the DASH cache.""" 361 | self.segment_cache.clear() 362 | self.init_segment_cache.clear() 363 | self.manifest_cache.clear() 364 | self.adaptation_segments.clear() 365 | logger.info("DASH pre-buffer cache cleared") 366 | 367 | async def close(self) -> None: 368 | """Close the pre-buffer system.""" 369 | await self.client.aclose() 370 | 371 | 372 | # Global DASH pre-buffer instance 373 | dash_prebuffer = DASHPreBuffer() 374 | -------------------------------------------------------------------------------- /mediaflow_proxy/utils/hls_utils.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import re 3 | from typing import List, Dict, Any, Optional, Tuple 4 | from urllib.parse import urljoin 5 | 6 | logger = logging.getLogger(__name__) 7 | 8 | 9 | def parse_hls_playlist(playlist_content: str, base_url: Optional[str] = None) -> List[Dict[str, Any]]: 10 | """ 11 | Parses an HLS master playlist to extract stream information. 12 | 13 | Args: 14 | playlist_content (str): The content of the M3U8 master playlist. 15 | base_url (str, optional): The base URL of the playlist for resolving relative stream URLs. Defaults to None. 16 | 17 | Returns: 18 | List[Dict[str, Any]]: A list of dictionaries, each representing a stream variant. 19 | """ 20 | streams = [] 21 | lines = playlist_content.strip().split('\n') 22 | 23 | # Regex to capture attributes from #EXT-X-STREAM-INF 24 | stream_inf_pattern = re.compile(r'#EXT-X-STREAM-INF:(.*)') 25 | 26 | for i, line in enumerate(lines): 27 | if line.startswith('#EXT-X-STREAM-INF'): 28 | stream_info = {'raw_stream_inf': line} 29 | match = stream_inf_pattern.match(line) 30 | if not match: 31 | logger.warning(f"Could not parse #EXT-X-STREAM-INF line: {line}") 32 | continue 33 | attributes_str = match.group(1) 34 | 35 | # Parse attributes like BANDWIDTH, RESOLUTION, etc. 36 | attributes = re.findall(r'([A-Z-]+)=("([^"]+)"|([^,]+))', attributes_str) 37 | for key, _, quoted_val, unquoted_val in attributes: 38 | value = quoted_val if quoted_val else unquoted_val 39 | if key == 'RESOLUTION': 40 | try: 41 | width, height = map(int, value.split('x')) 42 | stream_info['resolution'] = (width, height) 43 | except ValueError: 44 | stream_info['resolution'] = (0, 0) 45 | else: 46 | stream_info[key.lower().replace('-', '_')] = value 47 | 48 | # The next line should be the stream URL 49 | if i + 1 < len(lines) and not lines[i + 1].startswith('#'): 50 | stream_url = lines[i + 1].strip() 51 | stream_info['url'] = urljoin(base_url, stream_url) if base_url else stream_url 52 | streams.append(stream_info) 53 | 54 | return streams -------------------------------------------------------------------------------- /mediaflow_proxy/utils/m3u8_processor.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import codecs 3 | import re 4 | from typing import AsyncGenerator 5 | from urllib import parse 6 | 7 | from mediaflow_proxy.configs import settings 8 | from mediaflow_proxy.utils.crypto_utils import encryption_handler 9 | from mediaflow_proxy.utils.http_utils import encode_mediaflow_proxy_url, encode_stremio_proxy_url, get_original_scheme 10 | from mediaflow_proxy.utils.hls_prebuffer import hls_prebuffer 11 | 12 | 13 | class M3U8Processor: 14 | def __init__(self, request, key_url: str = None, force_playlist_proxy: bool = None, key_only_proxy: bool = False, no_proxy: bool = False): 15 | """ 16 | Initializes the M3U8Processor with the request and URL prefix. 17 | 18 | Args: 19 | request (Request): The incoming HTTP request. 20 | key_url (HttpUrl, optional): The URL of the key server. Defaults to None. 21 | force_playlist_proxy (bool, optional): Force all playlist URLs to be proxied through MediaFlow. Defaults to None. 22 | key_only_proxy (bool, optional): Only proxy the key URL, leaving segment URLs direct. Defaults to False. 23 | no_proxy (bool, optional): If True, returns the manifest without proxying any URLs. Defaults to False. 24 | """ 25 | self.request = request 26 | self.key_url = parse.urlparse(key_url) if key_url else None 27 | self.key_only_proxy = key_only_proxy 28 | self.no_proxy = no_proxy 29 | self.force_playlist_proxy = force_playlist_proxy 30 | self.mediaflow_proxy_url = str( 31 | request.url_for("hls_manifest_proxy").replace(scheme=get_original_scheme(request)) 32 | ) 33 | self.playlist_url = None # Will be set when processing starts 34 | 35 | async def process_m3u8(self, content: str, base_url: str) -> str: 36 | """ 37 | Processes the m3u8 content, proxying URLs and handling key lines. 38 | 39 | Args: 40 | content (str): The m3u8 content to process. 41 | base_url (str): The base URL to resolve relative URLs. 42 | 43 | Returns: 44 | str: The processed m3u8 content. 45 | """ 46 | # Store the playlist URL for prebuffering 47 | self.playlist_url = base_url 48 | 49 | lines = content.splitlines() 50 | processed_lines = [] 51 | for line in lines: 52 | if "URI=" in line: 53 | processed_lines.append(await self.process_key_line(line, base_url)) 54 | elif not line.startswith("#") and line.strip(): 55 | processed_lines.append(await self.proxy_content_url(line, base_url)) 56 | else: 57 | processed_lines.append(line) 58 | 59 | # Pre-buffer segments if enabled and this is a playlist 60 | if (settings.enable_hls_prebuffer and 61 | "#EXTM3U" in content and 62 | self.playlist_url): 63 | 64 | # Extract headers from request for pre-buffering 65 | headers = {} 66 | for key, value in self.request.query_params.items(): 67 | if key.startswith("h_"): 68 | headers[key[2:]] = value 69 | 70 | # Start pre-buffering in background using the actual playlist URL 71 | asyncio.create_task( 72 | hls_prebuffer.prebuffer_playlist(self.playlist_url, headers) 73 | ) 74 | 75 | return "\n".join(processed_lines) 76 | 77 | async def process_m3u8_streaming( 78 | self, content_iterator: AsyncGenerator[bytes, None], base_url: str 79 | ) -> AsyncGenerator[str, None]: 80 | """ 81 | Processes the m3u8 content on-the-fly, yielding processed lines as they are read. 82 | Optimized to avoid accumulating the entire playlist content in memory. 83 | 84 | Args: 85 | content_iterator: An async iterator that yields chunks of the m3u8 content. 86 | base_url (str): The base URL to resolve relative URLs. 87 | 88 | Yields: 89 | str: Processed lines of the m3u8 content. 90 | """ 91 | # Store the playlist URL for prebuffering 92 | self.playlist_url = base_url 93 | 94 | buffer = "" # String buffer for decoded content 95 | decoder = codecs.getincrementaldecoder("utf-8")(errors="replace") 96 | is_playlist_detected = False 97 | is_prebuffer_started = False 98 | 99 | # Process the content chunk by chunk 100 | async for chunk in content_iterator: 101 | if isinstance(chunk, str): 102 | chunk = chunk.encode("utf-8") 103 | 104 | # Incrementally decode the chunk 105 | decoded_chunk = decoder.decode(chunk) 106 | buffer += decoded_chunk 107 | 108 | # Check for playlist marker early to avoid accumulating content 109 | if not is_playlist_detected and "#EXTM3U" in buffer: 110 | is_playlist_detected = True 111 | 112 | # Process complete lines 113 | lines = buffer.split("\n") 114 | if len(lines) > 1: 115 | # Process all complete lines except the last one 116 | for line in lines[:-1]: 117 | if line: # Skip empty lines 118 | processed_line = await self.process_line(line, base_url) 119 | yield processed_line + "\n" 120 | 121 | # Keep the last line in the buffer (it might be incomplete) 122 | buffer = lines[-1] 123 | 124 | # Start pre-buffering early once we detect this is a playlist 125 | # This avoids waiting until the entire playlist is processed 126 | if (settings.enable_hls_prebuffer and 127 | is_playlist_detected and 128 | not is_prebuffer_started and 129 | self.playlist_url): 130 | 131 | # Extract headers from request for pre-buffering 132 | headers = {} 133 | for key, value in self.request.query_params.items(): 134 | if key.startswith("h_"): 135 | headers[key[2:]] = value 136 | 137 | # Start pre-buffering in background using the actual playlist URL 138 | asyncio.create_task( 139 | hls_prebuffer.prebuffer_playlist(self.playlist_url, headers) 140 | ) 141 | is_prebuffer_started = True 142 | 143 | # Process any remaining data in the buffer plus final bytes 144 | final_chunk = decoder.decode(b"", final=True) 145 | if final_chunk: 146 | buffer += final_chunk 147 | 148 | if buffer: # Process the last line if it's not empty 149 | processed_line = await self.process_line(buffer, base_url) 150 | yield processed_line 151 | 152 | async def process_line(self, line: str, base_url: str) -> str: 153 | """ 154 | Process a single line from the m3u8 content. 155 | 156 | Args: 157 | line (str): The line to process. 158 | base_url (str): The base URL to resolve relative URLs. 159 | 160 | Returns: 161 | str: The processed line. 162 | """ 163 | if "URI=" in line: 164 | return await self.process_key_line(line, base_url) 165 | elif not line.startswith("#") and line.strip(): 166 | return await self.proxy_content_url(line, base_url) 167 | else: 168 | return line 169 | 170 | async def process_key_line(self, line: str, base_url: str) -> str: 171 | """ 172 | Processes a key line in the m3u8 content, proxying the URI. 173 | 174 | Args: 175 | line (str): The key line to process. 176 | base_url (str): The base URL to resolve relative URLs. 177 | 178 | Returns: 179 | str: The processed key line. 180 | """ 181 | # If no_proxy is enabled, just resolve relative URLs without proxying 182 | if self.no_proxy: 183 | uri_match = re.search(r'URI="([^"]+)"', line) 184 | if uri_match: 185 | original_uri = uri_match.group(1) 186 | full_url = parse.urljoin(base_url, original_uri) 187 | line = line.replace(f'URI="{original_uri}"', f'URI="{full_url}"') 188 | return line 189 | 190 | uri_match = re.search(r'URI="([^"]+)"', line) 191 | if uri_match: 192 | original_uri = uri_match.group(1) 193 | uri = parse.urlparse(original_uri) 194 | if self.key_url: 195 | uri = uri._replace(scheme=self.key_url.scheme, netloc=self.key_url.netloc) 196 | new_uri = await self.proxy_url(uri.geturl(), base_url) 197 | line = line.replace(f'URI="{original_uri}"', f'URI="{new_uri}"') 198 | return line 199 | 200 | async def proxy_content_url(self, url: str, base_url: str) -> str: 201 | """ 202 | Proxies a content URL based on the configured routing strategy. 203 | 204 | Args: 205 | url (str): The URL to proxy. 206 | base_url (str): The base URL to resolve relative URLs. 207 | 208 | Returns: 209 | str: The proxied URL. 210 | """ 211 | full_url = parse.urljoin(base_url, url) 212 | 213 | # If no_proxy is enabled, return the direct URL without any proxying 214 | if self.no_proxy: 215 | return full_url 216 | 217 | # If key_only_proxy is enabled, return the direct URL for segments 218 | if self.key_only_proxy and not url.endswith((".m3u", ".m3u8")): 219 | return full_url 220 | 221 | # Determine routing strategy based on configuration 222 | routing_strategy = settings.m3u8_content_routing 223 | 224 | # Check if we should force MediaFlow proxy for all playlist URLs 225 | if self.force_playlist_proxy: 226 | return await self.proxy_url(full_url, base_url, use_full_url=True) 227 | 228 | # For playlist URLs, always use MediaFlow proxy regardless of strategy 229 | # Check for actual playlist file extensions, not just substring matches 230 | parsed_url = parse.urlparse(full_url) 231 | if (parsed_url.path.endswith((".m3u", ".m3u8", ".m3u_plus")) or 232 | parse.parse_qs(parsed_url.query).get("type", [""])[0] in ["m3u", "m3u8", "m3u_plus"]): 233 | return await self.proxy_url(full_url, base_url, use_full_url=True) 234 | 235 | # Route non-playlist content URLs based on strategy 236 | if routing_strategy == "direct": 237 | # Return the URL directly without any proxying 238 | return full_url 239 | elif routing_strategy == "stremio" and settings.stremio_proxy_url: 240 | # Use Stremio proxy for content URLs 241 | query_params = dict(self.request.query_params) 242 | request_headers = {k[2:]: v for k, v in query_params.items() if k.startswith("h_")} 243 | response_headers = {k[2:]: v for k, v in query_params.items() if k.startswith("r_")} 244 | 245 | return encode_stremio_proxy_url( 246 | settings.stremio_proxy_url, 247 | full_url, 248 | request_headers=request_headers if request_headers else None, 249 | response_headers=response_headers if response_headers else None, 250 | ) 251 | else: 252 | # Default to MediaFlow proxy (routing_strategy == "mediaflow" or fallback) 253 | return await self.proxy_url(full_url, base_url, use_full_url=True) 254 | 255 | async def proxy_url(self, url: str, base_url: str, use_full_url: bool = False) -> str: 256 | """ 257 | Proxies a URL, encoding it with the MediaFlow proxy URL. 258 | 259 | Args: 260 | url (str): The URL to proxy. 261 | base_url (str): The base URL to resolve relative URLs. 262 | use_full_url (bool): Whether to use the URL as-is (True) or join with base_url (False). 263 | 264 | Returns: 265 | str: The proxied URL. 266 | """ 267 | if use_full_url: 268 | full_url = url 269 | else: 270 | full_url = parse.urljoin(base_url, url) 271 | 272 | query_params = dict(self.request.query_params) 273 | has_encrypted = query_params.pop("has_encrypted", False) 274 | # Remove the response headers from the query params to avoid it being added to the consecutive requests 275 | [query_params.pop(key, None) for key in list(query_params.keys()) if key.startswith("r_")] 276 | # Remove force_playlist_proxy to avoid it being added to subsequent requests 277 | query_params.pop("force_playlist_proxy", None) 278 | 279 | return encode_mediaflow_proxy_url( 280 | self.mediaflow_proxy_url, 281 | "", 282 | full_url, 283 | query_params=query_params, 284 | encryption_handler=encryption_handler if has_encrypted else None, 285 | ) -------------------------------------------------------------------------------- /mediaflow_proxy/utils/packed.py: -------------------------------------------------------------------------------- 1 | #Adapted for use in MediaFlowProxy from: 2 | #https://github.com/einars/js-beautify/blob/master/python/jsbeautifier/unpackers/packer.py 3 | # Unpacker for Dean Edward's p.a.c.k.e.r, a part of javascript beautifier 4 | # by Einar Lielmanis 5 | # 6 | # written by Stefano Sanfilippo 7 | # 8 | # usage: 9 | # 10 | # if detect(some_string): 11 | # unpacked = unpack(some_string) 12 | # 13 | """Unpacker for Dean Edward's p.a.c.k.e.r""" 14 | 15 | import re 16 | from bs4 import BeautifulSoup, SoupStrainer 17 | from urllib.parse import urljoin, urlparse 18 | import logging 19 | 20 | 21 | logger = logging.getLogger(__name__) 22 | 23 | 24 | 25 | 26 | def detect(source): 27 | if "eval(function(p,a,c,k,e,d)" in source: 28 | mystr = "smth" 29 | return mystr is not None 30 | 31 | 32 | def unpack(source): 33 | """Unpacks P.A.C.K.E.R. packed js code.""" 34 | payload, symtab, radix, count = _filterargs(source) 35 | 36 | if count != len(symtab): 37 | raise UnpackingError("Malformed p.a.c.k.e.r. symtab.") 38 | 39 | try: 40 | unbase = Unbaser(radix) 41 | except TypeError: 42 | raise UnpackingError("Unknown p.a.c.k.e.r. encoding.") 43 | 44 | def lookup(match): 45 | """Look up symbols in the synthetic symtab.""" 46 | word = match.group(0) 47 | return symtab[unbase(word)] or word 48 | 49 | payload = payload.replace("\\\\", "\\").replace("\\'", "'") 50 | source = re.sub(r"\b\w+\b", lookup, payload) 51 | return _replacestrings(source) 52 | 53 | 54 | def _filterargs(source): 55 | """Juice from a source file the four args needed by decoder.""" 56 | juicers = [ 57 | (r"}\('(.*)', *(\d+|\[\]), *(\d+), *'(.*)'\.split\('\|'\), *(\d+), *(.*)\)\)"), 58 | (r"}\('(.*)', *(\d+|\[\]), *(\d+), *'(.*)'\.split\('\|'\)"), 59 | ] 60 | for juicer in juicers: 61 | args = re.search(juicer, source, re.DOTALL) 62 | if args: 63 | a = args.groups() 64 | if a[1] == "[]": 65 | a = list(a) 66 | a[1] = 62 67 | a = tuple(a) 68 | try: 69 | return a[0], a[3].split("|"), int(a[1]), int(a[2]) 70 | except ValueError: 71 | raise UnpackingError("Corrupted p.a.c.k.e.r. data.") 72 | 73 | # could not find a satisfying regex 74 | raise UnpackingError( 75 | "Could not make sense of p.a.c.k.e.r data (unexpected code structure)" 76 | ) 77 | 78 | 79 | def _replacestrings(source): 80 | """Strip string lookup table (list) and replace values in source.""" 81 | match = re.search(r'var *(_\w+)\=\["(.*?)"\];', source, re.DOTALL) 82 | 83 | if match: 84 | varname, strings = match.groups() 85 | startpoint = len(match.group(0)) 86 | lookup = strings.split('","') 87 | variable = "%s[%%d]" % varname 88 | for index, value in enumerate(lookup): 89 | source = source.replace(variable % index, '"%s"' % value) 90 | return source[startpoint:] 91 | return source 92 | 93 | 94 | class Unbaser(object): 95 | """Functor for a given base. Will efficiently convert 96 | strings to natural numbers.""" 97 | 98 | ALPHABET = { 99 | 62: "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ", 100 | 95: ( 101 | " !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ" 102 | "[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~" 103 | ), 104 | } 105 | 106 | def __init__(self, base): 107 | self.base = base 108 | 109 | # fill elements 37...61, if necessary 110 | if 36 < base < 62: 111 | if not hasattr(self.ALPHABET, self.ALPHABET[62][:base]): 112 | self.ALPHABET[base] = self.ALPHABET[62][:base] 113 | # attrs = self.ALPHABET 114 | # print ', '.join("%s: %s" % item for item in attrs.items()) 115 | # If base can be handled by int() builtin, let it do it for us 116 | if 2 <= base <= 36: 117 | self.unbase = lambda string: int(string, base) 118 | else: 119 | # Build conversion dictionary cache 120 | try: 121 | self.dictionary = dict( 122 | (cipher, index) for index, cipher in enumerate(self.ALPHABET[base]) 123 | ) 124 | except KeyError: 125 | raise TypeError("Unsupported base encoding.") 126 | 127 | self.unbase = self._dictunbaser 128 | 129 | def __call__(self, string): 130 | return self.unbase(string) 131 | 132 | def _dictunbaser(self, string): 133 | """Decodes a value to an integer.""" 134 | ret = 0 135 | for index, cipher in enumerate(string[::-1]): 136 | ret += (self.base**index) * self.dictionary[cipher] 137 | return ret 138 | class UnpackingError(Exception): 139 | """Badly packed source or general error. Argument is a 140 | meaningful description.""" 141 | 142 | pass 143 | 144 | 145 | 146 | async def eval_solver(self, url: str, headers: dict[str, str] | None, patterns: list[str]) -> str: 147 | try: 148 | response = await self._make_request(url, headers=headers) 149 | soup = BeautifulSoup(response.text, "lxml",parse_only=SoupStrainer("script")) 150 | script_all = soup.find_all("script") 151 | for i in script_all: 152 | if detect(i.text): 153 | unpacked_code = unpack(i.text) 154 | for pattern in patterns: 155 | match = re.search(pattern, unpacked_code) 156 | if match: 157 | extracted_url = match.group(1) 158 | if not urlparse(extracted_url).scheme: 159 | extracted_url = urljoin(url, extracted_url) 160 | 161 | return extracted_url 162 | raise UnpackingError("No p.a.c.k.e.d JS found or no pattern matched.") 163 | except Exception as e: 164 | logger.exception("Eval solver error for %s", url) 165 | raise UnpackingError("Error in eval_solver") from e -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "mediaflow-proxy" 3 | version = "2.1.9" 4 | description = "A high-performance proxy server for streaming media, supporting HTTP(S), HLS, and MPEG-DASH with real-time DRM decryption." 5 | authors = ["mhdzumair "] 6 | readme = "README.md" 7 | homepage = "https://github.com/mhdzumair/mediaflow-proxy" 8 | repository = "https://github.com/mhdzumair/mediaflow-proxy" 9 | documentation = "https://github.com/mhdzumair/mediaflow-proxy#readme" 10 | keywords = ["proxy", "media", "streaming", "hls", "dash", "drm"] 11 | license = "MIT" 12 | classifiers = [ 13 | "Development Status :: 4 - Beta", 14 | "Intended Audience :: Developers", 15 | "License :: OSI Approved :: MIT License", 16 | "Programming Language :: Python :: 3", 17 | "Programming Language :: Python :: 3.9", 18 | "Programming Language :: Python :: 3.10", 19 | "Programming Language :: Python :: 3.11", 20 | "Programming Language :: Python :: 3.12", 21 | "Programming Language :: Python :: 3.13", 22 | ] 23 | include = ["LICENSE", "README.md", "mediaflow_proxy/static/*"] 24 | 25 | 26 | [tool.poetry.dependencies] 27 | python = ">=3.9" 28 | fastapi = "0.115.12" 29 | httpx = {extras = ["socks", "zstd"], version = "^0.28.1"} 30 | tenacity = "^9.1.2" 31 | xmltodict = "^0.14.2" 32 | pydantic-settings = "^2.9.1" 33 | gunicorn = "^23.0.0" 34 | pycryptodome = "^3.22.0" 35 | uvicorn = "^0.34.2" 36 | tqdm = "^4.67.1" 37 | aiofiles = "^24.1.0" 38 | beautifulsoup4 = ">=4.13.4" 39 | lxml = "^5.4.0" 40 | psutil = "^6.1.0" 41 | 42 | [tool.poetry.group.dev.dependencies] 43 | black = "^25.1.0" 44 | 45 | [build-system] 46 | requires = ["poetry-core"] 47 | build-backend = "poetry.core.masonry.api" 48 | 49 | [tool.poetry.scripts] 50 | mediaflow-proxy = "mediaflow_proxy.main:run" 51 | 52 | [tool.black] 53 | line-length = 120 54 | --------------------------------------------------------------------------------