This commit is contained in:
LimeDrive 2024-07-09 04:13:29 +02:00
commit 6fe4fe55f3
96 changed files with 12113 additions and 0 deletions

150
.dockerignore Normal file
View file

@ -0,0 +1,150 @@
### Python template
deploy/
.idea/
.vscode/
.git/
# 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
# PEP 582; used by e.g. github.com/David-OConnor/pyflow
__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/
# database
*.db
# poetry
poetry.toml

30
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View file

@ -0,0 +1,30 @@
---
name: Bug report
about: Create a report to help us improve
title: "[Bug]"
labels: bug
assignees: ''
---
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Your system info (please complete the following information):**
- OS: [e.g. Ubuntu]
- Browser [e.g. chrome, safari]
- Version [e.g. 22]
- Docker config ?
**Additional context**
Add any other context about the problem here.

View file

@ -0,0 +1,20 @@
---
name: Feature request
about: Suggest an idea for this project
title: "[Feature]"
labels: enhancement
assignees: ''
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any other context or screenshots about the feature request here.

38
.github/workflows/docker-develop.yml vendored Normal file
View file

@ -0,0 +1,38 @@
name: Build and Push Docker image to GitHub Packages on Dev Branch
on:
push:
branches:
- develop
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v3
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
with:
platforms: all
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Login to GitHub Container Registry
uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GHCR_PAT }}
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: source/.
push: true
platforms: linux/amd64,linux/arm64
tags: |
ghcr.io/limedrive/stremio-jackett:develop

148
.gitignore vendored Normal file
View file

@ -0,0 +1,148 @@
### Python template
.idea/
.vscode/
# 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
*.log.zip
local_settings.py
*.sqlite3
*.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
# PEP 582; used by e.g. github.com/David-OConnor/pyflow
__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/
# database
*.db
# apple
.DS_Store

18
Dockerfile Normal file
View file

@ -0,0 +1,18 @@
FROM python:3.11.9-slim-bullseye
RUN pip install poetry
WORKDIR /app
COPY pyproject.toml poetry.lock ./
RUN poetry config virtualenvs.create false \
&& poetry install --only main --no-interaction --no-ansi --no-root
COPY . .
ARG GUNICORN_PORT=8080
ENV EXPOSE_PORT=${GUNICORN_PORT}
EXPOSE ${EXPOSE_PORT}
CMD ["python", "-m", "stream_fusion"]

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 LimeCat on the Hub's
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.

165
README.md Normal file
View file

@ -0,0 +1,165 @@
# StreamFusion
## Description
StreamFusion is an advanced plugin for Stremio that significantly enhances its streaming capabilities. It leverages torrent indexers and Jackett to integrate cached torrent sources from popular debrid services, offering a smooth and comprehensive streaming experience. This application acts as a bridge between Stremio, torrent indexers, and debrid services, providing users with a wide range of content options.
## Key Features
- **Torrent Indexer Integration**: Utilizes various torrent indexers and Jackett to source content.
- **Debrid Service Integration**: Adds cached torrents from RealDebrid, AllDebrid, and Premiumize as sources for Stremio.
- **Direct Streaming**: Uses indexers for direct streaming when cached torrents are unavailable.
- **Advanced Cache Management**:
- Utilizes Redis for efficient caching.
- Community cache for sharing cached torrent searches.
- **Zilean API**: Integration of the DMM Zilean API to obtain cached torrent hashes.
- **Stream Proxification**: All streams originate from a single IP address.
- **Download Management**: Capable of managing torrent downloads on debrid services.
## Installation
[Installation instructions to be added]
## Configuration
[Configuration details to be added]
## Usage
[Usage guide to be added]
## Contributing
Contributions are welcome! Please refer to our contribution guidelines for more details.
```bash
tree -I '*.pyc' -I '__pycache__'
.
├── Dockerfile
├── README.md
├── deploy
│   └── docker-compose.yml
├── poetry.lock
├── poetry.toml
├── pyproject.toml
└── stream_fusion
├── __init__.py
├── __main__.py
├── constants.py
├── gunicorn_runner.py
├── logging_config.py
├── services
│   ├── redis
│   │   └── redis_config.py
│   └── security_db
│   ├── __init__.py
│   └── _sqlite_access.py
├── settings.py
├── static
├── templates
│   ├── config.js
│   └── index.html
├── utils
│   ├── cache
│   │   ├── cache_base.py
│   │   └── local_redis.py
│   ├── cache.py
│   ├── debrid
│   │   ├── alldebrid.py
│   │   ├── base_debrid.py
│   │   ├── get_debrid_service.py
│   │   ├── premiumize.py
│   │   └── realdebrid.py
│   ├── detection.py
│   ├── filter
│   │   ├── base_filter.py
│   │   ├── language_filter.py
│   │   ├── max_size_filter.py
│   │   ├── quality_exclusion_filter.py
│   │   ├── results_per_quality_filter.py
│   │   └── title_exclusion_filter.py
│   ├── filter_results.py
│   ├── general.py
│   ├── jackett
│   │   ├── jackett_indexer.py
│   │   ├── jackett_result.py
│   │   └── jackett_service.py
│   ├── metdata
│   │   ├── cinemeta.py
│   │   ├── metadata_provider_base.py
│   │   └── tmdb.py
│   ├── models
│   │   ├── media.py
│   │   ├── movie.py
│   │   └── series.py
│   ├── parse_config.py
│   ├── security
│   │   ├── __init__.py
│   │   ├── security_api_key.py
│   │   └── security_secret.py
│   ├── string_encoding.py
│   ├── torrent
│   │   ├── torrent_item.py
│   │   ├── torrent_service.py
│   │   └── torrent_smart_container.py
│   └── zilean
│   ├── zilean_result.py
│   └── zilean_service.py
├── version.py
├── videos
│   └── nocache.mp4
└── web
├── __init__.py
├── api
│   ├── __init__.py
│   ├── auth
│   │   ├── __init__.py
│   │   ├── schemas.py
│   │   └── views.py
│   ├── docs
│   │   ├── __init__.py
│   │   └── views.py
│   ├── monitoring
│   │   ├── __init__.py
│   │   └── views.py
│   └── router.py
├── application.py
├── lifetime.py
├── playback
│   ├── __init__.py
│   ├── router.py
│   └── stream
│   ├── __init__.py
│   ├── schemas.py
│   └── views.py
└── root
├── __init__.py
├── config
│   ├── __init__.py
│   ├── schemas.py
│   └── views.py
├── router.py
└── search
├── __init__.py
├── schemas.py
├── stremio_parser.py
└── views.py
29 directories, 81 files
```
## License
[License information to be added]
## Disclaimer
This project is intended for educational and research purposes only. Users are responsible for their use of the software and must comply with local copyright and intellectual property laws.
## Contact
[Contact information to be added]
---
StreamFusion is a project derived from Stremio-Jackett, rewritten and improved to offer a better user experience and extended functionalities.

View file

@ -0,0 +1 @@
# TODO:

2463
poetry.lock generated Normal file

File diff suppressed because it is too large Load diff

3
poetry.toml Normal file
View file

@ -0,0 +1,3 @@
[virtualenvs]
in-project = true
create = true

34
pyproject.toml Normal file
View file

@ -0,0 +1,34 @@
[tool.poetry]
name = "stream-fusion"
version = "v0.1.0"
description = "StreamFusion is an advanced plugin for Stremio that significantly enhances its streaming capabilities with debrid service."
authors = ["LimeDrive <limecat@limedrive.eu>"]
readme = "README.md"
[tool.poetry.dependencies]
python = "^3.11"
fastapi = "^0.111.0"
uvicorn = "^0.30.1"
starlette = "^0.37.2"
requests = "^2.32.3"
bencode-py = "^4.0.0"
jinja2 = "^3.1.4"
aiocron = "^1.8"
python-dotenv = "^1.0.1"
rank-torrent-name = "^0.2.21"
cachetools = "^5.3.3"
redis = "^5.0.7"
aiohttp = "^3.9.5"
gunicorn = "^22.0.0"
loguru = "^0.7.2"
stackprinter = "^0.2.12"
pydantic-settings = "^2.3.4"
sqlalchemy = "^2.0.31"
aiosqlite = "^0.20.0"
toml = "^0.10.2"
jsonpickle = "^3.2.2"
[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"

View file

@ -0,0 +1 @@
"""stream_fusion package."""

36
stream_fusion/__main__.py Normal file
View file

@ -0,0 +1,36 @@
import uvicorn
from stream_fusion.gunicorn_runner import GunicornApplication
from stream_fusion.settings import settings
def main() -> None:
"""Entrypoint of the application."""
if settings.reload:
uvicorn.run(
"stream_fusion.web.application:get_app",
workers=settings.workers_count,
host=settings.host,
port=settings.port,
reload=settings.reload,
log_level=settings.log_level.value.lower(),
factory=True,
)
else:
# We choose gunicorn only if reload
# option is not used, because reload
# feature doen't work with GUvicorn workers.
GunicornApplication(
"stream_fusion.web.application:get_app",
host=settings.host,
port=settings.port,
workers=settings.workers_count,
factory=True,
accesslog="-",
loglevel=settings.log_level.value.lower(),
access_log_format='%r "-" %s "-" %Tf',
).run()
if __name__ == "__main__":
main()

View file

@ -0,0 +1,87 @@
NO_CONFIG = {'streams': [{'url': "#", 'title': "No configuration found"}]}
JACKETT_ERROR = {'streams': [{'url': "#", 'title': "An error occured"}]}
CACHER_URL = "https://stremio-jackett-cacher.elfhosted.com/"
DMM_API_URL = "http://135.181.110.62:8181"
NO_CACHE_VIDEO_URL = "https://github.com/aymene69/stremio-jackett/raw/main/source/videos/nocache.mp4"
EXCLUDED_TRACKERS = ['0day.kiev', '1ptbar', '2 Fast 4 You', '2xFree', '3ChangTrai', '3D Torrents', '3Wmg', '4thD',
'52PT', '720pier', 'Abnormal', 'ABtorrents', 'Acid-Lounge', 'Across The Tasman', 'Aftershock',
'AGSVPT', 'Aidoru!Online', 'Aither (API)', 'AlphaRatio', 'Amigos Share Club', 'AniDUB',
'Anime-Free', 'AnimeBytes', 'AnimeLayer', 'AnimeTorrents', 'AnimeTorrents.ro', 'AnimeWorld (API)',
'AniToons', 'Anthelion (API)', 'ArabaFenice', 'ArabP2P', 'ArabTorrents', 'ArenaBG', 'AsianCinema',
'AsianDVDClub', 'Audiences', 'AudioNews', 'Aussierul.es', 'AvistaZ', 'Azusa', 'Back-ups', 'BakaBT',
'BeiTai', 'Beload', 'Best-Core', 'Beyond-HD (API)', 'Bibliotik', 'Bit-Bázis', 'BIT-HDTV', 'Bitded',
'Bithorlo', 'BitHUmen', 'BitPorn', 'Bitspyder', 'Bittorrentfiles', 'BiTTuRK', 'BJ-Share',
'BlueBird', 'Blutopia (API)', 'BookTracker', 'BootyTape', 'Borgzelle', 'Boxing Torrents',
'BrasilTracker', 'BroadcasTheNet', 'BroadCity', 'BrokenStones', 'BrSociety (API)', 'BTArg',
'BTNext', 'BTSCHOOL', 'BwTorrents', 'BYRBT', 'Carp-Hunter', 'Carpathians', 'CarPT', 'CartoonChaos',
'Cathode-Ray.Tube', 'Catorrent', 'Central Torrent', 'CeskeForum', 'CGPeers', 'CHDBits', 'cheggit',
'ChileBT', 'Cinemageddon', 'CinemaMovieS_ZT', 'Cinematik', 'CinemaZ', 'Classix', 'Coastal-Crew',
'Concertos', 'CrazySpirits', 'CrnaBerza', 'CRT2FA', 'Dajiao', 'DanishBytes (API)', 'Dark-Shadow',
'DataScene (API)', 'Deildu', 'Demonoid', 'DesiTorrents (API)', 'Devil-Torrents', 'Diablo Torrent',
'DICMusic', 'DigitalCore', 'DimeADozen', 'DiscFan', 'DivTeam', 'DocsPedia', 'Dream Tracker',
'DreamingTree', 'Drugari', 'DXP', 'Ebooks-Shares', 'Electro-Torrent', 'Empornium', 'Empornium2FA',
'EniaHD', 'Enthralled', 'Enthralled2FA', 'Erai-Raws', 'eShareNet', 'eStone', 'Ex-torrenty',
'exitorrent.org', 'ExKinoRay', 'ExoticaZ', 'ExtremeBits', 'ExtremlymTorrents',
'Falkon Vision Team', 'FANO.IN', 'Fantastiko', 'Fappaizuri', 'FastScene', 'FearNoPeer',
'Femdomcult', 'File-Tracker', 'FileList', 'FinElite', 'FinVip', 'Flux-Zone', 'Free Farm', 'FSM',
'FunFile', 'FunkyTorrents', 'FutureTorrent', 'Fuzer', 'Gamera', 'Gay-Torrents.net',
'gay-torrents.org', 'GAYtorrent.ru', 'GazelleGames', 'GazelleGames (API)', 'Generation-Free (API)',
'Genesis-Movement', 'GigaTorrents', 'GimmePeers', 'Girotorrent', 'GreatPosterWall', 'GreekDiamond',
'HaiDan', 'Haitang', 'HappyFappy', 'Hares Club', 'hawke-uno', 'HD-CLUB', 'HD-CzTorrent',
'HD-Forever', 'HD-Olimpo (API)', 'HD-Only', 'HD-Space', 'HD-Torrents', 'HD-UNiT3D (API)',
'HD4FANS', 'HDArea', 'HDAtmos', 'HDBits (API)', 'HDC', 'HDDolby', 'HDFans', 'HDFun', 'HDGalaKtik',
'HDHome', 'HDMaYi', 'HDPT', 'HDRoute', 'HDSky', 'HDtime', 'HDTorrents.it', 'HDTurk', 'HDU',
'hdvbits', 'HDVIDEO', 'Hebits', 'HellasHut', 'HellTorrents', 'HHanClub', 'HomePornTorrents',
'House of Devil', 'HQMusic', 'HunTorrent', 'iAnon', 'ICC2022', 'Il Corsaro Blu', 'ilDraGoNeRo',
'ImmortalSeed', 'Immortuos', 'Indietorrents', 'Infire', 'Insane Tracker', 'IPTorrents',
'ItaTorrents', 'JME-REUNIT3D (API)', 'JoyHD', 'JPopsuki', 'JPTV (API)', 'KamePT', 'Karagarga',
'Keep Friends', 'KIMOJI', 'Kinorun', 'Kinozal', 'Kinozal (M)', 'Korsar', 'KrazyZone', 'Kufei',
'Kufirc', 'LaidBackManor (API)', 'Last Digital Underground', 'LastFiles', 'Lat-Team (API)',
'Le-Cinephile', 'LearnBits', 'LearnFlakes', 'leech24', 'Les-Cinephiles', 'LeSaloon', 'Lesbians4u',
'Libble', 'LibraNet', 'LinkoManija', 'Locadora', 'LosslessClub', 'LostFilm.tv', 'LST',
'M-Team - TP', 'M-Team - TP (2FA)', 'MaDs Revolution', 'Magnetico (Local DHT)', 'Majomparádé',
'Making Off', 'Marine Tracker', 'Masters-TB', 'Mazepa', 'MDAN', 'MegamixTracker',
'Mendigos da WEB', 'MeseVilág', 'Metal Tracker', 'MetalGuru', 'Milkie', 'MIRCrew', 'MMA-torrents',
'MNV', 'MOJBLiNK', 'MonikaDesign (API)', 'MoreThanTV (API)', 'MouseBits', 'Movie-Torrentz',
'MovieWorld', 'MuseBootlegs', 'MVGroup Forum', 'MVGroup Main', 'MyAnonamouse', 'MySpleen', 'nCore',
'NebulanceAPI', 'NetHD', 'NewStudioL', 'NicePT', 'NoNaMe ClubL', 'NorBits', 'NORDiCHD',
'Ntelogo (API)', 'OKPT', 'Old Toons World', 'OnlyEncodes (API)', 'OpenCD', 'Orpheus', 'OshenPT',
'Ostwiki', 'OurBits', 'P2PBG', 'Panda', 'Party-Tracker', 'PassThePopcorn', 'Peeratiko', 'Peers.FM',
'PigNetwork', 'PixelCove', 'PixelCove2FA', 'PiXELHD', 'PolishSource', 'PolishTracker (API)',
'Pornbay', 'PornoLab', 'Portugas (API)', 'PotUK', 'PreToMe', 'PrivateHD', 'ProAudioTorrents',
'PTCafe', 'PTChina', 'PTerClub', 'PTFiles', 'PThome', 'PTLSP', 'PTSBAO', 'PTTime', 'PT分享站',
"Punk's Horror Tracker", 'PuntoTorrent', 'PussyTorrents', 'PuTao', 'PWTorrents', 'R3V WTF!',
'Racing4Everyone (API)', 'RacingForMe', 'Rainbow Tracker', 'RareShare2 (API)', 'Red Leaves',
'Red Star Torrent', 'Redacted', 'RedBits (API)', 'ReelFLiX (API)', 'Resurrect The Net',
'RetroFlix', 'RevolutionTT', 'RGFootball', 'RinTor', 'RiperAM', 'RM-HD', 'RockBox',
'Romanian Metal Torrents', 'Rousi', 'RPTScene', 'RUDUB', 'Rustorka', 'RuTracker', 'SATClubbing',
'SceneHD', 'SceneLinks', 'SceneRush', 'SceneTime', 'Secret Cinema', 'SeedFile', 'seleZen',
'Shadowflow', 'Shareisland (API)', 'Sharewood', 'Sharewood API', 'SharkPT', 'Shazbat', 'SiamBIT',
'SkipTheCommercials (API)', 'SkipTheTrailers', 'SkTorrent', 'SkTorrent-org', 'slosoul', 'SnowPT',
'SoulVoice', 'Speed.cd', 'SpeedApp', 'Speedmaster HD', 'SpeedTorrent Reloaded',
'Spirit of Revolution', 'SportsCult', 'SpringSunday', 'SugoiMusic', 'Superbits', 'Swarmazon (API)',
'Tapochek', 'Tasmanit', 'Team CT Game', 'TeamHD', 'TeamOS', 'TEKNO3D', 'teracod', 'The Crazy Ones',
'The Empire', 'The Falling Angels', 'The Geeks', 'The New Retro', 'The Occult',
'The Old School (API)', 'The Place', 'The Shinning (API)', 'The Show', 'The Vault', 'The-New-Fun',
'TheLeachZone', 'themixingbowl', 'TheRebels (API)', 'TheScenePlace', "Thor's Land", 'TJUPT',
'TLFBits', 'TmGHuB', 'Toca Share', 'Toloka.to', 'ToonsForMe', 'Tornado', 'Torrent Heaven',
'Torrent Network', 'Torrent Sector Crew', 'Torrent Trader', 'Torrent-Explosiv', 'Torrent-Syndikat',
'TOrrent-tuRK', 'Torrent.LT', 'TorrentBD', 'TorrentBytes', 'TorrentCCF', 'TorrentDay', 'TorrentDD',
'Torrenteros (API)', 'TorrentHeaven', 'TorrentHR', 'Torrenting', 'Torrentland',
'Torrentland (API)', 'TorrentLeech', 'Torrentleech.pl', 'TorrentMasters', 'Torrents-Local',
'TorrentSeeds (API)', 'TotallyKids', 'ToTheGlory', 'ToTheGloryCookie', 'TrackerMK',
'TranceTraffic', 'Trellas', 'TreZzoR', 'TreZzoRCookie', 'TribalMixes', 'TurkTorrent', 'TV Store',
'TVChaosUK', 'TvRoad', 'Twisted-Music', 'U2', 'UBits', 'UHDBits', 'UltraHD', 'Union Fansub',
'UnionGang', 'UniOtaku', 'Universal-Torrents', 'Unlimitz', 'upload.cx', 'UTOPIA', 'WDT',
'White Angel', 'WinterSakura', 'World-In-HD', 'World-of-Tomorrow', 'Wukong', 'x-ite.me',
'XbytesV2', 'Xider-Torrent', 'XSpeeds', 'Xthor (API)', 'xTorrenty', 'Xtreme Bytes', 'XWT-Classics',
'XWtorrents', 'YDYPT', 'YGGcookie', 'YGGtorrent', 'Zamunda.net', 'Zelka.org', 'ZmPT (织梦)',
'ZOMB', 'ZonaQ', 'Ztracker']
REDIS_HOST = 'redis'
REDIS_PORT = 6379

View file

@ -0,0 +1,86 @@
import os
from typing import Any
from gunicorn.app.base import BaseApplication
from gunicorn.util import import_app
from uvicorn.workers import UvicornWorker as BaseUvicornWorker
from stream_fusion.logging_config import configure_logging
try:
import uvloop # (Found nested import)
except ImportError:
uvloop = None # type: ignore # (variables overlap)
class UvicornWorker(BaseUvicornWorker):
"""
Configuration for uvicorn workers.
This class is subclassing UvicornWorker and defines
some parameters class-wide, because it's impossible,
to pass these parameters through gunicorn.
"""
CONFIG_KWARGS = { # (upper-case constant in a class) # noqa: RUF012
"loop": "uvloop" if uvloop is not None else "asyncio",
"http": "httptools",
"lifespan": "on",
"factory": True,
"proxy_headers": False,
}
class GunicornApplication(BaseApplication):
"""
Custom gunicorn application.
This class is used to start guncicorn
with custom uvicorn workers.
"""
def __init__(self, app: str, host: str, port: int, workers: int, **kwargs: Any):
self.options = {
"bind": f"{host}:{port}",
"workers": workers,
"worker_class": "stream_fusion.gunicorn_runner.UvicornWorker",
"logconfig_dict": {
'version': 1,
'disable_existing_loggers': False,
},
**kwargs,
}
self.app = app
super().__init__()
def load(self) -> str:
os.environ["RUNNING_UNDER_GUNICORN"] = "1"
return import_app(self.app)
def when_ready(self, server):
configure_logging()
def load_config(self) -> None:
"""
Load config for web server.
This function is used to set parameters to gunicorn
main process. It only sets parameters that
gunicorn can handle. If you pass unknown
parameter to it, it crash with error.
"""
for key, value in self.options.items():
if key in self.cfg.settings and value is not None:
self.cfg.set(key.lower(), value)
def load(self) -> str:
"""
Load actual application.
Gunicorn loads application based on this
function's returns. We return python's path to
the app's factory.
:returns: python path to app factory.
"""
return import_app(self.app)

View file

@ -0,0 +1,109 @@
# logging_config.py
from loguru import logger
from stream_fusion.settings import settings
import sys
import logging
import inspect
import re
import stackprinter
REDACTED = settings.log_redacted
patterns = [
r"/ey.*?/",
]
class SecretFilter:
def __init__(self, patterns):
self._patterns = patterns
def __call__(self, record):
record["message"] = self.redact(record["message"])
if "stack" in record["extra"]:
record["extra"]["stack"] = self.redact(record["extra"]["stack"])
return record
def redact(self, message):
for pattern in self._patterns:
message = re.sub(pattern, "**<REDACTED>**", message)
return message
def format(record):
format_ = "{time} {level} {function} {message}\n"
pats = [
r"/ey.*?/",
]
if record["exception"] is not None:
stack = stackprinter.format(
record["exception"],
suppressed_vars=[
r".*ygg_playload.*",
],
)
if REDACTED:
for pat in pats:
stack = re.sub(pat, "/**<REDACTED>**/", stack)
record["extra"]["stack"] = stack
format_ += "{extra[stack]}\n"
return format_
class InterceptHandler(logging.Handler):
def emit(self, record: logging.LogRecord) -> None:
level: str | int
try:
level = logger.level(record.levelname).name
except ValueError:
level = record.levelno
frame, depth = inspect.currentframe(), 0
while frame and (depth == 0 or frame.f_code.co_filename == logging.__file__):
frame = frame.f_back
depth += 1
logger.opt(depth=depth, exception=record.exc_info).log(
level, record.getMessage()
)
def is_running_under_gunicorn():
return "gunicorn" in sys.modules
def configure_logging():
intercept_handler = InterceptHandler()
logging.basicConfig(handlers=[intercept_handler], level=logging.NOTSET)
if is_running_under_gunicorn():
# Configuration spécifique pour Gunicorn
gunicorn_logger = logging.getLogger('gunicorn.error')
logger.remove()
logger.add(gunicorn_logger.handlers[0], format=format, level=gunicorn_logger.level)
for logger_name in logging.root.manager.loggerDict:
if logger_name.startswith("uvicorn."):
logging.getLogger(logger_name).handlers = []
# change handler for default uvicorn logger
logging.getLogger("uvicorn").handlers = [intercept_handler]
logging.getLogger("uvicorn.access").handlers = [intercept_handler]
logger.remove()
logger.add(
sys.stdout,
format=format,
level=settings.log_level.value,
colorize=True,
filter=SecretFilter(patterns) if REDACTED else None,
)
logger.add(
settings.log_path,
format=format,
level="DEBUG",
rotation="2 MB",
retention="5 days",
compression="zip",
enqueue=True,
filter=SecretFilter(patterns) if REDACTED else None,
)

View file

@ -0,0 +1,26 @@
from functools import lru_cache
from fastapi import Depends
from stream_fusion.settings import settings
from stream_fusion.utils.cache.local_redis import RedisCache
@lru_cache()
def get_redis_config():
return {
"redisHost": settings.redis_host,
"redisPort": settings.redis_port,
"redisExpiration": settings.redis_expiration,
}
@lru_cache()
def create_redis_cache():
return RedisCache(get_redis_config())
def get_redis_cache():
return create_redis_cache()
async def get_redis_cache_dependency():
redis_cache = get_redis_cache()
try:
yield redis_cache
finally:
redis_cache.close()

View file

@ -0,0 +1,4 @@
"""Security Database Service"""
from stream_fusion.services.security_db._sqlite_access import security_db_access
__all__ = ["security_db_access"]

View file

@ -0,0 +1,181 @@
"""Interaction with SQLite database."""
import sqlite3
import threading
import uuid
from datetime import datetime, timedelta
from typing import List, Optional, Tuple
from fastapi import HTTPException
from starlette.status import HTTP_404_NOT_FOUND, HTTP_422_UNPROCESSABLE_ENTITY
from stream_fusion.logging_config import logger
from stream_fusion.settings import settings
class SQLiteAccess:
"""Class handling SQLite connection and operations."""
def __init__(self):
self.db_location = settings.db_path
self.expiration_limit = getattr(settings, "security_api_key_expiration", 15)
if self.expiration_limit == 15:
logger.warning(
"Security API key expiration not found or invalid in settings. Using default value of 15 days."
)
self.init_db()
def init_db(self):
"""Initialize the database and perform necessary migrations."""
with sqlite3.connect(self.db_location) as conn:
cursor = conn.cursor()
cursor.execute(
"""
CREATE TABLE IF NOT EXISTS stream_fusion_security (
api_key TEXT PRIMARY KEY,
is_active INTEGER,
never_expire INTEGER,
expiration_date TEXT,
latest_query_date TEXT,
total_queries INTEGER,
name TEXT
)
"""
)
conn.commit()
def create_key(self, name: str, never_expire: bool) -> str:
"""Create a new API key."""
api_key = str(uuid.uuid4())
expiration_date = (
(datetime.utcnow() + timedelta(days=self.expiration_limit)).isoformat(
timespec="seconds"
)
if not never_expire
else None
)
with sqlite3.connect(self.db_location) as conn:
conn.execute(
"""
INSERT INTO stream_fusion_security (
api_key,
is_active,
never_expire,
expiration_date,
latest_query_date,
total_queries, name) VALUES (?, 1, ?, ?, NULL, 0, ?)
""",
(api_key, int(never_expire), expiration_date, name),
)
return api_key
def renew_key(self, api_key: str, new_expiration_date: Optional[str] = None) -> str:
"""Renew an existing API key."""
with sqlite3.connect(self.db_location) as conn:
cursor = conn.cursor()
cursor.execute(
"SELECT is_active, never_expire FROM stream_fusion_security WHERE api_key = ?",
(api_key,),
)
result = cursor.fetchone()
if not result:
raise HTTPException(
status_code=HTTP_404_NOT_FOUND, detail="API key not found"
)
is_active, never_expire = result
response_lines = []
if not is_active:
response_lines.append(
"This API key was revoked and has been reactivated."
)
if not never_expire:
if not new_expiration_date:
new_expiration_date = (
datetime.utcnow() + timedelta(days=self.expiration_limit)
).isoformat(timespec="seconds")
else:
try:
new_expiration_date = datetime.fromisoformat(
new_expiration_date
).isoformat(timespec="seconds")
except ValueError:
raise HTTPException(
status_code=HTTP_422_UNPROCESSABLE_ENTITY,
detail="The expiration date could not be parsed. Please use ISO 8601 format.",
)
cursor.execute(
"UPDATE stream_fusion_security SET expiration_date = ?, is_active = 1 WHERE api_key = ?",
(new_expiration_date, api_key),
)
response_lines.append(
f"The new expiration date for the API key is {new_expiration_date}"
)
return " ".join(response_lines)
def revoke_key(self, api_key: str):
"""Revoke an API key."""
with sqlite3.connect(self.db_location) as conn:
conn.execute(
"UPDATE stream_fusion_security SET is_active = 0 WHERE api_key = ?",
(api_key,),
)
def check_key(self, api_key: str) -> bool:
"""Check if an API key is valid."""
with sqlite3.connect(self.db_location) as conn:
cursor = conn.cursor()
cursor.execute(
"SELECT is_active, total_queries, expiration_date, never_expire FROM stream_fusion_security WHERE api_key = ?",
(api_key,),
)
result = cursor.fetchone()
if (
not result
or not result[0]
or (
not result[3]
and datetime.fromisoformat(result[2]) < datetime.utcnow()
)
):
return False
threading.Thread(
target=self._update_usage, args=(api_key, result[1])
).start()
return True
def _update_usage(self, api_key: str, usage_count: int):
"""Update usage statistics for an API key."""
with sqlite3.connect(self.db_location) as conn:
conn.execute(
"UPDATE stream_fusion_security SET total_queries = ?, latest_query_date = ? WHERE api_key = ?",
(
usage_count + 1,
datetime.utcnow().isoformat(timespec="seconds"),
api_key,
),
)
def get_usage_stats(self) -> List[Tuple[str, bool, bool, str, str, int, str]]:
"""Return usage statistics for all API keys."""
with sqlite3.connect(self.db_location) as conn:
cursor = conn.cursor()
cursor.execute(
"""
SELECT api_key, is_active, never_expire, expiration_date, latest_query_date, total_queries, name
FROM stream_fusion_security
ORDER BY latest_query_date DESC
"""
)
return cursor.fetchall()
security_db_access = SQLiteAccess()

70
stream_fusion/settings.py Normal file
View file

@ -0,0 +1,70 @@
import enum
from pydantic import ValidationError
from pydantic_settings import BaseSettings, SettingsConfigDict
class LogLevel(str, enum.Enum):
"""Possible log levels."""
NOTSET = "NOTSET"
DEBUG = "DEBUG"
INFO = "INFO"
WARNING = "WARNING"
ERROR = "ERROR"
FATAL = "FATAL"
class Settings(BaseSettings):
"""Settings for the application"""
# STREAM-FUSION
workers_count: int = 4
port: int = 8080
host: str = "0.0.0.0"
timeout: int = 180
reload: bool = True
# LOGGING
log_level: LogLevel = LogLevel.INFO
log_path: str = "/app/config/logs/stream-fusion.log"
log_redacted: bool = True
# SECUIRITY
secret_api_key: str = "superkey_that_can_be_changed"
security_hide_docs: bool = True
# SQLITE
db_path: str = "/app/config/stream-fusion.db"
db_echo: bool = False
db_timeout: int = 15
# REDIS
redis_host: str = "redis"
redis_port: int = 6379
redis_expiration: int = 604800
# TMDB
tmdb_api_key: str = "tmdb_api_key"
# FLARESOLVERR
flaresolverr_host: str = "localhost"
flaresolverr_shema: str = "http"
flaresolverr_port: int = 8191
# JACKETT
jackett_host: str = "localhost"
jackett_shema: str = "http"
jackett_port: int = 9117
jackett_api_key: str = "jackett_api_key"
# ZILEAN DMM API
zilean_dmm_api_key: str = "zilean_dmm_api_key" # TODO: check to protéct Zilane API with APIKEY
zilean_url: str = "https://zilean.io/api/v1/dmm/search"
# DEVELOPMENT
debug: bool = True
dev_host: str = "0.0.0.0"
dev_port: int = 8080
# VERSION
version_path: str = "/app/pyproject.toml"
model_config = SettingsConfigDict(
env_file=".env", secrets_dir="/run/secrets", env_file_encoding="utf-8"
)
try:
settings = Settings()
except ValidationError as e:
raise RuntimeError(f"Configuration validation error: {e}")

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,270 @@
const sorts = ['quality', 'sizedesc', 'sizeasc', 'qualitythensize'];
const qualityExclusions = ['4K', '2160p', '1080p', '720p', '480p', 'rips', 'cam', 'unknown'];
const languages = ['en', 'fr', 'es', 'de', 'it', 'pt', 'ru', 'in', 'nl', 'hu', 'la', 'multi'];
document.addEventListener('DOMContentLoaded', function () {
updateProviderFields();
});
function setElementDisplay(elementId, displayStatus) {
const element = document.getElementById(elementId);
if (!element) {
return;
}
element.style.display = displayStatus;
}
function updateProviderFields(isChangeEvent = false) {
if (document.getElementById('debrid').checked) {
setElementDisplay('debrid-fields', 'block');
if (document.getElementById('service').value === 'realdebrid') {
setElementDisplay('anonymize-magnets-container', 'block');
} else {
setElementDisplay('anonymize-magnets-container', 'none');
}
} else {
setElementDisplay('debrid-fields', 'none');
setElementDisplay('anonymize-magnets-container', 'none');
}
if (document.getElementById('debrid').checked) {
setElementDisplay('debrid-fields', 'block');
} else {
setElementDisplay('debrid-fields', 'none');
}
if (document.getElementById('jackett')?.checked) {
setElementDisplay('jackett-fields', 'block');
} else {
setElementDisplay('jackett-fields', 'none');
}
if (document.getElementById('cache')?.checked) {
setElementDisplay('redis-fields', 'block');
setElementDisplay('cache-fields', 'block');
setElementDisplay('zilean-fields', 'block');
} else {
setElementDisplay('redis-fields', 'none');
setElementDisplay('cache-fields', 'none');
setElementDisplay('zilean-fields', 'none');
}
if (document.getElementById('tmdb')?.checked) {
setElementDisplay('tmdb-fields', 'block');
} else {
setElementDisplay('tmdb-fields', 'none');
}
// if (!isChangeEvent) {
// if (document.getElementById('jackett-fields')) {
// document.getElementById('jackett-host').value = '';
// document.getElementById('jackett-api').value = '';
// }
// document.getElementById('debrid-api').value = '';
// }
}
document.getElementById('service').addEventListener('change', function() {
updateProviderFields(true);
});
function loadData() {
const currentUrl = window.location.href;
let data = currentUrl.match(/\/([^\/]+)\/configure$/);
if (data && data[1].startsWith("ey")) {
data = atob(data[1]);
data = JSON.parse(data);
if (document.getElementById('jackett-fields')) {
document.getElementById('jackett-host').value = data.jackettHost;
document.getElementById('jackett-api').value = data.jackettApiKey;
}
if (document.getElementById('redis-fields')) {
document.getElementById('redis-host').value = data.redisHost;
document.getElementById('redis-port').value = data.redisPort;
document.getElementById('redis-expiration').value = data.redisExpiration;
}
if (document.getElementById('zilean-fields')) {
document.getElementById('zileanUrl').value = data.zileanUrl;
}
if (document.getElementById('cache-fields')) {
document.getElementById('cacheUrl').value = data.cacheUrl;
}
document.getElementById('debrid-api').value = data.debridKey;
document.getElementById('tmdb-api').value = data.tmdbApi;
document.getElementById('service').value = data.service;
if (data.anonymizeMagnets !== undefined) {
document.getElementById('anonymize-magnets').checked = data.anonymizeMagnets;
}
updateProviderFields();
document.getElementById('exclusion-keywords').value = (data.exclusionKeywords || []).join(', ');
document.getElementById('maxSize').value = data.maxSize;
document.getElementById('resultsPerQuality').value = data.resultsPerQuality;
document.getElementById('maxResults').value = data.maxResults;
document.getElementById('minCachedResults').value = data.minCachedResults;
if (document.getElementById('jackett')) {
document.getElementById('jackett').checked = data.jackett;
}
if (document.getElementById('cache')) {
document.getElementById('cache').checked = data.cache;
}
if (document.getElementById('zilean')) {
document.getElementById('zilean').checked = data.zilean;
}
document.getElementById('torrenting').checked = data.torrenting;
document.getElementById('debrid').checked = data.debrid;
document.getElementById('tmdb').checked = data.metadataProvider === 'tmdb';
document.getElementById('cinemeta').checked = data.metadataProvider === 'cinemeta';
sorts.forEach(sort => {
if (data.sort === sort) {
document.getElementById(sort).checked = true;
}
});
qualityExclusions.forEach(quality => {
if (data.exclusion.includes(quality)) {
document.getElementById(quality).checked = true;
}
})
languages.forEach(language => {
if (data.languages.includes(language)) {
document.getElementById(language).checked = true;
}
});
}
}
let showLanguageCheckBoxes = true;
function showCheckboxes() {
let checkboxes = document.getElementById("languageCheckBoxes");
if (showLanguageCheckBoxes) {
checkboxes.style.display = "block";
showLanguageCheckBoxes = false;
} else {
checkboxes.style.display = "none";
showLanguageCheckBoxes = true;
}
}
loadData();
function getLink(method) {
const addonHost = new URL(window.location.href).protocol.replace(':', '') + "://" + new URL(window.location.href).host
const jackettHost = document.getElementById('jackett-host')?.value;
const jackettApi = document.getElementById('jackett-api')?.value;
const redisHost = document.getElementById('redis-host')?.value;
let redisPort = document.getElementById('redis-port')?.value;
let redisExpiration = document.getElementById('redis-expiration')?.value;
const debridApi = document.getElementById('debrid-api').value;
const tmdbApi = document.getElementById('tmdb-api').value;
const zileanUrl = document.getElementById('zileanUrl').value;
const cacheUrl = document.getElementById('cacheUrl').value;
const service = document.getElementById('service').value;
const anonymizeMagnets = document.getElementById('anonymize-magnets').checked;
const exclusionKeywords = document.getElementById('exclusion-keywords').value.split(',').map(keyword => keyword.trim()).filter(keyword => keyword !== '');
let maxSize = document.getElementById('maxSize').value;
let resultsPerQuality = document.getElementById('resultsPerQuality').value;
let maxResults = document.getElementById('maxResults').value;
let minCachedResults = document.getElementById('minCachedResults').value;
const jackett = document.getElementById('jackett')?.checked;
const cache = document.getElementById('cache')?.checked;
const zilean = document.getElementById('zilean')?.checked;
const torrenting = document.getElementById('torrenting').checked;
const debrid = document.getElementById('debrid').checked;
const metadataProvider = document.getElementById('tmdb').checked ? 'tmdb' : 'cinemeta';
const selectedQualityExclusion = [];
console.log('Test');
qualityExclusions.forEach(quality => {
console.log(quality, document.getElementById(quality).checked);
if (document.getElementById(quality).checked) {
selectedQualityExclusion.push(quality);
}
});
const selectedLanguages = [];
languages.forEach(language => {
if (document.getElementById(language).checked) {
selectedLanguages.push(language);
}
});
let filter;
sorts.forEach(sort => {
if (document.getElementById(sort).checked) {
filter = sort;
}
});
if (maxSize === '' || isNaN(maxSize)) {
maxSize = 16;
}
if (maxResults === '' || isNaN(maxResults)) {
maxResults = 5;
}
if (resultsPerQuality === '' || isNaN(resultsPerQuality)) {
resultsPerQuality = 5;
}
if (minCachedResults === '' || isNaN(minCachedResults)) {
minCachedResults = 5;
}
if (redisPort === '' || isNaN(redisPort)) {
redisPort = 6379;
}
if (redisExpiration === '' || isNaN(redisExpiration)) {
redisExpiration = 3600;
}
let data = {
addonHost,
jackettHost,
'jackettApiKey': jackettApi,
redisHost,
redisPort,
redisExpiration,
service,
anonymizeMagnets,
'debridKey': debridApi,
maxSize,
exclusionKeywords,
'languages': selectedLanguages,
'sort': filter,
resultsPerQuality,
maxResults,
minCachedResults,
'exclusion': selectedQualityExclusion,
tmdbApi,
zileanUrl,
cacheUrl,
jackett,
cache,
zilean,
torrenting,
debrid,
metadataProvider
};
if ((jackett && (jackettHost === '' || jackettApi === '')) || (cache && (redisHost === '' || redisPort === '' || redisExpiration === '')) || (zilean && zileanUrl === '') || (debrid && debridApi === '') || (metadataProvider === 'tmdb' && tmdbApi === '') || languages.length === 0) {
alert('Please fill all required fields');
return false;
}
let stremio_link = `${window.location.host}/${btoa(JSON.stringify(data))}/manifest.json`;
if (method === 'link') {
window.open(`stremio://${stremio_link}`, "_blank");
} else if (method === 'copy') {
const link = window.location.protocol + '//' + stremio_link;
if (!navigator.clipboard) {
alert('Your browser does not support clipboard');
console.log(link);
return;
}
navigator.clipboard.writeText(link).then(() => {
alert('Link copied to clipboard');
}, () => {
alert('Error copying link to clipboard');
});
}
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,643 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<script src="https://cdn.tailwindcss.com"></script>
<title>Stremio-Jackett</title>
<style>
/*On va ajouter la photo de fond */
body {
background: linear-gradient(rgba(0, 0, 0, 0.5), rgba(0, 0, 0, 0.5)), url('https://i.ibb.co/WGTw4bV/stremio-bg.png') no-repeat fixed;
background-size: cover;
margin: 0;
padding: 0;
}
img {
width: 100px;
height: 100px;
text-align: center;
margin-left: auto;
margin-right: auto;
}
.multipleSelection {
width: 150px;
}
.selectBox {
position: relative;
}
.overSelect {
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
}
#languageCheckBoxes {
display: none;
}
#languageCheckBoxes label {
display: block;
}
</style>
</head>
<body class="backdrop-blur">
<div id="container" style="display: flex;justify-content: center;align-items: center;">
<form style="padding: 2rem;border-radius: 1rem"
class="bg-opacity-70 bg-white dark:bg-opacity-90 dark:bg-gray-900 w-1/3 m-12">
<img src="https://user-images.githubusercontent.com/27040483/28728094-99f3e3f6-73c7-11e7-8f8d-28912dc6ac0d.png"
alt="logo" style="width: 100px; height: 100px;">
<div class="space-y-12">
<p class="dark:text-white text-center">Stremio-Jackett Rework</p>
<h1 class="text-base font-semibold leading-7 text-gray-900 dark:text-white"
style="text-align: center;font-size: 2rem;">
Addon configuration</h1>
<div class="border-b border-gray-900/10 dark:border-white/50 pb-12">
<h2 class="text-base font-semibold leading-7 text-gray-900 dark:text-white">Streaming</h2>
<p class="mt-1 text-sm leading-6 text-gray-600 dark:text-gray-400">You will enter information
about streaming here</p>
<br>
<div class="relative flex gap-x-3">
<div class="flex h-6 items-center">
<input id="debrid" name="debrid" value="debrid" type="checkbox"
class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-600" checked
oninput="updateProviderFields(true)">
</div>
<div class="text-sm leading-6">
<label for="debrid" class="block text-sm font-medium mb-2 dark:text-white">Enable debrid
service</label>
<p class="text-gray-500 dark:text-gray-300">Enable debrid service for faster streaming</p>
</div>
</div>
<div class="mt-10 relative flex gap-x-3">
<div class="flex h-6 items-center">
<input id="torrenting" name="torrenting" value="torrenting" type="checkbox"
class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-600"
oninput="updateProviderFields(true)">
</div>
<div class="text-sm leading-6">
<label for="torrenting" class="block text-sm font-medium mb-2 dark:text-white">Enable
torrenting</label>
<p class="text-gray-500 dark:text-gray-300"><span
class="font-bold text-red-600 dark:text-red-500">(ATTENTION: this is illegal in some
countries)</span></p>
</div>
</div>
</div>
<div class="border-b border-gray-900/10 dark:border-white/50 pb-12">
<h2 class="text-base font-semibold leading-7 text-gray-900 dark:text-white">Torrent Providers</h2>
<p class="mt-1 text-sm leading-6 text-gray-600 dark:text-gray-400">You will enter information about
your torrent providers here</p>
<br>
<div class="relative flex gap-x-3">
<div class="flex h-6 items-center">
<input id="jackett" name="jackett" value="jackett" type="checkbox"
class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-600" checked
oninput="updateProviderFields(true)">
</div>
<div class="text-sm leading-6">
<label for="jackett" class="block text-sm font-medium mb-2 dark:text-white">Enable
Jackett</label>
<p class="text-gray-500 dark:text-gray-300">Enable Jackett for more results</p>
</div>
</div>
<div class="mt-10 relative flex gap-x-3">
<div class="flex h-6 items-center">
<input id="cache" name="cache" value="cache" type="checkbox"
class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-600" checked
oninput="updateProviderFields(true)">
</div>
<div class="text-sm leading-6">
<label for="cache" class="block text-sm font-medium mb-2 dark:text-white">Enable
cache</label>
<p class="text-gray-500 dark:text-gray-300">Enable caching for faster results</p>
</div>
</div>
<div class="mt-10 relative flex gap-x-3">
<div class="flex h-6 items-center">
<input id="zilean" name="zilean" value="zilean" type="checkbox"
class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-600" checked
oninput="updateProviderFields(true)">
</div>
<div class="text-sm leading-6">
<label for="zilean" class="block text-sm font-medium mb-2 dark:text-white">DMM api -
Zilean</label>
<p class="text-gray-500 dark:text-gray-300">Zilean's api - DMM hashlist scraper for more
results</p>
</div>
</div>
</div>
<div class="border-b border-gray-900/10 dark:border-white/50 pb-12" id="redis-fields">
<h2 class="text-base font-semibold leading-7 text-gray-900 dark:text-white">Redis Information</h2>
<p class="mt-1 text-sm leading-6 text-gray-600 dark:text-gray-400">You will enter information about your Redis server here</p>
<div class="mt-10 grid grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6">
<div class="sm:col-span-2">
<label for="redis-host" class="block text-sm font-medium mb-2 dark:text-white">Redis Host</label>
<div class="mt-2">
<input oninput="" type="text" name="redis-host" id="redis-host" autocomplete="jackett-host"
class="py-3 px-4 block w-full border-gray-200 rounded-lg text-sm focus:border-blue-500 focus:ring-blue-500 disabled:opacity-50 disabled:pointer-events-none dark:bg-slate-900 dark:border-gray-700 dark:text-gray-400 dark:focus:ring-gray-600"
placeholder="without http without port">
</div>
</div>
<div class="sm:col-span-2">
<label for="redis-port" class="block text-sm font-medium mb-2 dark:text-white">Redis Port</label>
<div class="mt-2">
<input oninput="" type="number" name="redis-port" id="redis-port" autocomplete="redis-port"
class="py-3 px-4 block w-full border-gray-200 rounded-lg text-sm focus:border-blue-500 focus:ring-blue-500 disabled:opacity-50 disabled:pointer-events-none dark:bg-slate-900 dark:border-gray-700 dark:text-gray-400 dark:focus:ring-gray-600"
placeholder="6379">
</div>
</div>
<div class="sm:col-span-2">
<label for="redis-expiration" class="block text-sm font-medium mb-2 dark:text-white">Cache Expiration Time (seconds)</label>
<div class="mt-2">
<input oninput="" type="number" name="redis-expiration" id="redis-expiration" autocomplete="redis-expiration"
class="py-3 px-4 block w-full border-gray-200 rounded-lg text-sm focus:border-blue-500 focus:ring-blue-500 disabled:opacity-50 disabled:pointer-events-none dark:bg-slate-900 dark:border-gray-700 dark:text-gray-400 dark:focus:ring-gray-600"
placeholder="604800">
</div>
</div>
</div>
</div>
<div class="border-b border-gray-900/10 dark:border-white/50 pb-12" id="jackett-fields">
<h2 class="text-base font-semibold leading-7 text-gray-900 dark:text-white">Jackett Information</h2>
<p class="mt-1 text-sm leading-6 text-gray-600 dark:text-gray-400">You will enter information about
your
Jackett server here</p>
<div class="mt-10 grid grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6">
<div class="sm:col-span-3">
<label for="jackett-host" class="block text-sm font-medium mb-2 dark:text-white">Jackett
Host</label>
<div class="mt-2">
<input oninput="" type="text" name="jackett-host" id="jackett-host"
autocomplete="jackett-host"
class="py-3 px-4 block w-full border-gray-200 rounded-lg text-sm focus:border-blue-500 focus:ring-blue-500 disabled:opacity-50 disabled:pointer-events-none dark:bg-slate-900 dark:border-gray-700 dark:text-gray-400 dark:focus:ring-gray-600"
placeholder="I.e: http(s)://jackett.com without / at end">
</div>
</div>
<div class="sm:col-span-3">
<label for="jackett-api" class="block text-sm font-medium mb-2 dark:text-white">Jackett API
key</label>
<div class="mt-2">
<input oninput="" type="text" name="jackett-api" id="jackett-api"
autocomplete="jackett-api"
class="py-3 px-4 block w-full border-gray-200 rounded-lg text-sm focus:border-blue-500 focus:ring-blue-500 disabled:opacity-50 disabled:pointer-events-none dark:bg-slate-900 dark:border-gray-700 dark:text-gray-400 dark:focus:ring-gray-600"
placeholder="Your Jackett API key">
</div>
</div>
</div>
</div>
<div class="border-b border-gray-900/10 dark:border-white/50 pb-12" id="debrid-fields">
<h2 class="text-base font-semibold leading-7 text-gray-900 dark:text-white">Debrid Information</h2>
<p class="mt-1 text-sm leading-6 text-gray-600 dark:text-gray-400">You will enter your debrid
provider
and its API key here</p>
<div class="mt-10 grid grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6">
<div class="sm:col-span-3">
<label for="service" class="block text-sm font-medium mb-2 dark:text-white">Debrid
Provider</label>
<div class="mt-2">
<select onchange="" id="service" name="service" autocomplete="service"
class="py-3 px-4 pe-9 block w-full border-gray-200 rounded-lg text-sm focus:border-blue-500 focus:ring-blue-500 disabled:opacity-50 disabled:pointer-events-none dark:bg-slate-900 dark:border-gray-700 dark:text-gray-400 dark:focus:ring-gray-600">
<option value="realdebrid">Real-Debrid</option>
<option value="alldebrid">All-Debrid</option>
<option value="premiumize">Premiumize</option>
</select>
</div>
</div>
<div class="sm:col-span-3">
<label for="debrid-api" class="block text-sm font-medium mb-2 dark:text-white">Debrid API
key</label>
<div class="mt-2">
<input oninput="" type="text" name="debrid-api" id="debrid-api"
autocomplete="debrid-api"
class="py-3 px-4 block w-full border-gray-200 rounded-lg text-sm focus:border-blue-500 focus:ring-blue-500 disabled:opacity-50 disabled:pointer-events-none dark:bg-slate-900 dark:border-gray-700 dark:text-gray-400 dark:focus:ring-gray-600"
placeholder="Your Debrid API key">
</div>
</div>
<div id="anonymize-magnets-container" class="sm:col-span-6" style="display: none;">
<div class="flex items-center">
<input type="checkbox" id="anonymize-magnets" name="anonymize-magnets"
class="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:ring-offset-gray-800 dark:focus:ring-blue-600">
<label for="anonymize-magnets" class="ml-2 block text-sm text-gray-900 dark:text-white">
/!\ Anon MagnetLink /!\
</label>
</div>
</div>
</div>
</div>
<div class="border-b border-gray-900/10 dark:border-white/50 pb-12">
<h2 class="text-base font-semibold leading-7 text-gray-900 dark:text-white">Metadata Provider</h2>
<p class="mt-1 text-sm leading-6 text-gray-600 dark:text-gray-400">You will enter information about
your metadata provider here</p>
<br>
<div class="relative flex gap-x-3">
<div class="flex h-6 items-center">
<input id="tmdb" name="metadataProvider" value="tmdb" type="radio"
class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-600" checked
oninput="updateProviderFields(true)">
</div>
<div class="text-sm leading-6">
<label for="tmdb" class="block text-sm font-medium mb-2 dark:text-white">TMDB</label>
<p class="text-gray-500 dark:text-gray-300">Use TMDB for more accurate metadata in different
languages</p>
</div>
</div>
<div class="mt-10 relative flex gap-x-3">
<div class="flex h-6 items-center">
<input id="cinemeta" name="metadataProvider" value="cinemeta" type="radio"
class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-600"
oninput="updateProviderFields(true)">
</div>
<div class="text-sm leading-6">
<label for="cinemeta"
class="block text-sm font-medium mb-2 dark:text-white">Cinemeta</label>
<p class="text-gray-500 dark:text-gray-300">Use Cinemeta for simple metadata</p>
</div>
</div>
</div>
<div class="border-b border-gray-900/10 dark:border-white/50 pb-12" id="tmdb-fields">
<h2 class="text-base font-semibold leading-7 text-gray-900 dark:text-white">TMDB Informations</h2>
<p class="mt-1 text-sm leading-6 text-gray-600 dark:text-gray-400">You will enter information
about TMDB here. You can
find your API key in "Settings" after registering <a href="https://www.themoviedb.org/signup"
target="_blank">here</a></p>
<div class="mt-10 grid grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6">
<div class="col-span-full">
<label for="tmdb-api" class="block text-sm font-medium mb-2 dark:text-white">TMDB API
key</label>
<div class="mt-2">
<input oninput="" type="text" name="tmdb-api" id="tmdb-api" autocomplete="tmdb-api"
class="py-3 px-4 block w-full border-gray-200 rounded-lg text-sm focus:border-blue-500 focus:ring-blue-500 disabled:opacity-50 disabled:pointer-events-none dark:bg-slate-900 dark:border-gray-700 dark:text-gray-400 dark:focus:ring-gray-600"
placeholder="Your TMDB API key">
</div>
</div>
</div>
</div>
<div class="border-b border-gray-900/10 dark:border-white/50 pb-12" id="zilean-fields">
<h2 class="text-base font-semibold leading-7 text-gray-900 dark:text-white">Zilean Cache
DMM API</h2>
<p class="mt-1 text-sm leading-6 text-gray-600 dark:text-gray-400">Please fill your Zilean DMM API
Cache
informations here.</p>
<div class="mt-10 grid grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6">
<div class="col-span-full">
<label for="zileanUrl" class="block text-sm font-medium mb-2 dark:text-white">Zilean
URL</label>
<div class="mt-2">
<input oninput="" type="text" name="zileanUrl" id="zileanUrl" autocomplete="zileanUrl"
class="py-3 px-4 block w-full border-gray-200 rounded-lg text-sm focus:border-blue-500 focus:ring-blue-500 disabled:opacity-50 disabled:pointer-events-none dark:bg-slate-900 dark:border-gray-700 dark:text-gray-400 dark:focus:ring-gray-600"
placeholder="Your Zilean Community Cache URL">
</div>
</div>
</div>
</div>
<div class="border-b border-gray-900/10 dark:border-white/50 pb-12" id="cache-fields">
<h2 class="text-base font-semibold leading-7 text-gray-900 dark:text-white">Shared Public Cache</h2>
<p class="mt-1 text-sm leading-6 text-gray-600 dark:text-gray-400">Please fill your Public
Cache URL
informations here.</p>
<div class="mt-10 grid grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6">
<div class="col-span-full">
<label for="cacheUrl" class="block text-sm font-medium mb-2 dark:text-white">Public Cache
URL</label>
<div class="mt-2">
<input oninput="" type="text" name="cacheUrl" id="cacheUrl" autocomplete="cacheUrl"
class="py-3 px-4 block w-full border-gray-200 rounded-lg text-sm focus:border-blue-500 focus:ring-blue-500 disabled:opacity-50 disabled:pointer-events-none dark:bg-slate-900 dark:border-gray-700 dark:text-gray-400 dark:focus:ring-gray-600"
placeholder="https://stremio-jackett-cacher.elfhosted.com/">
</div>
</div>
</div>
</div>
<div class="border-b border-gray-900/10 dark:border-white/50 pb-12">
<h2 class="text-base font-semibold leading-7 text-gray-900 dark:text-white">Filtering</h2>
<p class="mt-1 text-sm leading-6 text-gray-600 dark:text-gray-400">Set-up here your filtering
parameters. Suiting results
is a must :)</p>
<div class="mt-10 space-y-10">
<fieldset>
<legend class="text-sm font-semibold leading-6 text-gray-900 dark:text-white">Sorting
</legend>
<p class="mt-1 text-sm leading-6 text-gray-600 dark:text-gray-400">Choose the sorting that
suits
you the best.</p>
<div class="mt-6 space-y-6">
<div class="relative flex gap-x-3">
<div class="flex h-6 items-center">
<input oninput="" id="quality" name="sorting" value="quality" type="radio"
class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-600"
checked>
</div>
<div class="text-sm leading-6">
<label for="quality"
class="block text-sm font-medium mb-2 dark:text-white">Quality</label>
<p class="text-gray-500 dark:text-gray-300">Get the best quality on top</p>
</div>
</div>
<div class="relative flex gap-x-3">
<div class="flex h-6 items-center">
<input oninput="" id="sizedesc" name="sorting" value="sizedesc" type="radio"
class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-600">
</div>
<div class="text-sm leading-6">
<label for="sizedesc"
class="block text-sm font-medium mb-2 dark:text-white">Size
descending</label>
<p class="text-gray-500 dark:text-gray-300">Filter results by size descending
</p>
</div>
</div>
<div class="relative flex gap-x-3">
<div class="flex h-6 items-center">
<input oninput="" id="sizeasc" name="sorting" value="sizeasc" type="radio"
class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-600">
</div>
<div class="text-sm leading-6">
<label for="sizeasc" class="block text-sm font-medium mb-2 dark:text-white">Size
ascending</label>
<p class="text-gray-500 dark:text-gray-300">Filter results by size ascending</p>
</div>
</div>
<div class="relative flex gap-x-3">
<div class="flex h-6 items-center">
<input oninput="" id="qualitythensize" name="sorting" value="qualitythensize"
type="radio"
class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-600">
</div>
<div class="text-sm leading-6">
<label for="qualitythensize"
class="block text-sm font-medium mb-2 dark:text-white">Quality
then
size</label>
<p class="text-gray-500 dark:text-gray-300">Filter results by quality then size
descending</p>
</div>
</div>
</div>
</fieldset>
<fieldset>
<legend class="text-sm font-semibold leading-6 text-gray-900 dark:text-white">Quality
Exclusion
</legend>
<p class="mt-1 text-sm leading-6 text-gray-600 dark:text-gray-400">Check qualities that you
want
to exclude</p>
<div class="mt-6 space-y-6">
<div class="flex items-center gap-x-3">
<input oninput="" id="4K" name="4K" type="checkbox"
class="h-4 w-4 border-gray-300 text-indigo-600 focus:ring-indigo-600">
<label for="4K" class="block text-sm font-medium mb-2 dark:text-white">4K</label>
</div>
<div class="flex items-center gap-x-3">
<input oninput="" id="2160p" name="2160p" type="checkbox"
class="h-4 w-4 border-gray-300 text-indigo-600 focus:ring-indigo-600">
<label for="2160p"
class="block text-sm font-medium mb-2 dark:text-white">2160p</label>
</div>
<div class="flex items-center gap-x-3">
<input oninput="" id="1080p" name="1080p" type="checkbox"
class="h-4 w-4 border-gray-300 text-indigo-600 focus:ring-indigo-600">
<label for="1080p"
class="block text-sm font-medium mb-2 dark:text-white">1080p</label>
</div>
<div class="flex items-center gap-x-3">
<input oninput="" id="720p" name="720p" type="checkbox"
class="h-4 w-4 border-gray-300 text-indigo-600 focus:ring-indigo-600">
<label for="720p"
class="block text-sm font-medium mb-2 dark:text-white">720p</label>
</div>
<div class="flex items-center gap-x-3">
<input oninput="" id="480p" name="480p" type="checkbox"
class="h-4 w-4 border-gray-300 text-indigo-600 focus:ring-indigo-600">
<label for="480p"
class="block text-sm font-medium mb-2 dark:text-white">480p</label>
</div>
<div class="flex items-center gap-x-3">
<input oninput="" id="rips" name="rips" type="checkbox"
class="h-4 w-4 border-gray-300 text-indigo-600 focus:ring-indigo-600">
<label for="rips" class="block text-sm font-medium mb-2 dark:text-white">RIPs
(HDRip,
WEBRip, etc.)</label>
</div>
<div class="flex items-center gap-x-3">
<input oninput="" id="cam" name="cam" type="checkbox"
class="h-4 w-4 border-gray-300 text-indigo-600 focus:ring-indigo-600">
<label for="cam" class="block text-sm font-medium mb-2 dark:text-white">CAMs (CAM,
TS, etc.)</label>
</div>
<div class="flex items-center gap-x-3">
<input oninput="" id="unknown" name="unknown" type="checkbox"
class="h-4 w-4 border-gray-300 text-indigo-600 focus:ring-indigo-600">
<label for="unknown"
class="block text-sm font-medium mb-2 dark:text-white">Unknown</label>
</div>
<div>
<label for="exclusion-keywords"
class="block text-sm font-medium mb-2 dark:text-white">Exclusion
Keywords</label>
<div class="mt-2">
<input oninput="" type="text" name="exclusion-keywords" id="exclusion-keywords"
autocomplete="exclusion-keywords"
class="py-3 px-4 block w-full border-gray-200 rounded-lg text-sm focus:border-blue-500 focus:ring-blue-500 disabled:opacity-50 disabled:pointer-events-none dark:bg-slate-900 dark:border-gray-700 dark:text-gray-400 dark:focus:ring-gray-600"
placeholder="keyword1, keyword2, keyword3">
</div>
</div>
</div>
</fieldset>
</div>
<div class="mt-6 space-y-6">
<div class="sm:col-span-3">
<label for="language"
class="block text-sm font-medium mb-2 dark:text-white">Language</label>
<p class="mt-1 text-sm leading-6 text-gray-600 dark:text-gray-400">Choose your languages</p>
<div class="multipleSelection mt-2">
<div class="selectBox" onclick="showCheckboxes()">
<select
class="py-3 px-4 pe-9 block w-full border-gray-200 rounded-lg text-sm focus:border-blue-500 focus:ring-blue-500 disabled:opacity-50 disabled:pointer-events-none dark:bg-slate-900 dark:border-gray-700 dark:text-gray-400 dark:focus:ring-gray-600">
<option>Languages</option>
</select>
<div class="overSelect"></div>
</div>
<div id="languageCheckBoxes"
class="text-black dark:text-white bg-opacity-70 bg-white dark:bg-opacity-90 dark:bg-gray-900 border-gray-200">
<label for="en">
<input type="checkbox" id="en" />
🇬🇧 <span class="ml-1">English</span>
</label>
<label for="fr">
<input type="checkbox" id="fr" />
🇫🇷 <span class="ml-1">French</span>
</label>
<label for="es">
<input type="checkbox" id="es" />
🇪🇸 <span class="ml-1">Spanish</span>
</label>
<label for="de">
<input type="checkbox" id="de" />
🇩🇪 <span class="ml-1">German</span>
</label>
<label for="it">
<input type="checkbox" id="it" />
🇮🇹 <span class="ml-1">Italian</span>
</label>
<label for="pt">
<input type="checkbox" id="pt" />
🇵🇹 <span class="ml-1">Portuguese</span>
</label>
<label for="ru">
<input type="checkbox" id="ru" />
🇷🇺 <span class="ml-1">Russian</span>
</label>
<label for="in">
<input type="checkbox" id="in" />
🇮🇳 <span class="ml-1">Hindi</span>
</label>
<label for="nl">
<input type="checkbox" id="nl" />
🇳🇱 <span class="ml-1">Dutch</span>
</label>
<label for="hu">
<input type="checkbox" id="hu" />
🇭🇺 <span class="ml-1">Hungarian</span>
</label>
<label for="la">
<input type="checkbox" id="la" />
🇲🇽 <span class="ml-1">Latin</span>
</label>
<label for="multi">
<input type="checkbox" id="multi" />
🌍 <span class="ml-1">MULTi</span>
</label>
</div>
</div>
</div>
<div class="mt-6 space-y-6">
<div class="sm:col-span-3">
<label for="maxSize" class="block text-sm font-medium mb-2 dark:text-white">Maximum
size</label>
<p class="mt-1 text-sm leading-6 text-gray-600 dark:text-gray-400">Set a maximum size
for
your results in
GB.</p>
<div class="mt-2">
<input oninput="" type="text" name="maxSize" id="maxSize" autocomplete="maxSize"
class="py-3 px-4 block w-full border-gray-200 rounded-lg text-sm focus:border-blue-500 focus:ring-blue-500 disabled:opacity-50 disabled:pointer-events-none dark:bg-slate-900 dark:border-gray-700 dark:text-gray-400 dark:focus:ring-gray-600"
placeholder="5">
</div>
</div>
</div>
<div class="mt-6 space-y-6">
<div class="sm:col-span-3">
<label for="maxSize" class="block text-sm font-medium mb-2 dark:text-white">Results per
quality</label>
<p class="mt-1 text-sm leading-6 text-gray-600 dark:text-gray-400">Set the amount of
maximum
results you want
per
quality.</p>
<div class="mt-2">
<input oninput="" type="text" name="resultsPerQuality" id="resultsPerQuality"
autocomplete="resultsPerQuality"
class="py-3 px-4 block w-full border-gray-200 rounded-lg text-sm focus:border-blue-500 focus:ring-blue-500 disabled:opacity-50 disabled:pointer-events-none dark:bg-slate-900 dark:border-gray-700 dark:text-gray-400 dark:focus:ring-gray-600"
placeholder="5">
</div>
</div>
</div>
<div class="mt-6 space-y-6">
<div class="sm:col-span-3">
<label for="maxResults" class="block text-sm font-medium mb-2 dark:text-white">Maximum
results</label>
<p class="mt-1 text-sm leading-6 text-gray-600 dark:text-gray-400">Set the amount of
maximum
results you
want.</p>
<div class="mt-2">
<input oninput="" type="text" name="maxResults" id="maxResults"
autocomplete="maxResults"
class="py-3 px-4 block w-full border-gray-200 rounded-lg text-sm focus:border-blue-500 focus:ring-blue-500 disabled:opacity-50 disabled:pointer-events-none dark:bg-slate-900 dark:border-gray-700 dark:text-gray-400 dark:focus:ring-gray-600"
placeholder="5">
</div>
</div>
</div>
<div class="mt-6 space-y-6">
<div class="sm:col-span-3">
<label for="minCachedResults" class="block text-sm font-medium mb-2 dark:text-white">Min
Cached Results</label>
<p class="mt-1 text-sm leading-6 text-gray-600 dark:text-gray-400">Set the amount of
minimum cached results you want befor a new search.</p>
<div class="mt-2">
<input oninput="" type="text" name="minCachedResults" id="minCachedResults"
autocomplete="minCachedResults"
class="py-3 px-4 block w-full border-gray-200 rounded-lg text-sm focus:border-blue-500 focus:ring-blue-500 disabled:opacity-50 disabled:pointer-events-none dark:bg-slate-900 dark:border-gray-700 dark:text-gray-400 dark:focus:ring-gray-600"
placeholder="5">
</div>
</div>
</div>
</div>
</div>
<div class="mt-6 flex items-center justify-center gap-x-6">
<a id="install" onclick="return getLink('link')"
class="cursor-pointer rounded-md bg-black px-3 py-2 text-sm font-semibold text-white dark:text-black dark:bg-white shadow-sm hover:bg--500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600">Install</a>
<a id="copy" onclick="return getLink('copy')"
class="cursor-pointer rounded-md bg-black px-3 py-2 text-sm font-semibold text-white dark:text-black dark:bg-white shadow-sm hover:bg--500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600">Copy</a>
</div>
</div>
</form>
</div>
</body>
<script src="/static/config.js"></script>
</html>

4
stream_fusion/utils/cache/__init__.py vendored Normal file
View file

@ -0,0 +1,4 @@
from stream_fusion.utils.cache.cache_base import CacheBase
from stream_fusion.utils.cache.local_redis import RedisCache
__all__ = ["CacheBase", "RedisCache"]

142
stream_fusion/utils/cache/cache.py vendored Normal file
View file

@ -0,0 +1,142 @@
import os
import json
import copy
import redis
import pickle
import hashlib
import requests
from typing import List
from stream_fusion.logging_config import logger
from stream_fusion.utils.torrent.torrent_item import TorrentItem
from stream_fusion.utils.jackett.jackett_result import JackettResult
from stream_fusion.constants import CACHER_URL, EXCLUDED_TRACKERS
from stream_fusion.utils.models.movie import Movie
from stream_fusion.utils.models.series import Series
# Redis connection initialization
redis_client = redis.Redis(host="redis", port=6379)
CACHE_EXPIRATION_TIME = 48 * 60 * 60
def cache_key(media):
if isinstance(media, Movie):
key_string = f"movie:{media.titles[0]}:{media.languages[0]}"
elif isinstance(media, Series):
key_string = f"series:{media.titles[0]}:{media.languages[0]}:{media.season}"
else:
raise TypeError("Only Movie and Series are allowed as media!")
hashed_key = hashlib.sha256(key_string.encode('utf-8')).hexdigest()
return hashed_key[:16]
def search_redis(media):
try:
logger.info(f"Searching for cached {media.type} results")
hash_key = cache_key(media)
cached_result = redis_client.hgetall(hash_key)
if cached_result:
return [pickle.loads(value) for value in cached_result.values()]
else:
return []
except Exception as e:
logger.error(f"Error in search_cache: {str(e)}")
return []
def cache_redis(torrents: List[JackettResult], media):
if os.getenv("NODE_ENV") == "development":
return
logger.info("Started caching results")
cached_torrents = {}
for index, torrent in enumerate(torrents):
try:
cached_torrent = copy.deepcopy(torrent)
cached_torrent.indexer = "Locally cached"
cached_torrents[str(index)] = pickle.dumps(cached_torrent)
except Exception as e:
logger.error(f"Failed to create cached copy of torrent: {str(e)}")
try:
hash_key = cache_key(media)
redis_client.hmset(hash_key, cached_torrents)
redis_client.expire(hash_key, CACHE_EXPIRATION_TIME)
logger.info(f"Cached {len(cached_torrents)} {media.type} results")
except Exception as e:
logger.error(f"Failed to cache results: {str(e)}")
def search_public(media):
logger.info("Searching for public cached " + media.type + " results")
url = CACHER_URL + "getResult/" + media.type + "/"
# Without that, the cache doesn't return results. Maybe make multiple requests? One for each language, just like jackett?
cache_search = media.__dict__
cache_search["title"] = cache_search["titles"][0]
cache_search["language"] = cache_search["languages"][0]
# TODO: Wtf, why do we need to use __dict__ here? And also, why is it stuck when we use media directly?
response = requests.get(url, json=cache_search)
return response.json()
def cache_public(torrents: List[TorrentItem], media):
if os.getenv("NODE_ENV") == "development":
return
logger.info("Started Public Caching results")
cache_items = []
for torrent in torrents:
if torrent.indexer in EXCLUDED_TRACKERS:
continue
try:
cache_item = dict()
cache_item["title"] = torrent.raw_title
cache_item["trackers"] = "tracker:".join(torrent.trackers)
cache_item["magnet"] = torrent.magnet
cache_item["files"] = [] # I guess keep it empty?
cache_item["hash"] = torrent.info_hash
cache_item["indexer"] = torrent.indexer
cache_item["quality"] = (
torrent.parsed_data.resolution[0]
if len(torrent.parsed_data.resolution) > 0
else "Unknown"
)
cache_item["qualitySpec"] = ";".join(torrent.parsed_data.quality)
cache_item["seeders"] = torrent.seeders
cache_item["size"] = torrent.size
cache_item["language"] = ";".join(torrent.languages)
cache_item["type"] = media.type
cache_item["availability"] = torrent.availability
if media.type == "movie":
cache_item["year"] = media.year
elif media.type == "series":
cache_item["season"] = media.season
cache_item["episode"] = media.episode
cache_item["seasonfile"] = (
False # I guess keep it false to not mess up results?
)
cache_items.append(cache_item)
except:
logger.exception("An exception occured durring public cache parsing")
pass
try:
url = f"{CACHER_URL}pushResult/{media.type}"
cache_data = json.dumps(cache_items, indent=4)
response = requests.post(url, data=cache_data)
response.raise_for_status()
if response.status_code == 200:
logger.info(f"Cached {str(len(cache_items))} {media.type} results on Public Cache")
else:
logger.error(f"Failed to public cache {media.type} results: {str(response)}")
except:
logger.error("Failed to public cache results")
pass

85
stream_fusion/utils/cache/cache_base.py vendored Normal file
View file

@ -0,0 +1,85 @@
import hashlib
from abc import ABC, abstractmethod
from typing import Any, Callable
from stream_fusion.logging_config import logger
class CacheBase(ABC):
def __init__(self, config: dict):
"""
Initialize the cache base class.
Args:
config (dict): Configuration dictionary for the cache.
"""
self.logger = logger
self.config = config
@abstractmethod
def can_cache(self) -> bool:
"""
Check if caching is possible.
Returns:
bool: True if caching is possible, False otherwise.
"""
pass
@abstractmethod
def get(self, key: str) -> Any:
"""
Retrieve a value from the cache.
Args:
key (str): The cache key.
Returns:
Any: The cached value if found, None otherwise.
"""
pass
@abstractmethod
def set(self, key: str, value: Any) -> None:
"""
Store a value in the cache.
Args:
key (str): The cache key.
value (Any): The value to be cached.
"""
pass
def __call__(self, func: Callable) -> Callable:
"""
Decorator for caching function results.
Args:
func (Callable): The function to be cached.
Returns:
Callable: The wrapped function with caching functionality.
"""
def wrapper(*args, **kwargs):
if not self.can_cache():
self.logger.error("Cache is not available or cannot be used.")
return func(*args, **kwargs)
key = self.generate_key(func.__name__, *args, **kwargs)
cached_result = self.get(key)
if cached_result is not None:
self.logger.info(f"Result found in cache for key: {key}")
return cached_result
result = func(*args, **kwargs)
self.set(key, result)
return result
return wrapper
def generate_key(self, func_name: str, *args, **kwargs) -> str:
"""
Generate a cache key based on function name and arguments.
Args:
func_name (str): Name of the function being cached.
*args: Positional arguments of the function.
**kwargs: Keyword arguments of the function.
Returns:
str: A hashed key string.
"""
arg_string = f"{func_name}:{str(args)}:{str(kwargs)}"
hashed_key = hashlib.sha256(arg_string.encode("utf-8")).hexdigest()
return hashed_key[:16]

219
stream_fusion/utils/cache/local_redis.py vendored Normal file
View file

@ -0,0 +1,219 @@
import asyncio
import inspect
import jsonpickle
import time
from typing import Any, List
import hashlib
import redis
from stream_fusion.settings import settings
from stream_fusion.utils.models.movie import Movie
from stream_fusion.utils.models.series import Series
from stream_fusion.utils.cache.cache_base import CacheBase
class RedisCache(CacheBase):
def __init__(self, config):
super().__init__(config)
self.redis_host = settings.redis_host
self.redis_port = settings.redis_port
self.redis_url = f"redis://{self.redis_host}:{self.redis_port}"
self._redis_client = None
self.media_expiration = settings.redis_expiration
def get_redis_client(self):
if not self._redis_client:
try:
self._redis_client = redis.Redis.from_url(
self.redis_url,
max_connections=10
)
except redis.ConnectionError:
self.logger.error("Failed to create Redis client")
self._redis_client = None
return self._redis_client
def get_list(self, key: str) -> List[Any]:
result = self.get(key)
if isinstance(result, list):
return result
elif result is not None:
return [result]
return None
def generate_key(self, func_name: str, *args, **kwargs) -> str:
media = kwargs.get('media')
if media is None and args:
media = next((arg for arg in args if isinstance(arg, (Movie, Series))), None)
if media:
if isinstance(media, Movie):
key_string = f"movie:{media.titles[0]}:{media.languages[0]}"
elif isinstance(media, Series):
key_string = f"series:{media.titles[0]}:{media.languages[0]}:{media.season}"
else:
raise TypeError("Only Movie and Series are allowed as media!")
else:
key_string = f"{func_name}:{str(args)}:{str(kwargs)}"
hashed_key = hashlib.sha256(key_string.encode("utf-8")).hexdigest()
return hashed_key[:16]
def clear(self) -> None:
try:
client = self.get_redis_client()
client.flushdb()
self.logger.info("Cache cleared successfully")
except Exception as e:
self.logger.error(f"Error clearing cache: {e}")
def get_or_set(self, func: callable, *args, **kwargs) -> Any:
self.logger.debug(f"Entering get_or_set for function: {func.__name__}")
start_time = time.time()
if not self.can_cache():
self.logger.debug("Cache is not available, executing function directly")
return self._execute_func(func, *args, **kwargs)
key = self.generate_key(func.__name__, *args, **kwargs)
self.logger.debug(f"Generated cache key: {key}")
cached_result = self.get(key)
self.logger.debug(f"Attempted to get result from cache. Found: {cached_result is not None}")
if cached_result is not None:
self.logger.debug(f"Returning cached result for key: {key}")
return cached_result
self.logger.debug(f"Cache miss for key: {key}. Executing function.")
result = self._execute_func(func, *args, **kwargs)
self.logger.debug(f"Function execution completed. Setting result in cache.")
self.set(key, result)
end_time = time.time()
self.logger.debug(f"get_or_set completed in {end_time - start_time:.2f} seconds")
return result
def _execute_func(self, func, *args, **kwargs):
self.logger.debug(f"Executing function: {func.__name__}")
start_time = time.time()
sig = inspect.signature(func)
params = sig.parameters
self.logger.debug(f"Function {func.__name__} expects {len(params)} arguments")
self.logger.debug(f"Received args: {args}, kwargs: {kwargs}")
call_args = {}
for i, (param_name, param) in enumerate(params.items()):
if i < len(args):
call_args[param_name] = args[i]
elif param_name in kwargs:
call_args[param_name] = kwargs[param_name]
elif param.default is not param.empty:
call_args[param_name] = param.default
else:
self.logger.error(f"Missing required argument: {param_name}")
raise TypeError(f"Missing required argument: {param_name}")
self.logger.debug(f"Prepared arguments for {func.__name__}: {call_args}")
if asyncio.iscoroutinefunction(func):
self.logger.debug(f"Function {func.__name__} is asynchronous")
loop = asyncio.get_event_loop()
try:
result = loop.run_until_complete(func(**call_args))
self.logger.debug(f"Asynchronous function {func.__name__} completed successfully")
except Exception as e:
self.logger.error(f"Error executing asynchronous function {func.__name__}: {str(e)}")
raise
else:
self.logger.debug(f"Function {func.__name__} is synchronous")
try:
result = func(**call_args)
self.logger.debug(f"Synchronous function {func.__name__} completed successfully")
except Exception as e:
self.logger.error(f"Error executing synchronous function {func.__name__}: {str(e)}")
raise
end_time = time.time()
self.logger.debug(f"Function {func.__name__} executed in {end_time - start_time:.2f} seconds")
return result
def can_cache(self) -> bool:
self.logger.debug("Checking if caching is possible")
try:
client = self.get_redis_client()
result = client.ping()
self.logger.debug(f"Redis ping result: {result}")
return result
except redis.ConnectionError:
self.logger.error("Unable to connect to Redis")
return False
def get(self, key: str) -> Any:
self.logger.debug(f"Attempting to get value for key: {key}")
try:
client = self.get_redis_client()
cached_result = client.get(key)
if cached_result:
self.logger.debug(f"Value found in cache for key: {key}")
return jsonpickle.decode(cached_result)
self.logger.debug(f"No value found in cache for key: {key}")
return None
except Exception as e:
self.logger.error(f"Error retrieving from Redis: {e}")
return None
def set(self, key: str, value: Any, expiration: int = None) -> None:
self.logger.debug(f"Attempting to set value for key: {key}")
if expiration is None:
expiration = self.media_expiration
try:
client = self.get_redis_client()
cached_data = jsonpickle.encode(value)
result = client.set(key, cached_data, ex=expiration)
self.logger.debug(f"Set operation result for key {key}: {result}")
except Exception as e:
self.logger.error(f"Error storing in Redis: {e}")
def delete(self, key: str) -> bool:
try:
client = self.get_redis_client()
return bool(client.delete(key))
except Exception as e:
self.logger.error(f"Error deleting key from Redis: {e}")
return False
def exists(self, key: str) -> bool:
try:
client = self.get_redis_client()
return bool(client.exists(key))
except Exception as e:
self.logger.error(f"Error checking key existence in Redis: {e}")
return False
def get_ttl(self, key: str) -> int:
try:
client = self.get_redis_client()
return client.ttl(key)
except Exception as e:
self.logger.error(f"Error getting TTL from Redis: {e}")
return -1
def update_expiration(self, key: str, expiration: int) -> bool:
try:
client = self.get_redis_client()
return bool(client.expire(key, expiration))
except Exception as e:
self.logger.error(f"Error updating expiration in Redis: {e}")
return False
def close(self):
if self._redis_client:
self._redis_client.close()
def __enter__(self):
return self
def __exit__(self, exc_type, exc_val, exc_tb):
self.close()

View file

@ -0,0 +1,9 @@
from stream_fusion.utils.debrid.alldebrid import AllDebrid
from stream_fusion.utils.debrid.base_debrid import BaseDebrid
from stream_fusion.utils.debrid.realdebrid import RealDebrid
from stream_fusion.utils.debrid.premiumize import Premiumize
from stream_fusion.utils.debrid.get_debrid_service import get_debrid_service
__all__ = ["AllDebrid", "BaseDebrid", "RealDebrid", "Premiumize", "get_debrid_service"]

View file

@ -0,0 +1,126 @@
# alldebrid.py
import json
import uuid
from urllib.parse import unquote
from stream_fusion.constants import NO_CACHE_VIDEO_URL
from stream_fusion.utils.debrid.base_debrid import BaseDebrid
from stream_fusion.utils.general import season_episode_in_filename
from stream_fusion.logging_config import logger
class AllDebrid(BaseDebrid):
def __init__(self, config):
super().__init__(config)
self.base_url = "https://api.alldebrid.com/v4/"
def add_magnet(self, magnet, ip):
url = f"{self.base_url}magnet/upload?agent=jackett&apikey={self.config['debridKey']}&magnet={magnet}&ip={ip}"
return self.get_json_response(url)
def add_torrent(self, torrent_file, ip):
url = f"{self.base_url}magnet/upload/file?agent=jackett&apikey={self.config['debridKey']}&ip={ip}"
files = {"files[0]": (str(uuid.uuid4()) + ".torrent", torrent_file, 'application/x-bittorrent')}
return self.get_json_response(url, method='post', files=files)
def check_magnet_status(self, id, ip):
url = f"{self.base_url}magnet/status?agent=jackett&apikey={self.config['debridKey']}&id={id}&ip={ip}"
return self.get_json_response(url)
def unrestrict_link(self, link, ip):
url = f"{self.base_url}link/unlock?agent=jackett&apikey={self.config['debridKey']}&link={link}&ip={ip}"
return self.get_json_response(url)
def get_stream_link(self, query_string, config, ip):
query = json.loads(query_string)
magnet = query['magnet']
stream_type = query['type']
torrent_download = unquote(query["torrent_download"]) if query["torrent_download"] is not None else None
torrent_id = self.__add_magnet_or_torrent(magnet, torrent_download, ip)
logger.info(f"Torrent ID: {torrent_id}")
if not self.wait_for_ready_status(
lambda: self.check_magnet_status(torrent_id, ip)["data"]["magnets"]["status"] == "Ready"):
logger.error("Torrent not ready, caching in progress.")
return NO_CACHE_VIDEO_URL
logger.info("Torrent is ready.")
logger.info(f"Getting data for torrent id: {torrent_id}")
data = self.check_magnet_status(torrent_id, ip)["data"]
logger.info(f"Retrieved data for torrent id")
link = NO_CACHE_VIDEO_URL
if stream_type == "movie":
logger.info("Getting link for movie")
link = max(data["magnets"]['links'], key=lambda x: x['size'])['link']
elif stream_type == "series":
season = query['season']
episode = query['episode']
logger.info(f"Getting link for series {season}, {episode}")
matching_files = []
for file in data["magnets"]["links"]:
if season_episode_in_filename(file["filename"], season, episode):
matching_files.append(file)
if len(matching_files) == 0:
logger.error(f"No matching files for {season} {episode} in torrent.")
return f"Error: No matching files for {season} {episode} in torrent."
link = max(matching_files, key=lambda x: x["size"])["link"]
else:
logger.error("Unsupported stream type.")
return "Error: Unsupported stream type."
if link == NO_CACHE_VIDEO_URL:
return link
logger.info(f"Alldebrid link: {link}")
unlocked_link_data = self.unrestrict_link(link, ip)
if not unlocked_link_data:
logger.error("Failed to unlock link.")
return "Error: Failed to unlock link."
logger.info(f"Unrestricted link: {unlocked_link_data['data']['link']}")
return unlocked_link_data["data"]["link"]
def get_availability_bulk(self, hashes_or_magnets, ip=None):
if len(hashes_or_magnets) == 0:
logger.info("No hashes to be sent to All-Debrid.")
return dict()
url = f"{self.base_url}magnet/instant?agent=jackett&apikey={self.config['debridKey']}&magnets[]={'&magnets[]='.join(hashes_or_magnets)}&ip={ip}"
return self.get_json_response(url)
def __add_magnet_or_torrent(self, magnet, torrent_download=None, ip=None):
torrent_id = ""
if torrent_download is None:
logger.info(f"Adding magnet to AllDebrid")
magnet_response = self.add_magnet(magnet, ip)
logger.info(f"AllDebrid add magnet response: {magnet_response}")
if not magnet_response or "status" not in magnet_response or magnet_response["status"] != "success":
return "Error: Failed to add magnet."
torrent_id = magnet_response["data"]["magnets"][0]["id"]
else:
logger.info(f"Downloading torrent file from Jackett")
torrent_file = self.donwload_torrent_file(torrent_download)
logger.info(f"Torrent file downloaded from Jackett")
logger.info(f"Adding torrent file to AllDebrid")
upload_response = self.add_torrent(torrent_file, ip)
logger.info(f"AllDebrid add torrent file response: {upload_response}")
if not upload_response or "status" not in upload_response or upload_response["status"] != "success":
return "Error: Failed to add torrent file."
torrent_id = upload_response["data"]["files"][0]["id"]
logger.info(f"New torrent ID: {torrent_id}")
return torrent_id

View file

@ -0,0 +1,61 @@
import time
import requests
from stream_fusion.logging_config import logger
class BaseDebrid:
def __init__(self, config):
self.config = config
self.logger = logger
self.__session = requests.Session()
def get_json_response(self, url, method='get', data=None, headers=None, files=None):
if method == 'get':
response = self.__session.get(url, headers=headers)
elif method == 'post':
response = self.__session.post(url, data=data, headers=headers, files=files)
elif method == 'put':
response = self.__session.put(url, data=data, headers=headers)
elif method == 'delete':
response = self.__session.delete(url, headers=headers)
else:
raise ValueError(f"Unsupported HTTP method: {method}")
# Check if the request was successful
if response.ok:
try:
return response.json()
except ValueError:
self.logger.error(f"Failed to parse response as JSON: {response.text}")
return None
else:
self.logger.error(f"Request failed with status code {response.status_code}")
return None
def wait_for_ready_status(self, check_status_func, timeout=30, interval=5):
self.logger.info(f"Waiting for {timeout} seconds to cache.")
start_time = time.time()
while time.time() - start_time < timeout:
if check_status_func():
self.logger.info("File is ready!")
return True
time.sleep(interval)
self.logger.info(f"Waiting timed out.")
return False
def donwload_torrent_file(self, download_url):
response = requests.get(download_url)
response.raise_for_status()
return response.content
def get_stream_link(self, query, ip=None):
raise NotImplementedError
def add_magnet(self, magnet, ip=None):
raise NotImplementedError
def get_availability_bulk(self, hashes_or_magnets, ip=None):
raise NotImplementedError

View file

@ -0,0 +1,19 @@
from fastapi.exceptions import HTTPException
from stream_fusion.utils.debrid.alldebrid import AllDebrid
from stream_fusion.utils.debrid.premiumize import Premiumize
from stream_fusion.utils.debrid.realdebrid import RealDebrid
def get_debrid_service(config):
service_name = config['service']
if service_name == "realdebrid":
debrid_service = RealDebrid(config)
elif service_name == "alldebrid":
debrid_service = AllDebrid(config)
elif service_name == "premiumize":
debrid_service = Premiumize(config)
else:
raise HTTPException(status_code=500, detail="Invalid service configuration.")
return debrid_service

View file

@ -0,0 +1,120 @@
# Assuming the BaseDebrid class and necessary imports are already defined as shown previously
import json
from stream_fusion.constants import NO_CACHE_VIDEO_URL
from stream_fusion.utils.debrid.base_debrid import BaseDebrid
from stream_fusion.utils.general import get_info_hash_from_magnet, season_episode_in_filename
from stream_fusion.logging_config import logger
class Premiumize(BaseDebrid):
def __init__(self, config):
super().__init__(config)
self.base_url = "https://www.premiumize.me/api"
def add_magnet(self, magnet, ip=None):
url = f"{self.base_url}/transfer/create?apikey={self.config['debridKey']}"
form = {'src': magnet}
return self.get_json_response(url, method='post', data=form)
# Doesn't work for the time being. Premiumize does not support torrent file torrents
def add_torrent(self, torrent_file):
url = f"{self.base_url}/transfer/create?apikey={self.config['debridKey']}"
form = {'file': torrent_file}
return self.get_json_response(url, method='post', data=form)
def list_transfers(self):
url = f"{self.base_url}/transfer/list?apikey={self.config['debridKey']}"
return self.get_json_response(url)
def get_folder_or_file_details(self, item_id, is_folder=True):
if is_folder:
logger.info(f"Getting folder details with id: {item_id}")
url = f"{self.base_url}/folder/list?id={item_id}&apikey={self.config['debridKey']}"
else:
logger.info(f"Getting file details with id: {item_id}")
url = f"{self.base_url}/item/details?id={item_id}&apikey={self.config['debridKey']}"
return self.get_json_response(url)
def get_availability(self, hash):
url = f"{self.base_url}/cache/check?apikey={self.config['debridKey']}&items[]={hash}"
return self.get_json_response(url)
def get_availability_bulk(self, hashes_or_magnets, ip=None):
url = f"{self.base_url}/cache/check?apikey={self.config['debridKey']}&items[]=" + "&items[]=".join(
hashes_or_magnets)
return self.get_json_response(url)
def get_stream_link(self, query, config, ip=None):
query = json.loads(query)
magnet = query['magnet']
logger.info(f"Received query for magnet: {magnet}")
info_hash = get_info_hash_from_magnet(magnet)
logger.info(f"Info hash extracted: {info_hash}")
stream_type = query['type']
logger.info(f"Stream type: {stream_type}")
transfer_data = self.add_magnet(magnet)
if not transfer_data or 'id' not in transfer_data:
logger.error("Failed to create transfer.")
return "Error: Failed to create transfer."
transfer_id = transfer_data['id']
logger.info(f"Transfer created with ID: {transfer_id}")
if not self.wait_for_ready_status(lambda: self.get_availability(info_hash)["transcoded"][0] is True):
logger.info("Torrent not ready, caching in progress")
return NO_CACHE_VIDEO_URL
logger.info("Torrent is ready.")
# Assuming the transfer is complete, we need to find whether it's a file or a folder
transfers = self.list_transfers()
item_id, is_folder = None, False
for item in transfers.get('transfers', []):
if item['id'] == transfer_id:
if item.get('folder_id'):
item_id = item['folder_id']
is_folder = True
else:
item_id = item['file_id']
break
if not item_id:
logger.error("Transfer completed but no item ID found.")
return "Error: Transfer completed but no item ID found."
details = self.get_folder_or_file_details(item_id, is_folder)
logger.info(f"Got details")
if stream_type == "movie":
logger.info("Getting link for movie")
# For movies, we pick the largest file in the folder or the file itself
if is_folder:
link = max(details.get("content", []), key=lambda x: x["size"])["link"]
else:
link = details.get('link')
elif stream_type == "series":
logger.info("Getting link for series")
if is_folder:
season = query["season"]
episode = query["episode"]
files = details.get("content", [])
matching_files = []
for file in files:
if season_episode_in_filename(file["name"], season, episode):
matching_files.append(file)
if len(matching_files) == 0:
logger.error(f"No matching files for {season} {episode} in torrent.")
return f"Error: No matching files for {season} {episode} in torrent."
link = max(matching_files, key=lambda x: x["size"])["link"]
else:
link = details.get('link')
else:
logger.error("Unsupported stream type.")
return "Error: Unsupported stream type."
logger.info(f"Link generated: {link}")
return link

View file

@ -0,0 +1,285 @@
import re
import json
import time
from urllib.parse import unquote
import requests
from stream_fusion.constants import NO_CACHE_VIDEO_URL
from stream_fusion.utils.debrid.base_debrid import BaseDebrid
from stream_fusion.utils.general import get_info_hash_from_magnet
from stream_fusion.utils.general import is_video_file
from stream_fusion.utils.general import season_episode_in_filename
from stream_fusion.logging_config import logger
class RealDebrid(BaseDebrid):
def __init__(self, config):
super().__init__(config)
self.base_url = "https://api.real-debrid.com"
self.headers = {"Authorization": f"Bearer {self.config['debridKey']}"}
def add_magnet(self, magnet, ip=None):
url = f"{self.base_url}/rest/1.0/torrents/addMagnet"
data = {"magnet": magnet}
logger.info(f"Adding magnet to RD: {magnet}")
return self.get_json_response(url, method='post', headers=self.headers, data=data)
def add_torrent(self, torrent_file):
url = f"{self.base_url}/rest/1.0/torrents/addTorrent"
return self.get_json_response(url, method='put', headers=self.headers, data=torrent_file)
def delete_torrent(self, id):
url = f"{self.base_url}/rest/1.0/torrents/delete/{id}"
return self.get_json_response(url, method='delete', headers=self.headers)
def get_torrent_info(self, torrent_id):
logger.info(f"Getting torrent info for: {torrent_id}")
url = f"{self.base_url}/rest/1.0/torrents/info/{torrent_id}"
torrent_info = self.get_json_response(url, headers=self.headers)
if not torrent_info or 'files' not in torrent_info:
return None
return torrent_info
def select_files(self, torrent_id, file_id):
logger.info(f"Selecting file(s): {file_id}")
url = f"{self.base_url}/rest/1.0/torrents/selectFiles/{torrent_id}"
data = {"files": str(file_id)}
requests.post(url, headers=self.headers, data=data)
def unrestrict_link(self, link):
url = f"{self.base_url}/rest/1.0/unrestrict/link"
data = {"link": link}
return self.get_json_response(url, method='post', headers=self.headers, data=data)
def is_already_added(self, magnet):
hash = magnet.split("urn:btih:")[1].split("&")[0].lower()
url = f"{self.base_url}/rest/1.0/torrents"
torrents = self.get_json_response(url, headers=self.headers)
for torrent in torrents:
if torrent['hash'].lower() == hash:
return torrent['id']
return False
def wait_for_link(self, torrent_id, timeout=30, interval=5):
start_time = time.time()
while time.time() - start_time < timeout:
torrent_info = self.get_torrent_info(torrent_id)
if torrent_info and 'links' in torrent_info and len(torrent_info['links']) > 0:
return torrent_info['links']
time.sleep(interval)
return None
def get_availability_bulk(self, hashes_or_magnets, ip=None):
if len(hashes_or_magnets) == 0:
logger.info("No hashes to be sent to Real-Debrid.")
return dict()
url = f"{self.base_url}/rest/1.0/torrents/instantAvailability/{'/'.join(hashes_or_magnets)}"
return self.get_json_response(url, headers=self.headers)
def get_stream_link(self, query_string, config, ip=None):
query = json.loads(query_string)
magnet = query['magnet']
stream_type = query['type']
file_index = int(query['file_index']) if query['file_index'] is not None else None
season = query['season']
episode = query['episode']
torrent_download = unquote(query["torrent_download"]) if query["torrent_download"] is not None else None
info_hash = get_info_hash_from_magnet(magnet)
logger.info(f"RealDebrid get stream link for {stream_type} with hash: {info_hash}")
cached_torrent_ids = self.__get_cached_torrent_ids(info_hash)
logger.info(f"Found {len(cached_torrent_ids)} cached torrents with hash: {info_hash}")
torrent_info = None
if len(cached_torrent_ids) > 0:
if stream_type == "movie":
torrent_info = self.get_torrent_info(cached_torrent_ids[0])
elif stream_type == "series":
torrent_info = self.__get_cached_torrent_info(cached_torrent_ids, file_index, season, episode)
else:
return "Error: Unsupported stream type."
# The torrent is not yet added
if torrent_info is None:
torrent_info = self.__add_magnet_or_torrent(magnet, torrent_download, anon_magnet=config["anonymizeMagnets"])
if not torrent_info or 'files' not in torrent_info:
return "Error: Failed to get torrent info."
logger.info("Selecting file")
self.__select_file(torrent_info, stream_type, file_index, season, episode)
# == operator, to avoid adding the season pack twice and setting 5 as season pack treshold
if len(cached_torrent_ids) == 0 and stream_type == "series" and len(torrent_info["files"]) > 5:
logger.info("Prefetching season pack")
prefetched_torrent_info = self.__prefetch_season_pack(magnet, torrent_download)
if len(prefetched_torrent_info["links"]) > 0:
self.delete_torrent(torrent_info["id"])
torrent_info = prefetched_torrent_info
torrent_id = torrent_info["id"]
logger.info(f"Waiting for the link(s) to be ready for torrent ID: {torrent_id}")
# Waiting for the link(s) to be ready
links = self.wait_for_link(torrent_id)
if links is None:
return NO_CACHE_VIDEO_URL
if len(links) > 1:
logger.info("Finding appropiate link")
download_link = self.__find_appropiate_link(torrent_info, links, file_index, season, episode)
else:
download_link = links[0]
logger.info(f"Unrestricting the download link: {download_link}")
# Unrestricting the download link
unrestrict_response = self.unrestrict_link(download_link)
if not unrestrict_response or 'download' not in unrestrict_response:
return "Error: Failed to unrestrict link."
logger.info(f"Got download link: {unrestrict_response['download']}")
return unrestrict_response['download']
def __get_cached_torrent_ids(self, info_hash):
url = f"{self.base_url}/rest/1.0/torrents"
torrents = self.get_json_response(url, headers=self.headers)
logger.info(f"Searching users real-debrid downloads for {info_hash}")
torrent_ids = []
for torrent in torrents:
if torrent['hash'].lower() == info_hash:
torrent_ids.append(torrent['id'])
return torrent_ids
def __get_cached_torrent_info(self, cached_ids, file_index, season, episode):
cached_torrents = []
for cached_torrent_id in cached_ids:
cached_torrent_info = self.get_torrent_info(cached_torrent_id)
if self.__torrent_contains_file(cached_torrent_info, file_index, season, episode):
if len(cached_torrent_info["links"]) > 0: # If the links are ready
return cached_torrent_info
cached_torrents.append(cached_torrent_info)
if len(cached_torrents) == 0:
return None
return max(cached_torrents, key=lambda x: x['progress'])
def __torrent_contains_file(self, torrent_info, file_index, season, episode):
if not torrent_info or "files" not in torrent_info:
return False
if file_index is None:
for file in torrent_info["files"]:
if file["selected"] and season_episode_in_filename(file['path'], season, episode):
return True
else:
for file in torrent_info["files"]:
if file['id'] == file_index:
return file["selected"] == 1
return False
def __add_magnet_or_torrent(self, magnet, torrent_download=None, anon_magnet=False):
torrent_id = ""
if torrent_download is None:
logger.info(f"Adding magnet to RealDebrid")
if anon_magnet:
magnet = re.sub(r'&tr=[^&]*', '', magnet)
magnet_response = self.add_magnet(magnet)
logger.info(f"RealDebrid add magnet response: {magnet_response}")
if not magnet_response or 'id' not in magnet_response:
return "Error: Failed to add magnet."
torrent_id = magnet_response['id']
elif anon_magnet and torrent_download:
logger.info(f"Adding magnet to RealDebrid")
clean_magnet = re.sub(r'&tr=[^&]*', '', magnet)
magnet_response = self.add_magnet(clean_magnet)
logger.info(f"RealDebrid add magnet response: {magnet_response}")
if not magnet_response or 'id' not in magnet_response:
return "Error: Failed to add magnet."
torrent_id = magnet_response['id']
else:
logger.info(f"Downloading torrent file from Jackett")
torrent_file = self.donwload_torrent_file(torrent_download)
logger.info(f"Torrent file downloaded from Jackett")
logger.info(f"Adding torrent file to RealDebrid")
upload_response = self.add_torrent(torrent_file)
logger.info(f"RealDebrid add torrent file response: {upload_response}")
if not upload_response or 'id' not in upload_response:
return "Error: Failed to add torrent file."
torrent_id = upload_response['id']
logger.info(f"New torrent ID: {torrent_id}")
return self.get_torrent_info(torrent_id)
def __prefetch_season_pack(self, magnet, torrent_download):
torrent_info = self.__add_magnet_or_torrent(magnet, torrent_download)
video_file_indexes = []
for file in torrent_info["files"]:
if is_video_file(file["path"]):
video_file_indexes.append(str(file["id"]))
self.select_files(torrent_info["id"], ",".join(video_file_indexes))
time.sleep(10)
return self.get_torrent_info(torrent_info["id"])
def __select_file(self, torrent_info, stream_type, file_index, season, episode):
torrent_id = torrent_info["id"]
if file_index is not None:
logger.info(f"Selecting file_index: {file_index}")
self.select_files(torrent_id, file_index)
return
files = torrent_info["files"]
if stream_type == "movie":
largest_file_id = max(files, key=lambda x: x['bytes'])['id']
logger.info(f"Selecting file_index: {largest_file_id}")
self.select_files(torrent_id, largest_file_id)
elif stream_type == "series":
matching_files = []
for file in files:
if season_episode_in_filename(file["path"], season, episode):
matching_files.append(file)
largest_file_id = max(matching_files, key=lambda x: x['bytes'])['id']
logger.info(f"Selecting file_index: {largest_file_id}")
self.select_files(torrent_id, largest_file_id)
def __find_appropiate_link(self, torrent_info, links, file_index, season, episode):
selected_files = list(filter(lambda file: file["selected"] == 1, torrent_info["files"]))
index = 0
if file_index is not None:
for file in selected_files:
if file["id"] == file_index:
break
index += 1
else:
matching_indexes = []
for file in selected_files:
if season_episode_in_filename(file["path"], season, episode):
matching_indexes.append({"index": index, "file": file})
index += 1
index = max(matching_indexes, lambda x: x["file"]["bytes"])["index"]
if len(links) - 1 < index:
logger.debug(f"From selected files {selected_files}, index: {index} is out of range for {links}.")
return NO_CACHE_VIDEO_URL
return links[index]

View file

@ -0,0 +1,28 @@
import re
def detect_languages(torrent_name):
language_patterns = {
"fr": r"\b(?:FR(?:ench|a|e|anc[eê]s)?|V(?:O?F(?:F|I|i)?|O?Q)|TRUEFRENCH|VOST(?:FR)?|SUBFRENCH)\b",
"en": r"\b(?:EN(?:G(?:LISH)?)?|VOST(?:EN)?|SUBBED)\b",
"es": r"\b(?:ES(?:P(?:ANISH)?)?|VOSE|SUBESP)\b",
"de": r"\b(?:DE(?:UTSCH|RMAN)?|GER(?:MAN)?|SUBGER)\b",
"it": r"\b(?:IT(?:A(?:LIAN)?)?|SUBITA)\b",
"pt": r"\b(?:PT(?:-BR)?|POR(?:TUGUESE)?|LEGENDADO)\b",
"ru": r"\b(?:RU(?:S(?:SIAN)?)?|SUBSRUS)\b",
"in": r"\b(?:INDIAN|HINDI|TELUGU|TAMIL|KANNADA|MALAYALAM|PUNJABI|MARATHI|BENGALI|GUJARATI|URDU|ODIA|ASSAMESE|KONKANI|MANIPURI|NEPALI|SANSKRIT|SINHALA|SINDHI|TIBETAN|BHOJPURI|DHIVEHI|KASHMIRI|KURUKH|MAITHILI|NEWARI|RAJASTHANI|SANTALI|SINDHI|TULU)\b",
"nl": r"\b(?:NL(?:D)?|DUTCH|SUBSNL)\b",
"hu": r"\b(?:HU(?:N(?:GARIAN)?)?|SUBHUN)\b",
"la": r"\b(?:LA(?:TIN(?:O)?)?)\b",
"multi": r"\b(?:MULTI(?:LANG(?:UE)?)?|DUAL(?:AUDIO)?|VF2)\b",
}
languages = []
for language, pattern in language_patterns.items():
if re.search(pattern, torrent_name, re.IGNORECASE):
languages.append(language)
if len(languages) == 0:
return ["en"]
return languages

View file

@ -0,0 +1,15 @@
class BaseFilter:
def __init__(self, config, additional_config=None):
self.config = config
self.item_type = additional_config
def filter(self, data):
raise NotImplementedError
def can_filter(self):
raise NotImplementedError
def __call__(self, data):
if self.config is not None and self.can_filter():
return self.filter(data)
return data

View file

@ -0,0 +1,52 @@
import re
from stream_fusion.utils.filter.base_filter import BaseFilter
class LanguageFilter(BaseFilter):
def __init__(self, config):
super().__init__(config)
self.fr_regex_patterns = [
r"^(BlackAngel|Choco|HDForever|MAX|ONLY|Psaro|Sicario|Tezcat74|TyrellCorp|Zapax)$",
r"^(BDHD|FtLi|Goldenyann|HeavyWeight|MARBLECAKE|MUSTANG|Obi|PEPiTE|QUEBEC63|QC63|ROMKENT)$",
r"^(FLOP|FRATERNiTY|FoX|Psaro)$",
r"^(DUSTiN|FCK|FrIeNdS|QUALiTY)$",
r"^(BDHD|FoX|FRATERNiTY|FrIeNdS|MAX|Psaro|T3KASHi)$",
r"^(FUJiSAN|HANAMi|HDForever|HeavyWeight|MARBLECAKE|MYSTERiON|NoNE|ONLY|ONLYMOViE|TkHD|UTT)$",
r"^(BONBON|FCK|FW|FoX|FRATERNiTY|FrIeNdS|MOONLY|MTDK|PATOPESTO|Psaro|T3KASHi|TFA)$",
r"^(ALLDAYiN|ARK01|FUJiSAN|HANAMi|HeavyWeight|NEO|NoNe|ONLYMOViE|Slay3R|TkHD)$",
r"^(4FR|AiR3D|AiRDOCS|AiRFORCE|AiRLiNE|AiRTV|AKLHD|AMB3R)$",
r"^(ANMWR|AVON|AYMO|AZR|BANKAi|BAWLS|BiPOLAR|BLACKPANTERS|BODIE|BOOLZ|BRiNK|CARAPiLS|CiELOS)$",
r"^(CiNEMA|CMBHD|CoRa|COUAC|CRYPT0|D4KiD|DEAL|DiEBEX|DUPLI|DUSS|ENJOi|EUBDS|FHD|FiDELiO|FiDO|ForceBleue)$",
r"^(FREAMON|FRENCHDEADPOOL2|FRiES|FUTiL|FWDHD|GHOULS|GiMBAP|GLiMMER|Goatlove|HERC|HiggsBoson|HiRoSHiMa)$",
r"^(HYBRiS|HyDe|JMT|JoKeR|JUSTICELEAGUE|KAZETV|L0SERNiGHT|LaoZi|LeON|LOFiDEL|LOST|LOWIMDB|LYPSG|MAGiCAL)$",
r"^(MANGACiTY|MAXAGAZ|MaxiBeNoul|McNULTY|MELBA|MiND|MORELAND|MUNSTER|MUxHD|NERDHD|NERO|NrZ|NTK|OBSTACLE)$",
r"^(OohLaLa|OOKAMI|PANZeR|PiNKPANTERS|PKPTRS|PRiDEHD|PROPJOE|PURE|PUREWASTEOFBW|ROUGH|RUDE|Ryotox|SAFETY)$",
r"^(SASHiMi|SEiGHT|SESKAPiLE|SHEEEiT|SHiNiGAMi(UHD)?|SiGeRiS|SILVIODANTE|SLEEPINGFOREST|SODAPOP|S4LVE|SPINE)$",
r"^(SPOiLER|STRINGERBELL|SUNRiSE|tFR|THENiGHTMAREiNHD|THiNK|THREESOME|TiMELiNE|TSuNaMi|UKDHD|UKDTV|ULSHD|Ulysse)$",
r"^(USUNSKiLLED|URY|VENUE|VFC|VoMiT|Wednesday29th|ZEST|ZiRCON)$",
]
self.fr_regex = re.compile("|".join(self.fr_regex_patterns))
def filter(self, data):
filtered_data = []
for torrent in data:
if not torrent.languages:
continue
languages = torrent.languages.copy()
if torrent.from_cache and "multi" in languages:
if self.fr_regex.search(torrent.raw_title):
languages.remove("multi")
if "multi" in languages or any(
lang in self.config["languages"] for lang in languages
):
torrent.languages = languages
filtered_data.append(torrent)
return filtered_data
def can_filter(self):
return self.config["languages"] is not None

View file

@ -0,0 +1,23 @@
from stream_fusion.utils.filter.base_filter import BaseFilter
from stream_fusion.logging_config import logger
class MaxSizeFilter(BaseFilter):
def __init__(self, config, additional_config=None):
super().__init__(config, additional_config)
self.max_size_bytes = int(self.config['maxSize']) * 1024 * 1024 * 1024 # Convertir Go en octets
def filter(self, data):
filtered_data = []
for torrent in data:
torrent_size = int(torrent.size) if isinstance(torrent.size, str) else torrent.size
if torrent_size <= self.max_size_bytes:
filtered_data.append(torrent)
else:
logger.debug(f"Excluded torrent due to size: {torrent.raw_title}, Size: {torrent_size / (1024*1024*1024):.2f} GB")
logger.info(f"MaxSizeFilter: input {len(data)}, output {len(filtered_data)}")
return filtered_data
def can_filter(self):
return int(self.config['maxSize']) > 0 and self.item_type == 'movie'

View file

@ -0,0 +1,43 @@
from stream_fusion.utils.filter.base_filter import BaseFilter
from stream_fusion.logging_config import logger
class QualityExclusionFilter(BaseFilter):
RIPS = {"HDRIP", "BRRIP", "BDRIP", "WEBRIP", "TVRIP", "VODRIP"}
CAMS = {"CAM", "TS", "TC", "R5", "DVDSCR", "HDTV", "PDTV", "DSR", "WORKPRINT", "VHSRIP", "HDCAM"}
def __init__(self, config: dict):
super().__init__(config)
self.excluded_qualities = {quality.upper() for quality in self.config.get('exclusion', [])}
self.exclude_rips = "RIPS" in self.excluded_qualities
self.exclude_cams = "CAM" in self.excluded_qualities
def filter(self, data):
return [
stream for stream in data
if self._is_stream_allowed(stream)
]
def _is_stream_allowed(self, stream) -> bool:
if any(q.upper() in self.excluded_qualities for q in stream.parsed_data.quality):
logger.debug(f"Stream excluded due to main quality: {stream.parsed_data.quality}")
return False
if stream.parsed_data.resolution:
for item in stream.parsed_data.resolution:
item_upper = item.upper()
if item_upper in self.excluded_qualities:
logger.debug(f"Stream excluded due to quality spec: {item}")
return False
if self.exclude_rips and item_upper in self.RIPS:
logger.debug(f"Stream excluded due to RIP: {item}")
return False
if self.exclude_cams and item_upper in self.CAMS:
logger.debug(f"Stream excluded due to CAM: {item}")
return False
logger.debug("Stream allowed")
return True
def can_filter(self) -> bool:
return bool(self.excluded_qualities)

View file

@ -0,0 +1,33 @@
from stream_fusion.utils.filter.base_filter import BaseFilter
from stream_fusion.logging_config import logger
class ResultsPerQualityFilter(BaseFilter):
def __init__(self, config):
super().__init__(config)
self.max_results_per_quality = int(self.config.get('resultsPerQuality', 5))
def filter(self, data):
filtered_items = []
resolution_count = {}
for item in data:
resolutions = getattr(item.parsed_data, 'resolution', [])
if not resolutions:
resolutions = ["?.BZH.?"]
for resolution in resolutions:
if resolution not in resolution_count:
resolution_count[resolution] = 1
filtered_items.append(item)
break
elif resolution_count[resolution] < self.max_results_per_quality:
resolution_count[resolution] += 1
filtered_items.append(item)
break
logger.info(f"ResultsPerQualityFilter: input {len(data)}, output {len(filtered_items)}")
return filtered_items
def can_filter(self):
return self.max_results_per_quality > 0

View file

@ -0,0 +1,32 @@
from stream_fusion.utils.filter.base_filter import BaseFilter
from stream_fusion.logging_config import logger
class TitleExclusionFilter(BaseFilter):
def __init__(self, config):
super().__init__(config)
self.excluded_keywords = {keyword.upper() for keyword in self.config.get('exclusionKeywords', [])}
def filter(self, data):
filtered_items = []
for stream in data:
if self._should_include_stream(stream):
filtered_items.append(stream)
logger.info(f"TitleExclusionFilter: input {len(data)}, output {len(filtered_items)}")
return filtered_items
def _should_include_stream(self, stream):
try:
title_upper = stream.raw_title.upper()
for keyword in self.excluded_keywords:
if keyword in title_upper:
logger.debug(f"Excluded stream: {stream.raw_title} (keyword: {keyword})")
return False
return True
except AttributeError:
logger.warning(f"Stream has no title attribute: {stream}")
return False
def can_filter(self):
return bool(self.excluded_keywords)

View file

@ -0,0 +1,196 @@
import re
from typing import List
from RTN import title_match, RTN, DefaultRanking, SettingsModel, sort_torrents
from stream_fusion.utils.filter.language_filter import LanguageFilter
from stream_fusion.utils.filter.max_size_filter import MaxSizeFilter
from stream_fusion.utils.filter.quality_exclusion_filter import QualityExclusionFilter
from stream_fusion.utils.filter.results_per_quality_filter import ResultsPerQualityFilter
from stream_fusion.utils.filter.title_exclusion_filter import TitleExclusionFilter
from stream_fusion.utils.torrent.torrent_item import TorrentItem
from stream_fusion.logging_config import logger
quality_order = {"4k": 0, "2160p": 0, "1080p": 1, "720p": 2, "480p": 3}
def sort_quality(item):
logger.debug(f"Evaluating quality for item: {item.raw_title}")
if len(item.parsed_data.data.resolution) == 0:
return float("inf"), True
resolution = item.parsed_data.data.resolution[0]
priority = quality_order.get(resolution, float("inf"))
return priority, item.parsed_data.data.resolution is None
def items_sort(items, config):
logger.info(f"Starting item sorting. Sort method: {config['sort']}")
settings = SettingsModel(
require=[],
exclude=config["exclusionKeywords"] + config["exclusion"],
preferred=[],
)
rtn = RTN(settings=settings, ranking_model=DefaultRanking())
logger.debug("Applying RTN ranking to items")
torrents = [rtn.rank(item.raw_title, item.info_hash) for item in items]
sorted_torrents = sort_torrents(set(torrents))
for key, value in sorted_torrents.items():
index = next((i for i, item in enumerate(items) if item.info_hash == key), None)
if index is not None:
items[index].parsed_data = value
logger.info(f"Sorting items by method: {config['sort']}")
if config["sort"] == "quality":
sorted_items = sorted(items, key=sort_quality)
elif config["sort"] == "sizeasc":
sorted_items = sorted(items, key=lambda x: int(x.size))
elif config["sort"] == "sizedesc":
sorted_items = sorted(items, key=lambda x: int(x.size), reverse=True)
elif config["sort"] == "qualitythensize":
sorted_items = sorted(items, key=lambda x: (sort_quality(x), -int(x.size)))
else:
logger.warning(
f"Unrecognized sort method: {config['sort']}. No sorting applied."
)
sorted_items = items
logger.info(f"Sorting complete. Number of sorted items: {len(sorted_items)}")
return sorted_items
def filter_out_non_matching_movies(items, year):
logger.info(f"Filtering non-matching movies for year : {year}")
year_pattern = re.compile(rf'\b{year}\b')
filtered_items = []
for item in items:
logger.debug(f"Checking item: {item.raw_title}")
if year_pattern.search(item.raw_title):
logger.debug("Match found")
filtered_items.append(item)
else:
logger.debug("No match found")
return filtered_items
def filter_out_non_matching_series(items, season, episode):
logger.info(
f"Filtering non-matching items for season {season} and episode {episode}"
)
filtered_items = []
clean_season = season.replace("S", "")
clean_episode = episode.replace("E", "")
numeric_season = int(clean_season)
numeric_episode = int(clean_episode)
for item in items:
logger.debug(f"Checking item: {item.raw_title}")
if len(item.parsed_data.season) == 0 and len(item.parsed_data.episode) == 0:
logger.debug("Item with no season and episode, skipped")
continue
if (
len(item.parsed_data.episode) == 0
and numeric_season in item.parsed_data.season
):
logger.debug("Season match found, episode not specified")
filtered_items.append(item)
continue
if (
numeric_season in item.parsed_data.season
and numeric_episode in item.parsed_data.episode
):
logger.debug("Exact season and episode match found")
filtered_items.append(item)
continue
logger.info(
f"Filtering complete. {len(filtered_items)} matching items found out of {len(items)} total"
)
return filtered_items
def remove_non_matching_title(items, titles):
logger.info(f"Removing items not matching titles: {titles}")
filtered_items = []
for item in items:
for title in titles:
if title_match(title, item.parsed_data.parsed_title):
filtered_items.append(item)
break
else:
logger.debug("No title match found, item skipped")
logger.info(
f"Title filtering complete. {len(filtered_items)} items kept out of {len(items)} total"
)
return filtered_items
def filter_items(items, media, config):
logger.info(f"Starting item filtering for media: {media.titles[0]}")
filters = {
"languages": LanguageFilter(config),
"maxSize": MaxSizeFilter(config, media.type),
"exclusionKeywords": TitleExclusionFilter(config),
"exclusion": QualityExclusionFilter(config),
"resultsPerQuality": ResultsPerQualityFilter(config),
}
logger.info(f"Initial item count: {len(items)}")
if media.type == "series":
logger.info(f"Filtering out non-matching series torrents")
items = filter_out_non_matching_series(items, media.season, media.episode)
logger.info(f"Item count after season/episode filtering: {len(items)}")
if media.type == "movie":
logger.info(f"Filtering out non-matching movie torrents")
items = filter_out_non_matching_movies(items, media.year)
logger.info(f"Item count after year filtering: {len(items)}")
items = remove_non_matching_title(items, media.titles)
logger.info(f"Item count after title filtering: {len(items)}")
for filter_name, filter_instance in filters.items():
try:
logger.info(f"Applying {filter_name} filter: {config[filter_name]}")
items = filter_instance(items)
logger.info(f"Item count after {filter_name} filter: {len(items)}")
except Exception as e:
logger.error(f"Error while applying {filter_name} filter", exc_info=e)
logger.info(f"Filtering complete. Final item count: {len(items)}")
return items
def sort_items(items, config):
if config["sort"] is not None:
logger.info(f"Sorting items according to config: {config['sort']}")
return items_sort(items, config)
else:
logger.info("No sorting specified, returning items in original order")
return items
def merge_items(
cache_items: List[TorrentItem], search_items: List[TorrentItem]
) -> List[TorrentItem]:
logger.info(
f"Merging cached items ({len(cache_items)}) and search items ({len(search_items)})"
)
merged_dict = {}
def add_to_merged(item):
if item.raw_title not in merged_dict:
merged_dict[item.raw_title] = item
else:
if item.seeders > merged_dict[item.raw_title].seeders:
merged_dict[item.raw_title] = item
for item in cache_items:
add_to_merged(item)
for item in search_items:
add_to_merged(item)
merged_items = list(merged_dict.values())
logger.info(f"Merging complete. Total unique items: {len(merged_items)}")
return merged_items

View file

@ -0,0 +1,42 @@
from RTN import parse
from stream_fusion.logging_config import logger
video_formats = {".mkv", ".mp4", ".avi", ".mov", ".flv", ".wmv", ".webm", ".mpg", ".mpeg", ".m4v", ".3gp", ".3g2",
".ogv",
".ogg", ".drc", ".gif", ".gifv", ".mng", ".avi", ".mov", ".qt", ".wmv", ".yuv", ".rm", ".rmvb", ".asf",
".amv", ".m4p", ".m4v", ".mpg", ".mp2", ".mpeg", ".mpe", ".mpv", ".mpg", ".mpeg", ".m2v", ".m4v",
".svi", ".3gp", ".3g2", ".mxf", ".roq", ".nsv", ".flv", ".f4v", ".f4p", ".f4a", ".f4b"}
def season_episode_in_filename(filename, season, episode):
if not is_video_file(filename):
return False
parsed_name = parse(filename)
return season in parsed_name.season and episode in parsed_name.episode
def get_info_hash_from_magnet(magnet: str):
exact_topic_index = magnet.find("xt=")
if exact_topic_index == -1:
logger.debug(f"No exact topic in magnet {magnet}")
return None
exact_topic_substring = magnet[exact_topic_index:]
end_of_exact_topic = exact_topic_substring.find("&")
if end_of_exact_topic != -1:
exact_topic_substring = exact_topic_substring[:end_of_exact_topic]
info_hash = exact_topic_substring[exact_topic_substring.rfind(":") + 1:]
return info_hash.lower()
def is_video_file(filename):
extension_idx = filename.rfind(".")
if extension_idx == -1:
return False
return filename[extension_idx:] in video_formats

View file

@ -0,0 +1,6 @@
from stream_fusion.utils.jackett.jackett_indexer import JackettIndexer
from stream_fusion.utils.jackett.jackett_result import JackettResult
from stream_fusion.utils.jackett.jackett_service import JackettService
__all__ = ["JackettIndexer", "JackettResult", "JackettService"]

View file

@ -0,0 +1,9 @@
class JackettIndexer:
def __init__(self):
self.title = None
self.id = None
self.link = None
self.type = None
self.language = None
self.tv_search_capatabilities = None
self.movie_search_capatabilities = None

View file

@ -0,0 +1,61 @@
from RTN import parse
from stream_fusion.utils.torrent.torrent_item import TorrentItem
from stream_fusion.logging_config import logger
class JackettResult:
def __init__(self):
self.raw_title = None # Raw title of the torrent
self.size = None # Size of the torrent
self.link = None # Download link for the torrent file or magnet url
self.indexer = None # Indexer
self.seeders = None # Seeders count
self.magnet = None # Magnet url
self.info_hash = None # infoHash by Jackett
self.privacy = None # public or private
self.from_cache = None
# Extra processed details for further filtering
self.languages = None # Language of the torrent
self.type = None # series or movie
self.parsed_data = None # Ranked result
def convert_to_torrent_item(self):
return TorrentItem(
self.raw_title,
self.size,
self.magnet,
self.info_hash.lower() if self.info_hash is not None else None,
self.link,
self.seeders,
self.languages,
self.indexer,
self.privacy,
self.type,
self.parsed_data
)
def from_cached_item(self, cached_item, media):
if type(cached_item) is not dict:
logger.error(cached_item)
self.info_hash = cached_item['hash']
if len(self.info_hash) != 40:
raise ValueError(f"The hash '{self.info_hash}' does not have the expected length of 40 characters.")
parsed_result = parse(cached_item['title'])
self.raw_title = cached_item['title']
self.indexer = "Cache Public" # Cache doesn't return an indexer sadly (It stores it tho)
self.magnet = cached_item['magnet']
self.link = cached_item['magnet']
self.languages = cached_item['language'].split(";") if cached_item['language'] is not None else []
self.seeders = cached_item['seeders']
self.size = cached_item['size']
self.type = media.type
self.from_cache = True
self.parsed_data = parsed_result
return self

View file

@ -0,0 +1,279 @@
import os
import queue
import threading
import time
import xml.etree.ElementTree as ET
import requests
from RTN import parse, ParsedData
from stream_fusion.utils.jackett.jackett_indexer import JackettIndexer
from stream_fusion.utils.jackett.jackett_result import JackettResult
from stream_fusion.utils.models.movie import Movie
from stream_fusion.utils.models.series import Series
from stream_fusion.utils.detection import detect_languages
from stream_fusion.logging_config import logger
class JackettService:
def __init__(self, config):
self.logger = logger
self.__api_key = config['jackettApiKey']
self.__base_url = f"{config['jackettHost']}/api/v2.0"
self.__session = requests.Session()
def search(self, media):
self.logger.info("Started Jackett search for " + media.type + " " + media.titles[0])
indexers = self.__get_indexers()
threads = []
results_queue = queue.Queue() # Create a Queue instance to hold the results
# Define a wrapper function that calls the actual target function and stores its return value in the queue
def thread_target(media, indexer):
self.logger.info(f"Searching on {indexer.title}")
start_time = time.time()
# Call the actual function
if isinstance(media, Movie):
result = self.__search_movie_indexer(media, indexer)
elif isinstance(media, Series):
result = self.__search_series_indexer(media, indexer)
else:
raise TypeError("Only Movie and Series is allowed as media!")
self.logger.info(
f"Search on {indexer.title} took {time.time() - start_time} seconds and found {len([result for sublist in result for result in sublist])} results")
results_queue.put(result) # Put the result in the queue
for indexer in indexers:
# Pass the wrapper function as the target to Thread, with necessary arguments
threads.append(threading.Thread(target=thread_target, args=(media, indexer)))
for thread in threads:
thread.start()
for thread in threads:
thread.join()
results = []
# Retrieve results from the queue and append them to the results list
while not results_queue.empty():
results.extend(results_queue.get())
flatten_results = [result for sublist in results for result in sublist]
return self.__post_process_results(flatten_results, media)
def __search_movie_indexer(self, movie, indexer):
# url = f"{self.__base_url}/indexers/all/results/torznab/api?apikey={self.__api_key}&t=movie&cat=2000&q={movie.title}&year={movie.year}"
has_imdb_search_capability = (os.getenv(
"DISABLE_JACKETT_IMDB_SEARCH") != "true" and indexer.movie_search_capatabilities is not None and 'imdbid' in indexer.movie_search_capatabilities)
if has_imdb_search_capability:
languages = ['en']
index_of_language = [index for index, lang in enumerate(movie.languages) if lang == 'en'][0]
titles = [movie.titles[index_of_language]]
elif indexer.language == "en":
languages = movie.languages
titles = movie.titles
else:
index_of_language = [index for index, lang in enumerate(movie.languages) if
lang == indexer.language or lang == 'en']
languages = [movie.languages[index] for index in index_of_language]
titles = [movie.titles[index] for index in index_of_language]
results = []
for index, lang in enumerate(languages):
params = {
'apikey': self.__api_key,
't': 'movie',
'cat': '2000',
'q': titles[index],
'year': movie.year,
}
if has_imdb_search_capability:
params['imdbid'] = movie.id
url = f"{self.__base_url}/indexers/{indexer.id}/results/torznab/api"
url += '?' + '&'.join([f'{k}={v}' for k, v in params.items()])
try:
response = self.__session.get(url)
response.raise_for_status()
results.append(self.__get_torrent_links_from_xml(response.text))
except Exception:
self.logger.exception(
f"An exception occured while searching for a movie on Jackett with indexer {indexer.title} and "
f"language {lang}.")
return results
def __search_series_indexer(self, series, indexer):
season = str(int(series.season.replace('S', '')))
episode = str(int(series.episode.replace('E', '')))
has_imdb_search_capability = (os.getenv("DISABLE_JACKETT_IMDB_SEARCH") != "true"
and indexer.tv_search_capatabilities is not None
and 'imdbid' in indexer.tv_search_capatabilities)
if has_imdb_search_capability:
languages = ['en']
index_of_language = [index for index, lang in enumerate(series.languages) if lang == 'en'][0]
titles = [series.titles[index_of_language]]
elif indexer.language == "en":
languages = series.languages
titles = series.titles
else:
index_of_language = [index for index, lang in enumerate(series.languages) if
lang == indexer.language or lang == 'en']
languages = [series.languages[index] for index in index_of_language]
titles = [series.titles[index] for index in index_of_language]
results = []
for index, lang in enumerate(languages):
params = {
'apikey': self.__api_key,
't': 'tvsearch',
'cat': '5000',
'q': titles[index],
}
if has_imdb_search_capability:
params['imdbid'] = series.id
url_title = f"{self.__base_url}/indexers/{indexer.id}/results/torznab/api"
url_title += '?' + '&'.join([f'{k}={v}' for k, v in params.items()])
url_season = f"{self.__base_url}/indexers/{indexer.id}/results/torznab/api"
params['season'] = season
url_season += '?' + '&'.join([f'{k}={v}' for k, v in params.items()])
url_ep = f"{self.__base_url}/indexers/{indexer.id}/results/torznab/api"
params['ep'] = episode
url_ep += '?' + '&'.join([f'{k}={v}' for k, v in params.items()])
try:
# Current functionality is that it returns if the season, episode search was successful. This is subject to change
# TODO: what should we prioritize? season, episode or title?
response_ep = self.__session.get(url_ep)
response_ep.raise_for_status()
response_season = self.__session.get(url_season)
response_season.raise_for_status()
data_ep = self.__get_torrent_links_from_xml(response_ep.text)
data_season = self.__get_torrent_links_from_xml(response_season.text)
if data_ep:
results.append(data_ep)
if data_season:
results.append(data_season)
if not data_ep and not data_season:
response_title = self.__session.get(url_title)
response_title.raise_for_status()
data_title = self.__get_torrent_links_from_xml(response_title.text)
if data_title:
results.append(data_title)
except Exception:
self.logger.exception(
f"An exception occured while searching for a series on Jackett with indexer {indexer.title} and language {lang}.")
return results
def __get_indexers(self):
url = f"{self.__base_url}/indexers/all/results/torznab/api?apikey={self.__api_key}&t=indexers&configured=true"
try:
response = self.__session.get(url)
response.raise_for_status()
return self.__get_indexer_from_xml(response.text)
except Exception:
self.logger.exception("An exception occured while getting indexers from Jackett.")
return []
def __get_indexer_from_xml(self, xml_content):
xml_root = ET.fromstring(xml_content)
indexer_list = []
for item in xml_root.findall('.//indexer'):
indexer = JackettIndexer()
indexer.title = item.find('title').text
indexer.id = item.attrib['id']
indexer.link = item.find('link').text
indexer.type = item.find('type').text
indexer.language = item.find('language').text.split('-')[0]
self.logger.info(f"Indexer: {indexer.title} - {indexer.link} - {indexer.type}")
movie_search = item.find('.//searching/movie-search[@available="yes"]')
tv_search = item.find('.//searching/tv-search[@available="yes"]')
if movie_search is not None:
indexer.movie_search_capatabilities = movie_search.attrib['supportedParams'].split(',')
else:
self.logger.info(f"Movie search not available for {indexer.title}")
if tv_search is not None:
indexer.tv_search_capatabilities = tv_search.attrib['supportedParams'].split(',')
else:
self.logger.info(f"TV search not available for {indexer.title}")
indexer_list.append(indexer)
return indexer_list
def __get_torrent_links_from_xml(self, xml_content):
xml_root = ET.fromstring(xml_content)
result_list = []
for item in xml_root.findall('.//item'):
result = JackettResult()
result.seeders = item.find('.//torznab:attr[@name="seeders"]',
namespaces={'torznab': 'http://torznab.com/schemas/2015/feed'}).attrib['value']
if int(result.seeders) <= 0:
continue
result.raw_title = item.find('title').text
result.size = item.find('size').text
result.link = item.find('link').text
result.indexer = item.find('jackettindexer').text
result.privacy = item.find('type').text
# TODO: I haven't seen this in the Jackett XML response. Is this still relevant?
# Or which indexers provide this?
magnet = item.find('.//torznab:attr[@name="magneturl"]',
namespaces={'torznab': 'http://torznab.com/schemas/2015/feed'})
result.magnet = magnet.attrib['value'] if magnet is not None else None
infoHash = item.find('.//torznab:attr[@name="infohash"]',
namespaces={'torznab': 'http://torznab.com/schemas/2015/feed'})
result.info_hash = infoHash.attrib['value'] if infoHash is not None else None
result_list.append(result)
return result_list
def __post_process_results(self, results, media):
for result in results:
parsed_result = parse(result.raw_title)
result.parsed_data = parsed_result
result.languages = detect_languages(result.raw_title)
result.type = media.type
if isinstance(media, Series):
result.season = media.season
result.episode = media.episode
return results

View file

@ -0,0 +1,35 @@
import requests
from stream_fusion.utils.metdata.metadata_provider_base import MetadataProvider
from stream_fusion.utils.models.movie import Movie
from stream_fusion.utils.models.series import Series
class Cinemeta(MetadataProvider):
def get_metadata(self, id, type):
self.logger.info("Getting metadata for " + type + " with id " + id)
full_id = id.split(":")
url = f"https://v3-cinemeta.strem.io/meta/{type}/{full_id[0]}.json"
response = requests.get(url)
data = response.json()
if type == "movie":
result = Movie(
id=id,
titles=[self.replace_weird_characters(data["meta"]["name"])],
year=data["meta"]["year"],
languages=["en"]
)
else:
result = Series(
id=id,
titles=[self.replace_weird_characters(data["meta"]["name"])],
season="S{:02d}".format(int(full_id[1])),
episode="E{:02d}".format(int(full_id[2])),
languages=["en"]
)
self.logger.info("Got metadata for " + type + " with id " + id)
return result

View file

@ -0,0 +1,35 @@
from stream_fusion.logging_config import logger
class MetadataProvider:
def __init__(self, config):
self.config = config
self.logger = logger
def replace_weird_characters(self, string):
corresp = {
'ā': 'a', 'ă': 'a', 'ą': 'a', 'ć': 'c', 'č': 'c', 'ç': 'c',
'ĉ': 'c', 'ċ': 'c', 'ď': 'd', 'đ': 'd', 'è': 'e', 'é': 'e',
'ê': 'e', 'ë': 'e', 'ē': 'e', 'ĕ': 'e', 'ę': 'e', 'ě': 'e',
'ĝ': 'g', 'ğ': 'g', 'ġ': 'g', 'ģ': 'g', 'ĥ': 'h', 'î': 'i',
'ï': 'i', 'ì': 'i', 'í': 'i', 'ī': 'i', 'ĩ': 'i', 'ĭ': 'i',
'ı': 'i', 'ĵ': 'j', 'ķ': 'k', 'ĺ': 'l', 'ļ': 'l', 'ł': 'l',
'ń': 'n', 'ň': 'n', 'ñ': 'n', 'ņ': 'n', 'ʼn': 'n', 'ó': 'o',
'ô': 'o', 'õ': 'o', 'ö': 'o', 'ø': 'o', 'ō': 'o', 'ő': 'o',
'œ': 'oe', 'ŕ': 'r', 'ř': 'r', 'ŗ': 'r', 'š': 's', 'ş': 's',
'ś': 's', 'ș': 's', 'ß': 'ss', 'ť': 't', 'ţ': 't', 'ū': 'u',
'ŭ': 'u', 'ũ': 'u', 'û': 'u', 'ü': 'u', 'ù': 'u', 'ú': 'u',
'ų': 'u', 'ű': 'u', 'ŵ': 'w', 'ý': 'y', 'ÿ': 'y', 'ŷ': 'y',
'ž': 'z', 'ż': 'z', 'ź': 'z', 'æ': 'ae', 'ǎ': 'a', 'ǧ': 'g',
'ə': 'e', 'ƒ': 'f', 'ǐ': 'i', 'ǒ': 'o', 'ǔ': 'u', 'ǚ': 'u',
'ǜ': 'u', 'ǹ': 'n', 'ǻ': 'a', 'ǽ': 'ae', 'ǿ': 'o',
}
for weird_char in corresp:
string = string.replace(weird_char, corresp[weird_char])
return string
def get_metadata(self, id, type):
raise NotImplementedError

View file

@ -0,0 +1,43 @@
import requests
from stream_fusion.utils.metdata.metadata_provider_base import MetadataProvider
from stream_fusion.utils.models.movie import Movie
from stream_fusion.utils.models.series import Series
class TMDB(MetadataProvider):
def get_metadata(self, id, type):
self.logger.info("Getting metadata for " + type + " with id " + id)
full_id = id.split(":")
result = None
for lang in self.config['languages']:
url = f"https://api.themoviedb.org/3/find/{full_id[0]}?api_key={self.config['tmdbApi']}&external_source=imdb_id&language={lang}"
response = requests.get(url)
data = response.json()
if lang == self.config['languages'][0]:
if type == "movie":
result = Movie(
id=id,
titles=[self.replace_weird_characters(data["movie_results"][0]["title"])],
year=data["movie_results"][0]["release_date"][:4],
languages=self.config['languages']
)
else:
result = Series(
id=id,
titles=[self.replace_weird_characters(data["tv_results"][0]["name"])],
season="S{:02d}".format(int(full_id[1])),
episode="E{:02d}".format(int(full_id[2])),
languages=self.config['languages']
)
else:
if type == "movie":
result.titles.append(self.replace_weird_characters(data["movie_results"][0]["title"]))
else:
result.titles.append(self.replace_weird_characters(data["tv_results"][0]["name"]))
self.logger.info("Got metadata for " + type + " with id " + id)
return result

View file

@ -0,0 +1,6 @@
class Media:
def __init__(self, id, titles, languages, type):
self.id = id
self.titles = titles
self.languages = languages
self.type = type

View file

@ -0,0 +1,7 @@
from stream_fusion.utils.models.media import Media
class Movie(Media):
def __init__(self, id, titles, year, languages):
super().__init__(id, titles, languages, "movie")
self.year = year

View file

@ -0,0 +1,9 @@
from stream_fusion.utils.models.media import Media
class Series(Media):
def __init__(self, id, titles, season, episode, languages):
super().__init__(id, titles, languages, "series")
self.season = season
self.episode = episode
self.seasonfile = None

View file

@ -0,0 +1,16 @@
import json
from stream_fusion.utils.string_encoding import decodeb64
def parse_config(b64config):
config = json.loads(decodeb64(b64config))
# For backwards compatibility
if "languages" not in config:
config["languages"] = [config["language"]]
if "anonymizeMagnets" not in config:
config["anonymizeMagnets"] = False # Default to False if not specified
return config

View file

@ -0,0 +1,5 @@
"""Security utils."""
from stream_fusion.utils.security.security_api_key import api_key_security
from stream_fusion.utils.security.security_secret import secret_based_security
__all__ = ["secret_based_security", "api_key_security"]

View file

@ -0,0 +1,43 @@
"""Main dependency for other endpoints."""
from fastapi import Security
from fastapi.security import APIKeyHeader, APIKeyQuery
from starlette.exceptions import HTTPException
from starlette.status import HTTP_403_FORBIDDEN
from stream_fusion.services.security_db import security_db_access
API_KEY_NAME = "api-key"
api_key_query = APIKeyQuery(
name=API_KEY_NAME,
scheme_name="API key query",
auto_error=False,
)
api_key_header = APIKeyHeader(
name=API_KEY_NAME,
scheme_name="API key header",
auto_error=False,
)
async def api_key_security(
query_param: str = Security(api_key_query),
header_param: str = Security(api_key_header),
):
if not query_param and not header_param:
raise HTTPException(
status_code=HTTP_403_FORBIDDEN,
detail="An API key must be passed as query or header",
)
elif query_param and security_db_access.check_key(query_param):
return query_param
elif header_param and security_db_access.check_key(header_param):
return header_param
else:
raise HTTPException(
status_code=HTTP_403_FORBIDDEN,
detail="Wrong, revoked, or expired API key.",
)

View file

@ -0,0 +1,99 @@
"""Secret dependency.
"""
import secrets
import uuid
from typing import Optional
from fastapi import Security
from fastapi.security import APIKeyHeader
from starlette.exceptions import HTTPException
from starlette.status import HTTP_403_FORBIDDEN
from stream_fusion.logging_config import logger
from stream_fusion.settings import settings
class GhostLoadedSecret:
"""Ghost-loaded secret handler"""
def __init__(self) -> None:
self._secret = None
@property
def value(self):
if self._secret:
return self._secret
else:
self._secret = self.get_secret_value()
return self.value
def get_secret_value(self):
"""Get secret value from environment variable or generate a new one."""
if settings.secret_api_key:
try:
secret_value = settings.secret_api_key
except KeyError:
secret_value = str(uuid.uuid4())
logger.warning(
(
"ENVIRONMENT VARIABLE 'RD_SYNCRR_secret_api_key' FORMAT"
" INCORECT\n\tGenerated a single-use secret key for this"
f" session:\n\t{secret_value=}"
),
)
else:
secret_value = str(uuid.uuid4())
logger.warning(
(
"ENVIRONMENT VARIABLE 'RD_SYNCRR_secret_api_key' NOT FOUND\n"
"\tGenerated a single-use secret key for this session:\n"
f"\t{secret_value=}"
),
)
return secret_value
secret = GhostLoadedSecret()
SECRET_KEY_NAME = "secret-key" # noqa: S105
secret_header = APIKeyHeader(
name=SECRET_KEY_NAME,
scheme_name="Secret header",
auto_error=False,
)
async def secret_based_security(header_param: Optional[str] = Security(secret_header)):
"""
Args:
header_param: parsed header field secret_header
Returns:
True if the authentication was successful
Raises:
HTTPException if the authentication failed
"""
if not header_param:
raise HTTPException(
status_code=HTTP_403_FORBIDDEN,
detail="secret_key must be passed as a header field",
)
# We simply return True if the given secret-key has the right value
if not secrets.compare_digest(header_param, secret.value):
raise HTTPException(
status_code=HTTP_403_FORBIDDEN,
detail=(
"Wrong secret key. If not set through environment variable"
" 'secret_api_key', it was generated automatically at"
" startup and appears in the server logs."
),
)
else:
return True

View file

@ -0,0 +1,9 @@
import base64
def encodeb64(data):
return base64.b64encode(data.encode('utf-8')).decode('utf-8')
def decodeb64(data):
return base64.b64decode(data).decode('utf-8')

View file

@ -0,0 +1,93 @@
from RTN import parse
from urllib.parse import quote
from stream_fusion.utils.models.media import Media
from stream_fusion.utils.models.series import Series
from stream_fusion.logging_config import logger
class TorrentItem:
def __init__(self, raw_title, size, magnet, info_hash, link, seeders, languages, indexer,
privacy, type=None, parsed_data=None):
self.logger = logger
self.raw_title = raw_title # Raw title of the torrent
self.size = size # Size of the video file inside the torrent - it may be updated during __process_torrent()
self.magnet = magnet # Magnet to torrent
self.info_hash = info_hash # Hash of the torrent
self.link = link # Link to download torrent file or magnet link
self.seeders = seeders # The number of seeders
self.languages = languages # Language of the torrent
self.indexer = indexer # Indexer of the torrent
self.type = type # "series" or "movie"
self.privacy = privacy # "public" or "private"
self.file_name = None # it may be updated during __process_torrent()
self.files = None # The files inside of the torrent. If it's None, it means that there is only one file inside of the torrent
self.torrent_download = None # The torrent jackett download url if its None, it means that there is only a magnet link provided by Jackett. It also means, that we cant do series file filtering before debrid.
self.trackers = [] # Trackers of the torrent
self.file_index = None # Index of the file inside of the torrent - it may be updated durring __process_torrent() and update_availability(). If the index is None and torrent is not None, it means that the series episode is not inside of the torrent.
self.availability = False # If it's instantly available on the debrid service
self.parsed_data = parsed_data # Ranked result
def to_debrid_stream_query(self, media: Media) -> dict:
return {
"magnet": self.magnet,
"type": self.type,
"file_index": self.file_index,
"season": media.season if isinstance(media, Series) else None,
"episode": media.episode if isinstance(media, Series) else None,
"torrent_download": quote(self.torrent_download) if self.torrent_download is not None else None
}
def to_dict(self):
return {
'raw_title': self.raw_title,
'size': self.size,
'magnet': self.magnet,
'info_hash': self.info_hash,
'link': self.link,
'seeders': self.seeders,
'languages': self.languages,
'indexer': self.indexer,
'type': self.type,
'privacy': self.privacy,
'file_name': self.file_name,
'files': self.files,
'torrent_download': self.torrent_download,
'trackers': self.trackers,
'file_index': self.file_index,
'availability': self.availability,
}
@classmethod
def from_dict(cls, data):
if not isinstance(data, dict):
logger.error(f"Expected dict, got {type(data)}")
return None
instance = cls(
raw_title=data['raw_title'],
size=data['size'],
magnet=data['magnet'],
info_hash=data['info_hash'],
link=data['link'],
seeders=data['seeders'],
languages=data['languages'],
indexer=data['indexer'],
privacy=data['privacy'],
type=data['type']
)
instance.file_name = data['file_name']
instance.files = data['files']
instance.torrent_download = data['torrent_download']
instance.trackers = data['trackers']
instance.file_index = data['file_index']
instance.availability = data['availability']
instance.parsed_data = parse(instance.raw_title)
return instance

View file

@ -0,0 +1,195 @@
import hashlib
import queue
import threading
import time
import urllib.parse
from typing import List
import bencode
import requests
from RTN import parse
from stream_fusion.utils.jackett.jackett_result import JackettResult
from stream_fusion.utils.zilean.zilean_result import ZileanResult
from stream_fusion.utils.torrent.torrent_item import TorrentItem
from stream_fusion.utils.general import get_info_hash_from_magnet
from stream_fusion.logging_config import logger
class TorrentService:
def __init__(self):
self.logger = logger
self.__session = requests.Session()
def convert_and_process(self, results: List[JackettResult | ZileanResult]):
threads = []
torrent_items_queue = queue.Queue()
def thread_target(result: JackettResult | ZileanResult):
torrent_item = result.convert_to_torrent_item()
if torrent_item.link.startswith("magnet:"):
processed_torrent_item = self.__process_magnet(torrent_item)
else:
processed_torrent_item = self.__process_web_url(torrent_item)
torrent_items_queue.put(processed_torrent_item)
for result in results:
threads.append(threading.Thread(target=thread_target, args=(result,)))
for thread in threads:
thread.start()
for thread in threads:
thread.join()
torrent_items_result = []
while not torrent_items_queue.empty():
torrent_items_result.append(torrent_items_queue.get())
return torrent_items_result
def __process_web_url(self, result: TorrentItem):
try:
# TODO: is the timeout enough?
time.sleep(0.2)
response = self.__session.get(result.link, allow_redirects=False, timeout=40)
except requests.exceptions.RequestException:
self.logger.error(f"Error while processing url: {result.link}")
return result
except requests.exceptions.ReadTimeout:
self.logger.error(f"Timeout while processing url: {result.link}")
return result
if response.status_code == 200:
return self.__process_torrent(result, response.content)
elif response.status_code == 302:
result.magnet = response.headers['Location']
return self.__process_magnet(result)
else:
self.logger.error(f"Error code {response.status_code} while processing url: {result.link}")
return result
def __process_torrent(self, result: TorrentItem, torrent_file):
metadata = bencode.bdecode(torrent_file)
result.torrent_download = result.link
result.trackers = self.__get_trackers_from_torrent(metadata)
result.info_hash = self.__convert_torrent_to_hash(metadata["info"])
result.magnet = self.__build_magnet(result.info_hash, metadata["info"]["name"], result.trackers)
if "files" not in metadata["info"]:
result.file_index = 1
return result
result.files = metadata["info"]["files"]
if result.type == "series":
file_details = self.__find_episode_file(result.files, result.parsed_data.season, result.parsed_data.episode)
if file_details is not None:
self.logger.info("File details")
self.logger.info(file_details)
result.file_index = file_details["file_index"]
result.file_name = file_details["title"]
result.size = file_details["size"]
else:
result.file_index = self.__find_movie_file(result.files)
return result
def __process_magnet(self, result: TorrentItem):
if result.magnet is None:
result.magnet = result.link
if result.info_hash is None:
result.info_hash = get_info_hash_from_magnet(result.magnet)
result.trackers = self.__get_trackers_from_magnet(result.magnet)
return result
def __convert_torrent_to_hash(self, torrent_contents):
hashcontents = bencode.bencode(torrent_contents)
hexHash = hashlib.sha1(hashcontents).hexdigest()
return hexHash.lower()
def __build_magnet(self, hash, display_name, trackers):
magnet_base = "magnet:?xt=urn:btih:"
magnet = f"{magnet_base}{hash}&dn={display_name}"
if len(trackers) > 0:
magnet = f"{magnet}&tr={'&tr='.join(trackers)}"
return magnet
def __get_trackers_from_torrent(self, torrent_metadata):
# Sometimes list, sometimes string
announce = torrent_metadata["announce"] if "announce" in torrent_metadata else []
# Sometimes 2D array, sometimes 1D array
announce_list = torrent_metadata["announce-list"] if "announce-list" in torrent_metadata else []
trackers = set()
if isinstance(announce, str):
trackers.add(announce)
elif isinstance(announce, list):
for tracker in announce:
trackers.add(tracker)
for announce_list_item in announce_list:
if isinstance(announce_list_item, list):
for tracker in announce_list_item:
trackers.add(tracker)
if isinstance(announce_list_item, str):
trackers.add(announce_list_item)
return list(trackers)
def __get_trackers_from_magnet(self, magnet: str):
url_parts = urllib.parse.urlparse(magnet)
query_parts = urllib.parse.parse_qs(url_parts.query)
trackers = []
if "tr" in query_parts:
trackers = query_parts["tr"]
return trackers
def __find_episode_file(self, file_structure, season, episode):
if len(season) == 0 or len(episode) == 0:
return None
file_index = 1
strict_episode_files = []
episode_files = []
for files in file_structure:
for file in files["path"]:
parsed_file = parse(file)
if season[0] in parsed_file.season and episode[0] in parsed_file.episode:
episode_files.append({
"file_index": file_index,
"title": file,
"size": files["length"]
})
# Doesn't that need to be indented?
file_index += 1
return max(episode_files, key=lambda file: file["size"])
def __find_movie_file(self, file_structure):
max_size = 0
max_file_index = 1
current_file_index = 1
for files in file_structure:
if files["length"] > max_size:
max_file_index = current_file_index
max_size = files["length"]
current_file_index += 1
return max_file_index

View file

@ -0,0 +1,212 @@
import threading
from typing import List, Dict
from RTN import parse
from stream_fusion.utils.debrid.alldebrid import AllDebrid
from stream_fusion.utils.debrid.premiumize import Premiumize
from stream_fusion.utils.debrid.realdebrid import RealDebrid
from stream_fusion.utils.torrent.torrent_item import TorrentItem
from stream_fusion.utils.cache.cache import cache_public
from stream_fusion.utils.general import season_episode_in_filename
from stream_fusion.logging_config import logger
class TorrentSmartContainer:
def __init__(self, torrent_items: List[TorrentItem], media):
self.logger = logger
self.logger.info(f"Initializing TorrentSmartContainer with {len(torrent_items)} items")
self.__itemsDict: Dict[TorrentItem] = self.__build_items_dict_by_infohash(torrent_items)
self.__media = media
def get_hashes(self):
hashes = list(self.__itemsDict.keys())
self.logger.debug(f"Retrieved {len(hashes)} hashes")
return hashes
def get_items(self):
items = list(self.__itemsDict.values())
self.logger.debug(f"Retrieved {len(items)} items")
return items
def get_direct_torrentable(self):
self.logger.info("Retrieving direct torrentable items")
direct_torrentable_items = []
for torrent_item in self.__itemsDict.values():
if torrent_item.privacy == "public" and torrent_item.file_index is not None:
direct_torrentable_items.append(torrent_item)
self.logger.info(f"Found {len(direct_torrentable_items)} direct torrentable items")
return direct_torrentable_items
def get_best_matching(self):
self.logger.info("Finding best matching items")
best_matching = []
self.logger.debug(f"Total items to process: {len(self.__itemsDict)}")
for torrent_item in self.__itemsDict.values():
self.logger.debug(f"Processing item: {torrent_item.raw_title} - Has torrent: {torrent_item.torrent_download is not None}")
if torrent_item.torrent_download is not None:
self.logger.debug(f"Has file index: {torrent_item.file_index is not None}")
if torrent_item.file_index is not None:
best_matching.append(torrent_item)
self.logger.debug("Item added to best matching (has file index)")
else:
best_matching.append(torrent_item)
self.logger.debug("Item added to best matching (magnet link)")
self.logger.info(f"Found {len(best_matching)} best matching items")
return best_matching
def cache_container_items(self):
self.logger.info("Starting cache process for container items")
threading.Thread(target=self.__save_to_cache).start()
def __save_to_cache(self):
self.logger.info("Saving public items to cache")
public_torrents = list(filter(lambda x: x.privacy == "public", self.get_items()))
self.logger.debug(f"Found {len(public_torrents)} public torrents to cache")
cache_public(public_torrents, self.__media)
self.logger.info("Caching process completed")
def update_availability(self, debrid_response, debrid_type, media):
self.logger.info(f"Updating availability for {debrid_type.__name__}")
if debrid_type is RealDebrid:
self.__update_availability_realdebrid(debrid_response, media)
elif debrid_type is AllDebrid:
self.__update_availability_alldebrid(debrid_response, media)
elif debrid_type is Premiumize:
self.__update_availability_premiumize(debrid_response)
else:
self.logger.error(f"Unsupported debrid type: {debrid_type.__name__}")
raise NotImplementedError(f"Debrid type {debrid_type.__name__} not implemented")
def __update_availability_realdebrid(self, response, media):
self.logger.info("Updating availability for RealDebrid")
for info_hash, details in response.items():
if "rd" not in details:
self.logger.debug(f"Skipping hash {info_hash}: no RealDebrid data")
continue
torrent_item: TorrentItem = self.__itemsDict[info_hash]
self.logger.debug(f"Processing {torrent_item.type}: {torrent_item.raw_title}")
files = []
if torrent_item.type == "series":
self.__process_series_files(details, media, torrent_item, files)
else:
self.__process_movie_files(details, files)
self.__update_file_details(torrent_item, files)
self.logger.info("RealDebrid availability update completed")
def __process_series_files(self, details, media, torrent_item, files):
for variants in details["rd"]:
file_found = False
for file_index, file in variants.items():
clean_season = media.season.replace("S", "")
clean_episode = media.episode.replace("E", "")
numeric_season = int(clean_season)
numeric_episode = int(clean_episode)
if season_episode_in_filename(file["filename"], numeric_season, numeric_episode):
self.logger.debug(f"Matching file found: {file['filename']}")
torrent_item.file_index = file_index
torrent_item.file_name = file["filename"]
torrent_item.size = file["filesize"]
torrent_item.availability = True
file_found = True
files.append({
"file_index": file_index,
"title": file["filename"],
"size": file["filesize"]
})
break
if file_found:
break
def __process_movie_files(self, details, files):
for variants in details["rd"]:
for file_index, file in variants.items():
self.logger.debug(f"Adding movie file: {file['filename']}")
files.append({
"file_index": file_index,
"title": file["filename"],
"size": file["filesize"]
})
def __update_availability_alldebrid(self, response, media):
self.logger.info("Updating availability for AllDebrid")
if response["status"] != "success":
self.logger.error(f"AllDebrid API error: {response}")
return
for data in response["data"]["magnets"]:
if not data["instant"]:
self.logger.debug(f"Skipping non-instant magnet: {data['hash']}")
continue
torrent_item: TorrentItem = self.__itemsDict[data["hash"]]
files = []
self.__explore_folders(data["files"], files, 1, torrent_item.type, media.season, media.episode)
self.__update_file_details(torrent_item, files)
self.logger.info("AllDebrid availability update completed")
def __update_availability_premiumize(self, response):
self.logger.info("Updating availability for Premiumize")
if response["status"] != "success":
self.logger.error(f"Premiumize API error: {response}")
return
torrent_items = self.get_items()
for i, is_available in enumerate(response["response"]):
if bool(is_available):
torrent_items[i].availability = response["transcoded"][i]
self.logger.debug(f"Updated availability for item {i}: {torrent_items[i].availability}")
self.logger.info("Premiumize availability update completed")
def __update_file_details(self, torrent_item, files):
if not files:
self.logger.debug(f"No files to update for {torrent_item.raw_title}")
return
file = max(files, key=lambda file: file["size"])
torrent_item.availability = True
torrent_item.file_index = file["file_index"]
torrent_item.file_name = file["title"]
torrent_item.size = file["size"]
self.logger.debug(f"Updated file details for {torrent_item.raw_title}: {file['title']}")
def __build_items_dict_by_infohash(self, items: List[TorrentItem]):
self.logger.info(f"Building items dictionary by infohash ({len(items)} items)")
items_dict = {}
for item in items:
if item.info_hash is not None:
if item.info_hash not in items_dict:
self.logger.debug(f"Adding {item.info_hash} to items dict")
items_dict[item.info_hash] = item
else:
self.logger.debug(f"Skipping duplicate info hash: {item.info_hash}")
self.logger.info(f"Built dictionary with {len(items_dict)} unique items")
return items_dict
def __explore_folders(self, folder, files, file_index, type, season=None, episode=None):
if episode is None or season is None:
return file_index
if type == "series":
for file in folder:
if "e" in file:
file_index = self.__explore_folders(file["e"], files, file_index, type, season, episode)
continue
parsed_file = parse(file["n"])
if season in parsed_file.season and episode in parsed_file.episode:
self.logger.debug(f"Matching series file found: {file['n']}")
files.append({
"file_index": file_index,
"title": file["n"],
"size": file["s"] if "s" in file else 0
})
file_index += 1
elif type == "movie":
file_index = 1
for file in folder:
if "e" in file:
file_index = self.__explore_folders(file["e"], files, file_index, type)
continue
self.logger.debug(f"Adding movie file: {file['n']}")
files.append({
"file_index": file_index,
"title": file["n"],
"size": file["s"] if "s" in file else 0
})
file_index += 1
return file_index

View file

@ -0,0 +1,63 @@
from RTN import parse
from stream_fusion.utils.torrent.torrent_item import TorrentItem
from stream_fusion.logging_config import logger
from stream_fusion.utils.detection import detect_languages
class ZileanResult:
def __init__(self):
self.raw_title = None # Raw title of the torrent
self.size = None # Size of the torrent
self.link = None # Download link for the torrent file or magnet url
self.indexer = None # Indexer
self.seeders = None # Seeders count
self.magnet = None # Magnet url
self.info_hash = None # infoHash by Jackett
self.privacy = None # public or private
self.from_cache = None
# Extra processed details for further filtering
self.languages = None # Language of the torrent
self.type = None # series or movie
self.parsed_data = None # Ranked result
def convert_to_torrent_item(self):
return TorrentItem(
self.raw_title,
self.size,
self.magnet,
self.info_hash.lower() if self.info_hash is not None else None,
self.link,
self.seeders,
self.languages,
self.indexer,
self.privacy,
self.type,
self.parsed_data
)
def from_api_cached_item(self, api_cached_item, media):
if type(api_cached_item) is not dict:
logger.error(api_cached_item)
self.info_hash = api_cached_item['infoHash']
if len(self.info_hash) != 40:
raise ValueError(f"The hash '{self.info_hash}' does not have the expected length of 40 characters.")
parsed_result = parse(api_cached_item['filename'])
self.raw_title = parsed_result.raw_title
self.indexer = "DMM API"
self.magnet = "magnet:?xt=urn:btih:" + self.info_hash
self.link = self.magnet
self.languages = detect_languages(self.raw_title)
self.seeders = 0
self.size = api_cached_item['filesize']
self.type = media.type
self.privacy = "private"
self.from_cache = True
self.parsed_data = parsed_result
return self

View file

@ -0,0 +1,99 @@
import re
import requests
from concurrent.futures import ThreadPoolExecutor, as_completed
from stream_fusion.logging_config import logger
from stream_fusion.utils.models.movie import Movie
from stream_fusion.utils.models.series import Series
class ZileanService:
def __init__(self, config):
self.base_url = config["zileanUrl"]
self.search_endpoint = "/dmm/search"
self.session = requests.Session()
self.logger = logger
self.max_workers = config.get("max_workers", 4)
def search(self, media):
if isinstance(media, Movie):
return self.__search_movie(media)
elif isinstance(media, Series):
return self.__search_series(media)
else:
raise TypeError("Only Movie and Series are allowed as media!")
# TODO: remove duplicate title from titles list
def __clean_title(self, title):
pronouns_to_remove = [
'le', 'la', 'les', 'l\'', 'un', 'une', 'des', 'du', 'de', 'à', 'au', 'aux',
'the', 'a', 'an', 'some', 'of', 'to', 'at', 'in', 'on', 'for',
'he', 'she', 'it', 'they', 'we', 'you', 'i', 'me', 'him', 'her', 'them', 'us',
'il', 'elle', 'on', 'nous', 'vous', 'ils', 'elles', 'je', 'tu', 'moi', 'toi', 'lui'
]
title = title.lower()
title = re.sub(r'[^a-zA-Z0-9\s]', ' ', title)
words = title.split()
words = [word for word in words if word not in pronouns_to_remove]
cleaned_title = ' '.join(words)
cleaned_title = re.sub(r'\s+', ' ', cleaned_title).strip()
return cleaned_title
def __deduplicate_api_results(self, api_results):
unique_results = set()
deduplicated_results = []
for result in api_results:
result_tuple = tuple(sorted(result.items()))
if result_tuple not in unique_results:
unique_results.add(result_tuple)
deduplicated_results.append(result)
return deduplicated_results
def __remove_duplicate_titles(self, titles):
seen = set()
return [title for title in titles if not (title.lower() in seen or seen.add(title.lower()))]
def __search_movie(self, movie):
unique_titles = self.__remove_duplicate_titles(movie.titles)
clean_titles = [self.__clean_title(title) for title in unique_titles]
return self.__threaded_search(clean_titles)
def __search_series(self, series):
unique_titles = self.__remove_duplicate_titles(series.titles)
clean_titles = [self.__clean_title(title) for title in unique_titles]
search_texts = clean_titles.copy()
if hasattr(series, 'season') and hasattr(series, 'episode'):
search_texts.extend([f"{title} {series.season}{series.episode}" for title in clean_titles])
return self.__threaded_search(search_texts)
def __threaded_search(self, search_texts):
results = []
with ThreadPoolExecutor(max_workers=self.max_workers) as executor:
future_to_text = {executor.submit(self.__make_request, text): text for text in search_texts}
for future in as_completed(future_to_text):
results.extend(future.result())
return self.__deduplicate_api_results(results)
def __make_request(self, query_text):
payload = {"queryText": query_text}
headers = {"Content-Type": "application/json"}
url = self.base_url + self.search_endpoint
try:
response = self.session.post(url, json=payload, headers=headers, timeout=10)
response.raise_for_status()
json_response = response.json()
if isinstance(json_response, list):
return json_response
else:
self.logger.warning(f"Unexpected response format for query: {query_text}")
return []
except Exception as e:
self.logger.exception(f"An exception occurred while searching for '{query_text}' on Zilean: {str(e)}")
return []

12
stream_fusion/version.py Normal file
View file

@ -0,0 +1,12 @@
import toml
from stream_fusion.settings import settings
def get_version():
with open(settings.version_path, "r") as f:
pyproject_data = toml.load(f)
return pyproject_data["tool"]["poetry"]["version"]
if __name__ == "__main__":
pass

Binary file not shown.

View file

@ -0,0 +1 @@
"""WEB API for stream_fusion."""

View file

View file

@ -0,0 +1,4 @@
"""Auth by apikey endpoint."""
from stream_fusion.web.api.auth.views import router
__all__ = ["router"]

View file

@ -0,0 +1,17 @@
from typing import Optional
from pydantic import BaseModel
class UsageLog(BaseModel):
api_key: str
name: Optional[str]
is_active: bool
never_expire: bool
expiration_date: str
latest_query_date: Optional[str]
total_queries: int
class UsageLogs(BaseModel):
logs: list[UsageLog]

View file

@ -0,0 +1,92 @@
from fastapi import APIRouter, Depends, Query
from stream_fusion.services.security_db import security_db_access
from stream_fusion.settings import settings
from stream_fusion.utils.security import secret_based_security
from stream_fusion.web.api.auth.schemas import UsageLog, UsageLogs
router = APIRouter()
@router.get(
"/new",
dependencies=[Depends(secret_based_security)],
include_in_schema=settings.security_hide_docs,
)
def get_new_api_key(
name: str = Query(
None,
description="set API key name",
),
never_expires: bool = Query(
False,
description="if set, the created API key will never be considered expired",
),
) -> str:
"""
Returns:
api_key: a newly generated API key
"""
return security_db_access.create_key(name, never_expires)
@router.get(
"/revoke",
dependencies=[Depends(secret_based_security)],
include_in_schema=settings.security_hide_docs,
)
def revoke_api_key(
api_key: str = Query(..., alias="api-key", description="the api_key to revoke"),
):
"""
Revokes the usage of the given API key
"""
return security_db_access.revoke_key(api_key)
@router.get(
"/renew",
dependencies=[Depends(secret_based_security)],
include_in_schema=settings.security_hide_docs,
)
def renew_api_key(
api_key: str = Query(..., alias="api-key", description="the API key to renew"),
expiration_date: str = Query(
None,
alias="expiration-date",
description="the new expiration date in ISO format",
),
):
"""
Renews the chosen API key, reactivating it if it was revoked.
"""
return security_db_access.renew_key(api_key, expiration_date)
@router.get(
"/logs",
dependencies=[Depends(secret_based_security)],
response_model=UsageLogs,
include_in_schema=settings.security_hide_docs,
)
def get_api_key_usage_logs():
"""
Returns usage information for all API keys
"""
# TODO Add some sort of filtering on older keys/unused keys?
return UsageLogs(
logs=[
UsageLog(
api_key=row[0],
is_active=row[1],
never_expire=row[2],
expiration_date=row[3],
latest_query_date=row[4],
total_queries=row[5],
name=row[6],
)
for row in security_db_access.get_usage_stats()
],
)

View file

@ -0,0 +1,4 @@
"""Routes for swagger and redoc."""
from stream_fusion.web.api.docs.views import router
__all__ = ["router"]

View file

@ -0,0 +1,53 @@
from fastapi import APIRouter, Request
from fastapi.openapi.docs import (
get_redoc_html,
get_swagger_ui_html,
get_swagger_ui_oauth2_redirect_html,
)
from fastapi.responses import HTMLResponse
router = APIRouter()
@router.get("/docs", include_in_schema=False)
async def swagger_ui_html(request: Request) -> HTMLResponse:
"""
Swagger UI.
:param request: current request.
:return: rendered swagger UI.
"""
title = request.app.title
return get_swagger_ui_html(
openapi_url=request.app.openapi_url,
title=f"{title} - Swagger UI",
oauth2_redirect_url=str(request.url_for("swagger_ui_redirect")),
swagger_js_url="/static/docs/swagger-ui-bundle.js",
swagger_css_url="/static/docs/swagger-ui.css",
)
@router.get("/swagger-redirect", include_in_schema=False)
async def swagger_ui_redirect() -> HTMLResponse:
"""
Redirect to swagger.
:return: redirect.
"""
return get_swagger_ui_oauth2_redirect_html()
@router.get("/redoc", include_in_schema=False)
async def redoc_html(request: Request) -> HTMLResponse:
"""
Redoc UI.
:param request: current request.
:return: rendered redoc UI.
"""
title = request.app.title
return get_redoc_html(
openapi_url=request.app.openapi_url,
title=f"{title} - ReDoc",
redoc_js_url="/static/docs/redoc.standalone.js",
)

View file

@ -0,0 +1,4 @@
"""API for checking project status."""
from stream_fusion.web.api.monitoring.views import router
__all__ = ["router"]

View file

@ -0,0 +1,12 @@
from fastapi import APIRouter
router = APIRouter()
@router.get("/health")
def health_check() -> None:
"""
Checks the health of a project.
It returns 200 if the project is healthy.
"""

View file

@ -0,0 +1,7 @@
from fastapi.routing import APIRouter
from stream_fusion.web.api import auth, docs
api_router = APIRouter()
api_router.include_router(auth.router, prefix="/auth", tags=["_auth"])
api_router.include_router(docs.router)

View file

@ -0,0 +1,64 @@
import os
from importlib import metadata
from pathlib import Path
from fastapi import FastAPI
from fastapi.responses import UJSONResponse
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from stream_fusion.logging_config import configure_logging
from stream_fusion.version import get_version
from stream_fusion.web.api.router import api_router
from stream_fusion.web.root.router import root_router
from stream_fusion.web.playback.router import stream_router
from stream_fusion.web.lifetime import register_shutdown_event, register_startup_event
APP_ROOT = Path(__file__).parent.parent
def get_app() -> FastAPI:
"""
Get FastAPI application.
This is the main constructor of an application.
:return: application.
"""
if not os.environ.get("RUNNING_UNDER_GUNICORN"):
configure_logging()
app = FastAPI(
title="StreamFusion",
version=str(get_version()),
docs_url=None,
redoc_url=None,
openapi_url="/api/openapi.json",
default_response_class=UJSONResponse,
)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Adds startup and shutdown events.
register_startup_event(app)
register_shutdown_event(app)
# Main router for the API.
app.include_router(router=root_router)
app.include_router(router=stream_router)
app.include_router(router=api_router, prefix="/api")
# Adds static directory.
# This directory is used to access swagger files.
app.mount(
"/static",
StaticFiles(directory=APP_ROOT / "static"),
name="static",
)
return app

View file

@ -0,0 +1,44 @@
import os
from collections.abc import Awaitable
from typing import Callable
from fastapi import FastAPI
from stream_fusion.settings import settings
def register_startup_event(
app: FastAPI,
) -> Callable[[], Awaitable[None]]: # pragma: no cover
"""
Actions to run on application startup.
This function uses fastAPI app to store data
in the state, such as db_engine.
:param app: the fastAPI application.
:return: function that actually performs actions.
"""
@app.on_event("startup")
async def _startup() -> None:
app.middleware_stack = None
app.middleware_stack = app.build_middleware_stack()
return _startup
def register_shutdown_event(
app: FastAPI,
) -> Callable[[], Awaitable[None]]: # pragma: no cover
"""
Actions to run on application's shutdown.
:param app: fastAPI application.
:return: function that actually performs actions.
"""
@app.on_event("shutdown")
async def _shutdown() -> None:
pass
return _shutdown

View file

View file

@ -0,0 +1,6 @@
from fastapi.routing import APIRouter
from stream_fusion.web.playback import stream
stream_router = APIRouter()
stream_router.include_router(stream.router, prefix="/playback", tags=["stream"])

View file

@ -0,0 +1,3 @@
from stream_fusion.web.playback.stream.views import router
__all__ = ["router"]

View file

@ -0,0 +1,13 @@
from pydantic import BaseModel
class StreamResponse(BaseModel):
content_range: str | None
content_length: str | None
accept_ranges: str
content_type: str
class ErrorResponse(BaseModel):
detail: str
class HeadResponse(BaseModel):
status_code: int

View file

@ -0,0 +1,169 @@
import asyncio
from functools import lru_cache
import aiohttp
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response, status
from fastapi.responses import RedirectResponse, StreamingResponse
from stream_fusion.services.redis.redis_config import get_redis_cache_dependency
from stream_fusion.utils.cache.local_redis import RedisCache
from stream_fusion.logging_config import logger
from stream_fusion.utils.debrid.get_debrid_service import get_debrid_service
from stream_fusion.utils.parse_config import parse_config
from stream_fusion.utils.string_encoding import decodeb64
from stream_fusion.web.playback.stream.schemas import (
ErrorResponse,
HeadResponse,
StreamResponse,
)
router = APIRouter()
@lru_cache(maxsize=128)
def get_adaptive_chunk_size(file_size):
MB = 1024 * 1024
GB = 1024 * MB
if file_size < 1 * GB:
return 1 * MB # 1 MB
elif file_size < 3 * GB:
return 2 * MB # 2 MB
elif file_size < 9 * GB:
return 5 * MB # 5 MB
elif file_size < 20 * GB:
return 10 * MB # 10 MB
else:
return 20 * MB # 20 MB
async def proxy_stream(url: str, headers: dict, proxy: str = None):
async with aiohttp.ClientSession() as session:
async with session.get(url, headers=headers, proxy=proxy) as response:
file_size = int(response.headers.get("Content-Length", 0))
chunk_size = get_adaptive_chunk_size(file_size)
while True:
chunk = await response.content.read(chunk_size)
if not chunk:
break
yield chunk
def get_stream_link(
decoded_query: str, config: dict, ip: str, redis_cache: RedisCache
) -> str:
cache_key = f"stream_link:{decoded_query}_{ip}"
cached_link = redis_cache.get(cache_key)
if cached_link:
logger.info(f"Stream link cached: {cached_link}")
return cached_link
debrid_service = get_debrid_service(config)
link = debrid_service.get_stream_link(decoded_query, config, ip)
redis_cache.set(cache_key, link, expiration=3600) # Cache for 1 hour
logger.info(f"Stream link generated and cached: {link}")
return link
@router.get(
"/{config}/{query}",
response_model=StreamResponse,
responses={500: {"model": ErrorResponse}},
)
async def get_playback(
config: str,
query: str,
request: Request,
redis_cache: RedisCache = Depends(get_redis_cache_dependency),
):
try:
if not query:
raise HTTPException(status_code=400, detail="Query required.")
config = parse_config(config)
decoded_query = decodeb64(query)
ip = request.client.host
link = get_stream_link(decoded_query, config, ip, redis_cache)
range_header = request.headers.get("Range")
headers = {}
if range_header:
headers["Range"] = range_header
proxy = None # Not yet implemented
async with aiohttp.ClientSession() as session:
async with session.get(link, headers=headers, proxy=proxy) as response:
if response.status == 206:
return StreamingResponse(
proxy_stream(link, headers, proxy),
status_code=206,
headers=StreamResponse(
content_range=response.headers["Content-Range"],
content_length=response.headers["Content-Length"],
accept_ranges="bytes",
content_type="video/mp4",
).model_dump(),
)
elif response.status == 200:
return StreamingResponse(
proxy_stream(link, headers, proxy),
headers=StreamResponse(
content_range=None,
content_length=None,
accept_ranges="bytes",
content_type="video/mp4",
).model_dump(),
)
else:
return RedirectResponse(link, status_code=302)
except Exception as e:
logger.error(f"Playback error: {e}")
raise HTTPException(
status_code=500,
detail=ErrorResponse(
detail="An error occurred while processing the request."
).model_dump(),
)
@router.head(
"/{config}/{query}",
response_model=HeadResponse,
responses={500: {"model": ErrorResponse}},
)
async def head_playback(
config: str,
query: str,
request: Request,
redis_cache: RedisCache = Depends(get_redis_cache_dependency),
):
try:
if not query:
raise HTTPException(status_code=400, detail="Query required.")
config = parse_config(config)
decoded_query = decodeb64(query)
ip = request.client.host
cache_key = f"stream_link:{decoded_query}_{ip}"
if redis_cache.exists(cache_key):
return Response(status_code=status.HTTP_200_OK)
else:
await asyncio.sleep(0.2)
return Response(status_code=status.HTTP_200_OK)
except Exception as e:
logger.error(f"HEAD request error: {e}")
return Response(
status=500,
content=ErrorResponse(
detail="An error occurred while processing the request."
).model_dump_json(),
)

View file

@ -0,0 +1,3 @@
from stream_fusion.web.root.search import router
__all__ = ["router"]

View file

@ -0,0 +1,3 @@
from stream_fusion.web.root.config.views import router
__all__ = ["router"]

View file

@ -0,0 +1,22 @@
from pydantic import BaseModel, Field
from typing import List, Optional
class ManifestResponse(BaseModel):
id: str
icon: str
version: str
catalogs: List = Field(default_factory=list)
resources: List[str]
types: List[str]
name: str
description: str
behaviorHints: dict = Field(default_factory=lambda: {
"configurable": True,
})
config: Optional[List[dict]] = None
class ConfigureTemplateContext(BaseModel):
request: dict
class StaticFileResponse(BaseModel):
file_path: str

View file

@ -0,0 +1,78 @@
from cachetools import TTLCache
from fastapi import APIRouter, Request
from fastapi.responses import FileResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from stream_fusion.logging_config import logger
from stream_fusion.version import get_version
from stream_fusion.web.root.config.schemas import ManifestResponse, StaticFileResponse
router = APIRouter()
templates = Jinja2Templates(directory="/app/stream_fusion/templates")
stream_cache = TTLCache(maxsize=1000, ttl=3600)
@router.get("/")
async def root():
logger.info("Redirecting to /configure")
return RedirectResponse(url="/configure")
@router.get("/configure")
@router.get("/{config}/configure")
async def configure(request: Request):
logger.info("Serving configuration page")
return templates.TemplateResponse("index.html", {"request": request})
@router.get("/static/{file_path:path}", response_model=StaticFileResponse)
async def serve_static(file_path: str):
logger.debug(f"Serving static file: {file_path}")
return FileResponse(f"/app/stream_fusion/templates/{file_path}")
@router.get("/manifest.json")
async def get_manifest():
logger.info("Serving manifest.json")
return ManifestResponse(
id="community.limedrive.streamfusion",
icon="https://i.imgur.com/tVjqEJP.png",
version=str(get_version()),
resources=["stream"],
types=["movie", "series"],
name="StreamFusion",
description="StreamFusion enhances Stremio by integrating torrent indexers and debrid services,"
" providing access to a vast array of cached torrent sources. This plugin seamlessly bridges"
" Stremio with popular indexers and debrid platforms, offering users an expanded content"
" library and a smooth streaming experience.",
behaviorHints={
"configurable": True,
"configurationRequired": True
},
config=[
{
"key": "api_key",
"title": "API Key",
"type": "text",
"required": True
}
]
)
@router.get("/{params}/manifest.json")
async def get_manifest():
logger.info("Serving manifest.json")
return ManifestResponse(
id="community.limedrive.streamfusion",
icon="https://i.imgur.com/tVjqEJP.png",
version=str(get_version()),
resources=["stream"],
types=["movie", "series"],
name="StreamFusion",
description="StreamFusion enhances Stremio by integrating torrent indexers and debrid services,"
" providing access to a vast array of cached torrent sources. This plugin seamlessly bridges"
" Stremio with popular indexers and debrid platforms, offering users an expanded content"
" library and a smooth streaming experience."
)

View file

@ -0,0 +1,8 @@
from fastapi.routing import APIRouter
from stream_fusion.web.root import config
from stream_fusion.web.root import search
root_router = APIRouter()
root_router.include_router(config.router, tags=["config"])
root_router.include_router(search.router, tags=["search"])

View file

@ -0,0 +1,3 @@
from stream_fusion.web.root.search.views import router
__all__ = ["router"]

View file

@ -0,0 +1,13 @@
from pydantic import BaseModel, Field
from typing import List, Optional
class Stream(BaseModel):
name: str
description: str
url: Optional[str] = None
infoHash: Optional[str] = None
fileIdx: Optional[int] = None
behaviorHints: dict = Field(default_factory=dict)
class SearchResponse(BaseModel):
streams: List[Stream]

View file

@ -0,0 +1,141 @@
import json
import queue
import threading
from typing import List
from stream_fusion.utils.models.media import Media
from stream_fusion.utils.torrent.torrent_item import TorrentItem
from stream_fusion.utils.string_encoding import encodeb64
INSTANTLY_AVAILABLE = "[⚡]"
DOWNLOAD_REQUIRED = "[⬇️]"
DIRECT_TORRENT = "[🏴‍☠️]"
# TODO: Languages
def get_emoji(language):
emoji_dict = {
"fr": "🇫🇷 FRENCH",
"en": "🇬🇧 ENGLISH",
"es": "🇪🇸 SPANISH",
"de": "🇩🇪 GERMAN",
"it": "🇮🇹 ITALIAN",
"pt": "🇵🇹 PORTUGUESE",
"ru": "🇷🇺 RUSSIAN",
"in": "🇮🇳 INDIAN",
"nl": "🇳🇱 DUTCH",
"hu": "🇭🇺 HUNGARIAN",
"la": "🇲🇽 LATINO",
"multi": "🌍 MULTi",
}
return emoji_dict.get(language, "🇬🇧")
def filter_by_availability(item):
if item["name"].startswith(INSTANTLY_AVAILABLE):
return 0
else:
return 1
def filter_by_direct_torrnet(item):
if item["name"].startswith(DIRECT_TORRENT):
return 1
else:
return 0
def parse_to_debrid_stream(torrent_item: TorrentItem, configb64, host, torrenting, results: queue.Queue, media: Media):
if torrent_item.availability == True:
name = f"{INSTANTLY_AVAILABLE}\n"
else:
name = f"{DOWNLOAD_REQUIRED}\n"
parsed_data = torrent_item.parsed_data.data
resolution = parsed_data.resolution[0] if parsed_data.resolution else "Unknow"
name += f"{resolution}"
if parsed_data.quality:
name += f"\n({'|'.join(parsed_data.quality)})"
size_in_gb = round(int(torrent_item.size) / 1024 / 1024 / 1024, 2)
title = f"{torrent_item.raw_title}\n"
if torrent_item.file_name is not None:
title += f"{torrent_item.file_name}\n"
title += f"👥 {torrent_item.seeders} 💾 {size_in_gb}GB 🔍 {torrent_item.indexer}\n"
if parsed_data.codec:
title += f"🎥 {', '.join(parsed_data.codec)} "
if parsed_data.audio:
title += f"🎧 {', '.join(parsed_data.audio)} "
if parsed_data.codec or parsed_data.audio:
title += "\n"
# Gestion des langues
if torrent_item.languages:
title += "/".join(get_emoji(language) for language in torrent_item.languages)
else:
title += "🌐"
queryb64 = encodeb64(json.dumps(torrent_item.to_debrid_stream_query(media))).replace('=', '%3D')
results.put({
"name": name,
"description": title,
"url": f"{host}/playback/{configb64}/{queryb64}",
"behaviorHints":{
"bingeGroup": f"stremio-jackett-{torrent_item.info_hash}",
"filename": torrent_item.file_name if torrent_item.file_name is not None else torrent_item.raw_title # TODO: Use parsed title?
}
})
if torrenting and torrent_item.privacy == "public":
name = f"{DIRECT_TORRENT}\n{parsed_data.quality}\n"
if len(parsed_data.quality) > 0 and parsed_data.quality[0] != "Unknown" and \
parsed_data.quality[0] != "":
name += f"({'|'.join(parsed_data.quality)})"
results.put({
"name": name,
"description": title,
"infoHash": torrent_item.info_hash,
"fileIdx": int(torrent_item.file_index) if torrent_item.file_index else None,
"behaviorHints":{
"bingeGroup": f"stremio-jackett-{torrent_item.info_hash}",
"filename": torrent_item.file_name if torrent_item.file_name is not None else torrent_item.raw_title # TODO: Use parsed title?
}
# "sources": ["tracker:" + tracker for tracker in torrent_item.trackers]
})
def parse_to_stremio_streams(torrent_items: List[TorrentItem], config, media):
stream_list = []
threads = []
thread_results_queue = queue.Queue()
configb64 = encodeb64(json.dumps(config).replace('=', '%3D'))
for torrent_item in torrent_items[:int(config['maxResults'])]:
thread = threading.Thread(target=parse_to_debrid_stream,
args=(torrent_item, configb64, config['addonHost'], config['torrenting'],
thread_results_queue, media),
daemon=True)
thread.start()
threads.append(thread)
for thread in threads:
thread.join()
while not thread_results_queue.empty():
stream_list.append(thread_results_queue.get())
if len(stream_list) == 0:
return []
if config['debrid']:
stream_list = sorted(stream_list, key=filter_by_availability)
stream_list = sorted(stream_list, key=filter_by_direct_torrnet)
return stream_list

View file

@ -0,0 +1,255 @@
import hashlib
import time
from fastapi import APIRouter, Depends, Request
from stream_fusion.services.redis.redis_config import get_redis_cache_dependency
from stream_fusion.utils.cache.cache import search_public
from stream_fusion.utils.cache.local_redis import RedisCache
from stream_fusion.logging_config import logger
from stream_fusion.utils.debrid.get_debrid_service import get_debrid_service
from stream_fusion.utils.filter_results import (
filter_items,
filter_out_non_matching_movies,
filter_out_non_matching_series,
merge_items,
sort_items,
)
from stream_fusion.utils.jackett.jackett_result import JackettResult
from stream_fusion.utils.jackett.jackett_service import JackettService
from stream_fusion.utils.metdata.cinemeta import Cinemeta
from stream_fusion.utils.metdata.tmdb import TMDB
from stream_fusion.utils.models.movie import Movie
from stream_fusion.utils.models.series import Series
from stream_fusion.utils.parse_config import parse_config
from stream_fusion.utils.torrent.torrent_item import TorrentItem
from stream_fusion.web.root.search.schemas import SearchResponse, Stream
from stream_fusion.web.root.search.stremio_parser import parse_to_stremio_streams
from stream_fusion.utils.torrent.torrent_service import TorrentService
from stream_fusion.utils.torrent.torrent_smart_container import TorrentSmartContainer
from stream_fusion.utils.zilean.zilean_result import ZileanResult
from stream_fusion.utils.zilean.zilean_service import ZileanService
router = APIRouter()
@router.get("/{config}/stream/{stream_type}/{stream_id}", response_model=SearchResponse)
async def get_results(
config: str,
stream_type: str,
stream_id: str,
request: Request,
redis_cache: RedisCache = Depends(get_redis_cache_dependency),
) -> SearchResponse:
start = time.time()
logger.info(f"Stream request: {stream_type} - {stream_id}")
stream_id = stream_id.replace(".json", "")
config = parse_config(config)
logger.debug(f"Parsed configuration: {config}")
def get_metadata():
logger.info(f"Fetching metadata from {config['metadataProvider']}")
if config["metadataProvider"] == "tmdb" and config["tmdbApi"]:
metadata_provider = TMDB(config)
else:
metadata_provider = Cinemeta(config)
return metadata_provider.get_metadata(stream_id, stream_type)
media = redis_cache.get_or_set(
get_metadata, stream_id, stream_type, config["metadataProvider"]
)
logger.info(f"Retrieved media metadata: {str(media.titles)}")
def stream_cache_key(media):
if isinstance(media, Movie):
key_string = f"stream:{media.titles[0]}:{media.year}:{media.languages[0]}"
elif isinstance(media, Series):
key_string = f"stream:{media.titles[0]}:{media.languages[0]}:{media.season}{media.episode}"
else:
raise TypeError("Only Movie and Series are allowed as media!")
hashed_key = hashlib.sha256(key_string.encode("utf-8")).hexdigest()
return hashed_key[:16]
cached_result = redis_cache.get(stream_cache_key(media))
if cached_result is not None:
logger.info("Returning cached processed results")
total_time = time.time() - start
logger.info(f"Request completed in {total_time:.2f} seconds")
return SearchResponse(streams=cached_result)
debrid_service = get_debrid_service(config)
def filter_all_results(results, media):
if media.type == "series":
filtered = filter_out_non_matching_series(
results, media.season, media.episode
)
logger.info(
f"Filtered series results: {len(filtered)} (from {len(results)})"
)
return filtered
else:
filtered = filter_out_non_matching_movies(results, media.year)
logger.info(
f"Filtered movie results: {len(filtered)} (from {len(results)})"
)
return results
def media_cache_key(media):
if isinstance(media, Movie):
key_string = f"media:{media.titles[0]}:{media.year}:{media.languages[0]}"
elif isinstance(media, Series):
key_string = f"media:{media.titles[0]}:{media.languages[0]}:{media.season}"
else:
raise TypeError("Only Movie and Series are allowed as media!")
hashed_key = hashlib.sha256(key_string.encode("utf-8")).hexdigest()
return hashed_key[:16]
def get_search_results(media, config):
search_results = []
torrent_service = TorrentService()
def perform_search(update_cache=False):
nonlocal search_results
search_results = []
if config["cache"] and not update_cache:
public_cached_results = search_public(media)
if public_cached_results:
logger.info(
f"Found {len(public_cached_results)} public cached results"
)
public_cached_results = [
JackettResult().from_cached_item(torrent, media)
for torrent in public_cached_results
if len(torrent.get("hash", "")) == 40
]
public_cached_results = filter_items(
public_cached_results, media, config=config
)
public_cached_results = torrent_service.convert_and_process(
public_cached_results
)
search_results.extend(public_cached_results)
if config["zilean"] and len(search_results) < int(
config["minCachedResults"]
):
zilean_service = ZileanService(config)
zilean_search_results = zilean_service.search(media)
if zilean_search_results:
logger.info(
f"Found {len(zilean_search_results)} results from Zilean"
)
zilean_search_results = [
ZileanResult().from_api_cached_item(torrent, media)
for torrent in zilean_search_results
if len(torrent.get("infoHash", "")) == 40
]
zilean_search_results = filter_items(
zilean_search_results, media, config=config
)
zilean_search_results = torrent_service.convert_and_process(
zilean_search_results
)
search_results = merge_items(search_results, zilean_search_results)
if config["jackett"] and len(search_results) < int(
config["minCachedResults"]
):
jackett_service = JackettService(config)
jackett_search_results = jackett_service.search(media)
logger.info(f"Found {len(jackett_search_results)} results from Jackett")
filtered_jackett_search_results = filter_items(
jackett_search_results, media, config=config
)
logger.info(
f"Filtered Jackett results: {len(filtered_jackett_search_results)}"
)
if filtered_jackett_search_results:
torrent_results = torrent_service.convert_and_process(
filtered_jackett_search_results
)
logger.debug(
f"Converted {len(torrent_results)} Jackett results to TorrentItems"
)
search_results = merge_items(search_results, torrent_results)
if update_cache and search_results:
logger.info(f"Updating cache with {len(search_results)} results")
try:
cache_key = media_cache_key(media)
search_results_dict = [item.to_dict() for item in search_results]
redis_cache.set(cache_key, search_results_dict)
except Exception as e:
logger.error(f"Error updating cache: {e}")
perform_search()
return search_results
def get_and_filter_results(media, config):
min_results = int(config.get("minCachedResults", 1))
cache_key = media_cache_key(media)
unfiltered_results = redis_cache.get(cache_key)
if unfiltered_results is None:
logger.info("No results in cache. Performing new search.")
nocache_results = get_search_results(media, config)
nocache_results_dict = [item.to_dict() for item in nocache_results]
redis_cache.set(cache_key, nocache_results_dict)
return nocache_results
else:
logger.info(f"Results retrieved from redis cache - {len(unfiltered_results)}.")
unfiltered_results = [TorrentItem.from_dict(item) for item in unfiltered_results]
filtered_results = filter_all_results(unfiltered_results, media)
if len(filtered_results) < min_results:
logger.info(
f"Insufficient filtered results ({len(filtered_results)}). Performing new search."
)
redis_cache.delete(cache_key)
unfiltered_results = get_search_results(media, config)
unfiltered_results_dict = [item.to_dict() for item in unfiltered_results]
redis_cache.set(cache_key, unfiltered_results_dict)
filtered_results = filter_all_results(unfiltered_results, media)
logger.info(f"Final number of filtered results: {len(filtered_results)}")
return filtered_results
search_results = get_and_filter_results(media, config)
def stream_processing(search_results, media, config):
torrent_smart_container = TorrentSmartContainer(search_results, media)
if config["debrid"]:
hashes = torrent_smart_container.get_hashes()
ip = request.client.host
result = debrid_service.get_availability_bulk(hashes, ip)
torrent_smart_container.update_availability(
result, type(debrid_service), media
)
logger.info(f"Checked availability for {len(result.items())} items")
if config["cache"]:
logger.debug("Caching public container items")
torrent_smart_container.cache_container_items()
best_matching_results = torrent_smart_container.get_best_matching()
best_matching_results = sort_items(best_matching_results, config)
logger.info(f"Found {len(best_matching_results)} best matching results")
stream_list = parse_to_stremio_streams(best_matching_results, config, media)
logger.info(f"Processed {len(stream_list)} streams for Stremio")
return stream_list
stream_list = stream_processing(search_results, media, config)
streams = [Stream(**stream) for stream in stream_list]
redis_cache.set(
stream_cache_key(media), streams, expiration=3600
) # Make the cache expire after 1 hour TODO: Make this configurable
total_time = time.time() - start
logger.info(f"Request completed in {total_time:.2f} seconds")
return SearchResponse(streams=streams)