first commit

This commit is contained in:
Viren070
2025-02-18 14:07:52 +00:00
commit 3e311b50c8
37 changed files with 7042 additions and 0 deletions
+9
View File
@@ -0,0 +1,9 @@
docker-data/
__pycache__/
*.pyc
.env
.git/
.gitignore
.pytest_cache/
.coverage
*.log
+12
View File
@@ -0,0 +1,12 @@
ADMIN_USERNAME=
ADMIN_PASSWORD=
ENCRYPTION_KEY=
MEDIAFLOW_API_KEY=
MEDIAFLOW_STREAMING_PROGRESS=true
DEBRID_API_KEY=
MEDIAFUSION_OPTIONS=
EASYNEWS_USERNAME=
EASYNEWS_PASSWORD=
REDIS_HOST=debridproxy_redis
REDIS_PORT=6379
REDIS_PASSWORD=
+2
View File
@@ -0,0 +1,2 @@
github:
buy_me_a_coffee:
+64
View File
@@ -0,0 +1,64 @@
name: Deploy Docker image to GitHub Container Registry and Docker Hub
on:
push:
tags:
- 'v*' # Trigger on any tag that starts with 'v'
workflow_dispatch: # Allow manual triggering of workflow
inputs:
tag:
description: 'Tag to deploy'
required: true
jobs:
docker:
runs-on: ubuntu-latest
steps:
# Checkout the repository
- name: Checkout repository
uses: actions/checkout@v4
# Set up QEMU for multi-platform builds
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
# Set up Docker Buildx
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
# Determine the tag to deploy
- name: Determine tag to deploy
id: tag
run: |
if [ "${{ github.event_name }}" == "workflow_dispatch" ]; then
echo "TAG=${{ github.event.inputs.tag }}" >> $GITHUB_ENV
else
echo "TAG=${{ github.ref_name }}" >> $GITHUB_ENV
fi
# Log in to GitHub Container Registry (GHCR)
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# Log in to Docker Hub
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
# Build and push Docker image to both GHCR and Docker Hub
- name: Build and push Docker image (tags)
uses: docker/build-push-action@v6
with:
platforms: linux/amd64,linux/arm64
push: true
tags: |
ghcr.io/${{ secrets.GHCR_LOWERCASE_USERNAME }}/aiostremio:${{ env.TAG }}
ghcr.io/${{ secrets.GHCR_LOWERCASE_USERNAME }}/aiostremio:latest
docker.io/${{ secrets.DOCKERHUB_USERNAME }}/aiostremio:${{ env.TAG }}
docker.io/${{ secrets.DOCKERHUB_USERNAME }}/aiostremio:latest
+165
View File
@@ -0,0 +1,165 @@
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#pdm.lock
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
# in version control.
# https://pdm.fming.dev/#use-with-ide
.pdm.toml
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
db/users.json
redis_data/
Caddyfile
config.json
docker-compose.override.yml
+18
View File
@@ -0,0 +1,18 @@
FROM python:3.13-alpine
WORKDIR /app
RUN apk add --no-cache gcc python3-dev musl-dev linux-headers
RUN pip install --root-user-action ignore option uv
COPY requirements.txt .
RUN uv pip install --system -r requirements.txt
COPY . .
EXPOSE 8469
ENV PYTHONUNBUFFERED=1
CMD ["python", "main.py"]
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 stekc
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+195
View File
@@ -0,0 +1,195 @@
# AIOStremio
[![commit activity](https://img.shields.io/github/commit-activity/m/viren070/aiostremio)](https://github.com/viren070/aiostremio/commits)
[![last commit](https://img.shields.io/github/last-commit/viren070/aiostremio)](https://github.com/viren070/aiostremio/commits)
[![chat](https://img.shields.io/discord/1178091792504201357?logo=discord&logoColor=white)](https://discord.gg/MkCvXWjeAx)
[![Stremio](https://img.shields.io/badge/Stremio-mediumpurple)](https://stremio.com/)
[![Vidi](https://img.shields.io/badge/Vidi-black)](https://vidi.plomo.se/)
[![Madari](https://img.shields.io/badge/Madari-red)](https://downloads.madari.media/)
AIOStremio combines your favorite Stremio addons into one. Easily sync your setup with friends—changes update for all users without any reconfiguration on their end.
(Note: Not all services allow account sharing, and it may lead to a ban. Consider using [TorBox](https://torbox.app/subscription?referral=fe897519-fa8d-402d-bdb6-15570c60eff2) (referral link), which allows account sharing.)
![Stremio, Android TV, custom formatting applied](https://i.ibb.co/fxgjs5D/simple-on-bestpres-on.png)
<small>*Stremio, on Android TV, with custom formatting applied*</small>
<details>
<summary>More Images</summary>
| | |
|------------------------------------|--------------------|
| <img src="https://i.postimg.cc/2YgCXJ5Y/IMG-3880.png" width="300"> | *Homepage* |
| <img src="https://i.postimg.cc/npGZrhGV/IMG-3881.png" width="300"> | *Streaming Interface* |
| <img src="https://i.postimg.cc/Yt27WBd6/IMG-3884.png" width="300"> | *Title View* |
| <img src="https://i.postimg.cc/6tXtBvR1/IMG-3885.png" width="300"> | *Results* |
| <img src="https://i.postimg.cc/jKNRVXW7/IMG-3883.png" width="300"> | *User Settings* |
| <img src="https://i.postimg.cc/1yDWs8GM/IMG-3887.png" width="300"> | *Admin Settings* |
</details>
## Features
- Account system
- Web interface for streaming with support for VLC, Infuse and VidHub
- Track users' watch history
- Fetch links from multiple addons
- Redis cache that instantly returns already fetched links
- Automatically cache all episodes in a season when any episode from that season is requested
- Optional encryption of video URLs and proxy streams to bypass IP restrictions on debrid services (at your own risk) and avoid exposing your API keys/passwords
- Optional cleansing of confusing file names, show only relevant metadata
- Optional filtering of duplicate streams, show only the best file available per resolution
- Very easy to add support for new addons
## Supported Stremio Addons
- [Torrentio](https://torrentio.strem.fun/)
- [Comet](https://comet.elfhosted.com/)
- [MediaFusion](https://mediafusion.elfhosted.com/)
- [TorBox](https://torbox.app/)
- [Easynews](https://ea627ddf0ee7-easynews.baby-beamup.club/)
- [Debridio](https://debridio.adobotec.com/)
- [WatchHub](https://watchhub.stkc.win/)
- [Peerflix](https://config.peerflix.mov/)
## Supported Debrid Providers
All debrid providers supported in the addons listed above
> [!WARNING]
> If your debrid provider has IP sharing restrictions, and you will be accessing this addon from multiple IPs, you must configure the proxy to be ON for ALL users. If you will only be accessing this addon from one network it is safe to disable the proxy.
>
> Services such as TorBox and Premiumize do not have this restriction and you are free to enable the proxy for some users and disable it for others.
## Setup
<details>
<summary>Setup Guide</summary>
Requirements:
- Docker
- Reverse proxy (https://caddyserver.com/docs/quick-starts/reverse-proxy)
1. Clone the repo: `git clone https://github.com/viren070/AIOStremio`
2. Copy and rename .env.example to .env and fill out the required fields:
Create an admin account that will be used to add new users and toggle proxy streams:
```
ADMIN_USERNAME=
ADMIN_PASSWORD=
```
If you are using MediaFlow, or not using the proxy at all, this can be left blank. If using the built-in proxy, run `python3 gen_key.py` and add the key:
```
ENCRYPTION_KEY=
```
If you are proxying streams with MediaFlow, generate a secure password to prevent unauthorized access:
```
MEDIAFLOW_API_KEY=
```
When set to true, MediaFlow will log detailed proxied stream info:
```
MEDIAFLOW_STREAMING_PROGRESS=true
```
Your Real-Debrid/Premiumize/TorBox/etc. API key. If you are only using EasyNews, this can be left blank:
```
DEBRID_API_KEY=
```
Generate a MediaFusion manifest at https://mediafusion.elfhosted.com/, then copy the string of random characters between `https://mediafusion.elfhosted.com/` and `/manifest.json`. If you do not want to use MediaFusion, leave this field blank:
```
MEDIAFUSION_OPTIONS=
```
Your EasyNews username and password. If you are only using a debrid service, this can be left blank:
```
EASYNEWS_USERNAME=
EASYNEWS_PASSWORD=
```
Generate a secure password for the Redis cache. Host and port should be left as default when using Docker:
```
REDIS_HOST=debridproxy_redis
REDIS_PORT=6379
REDIS_PASSWORD=
```
3. Copy and rename config.json.example to config.json and fill out the required fields:
If using a debrid service, specify it here:
```
"debrid_service": "torbox",
```
If you want to use different debrid services for different addons, specify them here, otherwise leave blank:
```
"addon_config": {
"torrentio": {
"debrid_service": "",
"debrid_api_key": ""
},
"comet": {
"base_url": "https://comet.elfhosted.com",
"debrid_service": "",
"debrid_api_key": ""
},
"debridio": {
"debrid_service": "easydebrid",
"debrid_api_key": ""
},
"peerflix": {
"debrid_service": "",
"debrid_api_key": ""
}
},
```
The domain where the addon will be accessible:
```
"addon_url": "https://debridproxy.your-domain.com",
```
The domain used when generating links, leave as default unless using another instance (ElfHosted, etc.):
```
"mediaflow_url": "http://debridproxy_mediaflow:8888",
```
The domain returned in the generated links, set this to your domain:
```
"external_mediaflow_url": "https://mediaflow.your-domain.com",
```
When disabled, the addon will use the built-in proxy streaming. Unless you experience issues with MediaFlow, leave this set to true:
```
"mediaflow_enabled": true,
```
How long in seconds fetched links will be cached:
```
"cache_ttl_seconds": 604800,
```
Advanced built-in proxy options. Does not affect MediaFlow. Leave this as default unless the built-in proxy has issues:
```
"buffer_size_mb": 256,
"chunk_size_mb": 4,
```
3. Configure your reverse proxy. If you are using Caddy in Docker, this Caddyfile should work:
```
aiostremio.your-domain.com {
reverse_proxy debridproxy:8469
}
mediaflow.your-domain.com {
reverse_proxy debridproxy_mediaflow:8888
}
```
4. Run `docker compose up -d` to start the addon.
5. Navigate to aiostremio.your-domain.com/admin to add a user
6. Navigate to aiostremio.your-domain.com/ to generate a manifest
7. Add the generated URL to Stremio/Vidi/etc. and start watching
</details>
## Credits
Torrentio, Comet, MediaFusion, and all other upstream addons - Used for fetching links
[MediaFlow](https://github.com/mhdzumair/mediaflow-proxy) - Used for proxy streams
## Notes
- MediaFlow is recommended for video proxying, though you can use the internal proxy by editing the config if you have issues
- AIOStremio is primarily tested with TorBox. Please open an issue if other debrid services do not work
- Bypassing IP restrictions on debrid services is experimental
+29
View File
@@ -0,0 +1,29 @@
{
"debrid_service": "torbox",
"addon_config": {
"torrentio": {
"debrid_service": "",
"debrid_api_key": ""
},
"comet": {
"base_url": "https://comet.elfhosted.com",
"debrid_service": "",
"debrid_api_key": ""
},
"debridio": {
"debrid_service": "easydebrid",
"debrid_api_key": ""
},
"peerflix": {
"debrid_service": "",
"debrid_api_key": ""
}
},
"addon_url": "https://debridproxy.your-domain.com",
"mediaflow_url": "http://debridproxy_mediaflow:8888",
"external_mediaflow_url": "https://mediaflow.your-domain.com",
"mediaflow_enabled": true,
"cache_ttl_seconds": 604800,
"buffer_size_mb": 256,
"chunk_size_mb": 4
}
View File
+109
View File
@@ -0,0 +1,109 @@
services:
debridproxy:
build: .
container_name: debridproxy
volumes:
- .:/app
ports:
- "8469:8469"
env_file:
- .env
restart: unless-stopped
cap_drop:
- ALL
cap_add:
- DAC_OVERRIDE
- SETGID
- SETUID
- CHOWN
security_opt:
- no-new-privileges:true
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8469"]
interval: 10s
timeout: 5s
retries: 5
depends_on:
redis:
condition: service_healthy
mem_limit: 4G
redis:
image: redis:alpine
container_name: debridproxy_redis
command: ["redis-server", "--requirepass", "${REDIS_PASSWORD}", "--appendonly", "yes"]
volumes:
- ./docker-data/redis_data:/data
restart: unless-stopped
cap_drop:
- ALL
cap_add:
- DAC_OVERRIDE
- SETGID
- SETUID
- CHOWN
security_opt:
- no-new-privileges:true
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
healthcheck:
test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"]
interval: 10s
timeout: 5s
retries: 5
mem_limit: 1G
mediaflow:
image: mhdzumair/mediaflow-proxy
container_name: debridproxy_mediaflow
ports:
- "8888:8888"
environment:
- API_PASSWORD=${MEDIAFLOW_API_KEY}
- ENABLE_STREAMING_PROGRESS=${MEDIAFLOW_STREAMING_PROGRESS}
restart: unless-stopped
cap_drop:
- ALL
cap_add:
- DAC_OVERRIDE
- SETGID
- SETUID
- CHOWN
security_opt:
- no-new-privileges:true
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8888"]
interval: 10s
timeout: 5s
retries: 5
mem_limit: 2G
phpredisadmin:
container_name: debridproxy_phpredisadmin
image: erikdubbelboer/phpredisadmin
restart: unless-stopped
ports:
- "5080:80"
env_file:
- .env
environment:
- REDIS_1_HOST=debridproxy_redis
- REDIS_1_PORT=6379
- REDIS_1_AUTH=${REDIS_PASSWORD}
- ADMIN_USER=${ADMIN_USERNAME}
- ADMIN_PASS=${ADMIN_PASSWORD}
mem_limit: 512M
+5
View File
@@ -0,0 +1,5 @@
from cryptography.fernet import Fernet
key = Fernet.generate_key()
print("Encryption key:", key.decode())
+224
View File
@@ -0,0 +1,224 @@
import json
import asyncio
import httpx
import os
import time
import logging
import bcrypt
from collections import defaultdict
from contextlib import asynccontextmanager
import uvicorn
from cryptography.fernet import Fernet
from dotenv import load_dotenv
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.templating import Jinja2Templates
from pydantic import BaseModel
from routes.api import router
from services.comet import CometService
from services.easynews import EasynewsService
from services.mediafusion import MediaFusionService
from services.torbox import TorboxService
from services.torrentio import TorrentioService
from services.debridio import DebridioService
from services.peerflix import PeerflixService
from services.watchhub import WatchHubService
from utils.cache import get_cache_info
from utils.config import config
from utils.logger import logger
load_dotenv()
logging.getLogger("httpx").setLevel(logging.WARNING)
# Order is reflected in Stremio
streaming_services = [
service
for service in [
WatchHubService(),
TorboxService() if config.debrid_service.lower() == "torbox" else None,
TorrentioService() if config.debrid_service is not None else None,
CometService() if config.debrid_service is not None else None,
MediaFusionService() if os.getenv("MEDIAFUSION_OPTIONS") else None,
(
EasynewsService()
if os.getenv("EASYNEWS_USERNAME") and os.getenv("EASYNEWS_PASSWORD")
else None
),
DebridioService() if config.get_addon_debrid_api_key("debridio") != os.getenv("DEBRID_API_KEY") and config.get_addon_debrid_service("debridio") != config.debrid_service else None,
PeerflixService() if config.debrid_service is not None else None,
]
if service is not None
]
@asynccontextmanager
async def lifespan(app: FastAPI):
logger.info(f"Cache Info\nSize: {(await get_cache_info())['total_size_mb']}MB")
yield
app = FastAPI(lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
USERS_FILE = "db/users.json"
RATE_LIMIT_MINUTES = 1
MAX_REQUESTS = 30
CACHE_TTL = config.cache_ttl_seconds
ENCRYPTION_KEY = os.getenv("ENCRYPTION_KEY")
if not ENCRYPTION_KEY:
ENCRYPTION_KEY = Fernet.generate_key()
logger.warning(
"No ENCRYPTION_KEY set, a new key will be generated on every restart."
)
fernet = Fernet(ENCRYPTION_KEY)
rate_limits = defaultdict(list)
class User(BaseModel):
username: str
password: str
proxy_streams: bool = True
class RateLimiter:
def __init__(self, max_requests: int, window_minutes: int):
self.max_requests = max_requests
self.window_minutes = window_minutes
def is_rate_limited(self, user: str) -> bool:
now = time.time()
minute_ago = now - (self.window_minutes * 60)
# Clean old requests
rate_limits[user] = [
req_time for req_time in rate_limits[user] if req_time > minute_ago
]
# Check if rate limited
if len(rate_limits[user]) >= self.max_requests:
logger.info(
f"Rate limit exceeded for user: {user} ({len(rate_limits[user])}/{self.max_requests})"
)
return True
# Add new request
rate_limits[user].append(now)
return False
rate_limiter = RateLimiter(MAX_REQUESTS, RATE_LIMIT_MINUTES)
class AdminAuth:
def __init__(self):
admin_username = os.getenv("ADMIN_USERNAME")
admin_password = os.getenv("ADMIN_PASSWORD")
self.admin_credentials = {
"username": admin_username,
"password_hash": bcrypt.hashpw(admin_password.encode(), bcrypt.gensalt()),
}
def verify_admin(self, username: str, password: str) -> bool:
if username != self.admin_credentials["username"]:
return False
return bcrypt.checkpw(password.encode('utf-8'), self.admin_credentials["password_hash"])
admin_auth = AdminAuth()
templates = Jinja2Templates(directory="templates")
app.include_router(router)
async def sanity_check():
logger.info("Performing sanity check...")
addon_urls = [service.base_url for service in streaming_services]
logger.info("Addons | Checking addons...")
for url in addon_urls:
try:
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.get(url)
if response.status_code not in [200, 302, 307]:
logger.warning(f"Addons | ⚠️ {url} (Status: {response.status_code})")
else:
logger.info(f"Addons | ✅ {url}")
except (httpx.ReadTimeout, httpx.ConnectTimeout):
logger.warning(f"Addons | ⚠️ {url} (Timeout)")
continue
except Exception as e:
logger.warning(f"Addons | ⚠️ {url} ({str(e)})")
continue
logger.info("Config | Checking config...")
with open("config.json.example", "r") as f:
example_config = json.load(f)
def validate_config_structure(example: dict, current: dict, path: str = ""):
for key, value in example.items():
current_path = f"{path}.{key}" if path else key
if key not in current:
logger.warning(f"Config | ⚠️ The config is outdated (missing {current_path})")
exit(1)
if isinstance(value, dict):
if not isinstance(current[key], dict):
logger.warning(f"Config | ⚠️ The config is malformed (expected dict for {current_path} but got {type(current[key])})")
exit(1)
validate_config_structure(value, current[key], current_path)
validate_config_structure(example_config, config._config)
logger.info("Config | ✅ The config is up to date")
if (
not config.debrid_service
and not os.getenv("MEDIAFUSION_OPTIONS")
and not (os.getenv("EASYNEWS_USERNAME") and os.getenv("EASYNEWS_PASSWORD"))
):
logger.warning("Config | ⚠️ No services configured")
exit(1)
if config.debrid_service and not os.getenv("DEBRID_API_KEY"):
logger.warning("Config | ⚠️ Default debrid service is configured but no API key is set")
exit(1)
for service_name in config._config.get("addon_config", {}).keys():
debrid_service = config.get_addon_debrid_service(service_name)
debrid_api_key = config.get_addon_debrid_api_key(service_name)
if service_name == "debridio" and debrid_api_key == os.getenv("DEBRID_API_KEY") and debrid_service != config.debrid_service:
continue
if debrid_service == config.debrid_service:
logger.info(f"Config | *️⃣ Using default debrid service ({debrid_service}) for {service_name}")
else:
logger.info(f"Config | *️⃣ Using {debrid_service} for {service_name}")
if __name__ == "__main__":
asyncio.run(sanity_check())
uvicorn.run(
app,
host="0.0.0.0",
port=8469,
log_config={
"version": 1,
"disable_existing_loggers": False,
"loggers": {
"uvicorn.access": {"level": "WARNING"},
},
},
)
+12
View File
@@ -0,0 +1,12 @@
fastapi
uvicorn
httpx
python-dotenv
aiocache[redis]==0.12.3
jinja2
python-multipart
bcrypt
cryptography
aiohttp
fastapi-utils
typing-inspect
+1088
View File
File diff suppressed because it is too large Load Diff
+13
View File
@@ -0,0 +1,13 @@
from abc import ABC, abstractmethod
from typing import Dict, List
class StreamingService(ABC):
@abstractmethod
async def get_streams(self, meta_id: str) -> List[Dict]:
pass
@property
@abstractmethod
def name(self) -> str:
pass
+67
View File
@@ -0,0 +1,67 @@
import base64
import os
from typing import Dict, List
import httpx
from fastapi import HTTPException
from utils.config import config
from utils.logger import logger
from .base import StreamingService
class CometService(StreamingService):
def __init__(self):
self.base_url = config.get("addon_config", "comet", "base_url")
self.debrid_api_key = config.get_addon_debrid_api_key("comet")
self.debrid_service = config.get_addon_debrid_service("comet")
self.options = f"""{{
"indexers": ["bitsearch", "eztv", "thepiratebay", "therarbg", "yts"],
"maxResults": 0,
"maxResultsPerResolution": 0,
"maxSize": 0,
"reverseResultOrder": false,
"removeTrash": true,
"resultFormat": ["All"],
"resolutions": ["All"],
"languages": ["All"],
"debridService": "{self.debrid_service}",
"debridApiKey": "{self.debrid_api_key}",
"stremthruUrl": "",
"debridStreamProxyPassword": ""
}}"""
self.options_encoded = base64.b64encode(self.options.encode()).decode("utf-8")
@property
def name(self) -> str:
return "Comet"
async def _fetch_from_comet(self, url: str) -> Dict:
async with httpx.AsyncClient() as client:
try:
response = await client.get(url)
response.raise_for_status()
data = response.json()
logger.debug(f"Comet response: {data}")
return data
except httpx.HTTPError as e:
logger.error(f"Comet request failed: {str(e)}")
raise HTTPException(status_code=502, detail="Upstream service error")
async def get_streams(self, meta_id: str) -> List[Dict]:
url = f"{self.base_url}/{self.options_encoded}/stream/{meta_id}"
logger.debug(f"Comet stream url: {url}")
data = await self._fetch_from_comet(url)
streams = data.get("streams", [])
for stream in streams:
stream["service"] = self.name
stream_name = stream.get("name", "")
if stream_name.startswith("["):
prefix = stream_name[1:stream_name.find("]")] if "]" in stream_name else ""
stream["is_cached"] = "" in prefix
else:
stream["is_cached"] = True
return streams
+53
View File
@@ -0,0 +1,53 @@
import base64
import os
from typing import Dict, List
import httpx
from fastapi import HTTPException
from utils.config import config
from utils.logger import logger
from .base import StreamingService
class DebridioService(StreamingService):
def __init__(self):
self.base_url = "https://debridio.adobotec.com"
self.debrid_api_key = config.get_addon_debrid_api_key("debridio")
self.debrid_service = config.get_addon_debrid_service("debridio")
self.options = f'{{"provider":"{self.debrid_service}","apiKey":"{self.debrid_api_key}","disableUncached":false,"qualityOrder":[],"excludeSize":"","maxReturnPerQuality":""}}'
self.options_encoded = base64.b64encode(self.options.encode()).decode("utf-8")
@property
def name(self) -> str:
return "Debridio"
async def _fetch_from_debridio(self, url: str) -> Dict:
async with httpx.AsyncClient() as client:
try:
response = await client.get(url)
response.raise_for_status()
data = response.json()
logger.debug(f"Debridio response: {data}")
return data
except httpx.HTTPError as e:
logger.error(f"Debridio request failed: {str(e)}")
raise HTTPException(status_code=502, detail="Upstream service error")
async def get_streams(self, meta_id: str) -> List[Dict]:
url = f"{self.base_url}/{self.options_encoded}/stream/{meta_id}"
logger.debug(f"Debridio stream url: {url}")
data = await self._fetch_from_debridio(url)
streams = data.get("streams", [])
for stream in streams:
stream["service"] = self.name
stream_name = stream.get("name", "")
if stream_name.startswith("["):
prefix = stream_name[1:stream_name.find("]")] if "]" in stream_name else ""
stream["is_cached"] = "+" in prefix
else:
stream["is_cached"] = True
return streams
+46
View File
@@ -0,0 +1,46 @@
import os
from typing import Dict, List
import httpx
from fastapi import HTTPException
from utils.logger import logger
from .base import StreamingService
class EasynewsService(StreamingService):
def __init__(self):
self.base_url = "https://ea627ddf0ee7-easynews.baby-beamup.club"
self.username = os.getenv("EASYNEWS_USERNAME")
self.password = os.getenv("EASYNEWS_PASSWORD")
self.options = f"%7B%22username%22%3A%22{self.username}%22%2C%22password%22%3A%22{self.password}%22%7D"
@property
def name(self) -> str:
return "Easynews"
async def _fetch_from_easynews(self, url: str) -> Dict:
async with httpx.AsyncClient() as client:
try:
response = await client.get(url)
response.raise_for_status()
data = response.json()
logger.debug(f"Easynews response: {data}")
return data
except httpx.HTTPError as e:
logger.error(f"Easynews request failed: {str(e)}")
raise HTTPException(status_code=502, detail="Upstream service error")
async def get_streams(self, meta_id: str) -> List[Dict]:
url = f"{self.base_url}/{self.options}/stream/{meta_id}"
logger.debug(f"Easynews stream url: {url}")
data = await self._fetch_from_easynews(url)
streams = data.get("streams", [])
for stream in streams:
stream["service"] = self.name
# Easynews doesn't have a cache
stream["is_cached"] = True
return streams
+45
View File
@@ -0,0 +1,45 @@
import os
from typing import Dict, List
import httpx
from fastapi import HTTPException
from utils.logger import logger
from .base import StreamingService
class MediaFusionService(StreamingService):
def __init__(self):
self.base_url = "https://mediafusion.elfhosted.com"
# Generate a MediaFusion URL, then copy the data between https://mediafusion.elfhosted.com/ and /manifest.json
self.options = os.getenv("MEDIAFUSION_OPTIONS")
@property
def name(self) -> str:
return "MediaFusion"
async def _fetch_from_mediafusion(self, url: str) -> Dict:
async with httpx.AsyncClient(timeout=15) as client:
try:
response = await client.get(url)
response.raise_for_status()
data = response.json()
logger.debug(f"MediaFusion response: {data}")
return data
except httpx.HTTPError as e:
logger.error(f"MediaFusion request failed: {str(e)}")
raise HTTPException(status_code=502, detail="Upstream service error")
async def get_streams(self, meta_id: str) -> List[Dict]:
url = f"{self.base_url}/{self.options}/stream/{meta_id}"
logger.debug(f"MediaFusion stream url: {url}")
data = await self._fetch_from_mediafusion(url)
streams = data.get("streams", [])
for stream in streams:
stream["service"] = self.name
if "" in stream["name"]:
stream["is_cached"] = True
return streams
+48
View File
@@ -0,0 +1,48 @@
import base64
import os
from typing import Dict, List
import httpx
from fastapi import HTTPException
from utils.config import config
from utils.logger import logger
from .base import StreamingService
class PeerflixService(StreamingService):
def __init__(self):
self.base_url = "https://peerflix-addon.onrender.com"
self.debrid_api_key = config.get_addon_debrid_api_key("peerflix")
self.debrid_service = config.get_addon_debrid_service("peerflix")
self.options = f'language=en,es|debridoptions=nocatalog,nodownloadlinks|{self.debrid_service}={self.debrid_api_key}|sort=quality-desc'
@property
def name(self) -> str:
return "Peerflix"
async def _fetch_from_peerflix(self, url: str) -> Dict:
async with httpx.AsyncClient() as client:
try:
response = await client.get(url)
response.raise_for_status()
data = response.json()
logger.debug(f"Peerflix response: {data}")
return data
except httpx.HTTPError as e:
logger.error(f"Peerflix request failed: {str(e)}")
raise HTTPException(status_code=502, detail="Upstream service error")
async def get_streams(self, meta_id: str) -> List[Dict]:
url = f"{self.base_url}/{self.options}/stream/{meta_id}"
logger.debug(f"Peerflix stream url: {url}")
data = await self._fetch_from_peerflix(url)
streams = data.get("streams", [])
for stream in streams:
stream["service"] = self.name
# Peerflix streams are always cached
stream["is_cached"] = True
return streams
+41
View File
@@ -0,0 +1,41 @@
import os
from typing import Dict, List
import httpx
from fastapi import HTTPException
from utils.logger import logger
from .base import StreamingService
class TorboxService(StreamingService):
def __init__(self):
self.base_url = "https://stremio.torbox.app"
self.debrid_api_key = os.getenv("DEBRID_API_KEY")
self.options = f"{self.debrid_api_key}"
@property
def name(self) -> str:
return "TorBox"
async def _fetch_from_torbox(self, url: str) -> Dict:
async with httpx.AsyncClient(timeout=15) as client:
try:
response = await client.get(url)
response.raise_for_status()
data = response.json()
logger.debug(f"TorBox response: {data}")
return data
except httpx.HTTPError as e:
logger.error(f"TorBox request failed: {str(e)}")
raise HTTPException(status_code=502, detail="Upstream service error")
async def get_streams(self, meta_id: str) -> List[Dict]:
url = f"{self.base_url}/{self.options}/stream/{meta_id}"
logger.debug(f"TorBox stream url: {url}")
data = await self._fetch_from_torbox(url)
streams = data.get("streams", [])
for stream in streams:
stream["service"] = self.name
return streams
+51
View File
@@ -0,0 +1,51 @@
import os
from typing import Dict, List
import httpx
from fastapi import HTTPException
from utils.config import config
from utils.logger import logger
from .base import StreamingService
class TorrentioService(StreamingService):
def __init__(self):
self.base_url = "https://torrentio.strem.fun"
self.debrid_api_key = config.get_addon_debrid_api_key("torrentio")
self.debrid_service = config.get_addon_debrid_service("torrentio")
self.options = f"debridoptions=nocatalog|{self.debrid_service}={self.debrid_api_key}"
@property
def name(self) -> str:
return "Torrentio"
async def _fetch_from_torrentio(self, url: str) -> Dict:
async with httpx.AsyncClient() as client:
try:
response = await client.get(url)
response.raise_for_status()
data = response.json()
logger.debug(f"Torrentio response: {data}")
return data
except httpx.HTTPError as e:
logger.error(f"Torrentio request failed: {str(e)}")
raise HTTPException(status_code=502, detail="Upstream service error")
async def get_streams(self, meta_id: str) -> List[Dict]:
url = f"{self.base_url}/{self.options}/stream/{meta_id}"
logger.debug(f"Torrentio stream url: {url}")
data = await self._fetch_from_torrentio(url)
streams = data.get("streams", [])
for stream in streams:
stream["service"] = self.name
stream_name = stream.get("name", "")
if stream_name.startswith("["):
prefix = stream_name[1:stream_name.find("]")] if "]" in stream_name else ""
stream["is_cached"] = "+" in prefix
else:
stream["is_cached"] = True
return streams
+39
View File
@@ -0,0 +1,39 @@
import os
from typing import Dict, List
import httpx
from fastapi import HTTPException
from utils.logger import logger
from .base import StreamingService
class WatchHubService(StreamingService):
def __init__(self):
self.base_url = "https://watchhub.stkc.win"
@property
def name(self) -> str:
return "WatchHub"
async def _fetch_from_watchhub(self, url: str) -> Dict:
async with httpx.AsyncClient() as client:
try:
response = await client.get(url)
response.raise_for_status()
data = response.json()
logger.debug(f"WatchHub response: {data}")
return data
except httpx.HTTPError as e:
logger.error(f"WatchHub request failed: {str(e)}")
return {"streams": []}
async def get_streams(self, meta_id: str) -> List[Dict]:
url = f"{self.base_url}/stream/{meta_id}"
logger.debug(f"WatchHub stream url: {url}")
data = await self._fetch_from_watchhub(url)
streams = data.get("streams", [])
for stream in streams:
stream["service"] = self.name
return streams
+21
View File
@@ -0,0 +1,21 @@
{
"name": "AIOStremio",
"short_name": "AIOStremio",
"start_url": "/",
"display": "standalone",
"background_color": "#1a1a1a",
"theme_color": "#1a1a1a",
"orientation": "portrait",
"icons": [
{
"src": "/static/icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/static/icon-512.png",
"sizes": "512x512",
"type": "image/png"
}
]
}
+878
View File
@@ -0,0 +1,878 @@
<!DOCTYPE html>
<html>
<head>
<title>User Management</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="title" content="AIOStremio">
<meta name="description" content="A private AIOStremio instance.
https://github.com/Viren070/AIOStremio">
<meta name="keywords" content="aiostremio">
<meta name="robots" content="noindex, nofollow">
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<meta name="language" content="English">
<style>
@media (prefers-color-scheme: dark) {
:root {
--bg-color: #1a1a1a;
--text-color: #ffffff;
--input-bg: #333;
--border-color: #444;
--stats-bg: #242424;
--button-bg: #2563eb;
--button-hover: #1d4ed8;
--delete-button: #dc2626;
--delete-button-hover: #b91c1c;
--dropdown-bg: #242424;
--success-button: #059669;
--success-button-hover: #047857;
}
}
@media (prefers-color-scheme: light) {
:root {
--bg-color: #ffffff;
--text-color: #1a1a1a;
--input-bg: #ffffff;
--border-color: #e5e7eb;
--stats-bg: #f8f9fa;
--button-bg: #2563eb;
--button-hover: #1d4ed8;
--delete-button: #dc2626;
--delete-button-hover: #b91c1c;
--dropdown-bg: #ffffff;
--success-button: #059669;
--success-button-hover: #047857;
}
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: system-ui, -apple-system, sans-serif;
max-width: 1200px;
margin: 0 auto;
padding: 20px;
background: var(--bg-color);
color: var(--text-color);
line-height: 1.5;
transition: background-color 0.3s, color 0.3s;
}
.form-group {
margin-bottom: 1.5rem;
}
label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
}
input, select {
padding: 0.75rem;
width: 100%;
max-width: 400px;
border: 1px solid var(--border-color);
border-radius: 0.5rem;
background: var(--input-bg);
color: var(--text-color);
font-size: 1rem;
transition: border-color 0.3s;
}
.checkbox-group {
display: flex;
align-items: center;
gap: 0.5rem;
margin: 0.5rem 0;
}
.checkbox-group input[type="checkbox"] {
width: auto;
margin: 0;
cursor: pointer;
}
.checkbox-group label {
margin: 0;
cursor: pointer;
user-select: none;
font-weight: normal;
}
input:focus, select:focus {
outline: none;
border-color: var(--button-bg);
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
}
button {
padding: 0.75rem 1.5rem;
background-color: var(--button-bg);
color: white;
border: none;
border-radius: 0.5rem;
cursor: pointer;
font-size: 1rem;
font-weight: 500;
transition: background-color 0.3s, transform 0.1s;
line-height: 1;
box-sizing: border-box;
}
button:hover {
background-color: var(--button-hover);
}
button:active {
transform: translateY(1px);
}
.stats {
margin: 2rem 0;
padding: 1.5rem;
background-color: var(--stats-bg);
border: 1px solid var(--border-color);
border-radius: 0.75rem;
}
.user-list {
margin-top: 3rem;
}
.user-list ul {
list-style: none;
padding: 0;
}
.user-list li {
padding: 1.5rem;
border: 1px solid var(--border-color);
border-radius: 0.75rem;
margin-bottom: 1rem;
background: var(--stats-bg);
}
.recent-history {
margin-top: 1rem;
padding: 0;
background: var(--bg-color);
border-radius: 0.75rem;
border: 1px solid var(--border-color);
display: none;
width: 100%;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
overflow: hidden;
}
.recent-history.show {
display: block;
animation: slideDown 0.2s ease-out;
}
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.history-list {
list-style: none;
padding: 1rem;
margin: 0;
max-height: 400px;
overflow-y: auto;
background: var(--bg-color);
scrollbar-width: thin;
scrollbar-color: var(--border-color) transparent;
}
.history-list::-webkit-scrollbar {
width: 6px;
}
.history-list::-webkit-scrollbar-track {
background: transparent;
}
.history-list::-webkit-scrollbar-thumb {
background-color: var(--border-color);
border-radius: 3px;
}
.history-toggle {
background: var(--button-bg);
color: white;
border: none;
border-radius: 0.5rem;
cursor: pointer;
font-size: 1rem;
transition: all 0.2s ease;
flex: 1 1 0;
min-width: 150px;
margin: 0;
padding: 0.75rem 1.5rem;
}
.history-toggle:hover {
background-color: var(--button-hover);
}
.history-toggle:active {
transform: translateY(1px);
}
.history-item {
padding: 1rem;
margin-bottom: 1rem;
border: 1px solid var(--border-color);
border-radius: 0.5rem;
font-size: 0.9rem;
transition: background-color 0.2s ease;
}
.history-item:last-child {
margin-bottom: 0;
}
.history-item:hover {
background-color: var(--stats-bg);
}
.history-title-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
width: 100%;
}
.history-title {
font-weight: 500;
color: var(--text-color);
flex: 1;
}
.history-episode {
background: var(--button-bg);
color: white;
padding: 0.2rem 0.5rem;
border-radius: 0.25rem;
font-size: 0.8rem;
font-weight: 600;
letter-spacing: 0.02em;
margin-left: auto;
flex-shrink: 0;
}
.history-time {
color: var(--text-color);
opacity: 0.6;
font-size: 0.8rem;
margin-top: 0.25rem;
display: block;
}
.username {
font-size: 1.5rem;
font-weight: 600;
margin-bottom: 1rem;
color: var(--text-color);
display: block;
}
.user-actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
}
.user-actions > button,
.user-actions > .services-dropdown > button {
flex: 1 1 0;
min-width: 120px;
height: 42px;
padding: 0 1.5rem;
display: flex;
align-items: center;
justify-content: center;
}
/* History and addon buttons */
.user-actions > [id^="history-btn-"],
.user-actions > .services-dropdown {
flex-basis: calc(50% - 0.25rem);
order: 2;
margin-top: 0.5rem;
}
/* Main action buttons */
.user-actions > button:not([id^="history-btn-"]):not(:last-child) {
order: 1;
}
.user-actions > button:last-child {
order: 1;
}
.services-dropdown {
position: relative;
display: inline-block;
margin: 0;
flex: 1 1 0;
}
.services-dropdown button {
width: 100%;
}
.manage-user-btn {
display: none;
}
@media (max-width: 768px) {
.user-list li {
display: block;
}
.user-actions {
display: none;
}
.user-actions.show {
display: flex;
flex-direction: column;
gap: 0.75rem;
margin-top: 1rem;
}
.user-actions > button,
.user-actions > .services-dropdown {
flex: 1 1 auto;
min-width: 100%;
order: unset;
margin-top: 0;
flex-basis: auto;
height: 42px;
}
.user-actions > button {
min-height: 42px;
display: flex;
align-items: center;
justify-content: center;
}
.username {
text-align: center;
margin-bottom: 1.25rem;
}
.manage-user-btn {
display: flex !important;
width: 100%;
margin-top: 1rem;
background-color: var(--button-bg);
color: white;
border: none;
border-radius: 0.5rem;
padding: 0.75rem 1.5rem;
font-size: 1rem;
font-weight: 500;
cursor: pointer;
align-items: center;
justify-content: center;
}
.manage-user-btn:hover {
background-color: var(--button-hover);
}
}
.services-content {
display: none;
position: absolute;
background-color: var(--dropdown-bg);
min-width: 200px;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
border-radius: 0.5rem;
padding: 1rem;
z-index: 1;
border: 1px solid var(--border-color);
right: 0;
}
.services-content.show {
display: block;
}
.services-content label {
display: flex;
align-items: center;
margin: 0.5rem 0;
cursor: pointer;
}
.services-content input[type="checkbox"] {
width: auto;
margin-right: 0.5rem;
}
button[style*="background-color: #dc3545"] {
background-color: var(--delete-button) !important;
}
button[style*="background-color: #dc3545"]:hover {
background-color: var(--delete-button-hover) !important;
}
button[style*="background-color: #28a745"] {
background-color: var(--success-button) !important;
}
button[style*="background-color: #28a745"]:hover {
background-color: var(--success-button-hover) !important;
}
@media (max-width: 480px) {
body {
padding: 1rem;
}
.services-content {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 90%;
max-width: 300px;
max-height: 80vh;
overflow-y: auto;
}
}
</style>
</head>
<body>
<h1>User Management</h1>
<form id="userForm">
<div class="form-group">
<label for="username">Username:</label>
<input type="text" id="username" name="username" required>
</div>
<div class="form-group">
<label for="password">Password:</label>
<input type="password" id="password" name="password" required>
</div>
<div class="form-group">
<label>Settings:</label>
<div class="checkbox-group">
<input type="checkbox" id="proxy_streams" name="proxy_streams" checked>
<label for="proxy_streams">Enable Proxy Streams</label>
<small style="display: block; color: var(--text-color); opacity: 0.7; margin-left: 1.8rem;">Encrypts URLs and proxies the stream to hide API keys and bypass IP restrictions</small>
</div>
<div class="checkbox-group">
<input type="checkbox" id="vidi_mode" name="vidi_mode">
<label for="vidi_mode">Enable Vidi Mode</label>
<small style="display: block; color: var(--text-color); opacity: 0.7; margin-left: 1.8rem;">Shows the addon name in the description</small>
</div>
<div class="checkbox-group">
<input type="checkbox" id="simple_format" name="simple_format">
<label for="simple_format">Enable Simple Format</label>
<small style="display: block; color: var(--text-color); opacity: 0.7; margin-left: 1.8rem;">Hides confusing torrent filenames and shows a easy to understand description</small>
</div>
<div class="checkbox-group">
<input type="checkbox" id="one_per_quality" name="one_per_quality">
<label for="one_per_quality">Best Per Resolution</label>
<small style="display: block; color: var(--text-color); opacity: 0.7; margin-left: 1.8rem;">Shows the best available stream for each resolution and hides the rest</small>
</div>
<div class="checkbox-group">
<input type="checkbox" id="cached_only" name="cached_only">
<label for="cached_only">Cached Content Only</label>
<small style="display: block; color: var(--text-color); opacity: 0.7; margin-left: 1.8rem;">Shows only cached content that is immediately available</small>
</div>
</div>
<button type="submit">Add User</button>
</form>
<div class="user-list">
<ul>
{% for username, details in users.items() %}
<li data-username="{{ username }}">
<span class="username">{{ username }}{% if user_last_active[username] != "never" %} (Active {{ user_last_active[username] }}){% endif %}</span>
<button class="manage-user-btn" onclick="toggleUserActions('{{ username }}')">Manage User</button>
<div class="user-actions">
{% if user_histories[username] %}
<button onclick="toggleHistory('{{ username }}')" id="history-btn-{{ username }}">
Show History
</button>
{% endif %}
<button onclick="toggleProxy('{{ username }}')" id="proxy-btn-{{ username }}">
Proxy: {{ 'On' if details.proxy_streams else 'Off' }}
</button>
<button onclick="toggleVidiMode('{{ username }}')" id="vidi-btn-{{ username }}">
Vidi Mode: {{ 'On' if details.vidi_mode else 'Off' }}
</button>
<button onclick="toggleSimpleFormat('{{ username }}')" id="simple-btn-{{ username }}">
Simple Format: {{ 'On' if details.simple_format else 'Off' }}
</button>
<button onclick="toggleOnePerQuality('{{ username }}')" id="quality-btn-{{ username }}">
Best Per Resolution: {{ 'On' if details.one_per_quality else 'Off' }}
</button>
<button onclick="toggleCachedOnly('{{ username }}')" id="cached-btn-{{ username }}">
Cached Only: {{ 'On' if details.cached_only else 'Off' }}
</button>
<button onclick="deleteUser('{{ username }}')" style="background-color: #dc3545;">Delete User</button>
<div class="services-dropdown">
<button type="button" onclick="toggleServices('{{ username }}')" style="background-color: #28a745;">Toggle Addons</button>
<div id="services-{{ username }}" class="services-content">
<form id="services-form-{{ username }}" onsubmit="return false;">
<div id="services-checkboxes-{{ username }}">
<!-- Services will be populated by JavaScript -->
<p>Loading services...</p>
</div>
<button type="submit" style="margin-top: 10px;">Save Services</button>
</form>
</div>
</div>
</div>
{% if user_histories[username] %}
<div class="recent-history" id="history-{{ username }}">
<ul class="history-list">
{% for entry in user_histories[username][:25] %}
<li class="history-item">
<div class="history-title-row">
<span class="history-title">
{{ entry.get('title', 'Unknown Title') }}
{% if entry.get('type') == 'series' and entry.get('season') and entry.get('episode') %}
<span class="history-episode">S{{ entry.get('season') }}E{{ entry.get('episode') }}</span>
{% elif entry.get('type') == 'movie' %}
<span class="history-episode">MOVIE</span>
{% endif %}
</span>
</div>
<span class="history-time">{{ entry.get('timestamp', 'Unknown') }}</span>
</li>
{% endfor %}
</ul>
</div>
{% endif %}
</li>
{% endfor %}
</ul>
</div>
<script>
let availableServices = [];
// Fetch available services when page loads
async function fetchAvailableServices() {
try {
const response = await fetch('/admin/available_services');
if (!response.ok) {
throw new Error(`Failed to fetch services: ${response.status} ${response.statusText}`);
}
const data = await response.json();
console.log('Available services response:', data);
if (!data.services || !Array.isArray(data.services)) {
console.error('Invalid services data format:', data);
throw new Error('Invalid services data format received from server');
}
availableServices = data.services;
console.log('Processed available services:', availableServices);
if (availableServices.length === 0) {
console.warn('No services available from server');
}
// Initialize service checkboxes for all users
const users = document.querySelectorAll('.services-dropdown');
console.log('Found users to process:', users.length);
users.forEach(user => {
const userElement = user.closest('li');
if (!userElement) {
console.warn('Could not find li element for user dropdown');
return;
}
const username = userElement.getAttribute('data-username');
if (!username) {
console.warn('Could not find username in data attribute');
return;
}
console.log('Processing services for user:', username);
populateServiceCheckboxes(username);
});
} catch (error) {
console.error('Error in fetchAvailableServices:', error);
// Display error in UI
const users = document.querySelectorAll('.services-dropdown');
users.forEach(user => {
const container = user.querySelector('.services-content');
if (container) {
container.innerHTML = `<p style="color: red;">Error loading services: ${error.message}</p>`;
}
});
}
}
function populateServiceCheckboxes(username) {
const container = document.getElementById(`services-checkboxes-${username}`);
if (!container) {
console.error(`Container lookup failed for user ${username}. Full element ID: services-checkboxes-${username}`);
return;
}
fetch(`/admin/user_services/${username}`)
.then(response => {
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.json();
})
.then(data => {
const userServices = data.enabled_services || [];
if (availableServices.length === 0) {
container.innerHTML = '<p>No services available</p>';
return;
}
container.innerHTML = `
<p style="font-style: italic; color: #666; margin-bottom: 8px;">Note: If no addons are selected they will all be enabled.</p>
${availableServices.map(service => `
<label>
<input type="checkbox" name="services" value="${service}"
${userServices.includes(service) ? 'checked' : ''}>
${service}
</label>
`).join('')}`;
})
.catch(error => {
console.error(`Error loading services for ${username}:`, error);
container.innerHTML = `<p style="color: red;">Error loading services: ${error.message}</p>`;
});
}
function toggleServices(username) {
const dropdown = document.getElementById(`services-${username}`);
dropdown.classList.toggle('show');
}
window.onclick = function(event) {
if (!event.target.matches('.services-dropdown button') &&
!event.target.matches('.services-content *')) {
const dropdowns = document.getElementsByClassName('services-content');
for (const dropdown of dropdowns) {
if (dropdown.classList.contains('show')) {
dropdown.classList.remove('show');
}
}
}
}
document.addEventListener('submit', async function(e) {
if (e.target.id.startsWith('services-form-')) {
e.preventDefault();
const username = e.target.id.replace('services-form-', '');
const formData = new FormData(e.target);
const services = formData.getAll('services');
try {
const response = await fetch(`/admin/update_services/${username}`, {
method: 'POST',
body: formData
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const result = await response.json();
if (result.status === 'success') {
alert('Services updated successfully');
toggleServices(username); // Close the dropdown
} else {
throw new Error(result.message || 'Failed to update services');
}
} catch (error) {
console.error(`Error updating services for ${username}:`, error);
alert(`Error updating services: ${error.message}`);
}
}
});
fetchAvailableServices();
document.getElementById('userForm').addEventListener('submit', async (e) => {
e.preventDefault();
const formData = new FormData(e.target);
try {
const response = await fetch('/admin/add_user', {
method: 'POST',
body: formData
});
const result = await response.json();
if (response.ok) {
alert(result.message);
if (result.status === 'success') {
location.reload();
}
} else {
alert(result.detail || 'Error adding user');
}
} catch (error) {
alert('Network error occurred while adding user');
}
});
async function toggleProxy(username) {
try {
const response = await fetch(`/admin/toggle_proxy/${username}`, {
method: 'POST'
});
const result = await response.json();
if (response.ok && result.status === 'success') {
const proxyButton = document.getElementById(`proxy-btn-${username}`);
proxyButton.textContent = `Proxy: ${result.proxy_streams ? 'On' : 'Off'}`;
} else {
alert(result.detail || 'Error toggling proxy setting');
}
} catch (error) {
console.error(`Error toggling proxy for ${username}:`, error);
alert('Network error occurred while toggling proxy setting');
}
}
async function toggleVidiMode(username) {
try {
const response = await fetch(`/admin/toggle_vidi_mode/${username}`, {
method: 'POST'
});
const result = await response.json();
if (response.ok && result.status === 'success') {
const vidiButton = document.getElementById(`vidi-btn-${username}`);
vidiButton.textContent = `Vidi Mode: ${result.vidi_mode ? 'On' : 'Off'}`;
} else {
alert(result.detail || 'Error toggling Vidi mode');
}
} catch (error) {
console.error(`Error toggling Vidi mode for ${username}:`, error);
alert('Network error occurred while toggling Vidi mode');
}
}
async function toggleSimpleFormat(username) {
try {
const response = await fetch(`/admin/toggle_simple_format/${username}`, {
method: 'POST'
});
const result = await response.json();
if (response.ok && result.status === 'success') {
const simpleButton = document.getElementById(`simple-btn-${username}`);
simpleButton.textContent = `Simple Format: ${result.simple_format ? 'On' : 'Off'}`;
} else {
alert(result.detail || 'Error toggling simple format');
}
} catch (error) {
console.error(`Error toggling simple format for ${username}:`, error);
alert('Network error occurred while toggling simple format');
}
}
async function toggleOnePerQuality(username) {
try {
const response = await fetch(`/admin/toggle_one_per_quality/${username}`, {
method: 'POST'
});
const result = await response.json();
if (response.ok && result.status === 'success') {
const qualityButton = document.getElementById(`quality-btn-${username}`);
qualityButton.textContent = `Best Per Resolution: ${result.one_per_quality ? 'On' : 'Off'}`;
} else {
alert(result.detail || 'Error toggling quality setting');
}
} catch (error) {
console.error(`Error toggling one per quality for ${username}:`, error);
alert('Network error occurred while toggling quality setting');
}
}
async function toggleCachedOnly(username) {
try {
const response = await fetch(`/admin/toggle_cached_only/${username}`, {
method: 'POST'
});
const result = await response.json();
if (response.ok && result.status === 'success') {
const cachedButton = document.getElementById(`cached-btn-${username}`);
cachedButton.textContent = `Cached Only: ${result.cached_only ? 'On' : 'Off'}`;
} else {
alert(result.detail || 'Error toggling cached only setting');
}
} catch (error) {
console.error(`Error toggling cached only for ${username}:`, error);
alert('Network error occurred while toggling cached only setting');
}
}
async function deleteUser(username) {
if (!confirm(`Are you sure you want to delete user "${username}"?`)) {
return;
}
try {
const response = await fetch(`/admin/delete_user/${username}`, {
method: 'DELETE'
});
const result = await response.json();
if (response.ok && result.status === 'success') {
alert('User deleted successfully');
location.reload();
} else {
alert(result.detail || 'Failed to delete user');
}
} catch (error) {
console.error(`Error deleting user ${username}:`, error);
alert('Network error occurred while deleting user');
}
}
function toggleHistory(username) {
const historyDiv = document.getElementById(`history-${username}`);
const toggleButton = document.getElementById(`history-btn-${username}`);
if (historyDiv.classList.contains('show')) {
historyDiv.classList.remove('show');
toggleButton.textContent = 'Show History';
} else {
historyDiv.classList.add('show');
toggleButton.textContent = 'Hide History';
}
}
function toggleUserActions(username) {
const userActions = document.querySelector(`li[data-username="${username}"] .user-actions`);
const manageButton = document.querySelector(`li[data-username="${username}"] .manage-user-btn`);
if (userActions.classList.contains('show')) {
userActions.classList.remove('show');
manageButton.textContent = 'Manage User';
} else {
userActions.classList.add('show');
manageButton.textContent = 'Hide Actions';
}
}
</script>
</body>
</html>
+372
View File
@@ -0,0 +1,372 @@
<!DOCTYPE html>
<html>
<head>
<title>AIOStremio</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="title" content="AIOStremio">
<meta name="description" content="A private AIOStremio instance.
https://github.com/Viren070/AIOStremio">
<meta name="keywords" content="aiostremio">
<meta name="robots" content="noindex, nofollow">
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<meta name="language" content="English">
<style>
@media (prefers-color-scheme: dark) {
:root {
--bg-color: #1a1a1a;
--text-color: #ffffff;
--input-bg: #333;
--border-color: #444;
--stats-bg: #242424;
--button-bg: #2563eb;
--button-hover: #1d4ed8;
--notice-bg: #854d0f;
--notice-border: #a16207;
--notice-text: #fef3c7;
--result-bg: #242424;
}
}
@media (prefers-color-scheme: light) {
:root {
--bg-color: #ffffff;
--text-color: #1a1a1a;
--input-bg: #ffffff;
--border-color: #e5e7eb;
--stats-bg: #f8f9fa;
--button-bg: #2563eb;
--button-hover: #1d4ed8;
--notice-bg: #fef3c7;
--notice-border: #fcd34d;
--notice-text: #854d0f;
--result-bg: #f8f9fa;
}
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: system-ui, -apple-system, sans-serif;
max-width: 800px;
margin: 0 auto;
padding: 20px;
background: var(--bg-color);
color: var(--text-color);
line-height: 1.5;
transition: background-color 0.3s, color 0.3s;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
}
.admin-button {
padding: 0.5rem 1rem;
background-color: var(--button-bg);
color: white;
border: none;
border-radius: 0.5rem;
cursor: pointer;
font-size: 0.9rem;
font-weight: 500;
text-decoration: none;
transition: background-color 0.3s, transform 0.1s;
white-space: nowrap;
margin-left: 1rem;
}
.admin-button:hover {
background-color: var(--button-hover);
}
h1 {
margin-bottom: 2rem;
font-size: 2rem;
font-weight: 700;
}
.form-group {
margin-bottom: 1.5rem;
}
label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
}
input {
padding: 0.75rem;
width: 100%;
max-width: 400px;
border: 1px solid var(--border-color);
border-radius: 0.5rem;
background: var(--input-bg);
color: var(--text-color);
font-size: 1rem;
transition: border-color 0.3s;
}
input:focus {
outline: none;
border-color: var(--button-bg);
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
}
button {
padding: 0.75rem 1.5rem;
background-color: var(--button-bg);
color: white;
border: none;
border-radius: 0.5rem;
cursor: pointer;
font-size: 1rem;
font-weight: 500;
transition: background-color 0.3s, transform 0.1s;
}
button:hover {
background-color: var(--button-hover);
}
button:not(.toggle-password):active {
transform: translateY(1px);
}
.result {
margin-top: 2rem;
padding: 1.5rem;
background-color: var(--result-bg);
border: 1px solid var(--border-color);
border-radius: 0.75rem;
display: none;
}
.result pre {
word-wrap: break-word;
white-space: pre-wrap;
padding: 1rem;
background: var(--bg-color);
border-radius: 0.5rem;
border: 1px solid var(--border-color);
color: var(--text-color);
}
.stats {
margin: 2rem 0;
padding: 1.5rem;
background-color: var(--stats-bg);
border: 1px solid var(--border-color);
border-radius: 0.75rem;
}
.notice {
margin: 2rem 0;
padding: 1.5rem;
background-color: var(--notice-bg);
border: 1px solid var(--notice-border);
color: var(--notice-text);
font-weight: 500;
text-align: center;
border-radius: 0.75rem;
}
.notice a {
color: inherit;
text-decoration: underline;
}
.notice a:hover {
text-decoration: none;
}
@media (max-width: 480px) {
body {
padding: 1rem;
}
h1 {
font-size: 1.75rem;
}
.notice, .stats, .result {
margin: 1.5rem 0;
padding: 1rem;
}
}
.config-url-container {
position: relative;
display: flex;
align-items: stretch;
overflow-x: auto;
max-width: 100%;
}
.config-url-container pre {
flex-grow: 1;
margin: 0;
white-space: pre-wrap;
word-break: break-all;
word-wrap: break-word;
padding: 8px 42px 8px 10px;
}
.toggle-password {
position: absolute;
right: 10px;
top: 50%;
transform: translateY(-50%);
background: #374151;
border: none;
padding: 0;
cursor: pointer;
color: #9ca3af;
transition: all 0.3s;
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: 6px;
line-height: 1;
}
.toggle-password.active {
background: var(--button-bg);
color: white;
}
.toggle-password:hover {
opacity: 0.9;
}
.eye-icon {
font-size: 16px;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
}
</style>
</head>
<body>
<div class="header">
<h1>AIOStremio</h1>
<a href="/admin" class="admin-button">Admin Panel</a>
</div>
<div class="notice">
<p>Invite-only page. Ask for access or buy TorBox <a href="https://torbox.app/subscription?referral=fe897519-fa8d-402d-bdb6-15570c60eff2">here</a>.</p>
</div>
<form id="configForm">
<div class="form-group">
<label for="username">Username:</label>
<input type="text" id="username" name="username" required>
</div>
<div class="form-group">
<label for="password">Password:</label>
<input type="password" id="password" name="password" required>
</div>
<div style="display: flex; gap: 1rem;">
<button type="button" onclick="handleAddToStremio()">Add to Stremio</button>
<button type="button" onclick="handleWatchInBrowser()">Watch in Browser</button>
</div>
</form>
<div id="result" class="result">
<h3>Your Configuration URL:</h3>
<div class="config-url-container">
<pre id="configUrl"></pre>
<button id="togglePassword" class="toggle-password" title="Toggle password visibility">
<span class="eye-icon">👁️</span>
</button>
</div>
</div>
<script>
let isPasswordVisible = false;
let originalUrl = '';
function censorPassword(url) {
const censored = '•'.repeat(url.length);
return censored.match(/.{1,50}/g).join('\n');
}
function togglePasswordVisibility() {
const configUrlElement = document.getElementById('configUrl');
const toggleButton = document.getElementById('togglePassword');
isPasswordVisible = !isPasswordVisible;
configUrlElement.textContent = isPasswordVisible ? originalUrl : censorPassword(originalUrl);
toggleButton.title = isPasswordVisible ? "Hide URL" : "Show URL";
toggleButton.classList.toggle('active', isPasswordVisible);
}
document.getElementById('togglePassword').addEventListener('click', togglePasswordVisibility);
async function generateConfig() {
const username = document.getElementById('username').value;
const password = document.getElementById('password').value;
try {
const formData = new FormData();
formData.append('username', username);
formData.append('password', password);
const response = await fetch('/configure/generate', {
method: 'POST',
body: formData
});
const result = await response.json();
if (response.ok && result.status === 'success' && result.url) {
originalUrl = result.url;
return result.url;
} else {
const errorMessage = result.detail || result.message || 'Error generating configuration';
throw new Error(errorMessage);
}
} catch (error) {
console.error('Request failed:', error);
alert('Network error occurred while generating configuration');
throw error;
}
}
async function handleAddToStremio() {
try {
const url = await generateConfig();
isPasswordVisible = false;
document.getElementById('configUrl').textContent = censorPassword(url);
document.getElementById('result').style.display = 'block';
const cleanUrl = url.replace(/^https?:\/\//, '');
window.location.href = 'stremio://' + cleanUrl;
} catch (error) {
// Error already handled in generateConfig
}
}
async function handleWatchInBrowser() {
try {
const url = await generateConfig();
const userPath = new URL(url).pathname.split('/')[1];
window.location.href = `/${userPath}/watch`;
} catch (error) {
// Error already handled in generateConfig
}
}
</script>
</body>
</html>
+2248
View File
File diff suppressed because it is too large Load Diff
+94
View File
@@ -0,0 +1,94 @@
import functools
import hashlib
import inspect
import os
import pickle
from aiocache import Cache
from aiocache.serializers import PickleSerializer
from utils.logger import logger
from utils.config import config
def default_key_builder(func, *args, **kwargs):
module = func.__module__
qualname = func.__qualname__
func_name = f"{module}.{qualname}"
try:
if inspect.ismethod(func):
args = args[1:]
args_serialized = pickle.dumps(args)
kwargs_serialized = pickle.dumps(kwargs)
except pickle.PicklingError:
args_serialized = str(args).encode()
kwargs_serialized = str(kwargs).encode()
args_hash = hashlib.sha256(args_serialized).hexdigest()
kwargs_hash = hashlib.sha256(kwargs_serialized).hexdigest()
key = f"{func_name}:{args_hash}:{kwargs_hash}"
return key
cache = Cache.REDIS(
namespace="main",
endpoint=os.getenv("REDIS_HOST", "debridproxy_redis"),
port=int(os.getenv("REDIS_PORT", 6379)),
password=os.getenv("REDIS_PASSWORD"),
serializer=PickleSerializer(),
)
def cached_decorator(
ttl=config.cache_ttl_seconds, key_builder=default_key_builder, key_prefix=None, namespace=None
):
def wrapper(func):
cache_namespace = namespace or func.__name__
@functools.wraps(func)
async def wrapped(*args, **kwargs):
if key_prefix:
if callable(key_prefix):
key = key_prefix(*args, **kwargs)
else:
key = key_prefix
else:
key = key_builder(func, *args, **kwargs)
result = await cache.get(key)
if result is not None:
return result
result = await func(*args, **kwargs)
await cache.set(key, result, ttl=ttl)
return result
for attr in dir(func):
if not attr.startswith("__"):
setattr(wrapped, attr, getattr(func, attr))
return wrapped
return wrapper
async def get_cache_info():
try:
keys = await cache.raw("keys", "*")
total_size = 0
item_count = len(keys)
for key in keys:
value = await cache.raw("get", key)
total_size += len(value) if value else 0
return {
"total_size_mb": round(total_size / (1024 * 1024), 2),
"item_count": item_count,
}
except Exception as e:
logger.error(f"Error getting cache info: {str(e)}")
return {"total_size_mb": 0, "item_count": 0}
+124
View File
@@ -0,0 +1,124 @@
import json
import os
from typing import Any, Dict
class Config:
_instance = None
_config: Dict[str, Any] = {}
def __new__(cls):
if cls._instance is None:
cls._instance = super(Config, cls).__new__(cls)
cls._instance._load_config()
return cls._instance
def _load_config(self):
config_path = os.path.join(
os.path.dirname(os.path.dirname(__file__)), "config.json"
)
try:
with open(config_path, "r") as f:
self._config = json.load(f)
except Exception as e:
raise RuntimeError(f"Failed to load config.json: {str(e)}")
def get(self, *keys: str) -> Any:
"""Get a nested config value using a sequence of keys."""
value = self._config
for key in keys:
if not isinstance(value, dict):
return None
value = value.get(key)
if value is None:
return None
return value
def get_user_vidi_mode(self, username: str) -> bool:
users_path = os.path.join(
os.path.dirname(os.path.dirname(__file__)), "db/users.json"
)
try:
with open(users_path, "r") as f:
users = json.load(f)
return users.get(username, {}).get("vidi_mode", False)
except Exception:
return False
def get_user_simple_format(self, username: str) -> bool:
users_path = os.path.join(
os.path.dirname(os.path.dirname(__file__)), "db/users.json"
)
try:
with open(users_path, "r") as f:
users = json.load(f)
return users.get(username, {}).get("simple_format", False)
except Exception:
return False
def get_user_one_per_quality(self, username: str) -> bool:
users_path = os.path.join(
os.path.dirname(os.path.dirname(__file__)), "db/users.json"
)
try:
with open(users_path, "r") as f:
users = json.load(f)
return users.get(username, {}).get("one_per_quality", False)
except Exception:
return False
def get_user_cached_only(self, username: str) -> bool:
users_path = os.path.join(
os.path.dirname(os.path.dirname(__file__)), "db/users.json"
)
try:
with open(users_path, "r") as f:
users = json.load(f)
return users.get(username, {}).get("cached_only", False)
except Exception:
return False
@property
def debrid_service(self) -> str:
return self._config.get("debrid_service")
def get_addon_debrid_service(self, addon_name: str) -> str:
addon_config = self._config.get("addon_config", {}).get(addon_name, {})
service = addon_config.get("debrid_service")
return service if service else self.debrid_service
def get_addon_debrid_api_key(self, addon_name: str) -> str:
addon_config = self._config.get("addon_config", {}).get(addon_name, {})
config_key = addon_config.get("debrid_api_key")
return config_key if config_key else os.getenv("DEBRID_API_KEY")
@property
def addon_url(self) -> str:
return self._config.get("addon_url")
@property
def internal_mediaflow_url(self) -> str:
return self._config.get("mediaflow_url")
@property
def external_mediaflow_url(self) -> str:
return self._config.get("external_mediaflow_url")
@property
def mediaflow_enabled(self) -> bool:
return self._config.get("mediaflow_enabled", True)
@property
def cache_ttl_seconds(self) -> int:
return self._config.get("cache_ttl_seconds", 60)
@property
def buffer_size_mb(self) -> int:
return self._config.get("buffer_size_mb", 256)
@property
def chunk_size_mb(self) -> int:
return self._config.get("chunk_size_mb", 4)
config = Config()
+16
View File
@@ -0,0 +1,16 @@
import logging
def setup_logger():
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s - %(levelname)s - %(message)s",
handlers=[
logging.FileHandler("debridproxy.log", mode="a"),
logging.StreamHandler(),
],
)
return logging.getLogger(__name__)
logger = setup_logger()
+83
View File
@@ -0,0 +1,83 @@
import json
import os
import time
import asyncio
import aiohttp
from utils.logger import logger
from utils.config import config
from utils.service_manager import ServiceManager
from services.base import StreamingService
from typing import List
from utils.cache import cache
_caching_seasons = set()
async def cache_season(meta_id: str, services: List[StreamingService]):
try:
# Extract series ID and season from meta_id (tt1234567:1:2 for S1E2)
parts = meta_id.split(":")
if len(parts) == 3:
series_id, season, _ = parts
season_key = f"{series_id}:{season}"
if season_key in _caching_seasons:
logger.debug(f"Season {season_key} is already being cached")
return
_caching_seasons.add(season_key)
logger.info(f"Starting to cache season {season} for series {series_id}")
try:
async with aiohttp.ClientSession() as session:
cinemeta_url = f"https://v3-cinemeta.strem.io/meta/{series_id.split(':')[0]}.json"
async with session.get(cinemeta_url) as response:
if response.status == 200:
data = await response.json()
if "meta" in data and "videos" in data["meta"]:
# Filter episodes for the current season
season_episodes = [
video for video in data["meta"]["videos"]
if video.get("season") == int(season)
]
name = data["meta"]["name"]
logger.info(f"Found {len(season_episodes)} episodes in season {season} for {name}")
start_time = time.time()
# Create service manager instance
service_manager = ServiceManager(services)
for episode in season_episodes:
ep_num = episode.get("episode", 0)
if meta_id.endswith('.json'):
ep_meta_id = f"{series_id.split(':')[0]}:{season}:{ep_num}.json"
else:
ep_meta_id = f"{series_id.split(':')[0]}:{season}:{ep_num}"
ep_name = f"S{season}E{ep_num} - {episode.get('name', 'Unknown')}"
logger.info(f"Caching episode {ep_name} of {name}")
# Fetch streams using ServiceManager
try:
streams = await service_manager.fetch_all_streams(ep_meta_id)
if streams:
# Store streams in Redis cache
cache_key = f"raw_streams:{ep_meta_id}"
await cache.set(cache_key, {"streams": streams}, ttl=config.cache_ttl_seconds)
logger.info(f"Successfully cached streams for {ep_name}")
else:
logger.warning(f"No streams found for {ep_name}")
except Exception as e:
logger.error(f"Error fetching streams for {ep_name}: {str(e)}")
# Wait before next episode to avoid rate limits
await asyncio.sleep(60)
logger.info(f"Completed caching for all episodes in season {season} of {name} in {time.time() - start_time} seconds")
finally:
_caching_seasons.remove(season_key)
except Exception as e:
logger.error(f"Error in season caching: {str(e)}", exc_info=True)
+108
View File
@@ -0,0 +1,108 @@
import asyncio
import json
import os
from typing import Dict, List
from services.base import StreamingService
from utils.logger import logger
class ServiceManager:
def __init__(self, services: List[StreamingService]):
self.all_services = services
self.users_file = "db/users.json"
def _get_user_services(self, user: str) -> List[str]:
"""Get list of enabled service names for a user"""
if not os.path.exists(self.users_file):
return []
with open(self.users_file, "r") as f:
users = json.load(f)
if user not in users:
return []
return users[user].get("enabled_services", [])
async def fetch_all_streams(self, meta_id: str, user: str = None) -> List[Dict]:
"""Fetch streams from all services concurrently."""
service_streams_list = await asyncio.gather(
*[
self._fetch_service_streams(service, meta_id)
for service in self.all_services
]
)
return self._process_streams(service_streams_list)
async def _fetch_service_streams(
self, service: StreamingService, meta_id: str
) -> List[Dict]:
"""Fetch streams from a single service with error handling."""
try:
streams = await service.get_streams(meta_id)
for stream in streams:
stream["service"] = service.name
return streams
except Exception as e:
error_message = f"Error fetching streams from {service.name}:\n{str(e)}"
logger.error(error_message)
return [
{
"name": "Error",
"title": f"""{service.name}: {str(e)}""",
"url": "https://example.com/",
"service": service.name
}
]
def _process_streams(self, service_streams_list: List[List[Dict]]) -> List[Dict]:
"""Process and organize streams from all services."""
all_streams = []
error_streams = []
service_streams_map = {}
for service_streams in service_streams_list:
for stream in service_streams:
if stream.get("name") == "Error":
error_streams.append(stream)
else:
service_name = stream.get("service")
if service_name not in service_streams_map:
service_streams_map[service_name] = []
service_streams_map[service_name].append(stream)
final_streams = error_streams.copy()
# Add WatchHub streams first
if "WatchHub" in service_streams_map:
all_streams.extend(service_streams_map.pop("WatchHub"))
# First interleave cached streams
while any(service_streams_map.values()):
found_cached = False
for service_name in list(service_streams_map.keys()):
streams = service_streams_map[service_name]
if streams and streams[0].get("is_cached", False):
found_cached = True
all_streams.append(streams.pop(0))
if not streams:
del service_streams_map[service_name]
if not found_cached:
break
# Then interleave remaining uncached streams
while any(service_streams_map.values()):
for service_name in list(service_streams_map.keys()):
if service_streams_map[service_name]:
all_streams.append(service_streams_map[service_name].pop(0))
if not service_streams_map[service_name]:
del service_streams_map[service_name]
final_streams.extend(all_streams)
return final_streams
def get_enabled_services(self) -> List[str]:
"""Get list of enabled service names."""
return [service.name for service in self.all_services]
+136
View File
@@ -0,0 +1,136 @@
from typing import List, Dict, Any
from utils.config import config
from utils.url_processor import URLProcessor
from utils.video_info import VideoInfoParser
import copy
class StreamFormatter:
def __init__(self, url_processor: URLProcessor):
self.url_processor = url_processor
self.video_parser = VideoInfoParser()
async def process_streams(self, streams: List[Dict[str, Any]], user_path: str, proxy_streams: bool, meta_id: str, username: str = None) -> List[Dict[str, Any]]:
"""Process streams with URL processing and formatting."""
if not streams:
return []
regular_streams = [s for s in streams if s.get("name") != "Error"]
streams_to_return = {"streams": copy.deepcopy(regular_streams)}
await self.url_processor.process_stream_urls(
streams_to_return["streams"],
user_path,
proxy_streams,
meta_id=meta_id
)
self._process_stream_formatting(streams_to_return["streams"], username)
return streams_to_return["streams"]
def filter_streams_by_services(self, streams: List[Dict[str, Any]], enabled_services: List[str]) -> List[Dict[str, Any]]:
if not enabled_services:
return streams
return [s for s in streams if s.get("service") in enabled_services]
def vidi_format(self, streams: List[Dict[str, Any]], username: str = None) -> None:
for stream in streams:
stream_name = stream.get('name', stream['service'])
stream_name = ' '.join(stream_name.split())
if 'title' in stream and stream['title']:
stream['title'] = f"{stream_name}\n{stream['title']}"
elif 'description' in stream and stream['description']:
stream['description'] = f"{stream_name}\n{stream['description'].lstrip()}"
else:
stream['description'] = stream_name
def simple_format(self, streams: List[Dict[str, Any]], username: str = None) -> None:
for stream in streams:
info = self.video_parser.parse(stream)
formatted_info = info['formatted_description']
stream['name'] = stream.get('service', 'Unknown')
if 'title' in stream and stream['title']:
stream['title'] = formatted_info
elif 'description' in stream and stream['description']:
stream['description'] = formatted_info
else:
stream['description'] = formatted_info
def one_per_quality(self, streams: List[Dict[str, Any]], username: str = None) -> None:
"""Filter streams to keep only the best quality stream for each resolution.
The best quality is determined by:
0. Cache status (cached streams preferred)
1. HDR presence (DV > HDR10+ > HDR10 > HDR > None)
2. Codec (AV1 > H265 > VP9 > H264 > VP8 > MPEG-2 > MP4)
3. Audio quality (Atmos > TrueHD > DTS-HD > DTS > DD+ > DD > AAC > MP3)
4. File size (larger is assumed better quality)
"""
if not streams:
return
# Group streams by resolution
resolution_groups = {}
for stream in streams:
info = self.video_parser.parse(stream)
resolution = info['raw_info']['resolution']
if resolution not in resolution_groups:
resolution_groups[resolution] = []
resolution_groups[resolution].append((stream, info['raw_info']))
# Sort resolutions by quality (8K > 4K > 1080p > 720p > 480p > 360p > Unknown)
sorted_resolutions = sorted(
resolution_groups.keys(),
key=lambda x: self.video_parser.RESOLUTION_PRIORITY.get(x, -1) if x != 'Unknown' else -999,
reverse=True
)
# For each resolution, find the best quality stream
best_streams = []
for resolution in sorted_resolutions:
stream_infos = resolution_groups[resolution]
sorted_streams = sorted(stream_infos, key=lambda x: (
# Cache status
1000 if (x[0].get('cached', False) or x[1]['is_cached']) else 0,
# HDR
max([self.video_parser.HDR_PRIORITY.get(hdr, 0) for hdr in x[1]['hdr']]) if x[1]['hdr'] else 0,
# Codec
self.video_parser.CODEC_PRIORITY.get(x[1]['codec'].split()[0], 0),
# Audio
max([self.video_parser.AUDIO_PRIORITY.get(audio.split()[0], 0) for audio in x[1]['audio']]) if x[1]['audio'] else 0,
# Size (convert to bytes for comparison)
float(x[1]['size'].split()[0]) if x[1]['size'] and x[1]['size'].split()[0].replace('.', '').isdigit() else 0
), reverse=True)
if sorted_streams:
best_streams.append(sorted_streams[0][0])
streams[:] = best_streams
def _process_stream_formatting(self, streams: List[Dict[str, Any]], username: str = None) -> None:
cached_only = config.get_user_cached_only(username) if username else False
one_per_quality = config.get_user_one_per_quality(username) if username else False
simple_mode = config.get_user_simple_format(username) if username else False
vidi_mode = config.get_user_vidi_mode(username) if username else False
if not streams:
return
watchhub_streams = [s for s in streams if s.get("service") == "WatchHub"]
filtered_streams = [s for s in streams if s.get("service") != "WatchHub"]
if filtered_streams:
if cached_only:
filtered_streams = [s for s in filtered_streams if s.get("is_cached", False)]
if one_per_quality:
self.one_per_quality(filtered_streams, username)
if simple_mode:
self.simple_format(filtered_streams, username)
if vidi_mode:
self.vidi_format(filtered_streams, username)
streams[:] = watchhub_streams + filtered_streams
+117
View File
@@ -0,0 +1,117 @@
import asyncio
import io
from collections import deque
from typing import AsyncGenerator, Dict
import aiohttp
from fastapi.responses import StreamingResponse
from utils.config import config
from utils.logger import logger
class StreamManager:
def __init__(
self,
chunk_size: int = config.chunk_size_mb * 1024 * 1024,
buffer_size: int = config.buffer_size_mb * 1024 * 1024,
):
self.chunk_size = chunk_size
self.buffer_size = buffer_size
self.default_headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36",
"Accept": "*/*",
"Connection": "keep-alive",
}
async def create_streaming_response(
self, url: str, request_headers: Dict
) -> StreamingResponse:
headers = self.default_headers.copy()
if "range" in request_headers:
headers["range"] = request_headers["range"]
timeout = aiohttp.ClientTimeout(total=180)
async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.get(url, headers=headers) as response:
content_range = response.headers.get("Content-Range", "")
content_length = response.headers.get("Content-Length", "")
response_headers = {
"Accept-Ranges": "bytes",
"Content-Range": content_range if content_range else None,
"Content-Length": content_length if content_length else None,
"Connection": "keep-alive",
"Cache-Control": "no-cache",
}
response_headers = {k: v for k, v in response_headers.items() if v is not None}
media_type = response.headers.get("Content-Type", "video/mp4")
return StreamingResponse(
self._stream_content(url, headers),
media_type=media_type,
status_code=206 if "range" in request_headers else 200,
headers=response_headers,
)
async def _stream_content(
self, url: str, headers: Dict
) -> AsyncGenerator[bytes, None]:
timeout = aiohttp.ClientTimeout(total=None, connect=120, sock_read=120)
buffer = deque()
current_buffer_size = 0
buffer_low_threshold = self.buffer_size * 0.2 # 20% of buffer size
connector = aiohttp.TCPConnector(
limit=0,
ttl_dns_cache=300,
force_close=False,
enable_cleanup_closed=True,
)
async with aiohttp.ClientSession(
timeout=timeout, connector=connector
) as session:
async with session.get(url, headers=headers) as response:
async def fill_buffer():
nonlocal current_buffer_size
while True:
try:
if current_buffer_size < self.buffer_size:
chunk = await response.content.read(self.chunk_size)
if not chunk:
break
buffer.append(chunk)
current_buffer_size += len(chunk)
else:
await asyncio.sleep(0.1)
except (asyncio.TimeoutError, aiohttp.ClientError) as e:
logger.error(f"Buffer filling error: {str(e)}")
await asyncio.sleep(1)
continue
buffer_task = asyncio.create_task(fill_buffer())
try:
while not buffer:
await asyncio.sleep(0.1)
while True:
if not buffer and current_buffer_size == 0:
break
if buffer:
chunk = buffer.popleft()
current_buffer_size -= len(chunk)
yield chunk
if current_buffer_size < buffer_low_threshold:
await asyncio.sleep(0.1)
finally:
buffer_task.cancel()
try:
await buffer_task
except asyncio.CancelledError:
pass
+103
View File
@@ -0,0 +1,103 @@
import base64
import os
from typing import Dict, Optional, List
from urllib.parse import urlencode
import asyncio
import aiohttp
from cryptography.fernet import Fernet
from fastapi import HTTPException
from utils.cache import cached_decorator
from utils.config import config
from utils.logger import logger
from utils.season_cache import cache_season
from services.base import StreamingService
class URLProcessor:
def __init__(self, encryption_key: bytes):
self.fernet = Fernet(encryption_key)
self.addon_url = config.addon_url
self.mediaflow_api_key = os.getenv("MEDIAFLOW_API_KEY")
self.services = []
def set_services(self, services: List[StreamingService]):
self.services = services
async def _generate_mediaflow_url(self, url: str) -> str:
"""Generate an encrypted MediaFlow URL."""
params = {
"mediaflow_proxy_url": config.external_mediaflow_url,
"endpoint": "/proxy/stream",
"destination_url": url,
"query_params": {},
"request_headers": {
"referer": config.addon_url,
"origin": config.addon_url,
},
"response_headers": {},
"expiration": 21600,
"api_password": self.mediaflow_api_key,
}
async with aiohttp.ClientSession() as session:
async with session.post(
f"{config.internal_mediaflow_url}/generate_encrypted_or_encoded_url", json=params
) as response:
if response.status != 200:
raise HTTPException(
status_code=500, detail="Failed to generate MediaFlow URL"
)
data = await response.json()
logger.debug(f"Generated MediaFlow URL: {data['encoded_url']}")
return data["encoded_url"]
async def process_stream_urls(
self, streams: Dict[str, list], user_path: str, proxy_enabled: bool, meta_id: str = None
) -> None:
"""Process URLs in streams, encrypting them if proxy is enabled."""
# Start season caching if this is a series episode
if meta_id and ":" in meta_id and meta_id.count(":") == 2:
logger.info(f"Triggering background caching for season of {meta_id}")
asyncio.create_task(cache_season(meta_id, self.services))
for stream in streams:
if "url" in stream and proxy_enabled:
if config.mediaflow_enabled and config.external_mediaflow_url:
# Use MediaFlow URL encryption
try:
mediaflow_url = await self._generate_mediaflow_url(
stream["url"]
)
stream["url"] = mediaflow_url
except Exception as e:
logger.error(f"Failed to generate MediaFlow URL: {str(e)}")
streams.remove(stream)
continue
else:
encrypted_url = self.fernet.encrypt(stream["url"].encode()).decode()
safe_encrypted_url = base64.urlsafe_b64encode(
encrypted_url.encode()
).decode()
proxy_url = (
f"{self.addon_url}/{user_path}/proxy/{safe_encrypted_url}"
)
logger.debug(f"Generated proxy URL: {proxy_url}")
stream["url"] = proxy_url
def decrypt_url(self, encrypted_url: str) -> str:
"""Decrypt an encrypted URL."""
try:
# Add padding if needed
padding_needed = len(encrypted_url) % 4
if padding_needed:
encrypted_url += "=" * (4 - padding_needed)
decoded_url = base64.urlsafe_b64decode(encrypted_url.encode()).decode()
original_url = self.fernet.decrypt(decoded_url.encode()).decode()
return original_url
except Exception as e:
logger.error(f"URL processing error: {str(e)}")
raise HTTPException(status_code=400, detail="Invalid URL format")
+386
View File
@@ -0,0 +1,386 @@
import re
from typing import Dict, Optional
class VideoInfoParser:
CODEC_MAP = {
'HEVC': 'H265', 'X265': 'H265', 'H265': 'H265', 'H.265': 'H265',
'AVC': 'H264', 'X264': 'H264', 'H264': 'H264', 'H.264': 'H264',
'VP9': 'VP9', 'VP8': 'VP8',
'AV1': 'AV1',
'MPEG-4': 'MP4', 'MPEG-2': 'MPEG-2'
}
LANGUAGE_MAP = {
'eng': '🇬🇧', 'english': '🇬🇧', 'en': '🇬🇧', 'ingles': '🇬🇧', 'anglais': '🇬🇧', 'английский': '🇬🇧', 'engels': '🇬🇧', 'englisch': '🇬🇧',
'spa': '🇪🇸', 'spanish': '🇪🇸', 'es': '🇪🇸', 'latino': '🇪🇸', 'español': '🇪🇸', 'castellano': '🇪🇸', 'espanol': '🇪🇸', 'lat': '🇪🇸', 'латинский': '🇪🇸', 'latinoamericano': '🇪🇸', 'hispano': '🇪🇸',
'fre': '🇫🇷', 'french': '🇫🇷', 'fr': '🇫🇷', 'français': '🇫🇷', 'francais': '🇫🇷', 'французский': '🇫🇷', 'franz': '🇫🇷', 'vf': '🇫🇷', 'vff': '🇫🇷',
'ger': '🇩🇪', 'german': '🇩🇪', 'de': '🇩🇪', 'deutsch': '🇩🇪', 'немецкий': '🇩🇪', 'deu': '🇩🇪', 'deutsche': '🇩🇪',
'ita': '🇮🇹', 'italian': '🇮🇹', 'it': '🇮🇹', 'italiano': '🇮🇹', 'итальянский': '🇮🇹', 'ital': '🇮🇹',
'rus': '🇷🇺', 'russian': '🇷🇺', 'ru': '🇷🇺', 'русский': '🇷🇺', 'pусский': '🇷🇺', 'руc': '🇷🇺', 'россия': '🇷🇺',
'jpn': '🇯🇵', 'japanese': '🇯🇵', 'ja': '🇯🇵', '日本語': '🇯🇵', 'японский': '🇯🇵', 'jap': '🇯🇵', 'japan': '🇯🇵',
'kor': '🇰🇷', 'korean': '🇰🇷', 'ko': '🇰🇷', '한국어': '🇰🇷', 'корейский': '🇰🇷', 'kor': '🇰🇷', 'korea': '🇰🇷',
'chi': '🇨🇳', 'chinese': '🇨🇳', 'zh': '🇨🇳', '中文': '🇨🇳', 'китайский': '🇨🇳', 'mandarin': '🇨🇳', 'cn': '🇨🇳', 'hans': '🇨🇳', 'hant': '🇨🇳', 'cantonese': '🇨🇳',
'hin': '🇮🇳', 'hindi': '🇮🇳', 'hi': '🇮🇳', 'हिन्दी': '🇮🇳', 'хинди': '🇮🇳', 'हिंदी': '🇮🇳',
'por': '🇵🇹', 'portuguese': '🇵🇹', 'pt': '🇵🇹', 'português': '🇵🇹', 'portugues': '🇵🇹', 'pt-br': '🇵🇹', 'ptbr': '🇵🇹', 'brazilian': '🇵🇹', 'brasil': '🇵🇹', 'br': '🇵🇹', 'português-br': '🇵🇹',
'pol': '🇵🇱', 'polish': '🇵🇱', 'pl': '🇵🇱', 'polski': '🇵🇱', 'польский': '🇵🇱', 'polskie': '🇵🇱', 'pol': '🇵🇱',
'dut': '🇳🇱', 'dutch': '🇳🇱', 'nl': '🇳🇱', 'nederlands': '🇳🇱', 'голландский': '🇳🇱', 'flemish': '🇳🇱', 'vlaams': '🇳🇱', 'hollands': '🇳🇱',
'dan': '🇩🇰', 'danish': '🇩🇰', 'da': '🇩🇰', 'dansk': '🇩🇰', 'датский': '🇩🇰', 'dan': '🇩🇰',
'fin': '🇫🇮', 'finnish': '🇫🇮', 'fi': '🇫🇮', 'suomi': '🇫🇮', 'финский': '🇫🇮', 'suomalainen': '🇫🇮',
'nor': '🇳🇴', 'norwegian': '🇳🇴', 'no': '🇳🇴', 'norsk': '🇳🇴', 'норвежский': '🇳🇴', 'nob': '🇳🇴', 'nno': '🇳🇴',
'swe': '🇸🇪', 'swedish': '🇸🇪', 'sv': '🇸🇪', 'svenska': '🇸🇪', 'шведский': '🇸🇪', 'swe': '🇸🇪',
'tur': '🇹🇷', 'turkish': '🇹🇷', 'tr': '🇹🇷', 'türkçe': '🇹🇷', 'turkce': '🇹🇷', 'турецкий': '🇹🇷', 'turk': '🇹🇷',
'ara': '🇸🇦', 'arabic': '🇸🇦', 'ar': '🇸🇦', 'عربى': '🇸🇦', 'арабский': '🇸🇦', 'عربي': '🇸🇦', 'arab': '🇸🇦',
'tha': '🇹🇭', 'thai': '🇹🇭', 'th': '🇹🇭', 'ไทย': '🇹🇭', 'тайский': '🇹🇭', 'thai': '🇹🇭',
'vie': '🇻🇳', 'vietnamese': '🇻🇳', 'vi': '🇻🇳', 'tiếng việt': '🇻🇳', 'вьетнамский': '🇻🇳', 'tieng viet': '🇻🇳',
'ind': '🇮🇩', 'indonesian': '🇮🇩', 'id': '🇮🇩', 'bahasa': '🇮🇩', 'индонезийский': '🇮🇩', 'indo': '🇮🇩',
'ukr': '🇺🇦', 'ukrainian': '🇺🇦', 'uk': '🇺🇦', 'українська': '🇺🇦', 'украинский': '🇺🇦', 'ukr': '🇺🇦',
'heb': '🇮🇱', 'hebrew': '🇮🇱', 'he': '🇮🇱', 'עברית': '🇮🇱', 'иврит': '🇮🇱', 'heb': '🇮🇱',
'gre': '🇬🇷', 'greek': '🇬🇷', 'el': '🇬🇷', 'ελληνικά': '🇬🇷', 'греческий': '🇬🇷', 'gre': '🇬🇷'
}
QUALITY_MAP = {
'WEBDL': 'WEB-DL', 'WEB-RIP': 'WEBRip', 'WEBRIP': 'WEBRip',
'BLURAY': 'BluRay', 'BLU-RAY': 'BluRay', 'BDRIP': 'BluRay',
'HDTV': 'HDTV',
'CAMRIP': 'CAM', 'CAM-RIP': 'CAM', 'HDCAM': 'HDCAM',
'DVDRIP': 'DVDRip', 'DVD-RIP': 'DVDRip',
'TELESYNC': 'TS', 'TELE-SYNC': 'TS',
'PROPER': 'PROPER', 'REPACK': 'REPACK'
}
HDR_MAP = {
'DOLBYVISION': 'DV', 'DOLBY-VISION': 'DV', 'DV': 'DV',
'HDR10PLUS': 'HDR10+', 'HDR10+': 'HDR10+', 'HDR10P': 'HDR10+',
'HDR10': 'HDR10',
'HDR': 'HDR',
'HLG': 'HLG'
}
HDR_PRIORITY = {
'HDR': 1,
'HDR10': 2,
'HDR10+': 3,
'DV': 4
}
AUDIO_MAP = {
'DOLBYATMOS': 'Atmos', 'ATMOS': 'Atmos',
'TRUEHD': 'TrueHD', 'TRUE-HD': 'TrueHD',
'DTS-HD': 'DTS-HD', 'DTSHD': 'DTS-HD',
'DTS': 'DTS',
'DOLBYDIGITALPLUS': 'DD+', 'DOLBYDIGITAL+': 'DD+', 'DDP': 'DD+', 'EAC3': 'DD+', 'E-AC3': 'DD+',
'DOLBYDIGITAL': 'DD', 'AC3': 'DD', 'AC-3': 'DD',
'AAC': 'AAC',
'MP3': 'MP3'
}
AUDIO_PRIORITY = {
'MP3': 1,
'AAC': 2,
'DD': 3,
'DD+': 4,
'DTS': 5,
'DTS-HD': 6,
'TrueHD': 7,
'Atmos': 8
}
CODEC_PRIORITY = {
'MP4': 1,
'MPEG-2': 2,
'VP8': 3,
'H264': 4,
'VP9': 5,
'H265': 6,
'AV1': 7
}
RESOLUTION_PRIORITY = {
'360p': 1,
'480p': 2,
'720p': 3,
'1080p': 4,
'4K': 5,
'8K': 6
}
RESOLUTION_PATTERNS = [
r'(?i)\b(4320|2160|1080|720|480|360)p?\b',
r'(?i)\b(8K|4K|2K|UHD|FHD|HD|SD)\b',
]
QUALITY_PATTERNS = [
r'(?i)\b(WEB-?DL|WEB-?RIP|BLU-?RAY|HDTV|CAM-?RIP|HDCAM|DVD-?RIP)\b',
r'(?i)\b(TELESYNC|(?<![A-Z0-9])TS(?![A-Z0-9]))\b',
r'(?i)\b(PROPER|REPACK)\b',
]
CODEC_PATTERNS = [
r'(?i)\b(?:HEVC|(?:x|h)\.?265|(?:x|h)\.?264|AVC|MPEG-[24]|VP[89]|AV1)\b',
r'(?i)(10bit|10-bit|8bit|8-bit)',
]
HDR_PATTERNS = [
r'(?i)(HDR10\+|HDR10|DOLBY\s*VISION|DOLBY-?VISION|\bDV\b|HDR|HLG)',
]
AUDIO_PATTERNS = [
r'(?i)(DD\+?[257]\.1|E-?AC-?3|DDP?[257]\.1|DOLBY\s*DIGITAL\s*(?:PLUS|\+)?\s*(?:[257]\.1)?)',
r'(?i)(DTS(?:-(?:HD|X|ES|MA))?(?:\s*[257]\.1)?)',
r'(?i)(DOLBY\s*ATMOS|TRUEHD(?:\s*[257]\.1)?)',
r'(?i)(AAC(?:[257]\.1)?|MP3|AC-?3)',
r'(?i)(?<!\d)([257]\.1)(?:\s*CH(?:ANNEL)?)?(?!\.\d)',
]
LANGUAGE_PATTERNS = [
r'(?i)\b(eng(?:lish)?|spa(?:nish)?|fre(?:nch)?|ger(?:man)?|ita(?:lian)?|rus(?:sian)?|jpn|japanese|kor(?:ean)?|chi(?:nese)?|hin(?:di)?|por(?:tuguese)?|pol(?:ish)?|dut(?:ch)?|dan(?:ish)?|fin(?:nish)?|nor(?:wegian)?|swe(?:dish)?|tur(?:kish)?|ara(?:bic)?|tha(?:i)?|vie(?:tnamese)?|ind(?:onesian)?|ukr(?:ainian)?|heb(?:rew)?|gre(?:ek)?)\b',
r'(?i)(🇺🇸|🇬🇧|🇪🇸|🇫🇷|🇩🇪|🇮🇹|🇷🇺|🇯🇵|🇰🇷|🇨🇳|🇮🇳|🇵🇹|🇵🇱|🇳🇱|🇩🇰|🇫🇮|🇳🇴|🇸🇪|🇹🇷|🇸🇦|🇹🇭|🇻🇳|🇮🇩|🇺🇦|🇮🇱|🇬🇷)',
]
SIZE_PATTERNS = [
r'(?i)(?:size)?[:\s]*(\d+(?:\.\d+)?)\s*([KMGT]i?B)',
r'(?i)(\d+(?:\.\d+)?)\s*(?:GB|GiB|MB|MiB|TB|TiB|KB|KiB)',
]
@staticmethod
def clean_text(text: str) -> str:
return ' '.join(text.split())
@staticmethod
def find_pattern(text: str, patterns: list) -> Optional[str]:
for pattern in patterns:
match = re.search(pattern, text)
if match:
return match.group(0).strip()
return None
@staticmethod
def find_all_patterns(text: str, patterns: list) -> list:
matches = []
for pattern in patterns:
matches.extend(re.findall(pattern, text))
return list(set(matches))
@staticmethod
def parse_size_text(text: str) -> Optional[float]:
size_map = {'KB': 1024, 'MB': 1024**2, 'GB': 1024**3, 'TB': 1024**4}
for pattern in VideoInfoParser.SIZE_PATTERNS:
match = re.search(pattern, text)
if match:
try:
size = float(match.group(1))
unit = match.group(2)[:2].upper()
if unit in size_map:
return size * size_map[unit]
except (ValueError, IndexError):
continue
return None
@staticmethod
def format_size(size_bytes: int) -> str:
if not size_bytes:
return None
try:
size_bytes = float(size_bytes)
for unit in ['B', 'KB', 'MB', 'GB', 'TB']:
if size_bytes < 1024:
return f"{size_bytes:.2f} {unit}"
size_bytes /= 1024
return f"{size_bytes:.2f} PB"
except (ValueError, TypeError):
return None
def normalize_resolution(self, resolution: str) -> str:
if not resolution or resolution == 'Unknown':
return 'Unknown'
# Clean the input string
resolution = resolution.upper().replace(' ', '').replace('-', '')
# Convert common terms to their numeric resolution
if resolution in ['HD', 'HDRIP', 'HDRIP']:
return '720p'
if resolution in ['SD', 'SDRIP']:
return '480p'
if resolution in ['FHD']:
return '1080p'
if resolution in ['UHD', '4K']:
return '4K'
if resolution in ['8K']:
return '8K'
# Check for numeric resolutions
numeric_match = re.search(r'(?i)\b(4320|2160|1080|720|480|360)p?\b', resolution)
if numeric_match:
base = numeric_match.group(1)
if base == '4320':
return '8K'
if base == '2160':
return '4K'
return f"{base}p"
return resolution
def normalize_hdr(self, hdr: str) -> str:
if not hdr:
return None
hdr = hdr.upper().replace(' ', '')
return self.HDR_MAP.get(hdr, hdr)
def normalize_audio(self, audio: str) -> str:
if not audio or audio == 'Unknown':
return 'Unknown'
audio = audio.upper().replace(' ', '')
normalized = self.AUDIO_MAP.get(audio)
if normalized:
return normalized
channels = re.search(r'(?<!\d)([257]\.1)(?!\.\d)', audio)
if channels:
return f"{channels.group(1)} CH"
return audio
def normalize_quality(self, quality: str) -> str:
"""Convert quality to consistent format."""
if not quality or quality == 'Unknown':
return 'Unknown'
quality = quality.upper().replace(' ', '')
return self.QUALITY_MAP.get(quality, quality)
def normalize_languages(self, languages: list) -> list:
"""Convert language codes/names to flags."""
if not languages or languages == ['Unknown']:
return ['Unknown']
flags = set()
for lang in languages:
if any(x in lang.lower() for x in ['multi', 'dual', 'triple']):
continue
if any(flag in lang for flag in self.LANGUAGE_MAP.values()):
flags.add(lang)
continue
lang_lower = lang.lower()
flag = self.LANGUAGE_MAP.get(lang_lower)
if flag:
flags.add(flag)
return sorted(list(flags)) if flags else ['Unknown']
def normalize_codec(self, codec: str) -> str:
if not codec or codec == 'Unknown':
return 'Unknown'
codec = codec.upper().replace(' ', '')
# Normalize bit format first
for bit in ['10', '8', '12']:
if f'{bit}BIT' in codec or f'{bit}-BIT' in codec:
codec = codec.replace(f'{bit}BIT', '').replace(f'{bit}-BIT', '')
normalized = self.CODEC_MAP.get(codec, codec)
return f"{normalized}{' ' if normalized else ''}{bit}-bit"
return self.CODEC_MAP.get(codec, codec)
def sort_hdr_formats(self, formats: list) -> list:
return sorted(formats, key=lambda x: self.HDR_PRIORITY.get(x, 999))
def sort_audio_formats(self, formats: list) -> list:
return sorted(formats, key=lambda x: self.AUDIO_PRIORITY.get(x.split()[0], 0))
def sort_codecs(self, codecs: list) -> list:
return sorted(codecs, key=lambda x: self.CODEC_PRIORITY.get(x, 0))
def sort_resolutions(self, resolutions: list) -> list:
return sorted(resolutions, key=lambda x: self.RESOLUTION_PRIORITY.get(x, 0))
def parse(self, stream: Dict) -> Dict[str, str]:
text = ' '.join(filter(None, [
stream.get('title', ''),
stream.get('description', ''),
stream.get('torrentTitle', ''),
stream.get('behaviorHints', {}).get('filename', '')
]))
text = self.clean_text(text)
name = stream.get('name', '')
# Extract languages and resolutions from name and text
name_languages = self.normalize_languages(self.find_all_patterns(name, self.LANGUAGE_PATTERNS))
text_languages = self.normalize_languages(self.find_all_patterns(text, self.LANGUAGE_PATTERNS))
languages = list(dict.fromkeys(name_languages + text_languages))
if languages == []:
languages = ['Unknown']
name_resolutions = [self.normalize_resolution(r) for r in self.find_all_patterns(name, self.RESOLUTION_PATTERNS)]
text_resolutions = [self.normalize_resolution(r) for r in self.find_all_patterns(text, self.RESOLUTION_PATTERNS)]
resolutions = list(filter(lambda x: x != 'Unknown', dict.fromkeys(name_resolutions + text_resolutions)))
resolutions = self.sort_resolutions(resolutions)
resolution = resolutions[-1] if resolutions else 'Unknown'
size = stream.get('size', 0) or stream.get('torrentSize', 0) or \
stream.get('behaviorHints', {}).get('videoSize', 0)
if not size:
size = self.parse_size_text(text)
quality = self.normalize_quality(self.find_pattern(text, self.QUALITY_PATTERNS))
codecs = [self.normalize_codec(c) for c in self.find_all_patterns(text, self.CODEC_PATTERNS)]
codecs = list(filter(lambda x: x != 'Unknown', dict.fromkeys(codecs)))
codecs = self.sort_codecs(codecs)
codec = codecs[-1] if codecs else 'Unknown'
hdr_formats = [self.normalize_hdr(h) for h in self.find_all_patterns(text, self.HDR_PATTERNS)]
hdr_formats = list(filter(None, dict.fromkeys(hdr_formats)))
hdr_formats = self.sort_hdr_formats(hdr_formats)
audio_formats = [self.normalize_audio(a) for a in self.find_all_patterns(text, self.AUDIO_PATTERNS)]
audio_formats = list(filter(lambda x: x != 'Unknown', dict.fromkeys(audio_formats)))
audio_formats = self.sort_audio_formats(audio_formats)
info = {
'resolution': resolution,
'quality': quality,
'codec': codec,
'hdr': hdr_formats,
'audio': audio_formats,
'languages': languages,
'size': self.format_size(size) if size else None,
'is_cached': stream.get('is_cached', False)
}
description = []
if info['resolution']:
description.append(f"📺 {info['resolution']}")
if info['quality'] != 'Unknown':
description.append(f"🎞️ {info['quality']}")
if info['codec'] != 'Unknown':
description.append(f"⚙️ {info['codec']}")
if info['hdr']:
description.append(f"{', '.join(info['hdr'])}")
if info['audio']:
description.append(f"🔊 {', '.join(info['audio'])}")
if info['languages']:
display_languages = [lang for lang in info['languages'] if lang != 'Unknown']
if display_languages:
description.append(f"🎙️ {', '.join(display_languages)}")
if info['size']:
description.append(f"💾 {info['size']}")
if not info['is_cached']:
description.append("⚠️ Instant streaming unavailable")
return {
'raw_info': info,
'formatted_description': '\n'.join(description)
}