mirror of
https://github.com/g0ldyy/comet.git
synced 2026-01-12 01:16:12 +01:00
+9
-6
@@ -1,6 +1,9 @@
|
||||
.github/
|
||||
.env*
|
||||
.gitignore
|
||||
Dockerfile
|
||||
LICENSE
|
||||
README.md
|
||||
.github/
|
||||
*.env*
|
||||
.gitignore
|
||||
Dockerfile
|
||||
LICENSE
|
||||
*.md
|
||||
docker-compose*.*
|
||||
.vscode/
|
||||
config.json
|
||||
+13
-13
@@ -1,13 +1,13 @@
|
||||
FASTAPI_HOST=0.0.0.0
|
||||
FASTAPI_PORT=8000
|
||||
FASTAPI_WORKERS=1 # remove to destroy CPU -> max performances
|
||||
DATABASE_PATH=customFolder/database.db # REMOVE IF YOU DON'T KNOW WHAT THIS IS - folders in path must exist
|
||||
CACHE_TTL=86400
|
||||
DEBRID_PROXY_URL=http://127.0.0.1:1080
|
||||
INDEXER_MANAGER_TYPE=jackett # or prowlarr
|
||||
INDEXER_MANAGER_URL=http://127.0.0.1:9117
|
||||
INDEXER_MANAGER_API_KEY=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
|
||||
INDEXER_MANAGER_INDEXERS=INDEXER_NAME_CHANGETHIS1,INDEXER_NAME_CHANGETHIS2
|
||||
INDEXER_MANAGER_TIMEOUT=30
|
||||
GET_TORRENT_TIMEOUT=5
|
||||
CUSTOM_HEADER_HTML=
|
||||
FASTAPI_HOST=0.0.0.0
|
||||
FASTAPI_PORT=8000
|
||||
FASTAPI_WORKERS=1 # remove to destroy CPU -> max performances
|
||||
DATABASE_PATH=customFolder/database.db # REMOVE IF YOU DON'T KNOW WHAT THIS IS - folders in path must exist
|
||||
CACHE_TTL=86400
|
||||
DEBRID_PROXY_URL=http://127.0.0.1:1080
|
||||
INDEXER_MANAGER_TYPE=jackett # or prowlarr
|
||||
INDEXER_MANAGER_URL=http://127.0.0.1:9117
|
||||
INDEXER_MANAGER_API_KEY=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
|
||||
INDEXER_MANAGER_INDEXERS=INDEXER_NAME_CHANGETHIS1,INDEXER_NAME_CHANGETHIS2
|
||||
INDEXER_MANAGER_TIMEOUT=30
|
||||
GET_TORRENT_TIMEOUT=5
|
||||
CUSTOM_HEADER_HTML=
|
||||
@@ -1,56 +1,56 @@
|
||||
name: Docker Build and Push
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
# release:
|
||||
# types: [created]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
security-events: write
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4.1.2
|
||||
|
||||
- name: Docker Setup QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
id: qemu
|
||||
with:
|
||||
platforms: amd64,arm64
|
||||
|
||||
- name: Log into ghcr.io registry
|
||||
uses: docker/login-action@v3.1.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3.2.0
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v3.1.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v5.3.0
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
tags: |
|
||||
ghcr.io/g0ldyy/comet:latest
|
||||
docker.io/g0ldyy/comet:latest
|
||||
name: Docker Build and Push
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
# release:
|
||||
# types: [created]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
security-events: write
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4.1.2
|
||||
|
||||
- name: Docker Setup QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
id: qemu
|
||||
with:
|
||||
platforms: amd64,arm64
|
||||
|
||||
- name: Log into ghcr.io registry
|
||||
uses: docker/login-action@v3.1.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3.2.0
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v3.1.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v5.3.0
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
tags: |
|
||||
ghcr.io/g0ldyy/comet:latest
|
||||
docker.io/g0ldyy/comet:latest
|
||||
|
||||
+168
-164
@@ -1,164 +1,168 @@
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
logs/
|
||||
.vscode/
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
share/python-wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
|
||||
# PyInstaller
|
||||
# Usually these files are written by a python script from a template
|
||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||
*.manifest
|
||||
*.spec
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
|
||||
# Unit test / coverage reports
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*.cover
|
||||
*.py,cover
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
cover/
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
*.pot
|
||||
|
||||
# Django stuff:
|
||||
*.log
|
||||
local_settings.py
|
||||
db.sqlite3
|
||||
db.sqlite3-journal
|
||||
|
||||
# Flask stuff:
|
||||
instance/
|
||||
.webassets-cache
|
||||
|
||||
# Scrapy stuff:
|
||||
.scrapy
|
||||
|
||||
# Sphinx documentation
|
||||
docs/_build/
|
||||
|
||||
# PyBuilder
|
||||
.pybuilder/
|
||||
target/
|
||||
|
||||
# Jupyter Notebook
|
||||
.ipynb_checkpoints
|
||||
|
||||
# IPython
|
||||
profile_default/
|
||||
ipython_config.py
|
||||
|
||||
# pyenv
|
||||
# For a library or package, you might want to ignore these files since the code is
|
||||
# intended to run in multiple environments; otherwise, check them in:
|
||||
# .python-version
|
||||
|
||||
# pipenv
|
||||
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
||||
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
||||
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
||||
# install all needed dependencies.
|
||||
#Pipfile.lock
|
||||
|
||||
# poetry
|
||||
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
||||
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||
# commonly ignored for libraries.
|
||||
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
||||
#poetry.lock
|
||||
|
||||
# pdm
|
||||
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
||||
#pdm.lock
|
||||
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
||||
# in version control.
|
||||
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
|
||||
.pdm.toml
|
||||
.pdm-python
|
||||
.pdm-build/
|
||||
|
||||
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
||||
__pypackages__/
|
||||
|
||||
# Celery stuff
|
||||
celerybeat-schedule
|
||||
celerybeat.pid
|
||||
|
||||
# SageMath parsed files
|
||||
*.sage.py
|
||||
|
||||
# Environments
|
||||
.env
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
|
||||
# Spyder project settings
|
||||
.spyderproject
|
||||
.spyproject
|
||||
|
||||
# Rope project settings
|
||||
.ropeproject
|
||||
|
||||
# mkdocs documentation
|
||||
/site
|
||||
|
||||
# mypy
|
||||
.mypy_cache/
|
||||
.dmypy.json
|
||||
dmypy.json
|
||||
|
||||
# Pyre type checker
|
||||
.pyre/
|
||||
|
||||
# pytype static type analyzer
|
||||
.pytype/
|
||||
|
||||
# Cython debug symbols
|
||||
cython_debug/
|
||||
|
||||
# PyCharm
|
||||
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
||||
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
||||
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||
#.idea/
|
||||
# Project Based
|
||||
*.db
|
||||
config.json
|
||||
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
logs/
|
||||
.vscode/
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
share/python-wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
|
||||
# PyInstaller
|
||||
# Usually these files are written by a python script from a template
|
||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||
*.manifest
|
||||
*.spec
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
|
||||
# Unit test / coverage reports
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*.cover
|
||||
*.py,cover
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
cover/
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
*.pot
|
||||
|
||||
# Django stuff:
|
||||
*.log
|
||||
local_settings.py
|
||||
db.sqlite3
|
||||
db.sqlite3-journal
|
||||
|
||||
# Flask stuff:
|
||||
instance/
|
||||
.webassets-cache
|
||||
|
||||
# Scrapy stuff:
|
||||
.scrapy
|
||||
|
||||
# Sphinx documentation
|
||||
docs/_build/
|
||||
|
||||
# PyBuilder
|
||||
.pybuilder/
|
||||
target/
|
||||
|
||||
# Jupyter Notebook
|
||||
.ipynb_checkpoints
|
||||
|
||||
# IPython
|
||||
profile_default/
|
||||
ipython_config.py
|
||||
|
||||
# pyenv
|
||||
# For a library or package, you might want to ignore these files since the code is
|
||||
# intended to run in multiple environments; otherwise, check them in:
|
||||
# .python-version
|
||||
|
||||
# pipenv
|
||||
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
||||
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
||||
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
||||
# install all needed dependencies.
|
||||
#Pipfile.lock
|
||||
|
||||
# poetry
|
||||
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
||||
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||
# commonly ignored for libraries.
|
||||
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
||||
#poetry.lock
|
||||
|
||||
# pdm
|
||||
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
||||
#pdm.lock
|
||||
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
||||
# in version control.
|
||||
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
|
||||
.pdm.toml
|
||||
.pdm-python
|
||||
.pdm-build/
|
||||
|
||||
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
||||
__pypackages__/
|
||||
|
||||
# Celery stuff
|
||||
celerybeat-schedule
|
||||
celerybeat.pid
|
||||
|
||||
# SageMath parsed files
|
||||
*.sage.py
|
||||
|
||||
# Environments
|
||||
.env
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
|
||||
# Spyder project settings
|
||||
.spyderproject
|
||||
.spyproject
|
||||
|
||||
# Rope project settings
|
||||
.ropeproject
|
||||
|
||||
# mkdocs documentation
|
||||
/site
|
||||
|
||||
# mypy
|
||||
.mypy_cache/
|
||||
.dmypy.json
|
||||
dmypy.json
|
||||
|
||||
# Pyre type checker
|
||||
.pyre/
|
||||
|
||||
# pytype static type analyzer
|
||||
.pytype/
|
||||
|
||||
# Cython debug symbols
|
||||
cython_debug/
|
||||
|
||||
# PyCharm
|
||||
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
||||
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
||||
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||
#.idea/
|
||||
|
||||
+25
-35
@@ -1,35 +1,25 @@
|
||||
FROM python:3.11-alpine
|
||||
LABEL name="Comet" \
|
||||
description="Stremio's fastest torrent/debrid search add-on." \
|
||||
url="https://github.com/g0ldyy/comet"
|
||||
|
||||
# This is to prevent Python from buffering stdout and stderr
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
# Fix python-alpine gcc
|
||||
RUN apk add --no-cache \
|
||||
gcc \
|
||||
musl-dev \
|
||||
libffi-dev \
|
||||
make
|
||||
|
||||
# Install Poetry
|
||||
RUN pip install poetry
|
||||
|
||||
ENV POETRY_NO_INTERACTION=1 \
|
||||
POETRY_VIRTUALENVS_IN_PROJECT=1 \
|
||||
POETRY_VIRTUALENVS_CREATE=1 \
|
||||
POETRY_CACHE_DIR=/tmp/poetry_cache
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Copy the application code
|
||||
COPY . ./
|
||||
|
||||
RUN poetry install --no-root
|
||||
|
||||
ENV VIRTUAL_ENV=/app/.venv
|
||||
ENV PATH="/app/.venv/bin:$PATH"
|
||||
|
||||
CMD ["python", "run.py"]
|
||||
FROM python:3.11-alpine
|
||||
LABEL name="Comet" \
|
||||
description="Stremio's fastest torrent/debrid search add-on." \
|
||||
url="https://github.com/g0ldyy/comet"
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ARG DATABASE_PATH
|
||||
|
||||
ENV PYTHONUNBUFFERED=1 \
|
||||
PYTHONDONTWRITEBYTECODE=1 \
|
||||
POETRY_NO_INTERACTION=1 \
|
||||
POETRY_HOME='/usr/local' \
|
||||
FASTAPI_HOST=0.0.0.0 \
|
||||
FASTAPI_PORT=8000 \
|
||||
FASTAPI_WORKERS=1 \
|
||||
DATABASE_PATH=comet.db \
|
||||
FORCE_COLOR=1 \
|
||||
TERM=xterm-256color
|
||||
|
||||
RUN pip install poetry
|
||||
COPY . .
|
||||
RUN poetry install --no-cache --no-root --without dev
|
||||
|
||||
ENTRYPOINT ["poetry", "run", "python", "-m", "comet.main"]
|
||||
@@ -1,21 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2024 Goldy
|
||||
|
||||
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.
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2024 Goldy
|
||||
|
||||
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.
|
||||
|
||||
@@ -1,62 +1,62 @@
|
||||
<h1 align="center" id="title">☄️ Comet</h1>
|
||||
<p align="center"><img src="https://socialify.git.ci/g0ldyy/comet/image?description=1&font=Raleway&forks=1&issues=1&language=1&logo=https%3A%2F%2Fi.imgur.com%2FGj0KQwB.png&name=1&owner=1&pattern=Solid&pulls=1&stargazers=1&theme=Dark" /></p>
|
||||
<p align="center">
|
||||
<a href="https://ko-fi.com/E1E7ZVMAD">
|
||||
<img src="https://ko-fi.com/img/githubbutton_sm.svg">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
# Features
|
||||
- Jackett and Prowlarr support (change the `INDEXER_MANAGER_TYPE` environment variable to `jackett` or `prowlarr`)
|
||||
- Caching system ft. SQLite
|
||||
- Proxy support to bypass debrid restrictions
|
||||
- Only Real-Debrid supported right now *(if you want other debrid services, please provide an account)*
|
||||
|
||||
# Installation
|
||||
## With Docker
|
||||
- Simply run the Docker image after modifying the environment variables
|
||||
```
|
||||
docker run -p 8000:8000 -d \
|
||||
--name comet \
|
||||
-e FASTAPI_HOST=0.0.0.0 \
|
||||
-e FASTAPI_PORT=8000 \
|
||||
-e FASTAPI_WORKERS=1 \
|
||||
-e CACHE_TTL=86400 \
|
||||
-e DEBRID_PROXY_URL=http://127.0.0.1:1080 \
|
||||
-e INDEXER_MANAGER_TYPE=jackett \
|
||||
-e INDEXER_MANAGER_URL=http://127.0.0.1:9117 \
|
||||
-e INDEXER_MANAGER_API_KEY=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX \
|
||||
-e INDEXER_MANAGER_INDEXERS=INDEXER_NAME_CHANGETHIS1,INDEXER_NAME_CHANGETHIS2 \
|
||||
-e INDEXER_MANAGER_TIMEOUT=30 \
|
||||
-e GET_TORRENT_TIMEOUT=5 \
|
||||
g0ldyy/comet
|
||||
```
|
||||
- To update your container
|
||||
|
||||
- Find your existing container name
|
||||
```sh
|
||||
docker ps
|
||||
```
|
||||
|
||||
- Stop your existing container
|
||||
```sh
|
||||
docker stop <CONTAINER_ID>
|
||||
```
|
||||
|
||||
- Remove your existing container
|
||||
```sh
|
||||
docker rm <CONTAINER_ID>
|
||||
```
|
||||
|
||||
- Pull the latest version from docker hub
|
||||
```sh
|
||||
docker pull g0ldyy/comet
|
||||
```
|
||||
|
||||
- Finally, re-run the docker run command
|
||||
|
||||
# Real-Debrid IP Blacklist
|
||||
To bypass Real-Debrid's IP blacklist, start a cloudflare-warp container: https://github.com/cmj2002/warp-docker
|
||||
|
||||
# Web UI Showcase
|
||||
<img src="https://i.imgur.com/SaD365F.png" />
|
||||
<h1 align="center" id="title">☄️ Comet</h1>
|
||||
<p align="center"><img src="https://socialify.git.ci/g0ldyy/comet/image?description=1&font=Raleway&forks=1&issues=1&language=1&logo=https%3A%2F%2Fi.imgur.com%2FGj0KQwB.png&name=1&owner=1&pattern=Solid&pulls=1&stargazers=1&theme=Dark" /></p>
|
||||
<p align="center">
|
||||
<a href="https://ko-fi.com/E1E7ZVMAD">
|
||||
<img src="https://ko-fi.com/img/githubbutton_sm.svg">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
# Features
|
||||
- Jackett and Prowlarr support (change the `INDEXER_MANAGER_TYPE` environment variable to `jackett` or `prowlarr`)
|
||||
- Caching system ft. SQLite
|
||||
- Proxy support to bypass debrid restrictions
|
||||
- Only Real-Debrid supported right now *(if you want other debrid services, please provide an account)*
|
||||
|
||||
# Installation
|
||||
## With Docker
|
||||
- Simply run the Docker image after modifying the environment variables
|
||||
```
|
||||
docker run -p 8000:8000 -d \
|
||||
--name comet \
|
||||
-e FASTAPI_HOST=0.0.0.0 \
|
||||
-e FASTAPI_PORT=8000 \
|
||||
-e FASTAPI_WORKERS=1 \
|
||||
-e CACHE_TTL=86400 \
|
||||
-e DEBRID_PROXY_URL=http://127.0.0.1:1080 \
|
||||
-e INDEXER_MANAGER_TYPE=jackett \
|
||||
-e INDEXER_MANAGER_URL=http://127.0.0.1:9117 \
|
||||
-e INDEXER_MANAGER_API_KEY=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX \
|
||||
-e INDEXER_MANAGER_INDEXERS=INDEXER_NAME_CHANGETHIS1,INDEXER_NAME_CHANGETHIS2 \
|
||||
-e INDEXER_MANAGER_TIMEOUT=30 \
|
||||
-e GET_TORRENT_TIMEOUT=5 \
|
||||
g0ldyy/comet
|
||||
```
|
||||
- To update your container
|
||||
|
||||
- Find your existing container name
|
||||
```sh
|
||||
docker ps
|
||||
```
|
||||
|
||||
- Stop your existing container
|
||||
```sh
|
||||
docker stop <CONTAINER_ID>
|
||||
```
|
||||
|
||||
- Remove your existing container
|
||||
```sh
|
||||
docker rm <CONTAINER_ID>
|
||||
```
|
||||
|
||||
- Pull the latest version from docker hub
|
||||
```sh
|
||||
docker pull g0ldyy/comet
|
||||
```
|
||||
|
||||
- Finally, re-run the docker run command
|
||||
|
||||
# Real-Debrid IP Blacklist
|
||||
To bypass Real-Debrid's IP blacklist, start a cloudflare-warp container: https://github.com/cmj2002/warp-docker
|
||||
|
||||
# Web UI Showcase
|
||||
<img src="https://i.imgur.com/SaD365F.png" />
|
||||
@@ -0,0 +1,51 @@
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import RedirectResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from comet.utils.models import settings
|
||||
|
||||
templates = Jinja2Templates("comet/templates")
|
||||
main = APIRouter()
|
||||
|
||||
|
||||
@main.get("/", status_code=200)
|
||||
async def root():
|
||||
return RedirectResponse("/configure")
|
||||
|
||||
|
||||
@main.get("/health", status_code=200)
|
||||
async def health():
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@main.get("/configure")
|
||||
@main.get("/{b64config}/configure")
|
||||
async def configure(request: Request):
|
||||
return templates.TemplateResponse("index.html", {"request": request, "CUSTOM_HEADER_HTML": settings.CUSTOM_HEADER_HTML})
|
||||
|
||||
|
||||
@main.get("/manifest.json")
|
||||
@main.get("/{b64config}/manifest.json")
|
||||
async def manifest():
|
||||
return {
|
||||
"id": "stremio.comet.fast",
|
||||
"version": "1.0.0",
|
||||
"name": "Comet",
|
||||
"description": "Stremio's fastest torrent/debrid search add-on.",
|
||||
"logo": "https://i.imgur.com/jmVoVMu.jpeg",
|
||||
"background": "https://i.imgur.com/WwnXB3k.jpeg",
|
||||
"resources": [
|
||||
"stream"
|
||||
],
|
||||
"types": [
|
||||
"movie",
|
||||
"series"
|
||||
],
|
||||
"idPrefixes": [
|
||||
"tt"
|
||||
],
|
||||
"catalogs": [],
|
||||
"behaviorHints": {
|
||||
"configurable": True
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from typing import Dict
|
||||
|
||||
import aiohttp
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import RedirectResponse
|
||||
from RTN import ParsedData, Torrent, parse, sort_torrents
|
||||
|
||||
from comet.utils.general import (bytesToSize, configChecking,
|
||||
generateDownloadLink, getIndexerManager,
|
||||
getTorrentHash, isVideo, translate)
|
||||
from comet.utils.logger import logger
|
||||
from comet.utils.models import database, rtn, settings
|
||||
|
||||
streams = APIRouter()
|
||||
|
||||
@streams.get("/stream/{type}/{id}.json")
|
||||
@streams.get("/{b64config}/stream/{type}/{id}.json")
|
||||
async def stream(request: Request, b64config: str, type: str, id: str):
|
||||
config = configChecking(b64config)
|
||||
if not config:
|
||||
return {
|
||||
"streams": [
|
||||
{
|
||||
"name": "[⚠️] Comet",
|
||||
"title": "Invalid Comet config.",
|
||||
"url": "https://comet.fast"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
checkDebrid = await session.get("https://api.real-debrid.com/rest/1.0/user", headers={
|
||||
"Authorization": f"Bearer {config['debridApiKey']}"
|
||||
})
|
||||
checkDebrid = await checkDebrid.text()
|
||||
if not '"type": "premium"' in checkDebrid:
|
||||
return {
|
||||
"streams": [
|
||||
{
|
||||
"name": "[⚠️] Comet",
|
||||
"title": "Invalid Real-Debrid account.",
|
||||
"url": "https://comet.fast"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
season = None
|
||||
episode = None
|
||||
if type == "series":
|
||||
info = id.split(":")
|
||||
|
||||
id = info[0]
|
||||
season = int(info[1])
|
||||
episode = int(info[2])
|
||||
|
||||
getMetadata = await session.get(f"https://v3.sg.media-imdb.com/suggestion/a/{id}.json")
|
||||
metadata = await getMetadata.json()
|
||||
|
||||
name = metadata["d"][0]["l"]
|
||||
name = translate(name)
|
||||
|
||||
cacheKey = hashlib.md5(json.dumps({"debridService": config["debridService"], "name": name, "season": season, "episode": episode, "indexers": config["indexers"], "resolutions": config["resolutions"], "languages": config["languages"]}).encode("utf-8")).hexdigest()
|
||||
cached = await database.fetch_one(f"SELECT EXISTS (SELECT 1 FROM cache WHERE cacheKey = '{cacheKey}')")
|
||||
if cached[0] != 0:
|
||||
logger.info(f"Cache found for {name}")
|
||||
|
||||
timestamp = await database.fetch_one(f"SELECT timestamp FROM cache WHERE cacheKey = '{cacheKey}'")
|
||||
if timestamp[0] + settings.CACHE_TTL < time.time():
|
||||
await database.execute(f"DELETE FROM cache WHERE cacheKey = '{cacheKey}'")
|
||||
|
||||
logger.info(f"Cache expired for {name}")
|
||||
else:
|
||||
sortedRankedFiles = await database.fetch_one(f"SELECT results FROM cache WHERE cacheKey = '{cacheKey}'")
|
||||
sortedRankedFiles = json.loads(sortedRankedFiles[0])
|
||||
|
||||
results = []
|
||||
for hash in sortedRankedFiles:
|
||||
results.append({
|
||||
"name": f"[RD⚡] Comet {sortedRankedFiles[hash]['data']['resolution'][0] if len(sortedRankedFiles[hash]['data']['resolution']) > 0 else 'Unknown'}",
|
||||
"title": f"{sortedRankedFiles[hash]['data']['title']}\n💾 {bytesToSize(sortedRankedFiles[hash]['data']['size'])}",
|
||||
"url": f"{request.url.scheme}://{request.url.netloc}/{b64config}/playback/{hash}/{sortedRankedFiles[hash]['data']['index']}"
|
||||
})
|
||||
|
||||
return {"streams": results}
|
||||
else:
|
||||
logger.info(f"No cache found for {name} with user configuration")
|
||||
|
||||
indexerManagerType = settings.INDEXER_MANAGER_TYPE
|
||||
|
||||
logger.info(f"Start of {indexerManagerType} search for {name} with indexers {config['indexers']}")
|
||||
|
||||
tasks = []
|
||||
tasks.append(getIndexerManager(session, indexerManagerType, config["indexers"], name))
|
||||
if type == "series":
|
||||
tasks.append(getIndexerManager(session, indexerManagerType, config["indexers"], f"{name} S0{season}E0{episode}"))
|
||||
searchResponses = await asyncio.gather(*tasks)
|
||||
|
||||
torrents = []
|
||||
for results in searchResponses:
|
||||
if results == None:
|
||||
continue
|
||||
|
||||
for result in results:
|
||||
torrents.append(result)
|
||||
|
||||
logger.info(f"{len(torrents)} torrents found for {name}")
|
||||
|
||||
if len(torrents) == 0:
|
||||
return {"streams": []}
|
||||
|
||||
tasks = []
|
||||
filtered = 0
|
||||
for torrent in torrents:
|
||||
parsedTorrent: ParsedData = parse(torrent["Title"]) if indexerManagerType == "jackett" else parse(torrent["title"])
|
||||
if not "All" in config["resolutions"] and len(parsedTorrent.resolution) > 0 and parsedTorrent.resolution[0] not in config["resolutions"]:
|
||||
filtered += 1
|
||||
|
||||
continue
|
||||
if not "All" in config["languages"] and not parsedTorrent.is_multi_audio and not any(language in parsedTorrent.language for language in config["languages"]):
|
||||
filtered += 1
|
||||
|
||||
continue
|
||||
|
||||
tasks.append(getTorrentHash(session, indexerManagerType, torrent))
|
||||
|
||||
torrentHashes = await asyncio.gather(*tasks)
|
||||
torrentHashes = list(set([hash for hash in torrentHashes if hash]))
|
||||
|
||||
logger.info(f"{len(torrentHashes)} info hashes found for {name}")
|
||||
|
||||
if len(torrentHashes) == 0:
|
||||
return {"streams": []}
|
||||
|
||||
getAvailability = await session.get(f"https://api.real-debrid.com/rest/1.0/torrents/instantAvailability/{'/'.join(torrentHashes)}", headers={
|
||||
"Authorization": f"Bearer {config['debridApiKey']}"
|
||||
})
|
||||
|
||||
files = {}
|
||||
|
||||
availability = await getAvailability.json()
|
||||
for hash, details in availability.items():
|
||||
if not "rd" in details:
|
||||
continue
|
||||
|
||||
if type == "series":
|
||||
for variants in details["rd"]:
|
||||
for index, file in variants.items():
|
||||
filename = file["filename"].lower()
|
||||
|
||||
if not isVideo(filename):
|
||||
continue
|
||||
|
||||
filenameParsed = parse(file["filename"])
|
||||
if season in filenameParsed.season and episode in filenameParsed.episode:
|
||||
files[hash] = {
|
||||
"index": index,
|
||||
"title": file["filename"],
|
||||
"size": file["filesize"]
|
||||
}
|
||||
|
||||
continue
|
||||
|
||||
for variants in details["rd"]:
|
||||
for index, file in variants.items():
|
||||
filename = file["filename"].lower()
|
||||
|
||||
if not isVideo(filename):
|
||||
continue
|
||||
|
||||
files[hash] = {
|
||||
"index": index,
|
||||
"title": file["filename"],
|
||||
"size": file["filesize"]
|
||||
}
|
||||
|
||||
rankedFiles = set()
|
||||
for hash in files:
|
||||
# try:
|
||||
rankedFile = rtn.rank(files[hash]["title"], hash) # , remove_trash=True, correct_title=name - removed because it's not working great
|
||||
rankedFiles.add(rankedFile)
|
||||
# except:
|
||||
# continue
|
||||
|
||||
sortedRankedFiles: Dict[str, Torrent] = sort_torrents(rankedFiles)
|
||||
|
||||
logger.info(f"{len(sortedRankedFiles)} cached files found on Real-Debrid for {name}")
|
||||
|
||||
if len(sortedRankedFiles) == 0:
|
||||
return {"streams": []}
|
||||
|
||||
sortedRankedFiles = {
|
||||
key: (value.model_dump() if isinstance(value, Torrent) else value)
|
||||
for key, value in sortedRankedFiles.items()
|
||||
}
|
||||
for hash in sortedRankedFiles: # needed for caching
|
||||
sortedRankedFiles[hash]["data"]["title"] = files[hash]["title"]
|
||||
sortedRankedFiles[hash]["data"]["size"] = files[hash]["size"]
|
||||
sortedRankedFiles[hash]["data"]["index"] = files[hash]["index"]
|
||||
|
||||
jsonData = json.dumps(sortedRankedFiles).replace("'", "''")
|
||||
await database.execute(f"INSERT INTO cache (cacheKey, results, timestamp) VALUES ('{cacheKey}', '{jsonData}', {time.time()})")
|
||||
logger.info(f"Results have been cached for {name}")
|
||||
|
||||
results = []
|
||||
for hash in sortedRankedFiles:
|
||||
results.append({
|
||||
"name": f"[RD⚡] Comet {sortedRankedFiles[hash]['data']['resolution'][0] if len(sortedRankedFiles[hash]['data']['resolution']) > 0 else 'Unknown'}",
|
||||
"title": f"{sortedRankedFiles[hash]['data']['title']}\n💾 {bytesToSize(sortedRankedFiles[hash]['data']['size'])}",
|
||||
"url": f"{request.url.scheme}://{request.url.netloc}/{b64config}/playback/{hash}/{sortedRankedFiles[hash]['data']['index']}"
|
||||
})
|
||||
|
||||
return {
|
||||
"streams": results
|
||||
}
|
||||
|
||||
@streams.route("/{b64config}/playback/{hash}/{index}", methods=["HEAD", "GET"])
|
||||
async def stream(b64config: str, hash: str, index: str):
|
||||
config = configChecking(b64config)
|
||||
if not config:
|
||||
return
|
||||
|
||||
downloadLink = await generateDownloadLink(config["debridApiKey"], hash, index)
|
||||
|
||||
return RedirectResponse(downloadLink, status_code=302)
|
||||
+106
-441
@@ -1,53 +1,62 @@
|
||||
import aiohttp, asyncio, bencodepy, hashlib, re, base64, json, os, RTN, time
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import RedirectResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
import contextlib
|
||||
import signal
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import traceback
|
||||
from contextlib import asynccontextmanager
|
||||
from databases import Database
|
||||
|
||||
from .utils.logger import logger
|
||||
from .utils.general import translate, isVideo, bytesToSize
|
||||
import uvicorn
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.requests import Request
|
||||
|
||||
database = Database(f"sqlite:///{os.getenv('DATABASE_PATH', 'database.db')}")
|
||||
from comet.api.core import main
|
||||
from comet.api.stream import streams
|
||||
from comet.utils.db import setup_database, teardown_database, write_config
|
||||
from comet.utils.logger import logger
|
||||
from comet.utils.models import settings
|
||||
|
||||
class BestOverallRanking(RTN.BaseRankingModel):
|
||||
uhd: int = 100
|
||||
fhd: int = 90
|
||||
hd: int = 80
|
||||
sd: int = 70
|
||||
dolby_video: int = 100
|
||||
hdr: int = 80
|
||||
hdr10: int = 90
|
||||
dts_x: int = 100
|
||||
dts_hd: int = 80
|
||||
dts_hd_ma: int = 90
|
||||
atmos: int = 90
|
||||
truehd: int = 60
|
||||
ddplus: int = 40
|
||||
aac: int = 30
|
||||
ac3: int = 20
|
||||
remux: int = 150
|
||||
bluray: int = 120
|
||||
webdl: int = 90
|
||||
|
||||
settings = RTN.SettingsModel()
|
||||
ranking_model = BestOverallRanking()
|
||||
rtn = RTN.RTN(settings=settings, ranking_model=ranking_model)
|
||||
class LoguruMiddleware(BaseHTTPMiddleware):
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
start_time = time.time()
|
||||
try:
|
||||
response = await call_next(request)
|
||||
except Exception as e:
|
||||
logger.exception(f"Exception during request processing: {e}")
|
||||
raise
|
||||
finally:
|
||||
process_time = time.time() - start_time
|
||||
logger.log(
|
||||
"API",
|
||||
f"{request.method} {request.url.path} - {response.status_code if 'response' in locals() else '500'} - {process_time:.2f}s",
|
||||
)
|
||||
return response
|
||||
|
||||
infoHashPattern = re.compile(r"\b([a-fA-F0-9]{40})\b")
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
await database.connect()
|
||||
await database.execute("CREATE TABLE IF NOT EXISTS cache (cacheKey BLOB PRIMARY KEY, timestamp INTEGER, results TEXT)")
|
||||
write_config()
|
||||
await setup_database()
|
||||
yield
|
||||
await database.disconnect()
|
||||
await teardown_database()
|
||||
|
||||
app = FastAPI(lifespan=lifespan, docs_url=None)
|
||||
app = FastAPI(
|
||||
title="Comet",
|
||||
summary="Stremio's fastest torrent/debrid search add-on.",
|
||||
version="1.0.0",
|
||||
lifespan=lifespan,
|
||||
redoc_url=None,
|
||||
license_info={
|
||||
"name": "GPL-3.0",
|
||||
"url": "https://www.gnu.org/licenses/gpl-3.0.en.html",
|
||||
}
|
||||
)
|
||||
|
||||
app.add_middleware(LoguruMiddleware)
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
@@ -56,414 +65,70 @@ app.add_middleware(
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
templates = Jinja2Templates("comet/templates")
|
||||
app.mount("/static", StaticFiles(directory="comet/templates"), name="static")
|
||||
|
||||
@app.get("/")
|
||||
async def root():
|
||||
return RedirectResponse("/configure")
|
||||
app.include_router(main)
|
||||
app.include_router(streams)
|
||||
|
||||
indexers = os.getenv("INDEXER_MANAGER_INDEXERS")
|
||||
if "," in indexers:
|
||||
indexers = indexers.split(",")
|
||||
else:
|
||||
indexers = [indexers]
|
||||
|
||||
webConfig = {
|
||||
"indexers": indexers,
|
||||
"languages": [indexer.replace(" ", "_") for indexer in RTN.patterns.language_code_mapping.keys()],
|
||||
"resolutions": ["480p", "720p", "1080p", "1440p", "2160p", "2880p", "4320p"]
|
||||
}
|
||||
class Server(uvicorn.Server):
|
||||
def install_signal_handlers(self):
|
||||
pass
|
||||
|
||||
@app.get("/configure")
|
||||
@app.get("/{b64config}/configure")
|
||||
async def configure(request: Request):
|
||||
return templates.TemplateResponse("index.html", {"request": request, "CUSTOM_HEADER_HTML": os.getenv("CUSTOM_HEADER_HTML", ""), "webConfig": webConfig})
|
||||
@contextlib.contextmanager
|
||||
def run_in_thread(self):
|
||||
thread = threading.Thread(target=self.run, name="Comet")
|
||||
thread.start()
|
||||
try:
|
||||
while not self.started:
|
||||
time.sleep(1e-3)
|
||||
yield
|
||||
except Exception as e:
|
||||
logger.error(f"Error in server thread: {e}")
|
||||
logger.exception(traceback.format_exc())
|
||||
raise e
|
||||
finally:
|
||||
self.should_exit = True
|
||||
sys.exit(0)
|
||||
|
||||
def configChecking(b64config: str):
|
||||
def signal_handler(sig, frame):
|
||||
# This will handle kubernetes/docker shutdowns better
|
||||
# Toss anything that needs to be gracefully shutdown here
|
||||
logger.log('COMET', 'Exiting Gracefully.')
|
||||
sys.exit(0)
|
||||
|
||||
signal.signal(signal.SIGINT, signal_handler)
|
||||
signal.signal(signal.SIGTERM, signal_handler)
|
||||
|
||||
config = uvicorn.Config(
|
||||
app,
|
||||
host=settings.FASTAPI_HOST,
|
||||
port=settings.FASTAPI_PORT,
|
||||
workers=settings.FASTAPI_WORKERS,
|
||||
log_config=None
|
||||
)
|
||||
server = Server(config=config)
|
||||
|
||||
def start_log():
|
||||
logger.log('COMET', f'Server started on {settings.FASTAPI_HOST}:{settings.FASTAPI_PORT}')
|
||||
logger.log('COMET', f'Database: {settings.DATABASE_PATH}')
|
||||
logger.log('COMET', f'Workers: {settings.FASTAPI_WORKERS}')
|
||||
logger.log('COMET', f'Get Torrent Timeout: {settings.GET_TORRENT_TIMEOUT}')
|
||||
logger.log('COMET', f"Debrid Proxy: {settings.DEBRID_PROXY_URL}")
|
||||
logger.log('COMET', f'Indexers: {settings.INDEXER_MANAGER_INDEXERS}')
|
||||
|
||||
if settings.CUSTOM_HEADER_HTML:
|
||||
logger.log('COMET', f'Custom Header HTML Enabled: {bool(settings.CUSTOM_HEADER_HTML)}')
|
||||
|
||||
with server.run_in_thread():
|
||||
start_log()
|
||||
try:
|
||||
config = json.loads(base64.b64decode(b64config).decode())
|
||||
|
||||
if not isinstance(config["debridService"], str) or config["debridService"] not in ["realdebrid"]:
|
||||
return False
|
||||
|
||||
if not isinstance(config["debridApiKey"], str):
|
||||
return False
|
||||
|
||||
if not isinstance(config["indexers"], list):
|
||||
return False
|
||||
|
||||
if not isinstance(config["maxResults"], int) or config["maxResults"] < 0:
|
||||
return False
|
||||
|
||||
if not isinstance(config["resolutions"], list) or len(config["resolutions"]) == 0:
|
||||
return False
|
||||
|
||||
if not isinstance(config["languages"], list) or len(config["languages"]) == 0:
|
||||
return False
|
||||
|
||||
return config
|
||||
except:
|
||||
return False
|
||||
|
||||
@app.get("/manifest.json")
|
||||
@app.get("/{b64config}/manifest.json")
|
||||
async def manifest():
|
||||
return {
|
||||
"id": "stremio.comet.fast",
|
||||
"version": "1.0.0",
|
||||
"name": "Comet",
|
||||
"description": "Stremio's fastest torrent/debrid search add-on.",
|
||||
"logo": "https://i.imgur.com/jmVoVMu.jpeg",
|
||||
"background": "https://i.imgur.com/WwnXB3k.jpeg",
|
||||
"resources": [
|
||||
"stream"
|
||||
],
|
||||
"types": [
|
||||
"movie",
|
||||
"series"
|
||||
],
|
||||
"idPrefixes": [
|
||||
"tt"
|
||||
],
|
||||
"catalogs": [],
|
||||
"behaviorHints": {
|
||||
"configurable": True
|
||||
}
|
||||
}
|
||||
|
||||
async def getIndexerManager(session: aiohttp.ClientSession, indexerManagerType: str, indexers: list, query: str):
|
||||
try:
|
||||
timeout = aiohttp.ClientTimeout(total=int(os.getenv("INDEXER_MANAGER_TIMEOUT", 30)))
|
||||
results = []
|
||||
|
||||
if indexerManagerType == "jackett":
|
||||
response = await session.get(f"{os.getenv('INDEXER_MANAGER_URL', 'http://127.0.0.1:9117')}/api/v2.0/indexers/all/results?apikey={os.getenv('INDEXER_MANAGER_API_KEY')}&Query={query}&Tracker[]={'&Tracker[]='.join(indexer for indexer in indexers)}", timeout=timeout)
|
||||
response = await response.json()
|
||||
|
||||
for result in response["Results"]:
|
||||
results.append(result)
|
||||
|
||||
if indexerManagerType == "prowlarr":
|
||||
getIndexers = await session.get(f"{os.getenv('INDEXER_MANAGER_URL', 'http://127.0.0.1:9696')}/api/v1/indexer", headers={
|
||||
"X-Api-Key": os.getenv("INDEXER_MANAGER_API_KEY")
|
||||
})
|
||||
getIndexers = await getIndexers.json()
|
||||
|
||||
indexersId = []
|
||||
for indexer in getIndexers:
|
||||
if indexer["definitionName"] in indexers:
|
||||
indexersId.append(indexer["id"])
|
||||
|
||||
response = await session.get(f"{os.getenv('INDEXER_MANAGER_URL', 'http://127.0.0.1:9696')}/api/v1/search?query={query}&indexerIds={'&indexerIds='.join(str(indexerId) for indexerId in indexersId)}&type=search", headers={
|
||||
"X-Api-Key": os.getenv("INDEXER_MANAGER_API_KEY")
|
||||
})
|
||||
response = await response.json()
|
||||
|
||||
for result in response:
|
||||
results.append(result)
|
||||
|
||||
return results
|
||||
while True:
|
||||
time.sleep(1) # Keep the main thread alive
|
||||
except KeyboardInterrupt:
|
||||
logger.log("COMET", "Server stopped by user")
|
||||
except Exception as e:
|
||||
logger.warning(f"Exception while getting {indexerManagerType} results for {query} with {indexers}: {e}")
|
||||
|
||||
async def getTorrentHash(session: aiohttp.ClientSession, indexerManagerType: str, torrent: dict):
|
||||
if "InfoHash" in torrent and torrent["InfoHash"] != None:
|
||||
return torrent["InfoHash"]
|
||||
|
||||
if "infoHash" in torrent:
|
||||
return torrent["infoHash"]
|
||||
|
||||
url = torrent["Link"] if indexerManagerType == "jackett" else torrent["downloadUrl"]
|
||||
|
||||
try:
|
||||
timeout = aiohttp.ClientTimeout(total=int(os.getenv("GET_TORRENT_TIMEOUT", 5)))
|
||||
response = await session.get(url, allow_redirects=False, timeout=timeout)
|
||||
if response.status == 200:
|
||||
torrentData = await response.read()
|
||||
torrentDict = bencodepy.decode(torrentData)
|
||||
info = bencodepy.encode(torrentDict[b"info"])
|
||||
hash = hashlib.sha1(info).hexdigest()
|
||||
else:
|
||||
location = response.headers.get("Location", "")
|
||||
if not location:
|
||||
return
|
||||
|
||||
match = infoHashPattern.search(location)
|
||||
if not match:
|
||||
return
|
||||
|
||||
hash = match.group(1).upper()
|
||||
|
||||
return hash
|
||||
except Exception as e:
|
||||
logger.warning(f"Exception while getting torrent info hash for {torrent['indexer'] if 'indexer' in torrent else (torrent['Tracker'] if 'Tracker' in torrent else '')}|{url}: {e}")
|
||||
# logger.warning(f"Exception while getting torrent info hash for {jackettIndexerPattern.findall(url)[0]}|{jackettNamePattern.search(url)[0]}: {e}")
|
||||
|
||||
@app.get("/stream/{type}/{id}.json")
|
||||
@app.get("/{b64config}/stream/{type}/{id}.json")
|
||||
async def stream(request: Request, b64config: str, type: str, id: str):
|
||||
config = configChecking(b64config)
|
||||
if not config:
|
||||
return {
|
||||
"streams": [
|
||||
{
|
||||
"name": "[⚠️] Comet",
|
||||
"title": "Invalid Comet config.",
|
||||
"url": "https://comet.fast"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
checkDebrid = await session.get("https://api.real-debrid.com/rest/1.0/user", headers={
|
||||
"Authorization": f"Bearer {config['debridApiKey']}"
|
||||
})
|
||||
checkDebrid = await checkDebrid.text()
|
||||
if not '"type": "premium"' in checkDebrid:
|
||||
return {
|
||||
"streams": [
|
||||
{
|
||||
"name": "[⚠️] Comet",
|
||||
"title": "Invalid Real-Debrid account.",
|
||||
"url": "https://comet.fast"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
season = None
|
||||
episode = None
|
||||
if type == "series":
|
||||
info = id.split(":")
|
||||
|
||||
id = info[0]
|
||||
season = int(info[1])
|
||||
episode = int(info[2])
|
||||
|
||||
getMetadata = await session.get(f"https://v3.sg.media-imdb.com/suggestion/a/{id}.json")
|
||||
metadata = await getMetadata.json()
|
||||
|
||||
name = metadata["d"][0]["l"]
|
||||
name = translate(name)
|
||||
|
||||
cacheKey = hashlib.md5(json.dumps({"debridService": config["debridService"], "name": name, "season": season, "episode": episode, "indexers": config["indexers"], "resolutions": config["resolutions"], "languages": config["languages"]}).encode("utf-8")).hexdigest()
|
||||
cached = await database.fetch_one(f"SELECT EXISTS (SELECT 1 FROM cache WHERE cacheKey = '{cacheKey}')")
|
||||
if cached[0] != 0:
|
||||
logger.info(f"Cache found for {name}")
|
||||
|
||||
timestamp = await database.fetch_one(f"SELECT timestamp FROM cache WHERE cacheKey = '{cacheKey}'")
|
||||
if timestamp[0] + int(os.getenv("CACHE_TTL", 86400)) < time.time():
|
||||
await database.execute(f"DELETE FROM cache WHERE cacheKey = '{cacheKey}'")
|
||||
|
||||
logger.info(f"Cache expired for {name}")
|
||||
else:
|
||||
sortedRankedFiles = await database.fetch_one(f"SELECT results FROM cache WHERE cacheKey = '{cacheKey}'")
|
||||
sortedRankedFiles = json.loads(sortedRankedFiles[0])
|
||||
|
||||
results = []
|
||||
for hash in sortedRankedFiles:
|
||||
results.append({
|
||||
"name": f"[RD⚡] Comet {sortedRankedFiles[hash]['data']['resolution'][0] if len(sortedRankedFiles[hash]['data']['resolution']) > 0 else 'Unknown'}",
|
||||
"title": f"{sortedRankedFiles[hash]['data']['title']}\n💾 {bytesToSize(sortedRankedFiles[hash]['data']['size'])}",
|
||||
"url": f"{request.url.scheme}://{request.url.netloc}/{b64config}/playback/{hash}/{sortedRankedFiles[hash]['data']['index']}"
|
||||
})
|
||||
|
||||
return {"streams": results}
|
||||
else:
|
||||
logger.info(f"No cache found for {name} with user configuration")
|
||||
|
||||
indexerManagerType = os.getenv("INDEXER_MANAGER_TYPE", "jackett")
|
||||
|
||||
logger.info(f"Start of {indexerManagerType} search for {name} with indexers {config['indexers']}")
|
||||
|
||||
tasks = []
|
||||
tasks.append(getIndexerManager(session, indexerManagerType, config["indexers"], name))
|
||||
if type == "series":
|
||||
tasks.append(getIndexerManager(session, indexerManagerType, config["indexers"], f"{name} S0{season}E0{episode}"))
|
||||
searchResponses = await asyncio.gather(*tasks)
|
||||
|
||||
torrents = []
|
||||
for results in searchResponses:
|
||||
if results == None:
|
||||
continue
|
||||
|
||||
for result in results:
|
||||
torrents.append(result)
|
||||
|
||||
logger.info(f"{len(torrents)} torrents found for {name}")
|
||||
|
||||
if len(torrents) == 0:
|
||||
return {"streams": []}
|
||||
|
||||
tasks = []
|
||||
filtered = 0
|
||||
for torrent in torrents:
|
||||
parsedTorrent = RTN.parse(torrent["Title"]) if indexerManagerType == "jackett" else RTN.parse(torrent["title"])
|
||||
if not "All" in config["resolutions"] and len(parsedTorrent.resolution) > 0 and parsedTorrent.resolution[0] not in config["resolutions"]:
|
||||
filtered += 1
|
||||
|
||||
continue
|
||||
if not "All" in config["languages"] and not parsedTorrent.is_multi_audio and not any(language in parsedTorrent.language for language in config["languages"]):
|
||||
filtered += 1
|
||||
|
||||
continue
|
||||
|
||||
tasks.append(getTorrentHash(session, indexerManagerType, torrent))
|
||||
|
||||
torrentHashes = await asyncio.gather(*tasks)
|
||||
torrentHashes = list(set([hash for hash in torrentHashes if hash]))
|
||||
|
||||
logger.info(f"{len(torrentHashes)} info hashes found for {name}")
|
||||
|
||||
if len(torrentHashes) == 0:
|
||||
return {"streams": []}
|
||||
|
||||
getAvailability = await session.get(f"https://api.real-debrid.com/rest/1.0/torrents/instantAvailability/{'/'.join(torrentHashes)}", headers={
|
||||
"Authorization": f"Bearer {config['debridApiKey']}"
|
||||
})
|
||||
|
||||
files = {}
|
||||
|
||||
availability = await getAvailability.json()
|
||||
for hash, details in availability.items():
|
||||
if not "rd" in details:
|
||||
continue
|
||||
|
||||
if type == "series":
|
||||
for variants in details["rd"]:
|
||||
for index, file in variants.items():
|
||||
filename = file["filename"].lower()
|
||||
|
||||
if not isVideo(filename):
|
||||
continue
|
||||
|
||||
filenameParsed = RTN.parse(file["filename"])
|
||||
if season in filenameParsed.season and episode in filenameParsed.episode:
|
||||
files[hash] = {
|
||||
"index": index,
|
||||
"title": file["filename"],
|
||||
"size": file["filesize"]
|
||||
}
|
||||
|
||||
continue
|
||||
|
||||
for variants in details["rd"]:
|
||||
for index, file in variants.items():
|
||||
filename = file["filename"].lower()
|
||||
|
||||
if not isVideo(filename):
|
||||
continue
|
||||
|
||||
files[hash] = {
|
||||
"index": index,
|
||||
"title": file["filename"],
|
||||
"size": file["filesize"]
|
||||
}
|
||||
|
||||
rankedFiles = set()
|
||||
for hash in files:
|
||||
# try:
|
||||
rankedFile = rtn.rank(files[hash]["title"], hash) # , remove_trash=True, correct_title=name - removed because it's not working great
|
||||
rankedFiles.add(rankedFile)
|
||||
# except:
|
||||
# continue
|
||||
|
||||
sortedRankedFiles = RTN.sort_torrents(rankedFiles)
|
||||
|
||||
logger.info(f"{len(sortedRankedFiles)} cached files found on Real-Debrid for {name}")
|
||||
|
||||
if len(sortedRankedFiles) == 0:
|
||||
return {"streams": []}
|
||||
|
||||
sortedRankedFiles = {
|
||||
key: (value.model_dump() if isinstance(value, RTN.Torrent) else value)
|
||||
for key, value in sortedRankedFiles.items()
|
||||
}
|
||||
for hash in sortedRankedFiles: # needed for caching
|
||||
sortedRankedFiles[hash]["data"]["title"] = files[hash]["title"]
|
||||
sortedRankedFiles[hash]["data"]["size"] = files[hash]["size"]
|
||||
sortedRankedFiles[hash]["data"]["index"] = files[hash]["index"]
|
||||
|
||||
jsonData = json.dumps(sortedRankedFiles).replace("'", "''")
|
||||
await database.execute(f"INSERT INTO cache (cacheKey, results, timestamp) VALUES ('{cacheKey}', '{jsonData}', {time.time()})")
|
||||
logger.info(f"Results have been cached for {name}")
|
||||
|
||||
results = []
|
||||
for hash in sortedRankedFiles:
|
||||
results.append({
|
||||
"name": f"[RD⚡] Comet {sortedRankedFiles[hash]['data']['resolution'][0] if len(sortedRankedFiles[hash]['data']['resolution']) > 0 else 'Unknown'}",
|
||||
"title": f"{sortedRankedFiles[hash]['data']['title']}\n💾 {bytesToSize(sortedRankedFiles[hash]['data']['size'])}",
|
||||
"url": f"{request.url.scheme}://{request.url.netloc}/{b64config}/playback/{hash}/{sortedRankedFiles[hash]['data']['index']}"
|
||||
})
|
||||
|
||||
return {
|
||||
"streams": results
|
||||
}
|
||||
|
||||
async def generateDownloadLink(debridApiKey: str, hash: str, index: str):
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
checkBlacklisted = await session.get("https://real-debrid.com/vpn")
|
||||
checkBlacklisted = await checkBlacklisted.text()
|
||||
|
||||
proxy = None
|
||||
if "Your ISP or VPN provider IP address is currently blocked on our website" in checkBlacklisted:
|
||||
proxy = os.getenv("DEBRID_PROXY_URL", "http://127.0.0.1:1080")
|
||||
|
||||
logger.warning(f"Real-Debrid blacklisted server's IP. Switching to proxy {proxy} for {hash}|{index}")
|
||||
|
||||
addMagnet = await session.post(f"https://api.real-debrid.com/rest/1.0/torrents/addMagnet", headers={
|
||||
"Authorization": f"Bearer {debridApiKey}"
|
||||
}, data={
|
||||
"magnet": f"magnet:?xt=urn:btih:{hash}"
|
||||
}, proxy=proxy)
|
||||
addMagnet = await addMagnet.json()
|
||||
|
||||
getMagnetInfo = await session.get(addMagnet["uri"], headers={
|
||||
"Authorization": f"Bearer {debridApiKey}"
|
||||
}, proxy=proxy)
|
||||
getMagnetInfo = await getMagnetInfo.json()
|
||||
|
||||
selectFile = await session.post(f"https://api.real-debrid.com/rest/1.0/torrents/selectFiles/{addMagnet['id']}", headers={
|
||||
"Authorization": f"Bearer {debridApiKey}"
|
||||
}, data={
|
||||
"files": index
|
||||
}, proxy=proxy)
|
||||
|
||||
getMagnetInfo = await session.get(addMagnet["uri"], headers={
|
||||
"Authorization": f"Bearer {debridApiKey}"
|
||||
}, proxy=proxy)
|
||||
getMagnetInfo = await getMagnetInfo.json()
|
||||
|
||||
unrestrictLink = await session.post(f"https://api.real-debrid.com/rest/1.0/unrestrict/link", headers={
|
||||
"Authorization": f"Bearer {debridApiKey}"
|
||||
}, data={
|
||||
"link": getMagnetInfo["links"][0]
|
||||
}, proxy=proxy)
|
||||
unrestrictLink = await unrestrictLink.json()
|
||||
|
||||
return unrestrictLink["download"]
|
||||
except Exception as e:
|
||||
logger.warning(f"Exception while getting download link from Real Debrid for {hash}|{index}: {e}")
|
||||
|
||||
return "https://comet.fast"
|
||||
|
||||
@app.head("/{b64config}/playback/{hash}/{index}")
|
||||
async def stream(b64config: str, hash: str, index: str):
|
||||
config = configChecking(b64config)
|
||||
if not config:
|
||||
return
|
||||
|
||||
downloadLink = await generateDownloadLink(config["debridApiKey"], hash, index)
|
||||
|
||||
return RedirectResponse(downloadLink, status_code=302)
|
||||
|
||||
@app.get("/{b64config}/playback/{hash}/{index}")
|
||||
async def stream(b64config: str, hash: str, index: str):
|
||||
config = configChecking(b64config)
|
||||
if not config:
|
||||
return
|
||||
|
||||
downloadLink = await generateDownloadLink(config["debridApiKey"], hash, index)
|
||||
|
||||
return RedirectResponse(downloadLink, status_code=302)
|
||||
logger.error(f"Unexpected error: {e}")
|
||||
logger.exception(traceback.format_exc())
|
||||
finally:
|
||||
logger.log("COMET", "Server Shutdown")
|
||||
+563
-564
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,47 @@
|
||||
import json
|
||||
import os
|
||||
|
||||
from comet.utils.general import lang_code_map
|
||||
from comet.utils.logger import logger
|
||||
from comet.utils.models import database, settings
|
||||
|
||||
|
||||
async def setup_database():
|
||||
"""Setup the database by ensuring the directory and file exist, and creating the necessary tables."""
|
||||
try:
|
||||
# Ensure the database directory exists
|
||||
os.makedirs(os.path.dirname(settings.DATABASE_PATH), exist_ok=True)
|
||||
|
||||
# Ensure the database file exists
|
||||
if not os.path.exists(settings.DATABASE_PATH):
|
||||
open(settings.DATABASE_PATH, 'a').close()
|
||||
|
||||
await database.connect()
|
||||
await database.execute("CREATE TABLE IF NOT EXISTS cache (cacheKey BLOB PRIMARY KEY, timestamp INTEGER, results TEXT)")
|
||||
except Exception as e:
|
||||
logger.error(f"Error setting up the database: {e}")
|
||||
|
||||
async def teardown_database():
|
||||
"""Teardown the database by disconnecting."""
|
||||
try:
|
||||
await database.disconnect()
|
||||
except Exception as e:
|
||||
logger.error(f"Error tearing down the database: {e}")
|
||||
|
||||
def write_config():
|
||||
"""Write the config file."""
|
||||
indexers = settings.INDEXER_MANAGER_INDEXERS
|
||||
if indexers:
|
||||
if isinstance(indexers, str):
|
||||
indexers = indexers.split(",")
|
||||
elif not isinstance(indexers, list):
|
||||
logger.error(f"Invalid indexers: {indexers}")
|
||||
|
||||
config_data = {
|
||||
"indexers": indexers,
|
||||
"languages": lang_code_map,
|
||||
"resolutions": ["480p", "720p", "1080p", "1440p", "2160p", "2880p", "4320p"]
|
||||
}
|
||||
|
||||
with open("comet/templates/config.json", "w", encoding="utf-8") as config_file:
|
||||
json.dump(config_data, config_file, indent=4)
|
||||
+161
-1
@@ -1,4 +1,16 @@
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
|
||||
import aiohttp
|
||||
import bencodepy
|
||||
from RTN.patterns import language_code_mapping
|
||||
|
||||
from comet.utils.logger import logger
|
||||
from comet.utils.models import settings
|
||||
|
||||
translationTable = {
|
||||
'ā': 'a', 'ă': 'a', 'ą': 'a', 'ć': 'c', 'č': 'c', 'ç': 'c',
|
||||
@@ -17,14 +29,19 @@ translationTable = {
|
||||
'ə': 'e', 'ƒ': 'f', 'ǐ': 'i', 'ǒ': 'o', 'ǔ': 'u', 'ǚ': 'u',
|
||||
'ǜ': 'u', 'ǹ': 'n', 'ǻ': 'a', 'ǽ': 'ae', 'ǿ': 'o',
|
||||
}
|
||||
|
||||
translationTable = str.maketrans(translationTable)
|
||||
infoHashPattern = re.compile(r"\b([a-fA-F0-9]{40})\b")
|
||||
lang_code_map = [indexer.replace(" ", "_") for indexer in language_code_mapping.keys()]
|
||||
|
||||
def translate(title: str):
|
||||
return title.translate(translationTable)
|
||||
|
||||
|
||||
def isVideo(title: str):
|
||||
return title.endswith(tuple([".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 bytesToSize(bytes: int):
|
||||
sizes = ["Bytes", "KB", "MB", "GB", "TB"]
|
||||
|
||||
@@ -33,4 +50,147 @@ def bytesToSize(bytes: int):
|
||||
|
||||
i = int(math.floor(math.log(bytes, 1024)))
|
||||
|
||||
return f"{round(bytes / math.pow(1024, i), 2)} {sizes[i]}"
|
||||
return f"{round(bytes / math.pow(1024, i), 2)} {sizes[i]}"
|
||||
|
||||
|
||||
def configChecking(b64config: str):
|
||||
try:
|
||||
config = json.loads(base64.b64decode(b64config).decode())
|
||||
if not isinstance(config["debridService"], str) or config["debridService"] not in ["realdebrid"]:
|
||||
return False
|
||||
if not isinstance(config["debridApiKey"], str):
|
||||
return False
|
||||
if not isinstance(config["indexers"], list):
|
||||
return False
|
||||
if not isinstance(config["maxResults"], int) or config["maxResults"] < 0:
|
||||
return False
|
||||
if not isinstance(config["resolutions"], list) or len(config["resolutions"]) == 0:
|
||||
return False
|
||||
if not isinstance(config["languages"], list) or len(config["languages"]) == 0:
|
||||
return False
|
||||
|
||||
return config
|
||||
except:
|
||||
return False
|
||||
|
||||
|
||||
async def getIndexerManager(session: aiohttp.ClientSession, indexerManagerType: str, indexers: list, query: str):
|
||||
try:
|
||||
timeout = aiohttp.ClientTimeout(total=settings.INDEXER_MANAGER_TIMEOUT)
|
||||
results = []
|
||||
|
||||
if indexerManagerType == "jackett":
|
||||
response = await session.get(f"{settings.INDEXER_MANAGER_URL}/api/v2.0/indexers/all/results?apikey={settings.INDEXER_MANAGER_API_KEY}&Query={query}&Tracker[]={'&Tracker[]='.join(indexer for indexer in indexers)}", timeout=timeout)
|
||||
response = await response.json()
|
||||
|
||||
for result in response["Results"]:
|
||||
results.append(result)
|
||||
|
||||
if indexerManagerType == "prowlarr":
|
||||
getIndexers = await session.get(f"{settings.INDEXER_MANAGER_URL}/api/v1/indexer", headers={
|
||||
"X-Api-Key": settings.INDEXER_MANAGER_API_KEY
|
||||
})
|
||||
getIndexers = await getIndexers.json()
|
||||
|
||||
indexersId = []
|
||||
for indexer in getIndexers:
|
||||
if indexer["definitionName"] in indexers:
|
||||
indexersId.append(indexer["id"])
|
||||
|
||||
response = await session.get(f"{settings.INDEXER_MANAGER_URL}/api/v1/search?query={query}&indexerIds={'&indexerIds='.join(str(indexerId) for indexerId in indexersId)}&type=search", headers={
|
||||
"X-Api-Key": settings.INDEXER_MANAGER_API_KEY
|
||||
})
|
||||
response = await response.json()
|
||||
|
||||
for result in response:
|
||||
results.append(result)
|
||||
|
||||
return results
|
||||
except Exception as e:
|
||||
logger.warning(f"Exception while getting {indexerManagerType} results for {query} with {indexers}: {e}")
|
||||
|
||||
|
||||
async def getTorrentHash(session: aiohttp.ClientSession, indexerManagerType: str, torrent: dict):
|
||||
if "InfoHash" in torrent and torrent["InfoHash"] != None:
|
||||
return torrent["InfoHash"]
|
||||
|
||||
if "infoHash" in torrent:
|
||||
return torrent["infoHash"]
|
||||
|
||||
url = torrent["Link"] if indexerManagerType == "jackett" else torrent["downloadUrl"]
|
||||
|
||||
try:
|
||||
timeout = aiohttp.ClientTimeout(total=settings.GET_TORRENT_TIMEOUT)
|
||||
response = await session.get(url, allow_redirects=False, timeout=timeout)
|
||||
if response.status == 200:
|
||||
torrentData = await response.read()
|
||||
torrentDict = bencodepy.decode(torrentData)
|
||||
info = bencodepy.encode(torrentDict[b"info"])
|
||||
hash = hashlib.sha1(info).hexdigest()
|
||||
else:
|
||||
location = response.headers.get("Location", "")
|
||||
if not location:
|
||||
return
|
||||
|
||||
match = infoHashPattern.search(location)
|
||||
if not match:
|
||||
return
|
||||
|
||||
hash = match.group(1).upper()
|
||||
|
||||
return hash
|
||||
except Exception as e:
|
||||
logger.warning(f"Exception while getting torrent info hash for {torrent['indexer'] if 'indexer' in torrent else (torrent['Tracker'] if 'Tracker' in torrent else '')}|{url}: {e}")
|
||||
# logger.warning(f"Exception while getting torrent info hash for {jackettIndexerPattern.findall(url)[0]}|{jackettNamePattern.search(url)[0]}: {e}")
|
||||
|
||||
|
||||
async def generateDownloadLink(debridApiKey: str, hash: str, index: str):
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
checkBlacklisted = await session.get("https://real-debrid.com/vpn")
|
||||
checkBlacklisted = await checkBlacklisted.text()
|
||||
|
||||
proxy = None
|
||||
if "Your ISP or VPN provider IP address is currently blocked on our website" in checkBlacklisted:
|
||||
proxy = settings.DEBRID_PROXY_URL
|
||||
if not proxy:
|
||||
logger.warning(f"Real-Debrid blacklisted server's IP. No proxy found.")
|
||||
return "https://comet.fast" # TODO: This needs to be handled better
|
||||
else:
|
||||
logger.warning(f"Real-Debrid blacklisted server's IP. Switching to proxy {proxy} for {hash}|{index}")
|
||||
|
||||
addMagnet = await session.post(f"https://api.real-debrid.com/rest/1.0/torrents/addMagnet", headers={
|
||||
"Authorization": f"Bearer {debridApiKey}"
|
||||
}, data={
|
||||
"magnet": f"magnet:?xt=urn:btih:{hash}"
|
||||
}, proxy=proxy)
|
||||
addMagnet = await addMagnet.json()
|
||||
|
||||
getMagnetInfo = await session.get(addMagnet["uri"], headers={
|
||||
"Authorization": f"Bearer {debridApiKey}"
|
||||
}, proxy=proxy)
|
||||
getMagnetInfo = await getMagnetInfo.json()
|
||||
|
||||
selectFile = await session.post(f"https://api.real-debrid.com/rest/1.0/torrents/selectFiles/{addMagnet['id']}", headers={
|
||||
"Authorization": f"Bearer {debridApiKey}"
|
||||
}, data={
|
||||
"files": index
|
||||
}, proxy=proxy)
|
||||
|
||||
getMagnetInfo = await session.get(addMagnet["uri"], headers={
|
||||
"Authorization": f"Bearer {debridApiKey}"
|
||||
}, proxy=proxy)
|
||||
getMagnetInfo = await getMagnetInfo.json()
|
||||
|
||||
unrestrictLink = await session.post(f"https://api.real-debrid.com/rest/1.0/unrestrict/link", headers={
|
||||
"Authorization": f"Bearer {debridApiKey}"
|
||||
}, data={
|
||||
"link": getMagnetInfo["links"][0]
|
||||
}, proxy=proxy)
|
||||
unrestrictLink = await unrestrictLink.json()
|
||||
|
||||
return unrestrictLink["download"]
|
||||
except Exception as e:
|
||||
logger.warning(f"Exception while getting download link from Real Debrid for {hash}|{index}: {e}")
|
||||
|
||||
return "https://comet.fast"
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import sys
|
||||
|
||||
from loguru import logger
|
||||
|
||||
|
||||
def setupLogger(level: str):
|
||||
logger.level("COMET", no=50, icon="🌠", color="<fg #7871d6>")
|
||||
logger.level("API", no=40, icon="👾", color="<fg #7871d6>")
|
||||
|
||||
logger.level("INFO", icon="📰", color="<fg #FC5F39>")
|
||||
logger.level("DEBUG", icon="🕸️", color="<fg #DC5F00>")
|
||||
logger.level("WARNING", icon="⚠️", color="<fg #DC5F00>")
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import os
|
||||
from typing import List, Optional
|
||||
|
||||
from databases import Database
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
from RTN import RTN, BaseRankingModel, SettingsModel
|
||||
|
||||
|
||||
class AppSettings(BaseSettings):
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
)
|
||||
|
||||
FASTAPI_HOST: str = "0.0.0.0"
|
||||
FASTAPI_PORT: int = 8000
|
||||
FASTAPI_WORKERS: int = 2 * (os.cpu_count() or 1)
|
||||
DATABASE_PATH: str = "data/comet.db"
|
||||
CACHE_TTL: int = 86400
|
||||
GET_TORRENT_TIMEOUT: int = 5
|
||||
INDEXER_MANAGER_INDEXERS: List[str] = ["jackett", "qbittorrent"]
|
||||
INDEXER_MANAGER_TYPE: str = "jackett"
|
||||
INDEXER_MANAGER_URL: str = "http://127.0.0.1:9117"
|
||||
INDEXER_MANAGER_API_KEY: str = ""
|
||||
INDEXER_MANAGER_TIMEOUT: int = 30
|
||||
DEBRID_PROXY_URL: Optional[str] = None
|
||||
CUSTOM_HEADER_HTML: Optional[str] = None
|
||||
|
||||
|
||||
class BestOverallRanking(BaseRankingModel):
|
||||
uhd: int = 100
|
||||
fhd: int = 90
|
||||
hd: int = 80
|
||||
sd: int = 70
|
||||
dolby_video: int = 100
|
||||
hdr: int = 80
|
||||
hdr10: int = 90
|
||||
dts_x: int = 100
|
||||
dts_hd: int = 80
|
||||
dts_hd_ma: int = 90
|
||||
atmos: int = 90
|
||||
truehd: int = 60
|
||||
ddplus: int = 40
|
||||
aac: int = 30
|
||||
ac3: int = 20
|
||||
remux: int = 150
|
||||
bluray: int = 120
|
||||
webdl: int = 90
|
||||
|
||||
rtn_settings: SettingsModel = SettingsModel()
|
||||
rtn_ranking: BestOverallRanking = BestOverallRanking()
|
||||
|
||||
# For use anywhere
|
||||
rtn: RTN = RTN(settings=rtn_settings, ranking_model=rtn_ranking)
|
||||
settings: AppSettings = AppSettings()
|
||||
database = Database(f"sqlite:///{settings.DATABASE_PATH}")
|
||||
@@ -0,0 +1,17 @@
|
||||
services:
|
||||
comet:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
image: comet:dev
|
||||
container_name: comet
|
||||
environment:
|
||||
- FASTAPI_HOST=0.0.0.0
|
||||
- FASTAPI_PORT=8000
|
||||
- FASTAPI_WORKERS=1
|
||||
- DATABASE_PATH=/app/data/comet.db
|
||||
tty: true
|
||||
ports:
|
||||
- "8000:8000"
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
@@ -0,0 +1,14 @@
|
||||
services:
|
||||
comet:
|
||||
image: g0ldyy/comet:latest
|
||||
container_name: comet
|
||||
tty: true
|
||||
ports:
|
||||
- "8000:8000"
|
||||
environment:
|
||||
- FASTAPI_HOST=0.0.0.0
|
||||
- FASTAPI_PORT=8000
|
||||
- FASTAPI_WORKERS=1
|
||||
- DATABASE_PATH=/app/data/comet.db
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
@@ -0,0 +1,106 @@
|
||||
.PHONY: help install run start start-dev stop restart logs logs-dev shell build push push-dev clean check lint sort test coverage pr-ready
|
||||
|
||||
# Detect operating system
|
||||
ifeq ($(OS),Windows_NT)
|
||||
# For Windows
|
||||
DATA_PATH := $(shell echo %cd%)\data
|
||||
else
|
||||
# For Linux
|
||||
DATA_PATH := $(PWD)/data
|
||||
endif
|
||||
|
||||
help:
|
||||
@echo "Comet Local Development Environment"
|
||||
@echo "-------------------------------------------------------------------------"
|
||||
@echo "install : Install the required packages"
|
||||
@echo "run : Run Comet"
|
||||
@echo "start : Build and run the Comet container (requires Docker)"
|
||||
@echo "start-dev : Build and run the Comet container in development mode (requires Docker)"
|
||||
@echo "stop : Stop and remove the Comet container (requires Docker)"
|
||||
@echo "logs : Show the logs of the Comet container (requires Docker)"
|
||||
@echo "logs-dev : Show the logs of the Comet container in development mode (requires Docker)"
|
||||
@echo "clean : Remove all the temporary files"
|
||||
@echo "format : Format the code using isort"
|
||||
@echo "lint : Lint the code using ruff and isort"
|
||||
@echo "test : Run the tests using pytest"
|
||||
@echo "coverage : Run the tests and generate coverage report"
|
||||
@echo "pr-ready : Run the linter and tests"
|
||||
@echo "-------------------------------------------------------------------------"
|
||||
# Docker related commands
|
||||
|
||||
start: stop
|
||||
@docker compose -f docker-compose.yml up --build -d --force-recreate --remove-orphans
|
||||
@docker compose -f docker-compose.yml logs -f
|
||||
|
||||
start-dev: stop
|
||||
@docker compose -f docker-compose-dev.yml up --build -d --force-recreate --remove-orphans
|
||||
@docker compose -f docker-compose-dev.yml logs -f
|
||||
|
||||
stop:
|
||||
@docker compose -f docker-compose.yml down
|
||||
@docker compose -f docker-compose-dev.yml down
|
||||
|
||||
restart:
|
||||
@docker restart comet
|
||||
@docker logs -f comet
|
||||
|
||||
logs:
|
||||
@docker logs -f comet
|
||||
|
||||
logs-dev:
|
||||
@docker compose -f docker-compose-dev.yml logs -f
|
||||
|
||||
shell:
|
||||
@docker exec -it comet fish
|
||||
|
||||
build:
|
||||
@docker build -t comet .
|
||||
|
||||
push: build
|
||||
@docker tag comet:latest g0ldyy/comet:latest
|
||||
@docker push g0ldyy/comet:latest
|
||||
|
||||
push-dev: build
|
||||
@docker tag comet:latest g0ldyy/comet:dev
|
||||
@docker push g0ldyy/comet:dev
|
||||
|
||||
tidy:
|
||||
@docker rmi $(docker images | awk '$1 == "<none>" || $1 == "comet" {print $3}') -f
|
||||
|
||||
|
||||
# Poetry related commands
|
||||
clean:
|
||||
@find . -type f -name '*.pyc' -exec rm -f {} +
|
||||
@find . -type d -name '__pycache__' -exec rm -rf {} +
|
||||
@find . -type d -name '.pytest_cache' -exec rm -rf {} +
|
||||
@find . -type d -name '.ruff_cache' -exec rm -rf {} +
|
||||
|
||||
install:
|
||||
@poetry install --without dev --no-root
|
||||
|
||||
install-dev:
|
||||
@poetry install --with dev --no-root
|
||||
|
||||
# Run the application
|
||||
run:
|
||||
@poetry run python comet/main.py
|
||||
|
||||
# Code quality commands
|
||||
check:
|
||||
@poetry run pyright
|
||||
|
||||
lint:
|
||||
@poetry run ruff check comet
|
||||
@poetry run isort --check-only comet
|
||||
|
||||
sort:
|
||||
@poetry run isort comet
|
||||
|
||||
test:
|
||||
@poetry run pytest comet
|
||||
|
||||
coverage: clean
|
||||
@poetry run pytest comet --cov=comet --cov-report=xml --cov-report=term
|
||||
|
||||
# Run the linter and tests
|
||||
pr-ready: clean lint test
|
||||
Generated
+2263
-2144
File diff suppressed because it is too large
Load Diff
@@ -19,7 +19,12 @@ asyncio = "^3.4.3"
|
||||
loguru = "^0.7.2"
|
||||
databases = "^0.9.0"
|
||||
aiosqlite = "^0.20.0"
|
||||
pydantic-settings = "^2.3.4"
|
||||
|
||||
[tool.poetry.group.dev.dependencies]
|
||||
isort = "^5.13.2"
|
||||
pyright = "^1.1.369"
|
||||
pytest = "^8.2.2"
|
||||
|
||||
[build-system]
|
||||
requires = ["poetry-core"]
|
||||
|
||||
Reference in New Issue
Block a user