init
This commit is contained in:
commit
6fe4fe55f3
96 changed files with 12113 additions and 0 deletions
150
.dockerignore
Normal file
150
.dockerignore
Normal 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
30
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal 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.
|
||||
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal 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
38
.github/workflows/docker-develop.yml
vendored
Normal 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
148
.gitignore
vendored
Normal 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
18
Dockerfile
Normal 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
21
LICENSE
Normal 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
165
README.md
Normal 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.
|
||||
1
deploy/docker-compose.yml
Normal file
1
deploy/docker-compose.yml
Normal file
|
|
@ -0,0 +1 @@
|
|||
# TODO:
|
||||
2463
poetry.lock
generated
Normal file
2463
poetry.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
3
poetry.toml
Normal file
3
poetry.toml
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
[virtualenvs]
|
||||
in-project = true
|
||||
create = true
|
||||
34
pyproject.toml
Normal file
34
pyproject.toml
Normal 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"
|
||||
1
stream_fusion/__init__.py
Normal file
1
stream_fusion/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""stream_fusion package."""
|
||||
36
stream_fusion/__main__.py
Normal file
36
stream_fusion/__main__.py
Normal 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()
|
||||
87
stream_fusion/constants.py
Normal file
87
stream_fusion/constants.py
Normal 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
|
||||
86
stream_fusion/gunicorn_runner.py
Normal file
86
stream_fusion/gunicorn_runner.py
Normal 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)
|
||||
109
stream_fusion/logging_config.py
Normal file
109
stream_fusion/logging_config.py
Normal 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,
|
||||
)
|
||||
26
stream_fusion/services/redis/redis_config.py
Normal file
26
stream_fusion/services/redis/redis_config.py
Normal 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()
|
||||
4
stream_fusion/services/security_db/__init__.py
Normal file
4
stream_fusion/services/security_db/__init__.py
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
"""Security Database Service"""
|
||||
from stream_fusion.services.security_db._sqlite_access import security_db_access
|
||||
|
||||
__all__ = ["security_db_access"]
|
||||
181
stream_fusion/services/security_db/_sqlite_access.py
Normal file
181
stream_fusion/services/security_db/_sqlite_access.py
Normal 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
70
stream_fusion/settings.py
Normal 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}")
|
||||
1804
stream_fusion/static/docs/redoc.standalone.js
Normal file
1804
stream_fusion/static/docs/redoc.standalone.js
Normal file
File diff suppressed because one or more lines are too long
3
stream_fusion/static/docs/swagger-ui-bundle.js
Normal file
3
stream_fusion/static/docs/swagger-ui-bundle.js
Normal file
File diff suppressed because one or more lines are too long
3
stream_fusion/static/docs/swagger-ui.css
Normal file
3
stream_fusion/static/docs/swagger-ui.css
Normal file
File diff suppressed because one or more lines are too long
270
stream_fusion/templates/config.js
Normal file
270
stream_fusion/templates/config.js
Normal 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');
|
||||
});
|
||||
}
|
||||
}
|
||||
1804
stream_fusion/templates/docs/redoc.standalone.js
Normal file
1804
stream_fusion/templates/docs/redoc.standalone.js
Normal file
File diff suppressed because one or more lines are too long
3
stream_fusion/templates/docs/swagger-ui-bundle.js
Normal file
3
stream_fusion/templates/docs/swagger-ui-bundle.js
Normal file
File diff suppressed because one or more lines are too long
3
stream_fusion/templates/docs/swagger-ui.css
Normal file
3
stream_fusion/templates/docs/swagger-ui.css
Normal file
File diff suppressed because one or more lines are too long
643
stream_fusion/templates/index.html
Normal file
643
stream_fusion/templates/index.html
Normal 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
4
stream_fusion/utils/cache/__init__.py
vendored
Normal 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
142
stream_fusion/utils/cache/cache.py
vendored
Normal 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
85
stream_fusion/utils/cache/cache_base.py
vendored
Normal 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
219
stream_fusion/utils/cache/local_redis.py
vendored
Normal 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()
|
||||
9
stream_fusion/utils/debrid/__init__.py
Normal file
9
stream_fusion/utils/debrid/__init__.py
Normal 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"]
|
||||
126
stream_fusion/utils/debrid/alldebrid.py
Normal file
126
stream_fusion/utils/debrid/alldebrid.py
Normal 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
|
||||
61
stream_fusion/utils/debrid/base_debrid.py
Normal file
61
stream_fusion/utils/debrid/base_debrid.py
Normal 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
|
||||
19
stream_fusion/utils/debrid/get_debrid_service.py
Normal file
19
stream_fusion/utils/debrid/get_debrid_service.py
Normal 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
|
||||
120
stream_fusion/utils/debrid/premiumize.py
Normal file
120
stream_fusion/utils/debrid/premiumize.py
Normal 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
|
||||
285
stream_fusion/utils/debrid/realdebrid.py
Normal file
285
stream_fusion/utils/debrid/realdebrid.py
Normal 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]
|
||||
28
stream_fusion/utils/detection.py
Normal file
28
stream_fusion/utils/detection.py
Normal 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
|
||||
15
stream_fusion/utils/filter/base_filter.py
Normal file
15
stream_fusion/utils/filter/base_filter.py
Normal 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
|
||||
52
stream_fusion/utils/filter/language_filter.py
Normal file
52
stream_fusion/utils/filter/language_filter.py
Normal 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
|
||||
23
stream_fusion/utils/filter/max_size_filter.py
Normal file
23
stream_fusion/utils/filter/max_size_filter.py
Normal 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'
|
||||
|
||||
43
stream_fusion/utils/filter/quality_exclusion_filter.py
Normal file
43
stream_fusion/utils/filter/quality_exclusion_filter.py
Normal 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)
|
||||
33
stream_fusion/utils/filter/results_per_quality_filter.py
Normal file
33
stream_fusion/utils/filter/results_per_quality_filter.py
Normal 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
|
||||
32
stream_fusion/utils/filter/title_exclusion_filter.py
Normal file
32
stream_fusion/utils/filter/title_exclusion_filter.py
Normal 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)
|
||||
196
stream_fusion/utils/filter_results.py
Normal file
196
stream_fusion/utils/filter_results.py
Normal 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
|
||||
42
stream_fusion/utils/general.py
Normal file
42
stream_fusion/utils/general.py
Normal 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
|
||||
6
stream_fusion/utils/jackett/__init__.py
Normal file
6
stream_fusion/utils/jackett/__init__.py
Normal 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"]
|
||||
9
stream_fusion/utils/jackett/jackett_indexer.py
Normal file
9
stream_fusion/utils/jackett/jackett_indexer.py
Normal 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
|
||||
61
stream_fusion/utils/jackett/jackett_result.py
Normal file
61
stream_fusion/utils/jackett/jackett_result.py
Normal 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
|
||||
279
stream_fusion/utils/jackett/jackett_service.py
Normal file
279
stream_fusion/utils/jackett/jackett_service.py
Normal 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
|
||||
35
stream_fusion/utils/metdata/cinemeta.py
Normal file
35
stream_fusion/utils/metdata/cinemeta.py
Normal 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
|
||||
35
stream_fusion/utils/metdata/metadata_provider_base.py
Normal file
35
stream_fusion/utils/metdata/metadata_provider_base.py
Normal 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
|
||||
43
stream_fusion/utils/metdata/tmdb.py
Normal file
43
stream_fusion/utils/metdata/tmdb.py
Normal 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
|
||||
6
stream_fusion/utils/models/media.py
Normal file
6
stream_fusion/utils/models/media.py
Normal 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
|
||||
7
stream_fusion/utils/models/movie.py
Normal file
7
stream_fusion/utils/models/movie.py
Normal 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
|
||||
9
stream_fusion/utils/models/series.py
Normal file
9
stream_fusion/utils/models/series.py
Normal 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
|
||||
16
stream_fusion/utils/parse_config.py
Normal file
16
stream_fusion/utils/parse_config.py
Normal 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
|
||||
5
stream_fusion/utils/security/__init__.py
Normal file
5
stream_fusion/utils/security/__init__.py
Normal 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"]
|
||||
43
stream_fusion/utils/security/security_api_key.py
Normal file
43
stream_fusion/utils/security/security_api_key.py
Normal 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.",
|
||||
)
|
||||
99
stream_fusion/utils/security/security_secret.py
Normal file
99
stream_fusion/utils/security/security_secret.py
Normal 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
|
||||
9
stream_fusion/utils/string_encoding.py
Normal file
9
stream_fusion/utils/string_encoding.py
Normal 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')
|
||||
93
stream_fusion/utils/torrent/torrent_item.py
Normal file
93
stream_fusion/utils/torrent/torrent_item.py
Normal 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
|
||||
195
stream_fusion/utils/torrent/torrent_service.py
Normal file
195
stream_fusion/utils/torrent/torrent_service.py
Normal 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
|
||||
212
stream_fusion/utils/torrent/torrent_smart_container.py
Normal file
212
stream_fusion/utils/torrent/torrent_smart_container.py
Normal 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
|
||||
63
stream_fusion/utils/zilean/zilean_result.py
Normal file
63
stream_fusion/utils/zilean/zilean_result.py
Normal 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
|
||||
99
stream_fusion/utils/zilean/zilean_service.py
Normal file
99
stream_fusion/utils/zilean/zilean_service.py
Normal 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
12
stream_fusion/version.py
Normal 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
|
||||
BIN
stream_fusion/videos/nocache.mp4
Normal file
BIN
stream_fusion/videos/nocache.mp4
Normal file
Binary file not shown.
1
stream_fusion/web/__init__.py
Normal file
1
stream_fusion/web/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""WEB API for stream_fusion."""
|
||||
0
stream_fusion/web/api/__init__.py
Normal file
0
stream_fusion/web/api/__init__.py
Normal file
4
stream_fusion/web/api/auth/__init__.py
Normal file
4
stream_fusion/web/api/auth/__init__.py
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
"""Auth by apikey endpoint."""
|
||||
from stream_fusion.web.api.auth.views import router
|
||||
|
||||
__all__ = ["router"]
|
||||
17
stream_fusion/web/api/auth/schemas.py
Normal file
17
stream_fusion/web/api/auth/schemas.py
Normal 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]
|
||||
92
stream_fusion/web/api/auth/views.py
Normal file
92
stream_fusion/web/api/auth/views.py
Normal 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()
|
||||
],
|
||||
)
|
||||
4
stream_fusion/web/api/docs/__init__.py
Normal file
4
stream_fusion/web/api/docs/__init__.py
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
"""Routes for swagger and redoc."""
|
||||
from stream_fusion.web.api.docs.views import router
|
||||
|
||||
__all__ = ["router"]
|
||||
53
stream_fusion/web/api/docs/views.py
Normal file
53
stream_fusion/web/api/docs/views.py
Normal 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",
|
||||
)
|
||||
4
stream_fusion/web/api/monitoring/__init__.py
Normal file
4
stream_fusion/web/api/monitoring/__init__.py
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
"""API for checking project status."""
|
||||
from stream_fusion.web.api.monitoring.views import router
|
||||
|
||||
__all__ = ["router"]
|
||||
12
stream_fusion/web/api/monitoring/views.py
Normal file
12
stream_fusion/web/api/monitoring/views.py
Normal 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.
|
||||
"""
|
||||
7
stream_fusion/web/api/router.py
Normal file
7
stream_fusion/web/api/router.py
Normal 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)
|
||||
64
stream_fusion/web/application.py
Normal file
64
stream_fusion/web/application.py
Normal 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
|
||||
44
stream_fusion/web/lifetime.py
Normal file
44
stream_fusion/web/lifetime.py
Normal 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
|
||||
0
stream_fusion/web/playback/__init__.py
Normal file
0
stream_fusion/web/playback/__init__.py
Normal file
6
stream_fusion/web/playback/router.py
Normal file
6
stream_fusion/web/playback/router.py
Normal 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"])
|
||||
3
stream_fusion/web/playback/stream/__init__.py
Normal file
3
stream_fusion/web/playback/stream/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
from stream_fusion.web.playback.stream.views import router
|
||||
|
||||
__all__ = ["router"]
|
||||
13
stream_fusion/web/playback/stream/schemas.py
Normal file
13
stream_fusion/web/playback/stream/schemas.py
Normal 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
|
||||
169
stream_fusion/web/playback/stream/views.py
Normal file
169
stream_fusion/web/playback/stream/views.py
Normal 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(),
|
||||
)
|
||||
3
stream_fusion/web/root/__init__.py
Normal file
3
stream_fusion/web/root/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
from stream_fusion.web.root.search import router
|
||||
|
||||
__all__ = ["router"]
|
||||
3
stream_fusion/web/root/config/__init__.py
Normal file
3
stream_fusion/web/root/config/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
from stream_fusion.web.root.config.views import router
|
||||
|
||||
__all__ = ["router"]
|
||||
22
stream_fusion/web/root/config/schemas.py
Normal file
22
stream_fusion/web/root/config/schemas.py
Normal 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
|
||||
78
stream_fusion/web/root/config/views.py
Normal file
78
stream_fusion/web/root/config/views.py
Normal 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."
|
||||
)
|
||||
8
stream_fusion/web/root/router.py
Normal file
8
stream_fusion/web/root/router.py
Normal 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"])
|
||||
3
stream_fusion/web/root/search/__init__.py
Normal file
3
stream_fusion/web/root/search/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
from stream_fusion.web.root.search.views import router
|
||||
|
||||
__all__ = ["router"]
|
||||
13
stream_fusion/web/root/search/schemas.py
Normal file
13
stream_fusion/web/root/search/schemas.py
Normal 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]
|
||||
141
stream_fusion/web/root/search/stremio_parser.py
Normal file
141
stream_fusion/web/root/search/stremio_parser.py
Normal 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
|
||||
255
stream_fusion/web/root/search/views.py
Normal file
255
stream_fusion/web/root/search/views.py
Normal 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)
|
||||
Loading…
Reference in a new issue