mirror of
https://github.com/Viren070/AIOStremio.git
synced 2025-12-01 23:24:19 +01:00
first commit
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
docker-data/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.env
|
||||
.git/
|
||||
.gitignore
|
||||
.pytest_cache/
|
||||
.coverage
|
||||
*.log
|
||||
@@ -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=
|
||||
@@ -0,0 +1,2 @@
|
||||
github:
|
||||
buy_me_a_coffee:
|
||||
@@ -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
@@ -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
@@ -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"]
|
||||
@@ -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.
|
||||
@@ -0,0 +1,195 @@
|
||||
# AIOStremio
|
||||
|
||||
[](https://github.com/viren070/aiostremio/commits)
|
||||
[](https://github.com/viren070/aiostremio/commits)
|
||||
[](https://discord.gg/MkCvXWjeAx)
|
||||
|
||||
[](https://stremio.com/)
|
||||
[](https://vidi.plomo.se/)
|
||||
[](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.)
|
||||
|
||||

|
||||
<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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -0,0 +1,5 @@
|
||||
from cryptography.fernet import Fernet
|
||||
|
||||
key = Fernet.generate_key()
|
||||
|
||||
print("Encryption key:", key.decode())
|
||||
@@ -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"},
|
||||
},
|
||||
},
|
||||
)
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user