├── .gitignore ├── .readthedocs.yaml ├── Docker ├── dockerfile ├── unit3dup.ps1 └── unit3dup.sh ├── LICENSE ├── README.rst ├── common ├── __init__.py ├── bdinfo_string.py ├── bittorrent.py ├── command.py ├── constants.py ├── database.py ├── external_services │ ├── Pw │ │ ├── __init__.py │ │ ├── core │ │ │ ├── __init__.py │ │ │ ├── models │ │ │ │ ├── __init__.py │ │ │ │ ├── indexers.py │ │ │ │ ├── search.py │ │ │ │ └── torrent_client_config.py │ │ │ └── pw_api.py │ │ ├── pw_manager.py │ │ └── pw_service.py │ ├── __init__.py │ ├── ftpx │ │ ├── __init__.py │ │ ├── client.py │ │ └── core │ │ │ ├── __init__.py │ │ │ ├── ftpx_service.py │ │ │ ├── ftpx_session.py │ │ │ ├── menu.py │ │ │ └── models │ │ │ ├── __init__.py │ │ │ └── list.py │ ├── igdb │ │ ├── __init__.py │ │ ├── client.py │ │ └── core │ │ │ ├── __init__.py │ │ │ ├── api.py │ │ │ ├── models │ │ │ ├── __init__.py │ │ │ └── search.py │ │ │ ├── platformid.py │ │ │ └── tags.py │ ├── imageHost.py │ ├── imdb.py │ ├── mediaresult.py │ ├── sessions │ │ ├── __init__.py │ │ ├── agents.py │ │ ├── exceptions.py │ │ └── session.py │ ├── theMovieDB │ │ ├── __init__.py │ │ └── core │ │ │ ├── __init__.py │ │ │ ├── api.py │ │ │ ├── keywords.py │ │ │ ├── models │ │ │ ├── __init__.py │ │ │ ├── movie │ │ │ │ ├── __init__.py │ │ │ │ ├── alternative_titles.py │ │ │ │ ├── details.py │ │ │ │ ├── movie.py │ │ │ │ ├── nowplaying.py │ │ │ │ └── release_info.py │ │ │ └── tvshow │ │ │ │ ├── __init__.py │ │ │ │ ├── alternative.py │ │ │ │ ├── details.py │ │ │ │ ├── on_the_air.py │ │ │ │ ├── translations.py │ │ │ │ └── tvshow.py │ │ │ └── videos.py │ └── trailers │ │ ├── __init__.py │ │ ├── api.py │ │ └── response.py ├── extractor.py ├── frames.py ├── mediainfo.py ├── mediainfo_string.py ├── settings.py ├── title.py ├── torrent_clients.py ├── trackers │ ├── __init__.py │ ├── data.py │ ├── itt.py │ ├── sis.py │ └── trackers.py └── utility.py ├── docs ├── .nojekyll ├── Makefile ├── conf.py ├── config.rst ├── docker.rst ├── index.rst ├── main.rst ├── make.bat ├── requirements.txt └── watcher.rst ├── mypy.ini ├── pyproject.toml ├── requirements.txt ├── tests ├── __init__.py ├── contents.py ├── dbonline.py ├── faketitle.py ├── game.py ├── movieshow.py ├── video.py └── watcher.py ├── unit3dup ├── __init__.py ├── __main__.py ├── automode.py ├── bot.py ├── duplicate.py ├── exceptions.py ├── media.py ├── media_manager │ ├── ContentManager.py │ ├── DocuManager.py │ ├── GameManager.py │ ├── MediaInfoManager.py │ ├── SeedManager.py │ ├── TorrentManager.py │ ├── VideoManager.py │ ├── __init__.py │ └── common.py ├── pvtDocu.py ├── pvtTorrent.py ├── pvtTracker.py ├── pvtVideo.py ├── torrent.py └── upload.py └── view ├── __init__.py └── custom_console.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # Env file 7 | *.env 8 | *.db 9 | 10 | # C extensions 11 | *.so 12 | 13 | *.note 14 | 15 | # Distribution / packaging 16 | .Python 17 | build/ 18 | develop-eggs/ 19 | dist/ 20 | downloads/ 21 | eggs/ 22 | .eggs/ 23 | lib/ 24 | lib64/ 25 | parts/ 26 | sdist/ 27 | var/ 28 | wheels/ 29 | share/python-wheels/ 30 | client/ 31 | .nicegui/ 32 | *.egg-info/ 33 | .installed.cfg 34 | *.egg 35 | 36 | MANIFEST 37 | 38 | # PyInstaller 39 | # Usually these files are written by a python script from a template 40 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 41 | *.manifest 42 | *.spec 43 | 44 | # Installer logs 45 | pip-log.txt 46 | pip-delete-this-directory.txt 47 | 48 | # Unit test / coverage reports 49 | htmlcov/ 50 | .tox/ 51 | .nox/ 52 | .coverage 53 | .coverage.* 54 | .cache 55 | nosetests.xml 56 | coverage.xml 57 | *.cover 58 | *.py,cover 59 | .hypothesis/ 60 | .pytest_cache/ 61 | cover/ 62 | 63 | # Translations 64 | *.mo 65 | *.pot 66 | 67 | # Django stuff: 68 | *.log 69 | local_settings.py 70 | db.sqlite3 71 | db.sqlite3-journal 72 | 73 | # Flask stuff: 74 | instance/ 75 | .webassets-cache 76 | 77 | # Scrapy stuff: 78 | .scrapy 79 | 80 | # Sphinx documentation 81 | docs/_build/ 82 | *.html 83 | .buildinfo 84 | 85 | # PyBuilder 86 | .pybuilder/ 87 | target/ 88 | 89 | # Jupyter Notebook 90 | .ipynb_checkpoints 91 | 92 | # IPython 93 | profile_default/ 94 | ipython_config.py 95 | 96 | # pyenv 97 | # For a library or package, you might want to ignore these files since the code is 98 | # intended to run in multiple environments; otherwise, check them in: 99 | # .python-version 100 | 101 | # pipenv 102 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 103 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 104 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 105 | # install all needed dependencies. 106 | #Pipfile.lock 107 | 108 | # poetry 109 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 110 | # This is especially recommended for binary packages to ensure reproducibility, and is more 111 | # commonly ignored for libraries. 112 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 113 | #poetry.lock 114 | 115 | # pdm 116 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 117 | #pdm.lock 118 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 119 | # in version control. 120 | # https://pdm.fming.dev/latest/usage/project/#working-with-version-control 121 | .pdm.toml 122 | .pdm-python 123 | .pdm-build/ 124 | 125 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 126 | __pypackages__/ 127 | 128 | # Celery stuff 129 | celerybeat-schedule 130 | celerybeat.pid 131 | 132 | # SageMath parsed files 133 | *.sage.py 134 | 135 | # Environments 136 | .env 137 | .venv 138 | venv 139 | env/ 140 | venv/ 141 | ENV/ 142 | env.bak/ 143 | venv.bak/ 144 | 145 | # Spyder project settings 146 | .spyderproject 147 | .spyproject 148 | 149 | # Rope project settings 150 | .ropeproject 151 | 152 | # mkdocs documentation 153 | /site 154 | 155 | # mypy 156 | .mypy_cache/ 157 | .dmypy.json 158 | dmypy.json 159 | 160 | # Pyre type checker 161 | .pyre/ 162 | 163 | # pytype static type analyzer 164 | .pytype/ 165 | 166 | # Cython debug symbols 167 | cython_debug/ 168 | 169 | # PyCharm 170 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 171 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 172 | # and can be added to the global gitignore or merged into this file. For a more nuclear 173 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 174 | .idea/ 175 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | # .readthedocs.yaml 2 | version: 2 3 | 4 | build: 5 | os: ubuntu-22.04 6 | tools: 7 | python: "3.10" 8 | 9 | sphinx: 10 | configuration: docs/conf.py 11 | 12 | python: 13 | install: 14 | - requirements: docs/requirements.txt -------------------------------------------------------------------------------- /Docker/dockerfile: -------------------------------------------------------------------------------- 1 | # 02/05/2025 2 | # Build "docker build -t unit3dup ." inside the Docker folder first 3 | # Remove "docker images", "docker rmi -f unit3dup:latest" 4 | 5 | FROM python:3.11-slim 6 | 7 | # Set the username as your Host 8 | ARG USERNAME=pc 9 | 10 | # environment 11 | ENV VIRTUAL_ENV=/home/$USERNAME/venv 12 | ENV PATH="$VIRTUAL_ENV/bin:$PATH" 13 | 14 | # Install dependencies and clean 15 | RUN apt-get update && \ 16 | apt-get install -y --no-install-recommends \ 17 | sudo \ 18 | ffmpeg \ 19 | poppler-utils \ 20 | libmediainfo-dev \ 21 | build-essential \ 22 | python3-venv \ 23 | python3-dev \ 24 | && rm -rf /var/lib/apt/lists/* 25 | 26 | # Crea il gruppo con ID 1000 e poi l'utente $USERNAME 27 | RUN groupadd -g 1000 $USERNAME && \ 28 | useradd $USERNAME -u 1000 -g 1000 -m -s /bin/bash && \ 29 | echo "$USERNAME ALL=(ALL) NOPASSWD:ALL" >> /etc/sudoers 30 | 31 | 32 | # Set the user 33 | USER $USERNAME 34 | WORKDIR /home/$USERNAME 35 | 36 | # Upgrade pip 37 | RUN pip install --upgrade pip 38 | 39 | # A new virtual env 40 | RUN python3 -m venv $VIRTUAL_ENV 41 | 42 | # Activate virtual 43 | RUN . $VIRTUAL_ENV/bin/activate 44 | 45 | # Copy .whl in the container 46 | # COPY unit3dup-0.8.6-py3-none-any.whl /app/ 47 | 48 | # Install .whl 49 | # RUN pip install --no-cache-dir /app/unit3dup-0.8.6-py3-none-any.whl 50 | RUN pip install --no-cache-dir unit3dup 51 | 52 | # Set the entry point ( see pyproject.toml) 53 | ENTRYPOINT ["unit3dup"] -------------------------------------------------------------------------------- /Docker/unit3dup.ps1: -------------------------------------------------------------------------------- 1 | # Parse parameters 2 | param( 3 | [string]$u, 4 | [string]$f, 5 | [string]$scan, 6 | [switch]$help 7 | ) 8 | 9 | # [EDITABLE] Windows Username Host 10 | $username = "pc" 11 | # [EDITABLE] Unit3Dup config path Host 12 | $hostJsonPath = "$env:USERPROFILE\AppData\Local\Unit3Dup_config\" 13 | # [EDITABLE] Data Files Path Host 14 | $hostDataPath = "c:\vm_share\" 15 | 16 | # Don't edit 17 | $DockerJsonPath = "/home/$username/Unit3Dup_config/" 18 | # Don't edit 19 | $DockerDataPath = "/home/$username/data/" 20 | 21 | 22 | # Default -help 23 | if (-not $u -and -not $f -and -not $scan -and -not $help) { 24 | Write-Host "Usage: .\unit3dup.ps1 -u -f -scan -help" 25 | exit 26 | } 27 | 28 | # Only one flag at once 29 | if (($u -and ($f -or $scan)) -or ($f -and $scan)) { 30 | Write-Host "Error: Only one flag can be used at a time" 31 | exit 1 32 | } 33 | 34 | # Check if JSON file exists 35 | if (-not (Test-Path $hostJsonPath)) { 36 | Write-Host "Error: configuration file not found : $hostJsonPath" 37 | exit 1 38 | } 39 | 40 | # Host <--> Docker 41 | Write-Host "[mount] $hostJsonPath -> $DockerJsonPath" 42 | Write-Host "[mount] $hostDataPath -> $DockerDataPath" 43 | 44 | # Docker "run string" 45 | $dockerFlags = "" 46 | 47 | # flag -u and subparam 48 | if ($u) { 49 | $dockerFlags = "-u ${DockerDataPath}${u}" 50 | } 51 | 52 | # flag -f and subparam 53 | if ($f) { 54 | $dockerFlags = "-f ${DockerDataPath}${f}" 55 | } 56 | 57 | # flag -scan and subparam 58 | if ($scan) { 59 | $dockerFlags = "-scan ${DockerDataPath}${scan}" 60 | } 61 | 62 | Write-Host "$dockerFlags" 63 | Write-Host "$hostDataPath, $DockerDataPath" 64 | Read-Host "Press any key to continue..." 65 | 66 | # RUN 67 | # -v mount 68 | # -p qbittorrent host port 8080 69 | 70 | docker run --rm ` 71 | -u 1000:1000 ` 72 | -v "${hostJsonPath}:${DockerJsonPath}" ` 73 | -v "${hostDataPath}:${DockerDataPath}" ` 74 | -p 8081:8080 ` 75 | unit3dup "$dockerFlags" 76 | 77 | -------------------------------------------------------------------------------- /Docker/unit3dup.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Parse parameters 4 | while [[ $# -gt 0 ]]; do 5 | case "$1" in 6 | -u) 7 | u="$2" 8 | shift 2 9 | ;; 10 | -f) 11 | f="$2" 12 | shift 2 13 | ;; 14 | -scan) 15 | scan="$2" 16 | shift 2 17 | ;; 18 | -help) 19 | help=1 20 | shift 21 | ;; 22 | *) 23 | echo "Unknown parameter: $1" 24 | exit 1 25 | ;; 26 | esac 27 | done 28 | 29 | # Default -help 30 | if [[ -z "$u" && -z "$f" && -z "$scan" && -z "$help" ]]; then 31 | echo "Usage: ./unit3dup.sh -u -f -scan -help" 32 | exit 0 33 | fi 34 | 35 | # Only one flag at once 36 | if { [[ -n "$u" && ( -n "$f" || -n "$scan" ) ]] || [[ -n "$f" && -n "$scan" ]]; }; then 37 | echo "Error: Only one flag can be used at a time" 38 | exit 1 39 | fi 40 | 41 | # [EDITABLE] Linux Username Host 42 | username="parzival" 43 | # [EDITABLE] Unit3Dup config path Host 44 | hostJsonPath="$HOME/Unit3Dup_config/" 45 | # [EDITABLE] Data Files Path Host 46 | hostDataPath="/mnt/vm_share/" 47 | 48 | # Don't edit 49 | DockerJsonPath="/home/$username/Unit3Dup_config/" 50 | # Don't edit 51 | DockerDataPath="/home/$username/data/" 52 | 53 | # Check if JSON file exists 54 | if [[ ! -d "$hostJsonPath" ]]; then 55 | echo "Error: configuration folder not found : $hostJsonPath" 56 | exit 1 57 | fi 58 | 59 | # Host <--> Docker 60 | echo "[mount] $hostJsonPath -> $DockerJsonPath" 61 | echo "[mount] $hostDataPath -> $DockerDataPath" 62 | 63 | # Docker "run string" 64 | dockerFlags="" 65 | 66 | # flag -u and subparam 67 | if [[ -n "$u" ]]; then 68 | dockerFlags="-u ${DockerDataPath}${u}" 69 | fi 70 | 71 | # flag -f and subparam 72 | if [[ -n "$f" ]]; then 73 | dockerFlags="-f ${DockerDataPath}${f}" 74 | fi 75 | 76 | # flag -scan and subparam 77 | if [[ -n "$scan" ]]; then 78 | dockerFlags="-scan ${DockerDataPath}${scan}" 79 | fi 80 | 81 | echo "$dockerFlags" 82 | echo "$hostDataPath, $DockerDataPath" 83 | read -p "Press ENTER to continue..." 84 | 85 | # RUN 86 | # -v mount 87 | # -p qbittorrent host port 8080 88 | 89 | docker run --rm \ 90 | -u 1000:1000 \ 91 | -v "${hostJsonPath}:${DockerJsonPath}" \ 92 | -v "${hostDataPath}:${DockerDataPath}" \ 93 | -p 8081:8080 \ 94 | unit3dup "$dockerFlags" 95 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 31December99 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | 2 | **Hi !** 3 | =============================================== 4 | |version| |online| |status| |python| |ubuntu| |debian| |windows| 5 | 6 | .. |version| image:: https://img.shields.io/pypi/v/unit3dup.svg 7 | .. |online| image:: https://img.shields.io/badge/Online-green 8 | .. |status| image:: https://img.shields.io/badge/Status-Active-brightgreen 9 | .. |python| image:: https://img.shields.io/badge/Python-3.10+-blue 10 | .. |ubuntu| image:: https://img.shields.io/badge/Ubuntu-22-blue 11 | .. |debian| image:: https://img.shields.io/badge/Debian-12-blue 12 | .. |windows| image:: https://img.shields.io/badge/Windows-10-blue 13 | 14 | Auto Torrent Generator and Uploader 15 | =================================== 16 | 17 | This Python script generates and uploads torrents based on input provided for movies or TV series and Games. 18 | 19 | It performs the following tasks: 20 | 21 | - Scan folder and subfolders 22 | - Compiles various metadata information to create a torrent 23 | - Extracts a series of screenshots directly from the video 24 | - Add webp to your torrent description page 25 | - Extracts cover from the PDF documents 26 | - Generates meta-info derived from the video or game 27 | - Searches for the corresponding ID on TMDB, IGDB or IMDB 28 | - Add trailer from TMDB or YouTube 29 | - Seeding in qBittorrent, Transmission or rTorrent 30 | - Reseeding one or more torrents at a time 31 | - Seed your torrents across different OS 32 | - Add a custom title to your seasons 33 | - Generate info for a title using MediaInfo 34 | 35 | unit3dup can grab the first page, convert it to an image (using xpdf), 36 | and then the bot can upload it to an image host, then add the link to the torrent page description. 37 | 38 | 39 | Install and Upgrade 40 | =================== 41 | 42 | - pip install unit3dup --upgrade 43 | 44 | Windows Dependencies 45 | -------------------- 46 | 1. Download and unzip https://www.ffmpeg.org/download.html and add its folder to 47 | PATH environment user variable 48 | 49 | 50 | Only for pdf 51 | ~~~~~~~~~~~~ 52 | 1. Download and unzip poppler for Windows from https://github.com/oschwartz10612/poppler-windows/releases 53 | 2. Put the folder 'bin' in the system path (e.g. ``C:\poppler-24.08.0\Library\bin``) 54 | 3. *Close and reopen a new console window* 55 | 4. Test it: Run ``pdftocairo`` in the terminal 56 | 57 | 58 | Ubuntu/Debian Dependencies 59 | -------------------------- 60 | - sudo apt install ffmpeg 61 | 62 | Only for pdf 63 | ~~~~~~~~~~~~ 64 | - sudo apt install poppler-utils 65 | 66 | 67 | RUN 68 | ====== 69 | 70 | .. code-block:: python 71 | 72 | unit3dup -u 73 | unit3dup -f 74 | unit3dup -scan 75 | 76 | 77 | 78 | DOC 79 | === 80 | 81 | Link `Unit3DUP `_ 82 | 83 | 84 | 85 | Trackers 86 | ======== 87 | 88 | The Italian tracker: a multitude of people from diverse technical and social backgrounds, 89 | united by a shared passion for torrents and more 90 | 91 | +------------------+----------------------------+ 92 | | **Trackers** | **Description** | 93 | +==================+============================+ 94 | | ``ITT`` | https://itatorrents.xyz/ | 95 | +------------------+----------------------------+ 96 | 97 | 98 | .. image:: https://img.shields.io/badge/Telegram-Join-blue?logo=telegram 99 | :target: https://t.me/+hj294GabGWJlMDI8 100 | :alt: Unisciti su Telegram 101 | 102 | .. image:: https://img.shields.io/discord/1214696147600408698?label=Discord&logo=discord&style=flat 103 | :target: https://discord.gg/8hRTjV8Q 104 | :alt: Discord Server 105 | 106 | 107 | 108 | 🎯 Streaming Community 109 | ====================== 110 | 111 | `goto GitHub Project `_ 112 | 113 | An open-source script for downloading movies, TV shows, and anime from various websites, 114 | built by a community of people with a shared interest in programming. 115 | 116 | .. image:: https://img.shields.io/badge/Telegram-Join-blue?logo=telegram 117 | :target: https://t.me/+hj294GabGWJlMDI8 118 | :alt: Unisciti su Telegram 119 | 120 | .. image:: https://img.shields.io/badge/StreamingCommunity-blue.svg 121 | :target: https://github.com/Arrowar/StreamingCommunity 122 | :alt: StreamingCommunity Badge 123 | 124 | -------------------------------------------------------------------------------- /common/__init__.py: -------------------------------------------------------------------------------- 1 | from common.settings import Load 2 | 3 | config_settings = Load.load_config() 4 | 5 | -------------------------------------------------------------------------------- /common/bdinfo_string.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | class BDInfo: 4 | """ 5 | A class to represent information about a Blu-ray disc. 6 | 7 | Attributes: 8 | disc_label (str): The label or name of the disc. 9 | disc_size (str): The size of the disc. 10 | protection (str): The type of protection applied to the disc. 11 | playlist (str): The playlist information. 12 | size (str): The size of the content on the disc. 13 | length (str): The length of the content on the disc. 14 | total_bitrate (str): The total bitrate of the disc content. 15 | video (str): The video specifications. 16 | audio (list[str]): A list of audio specifications. 17 | languages (list[str]): A list of languages extracted from audio specifications. 18 | subtitles (list[str]): A list of subtitle specifications. 19 | 20 | Methods: 21 | from_bdinfo_string(bd_info_string: str) -> 'BDInfo': 22 | Creates an instance of BDInfo from a formatted string. 23 | """ 24 | 25 | def __init__( 26 | self, 27 | disc_label: str, 28 | disc_size: str, 29 | protection: str, 30 | playlist: str, 31 | size: str, 32 | length: str, 33 | total_bitrate: str, 34 | video: str, 35 | audio: list[str], 36 | languages: list[str], 37 | subtitles: list[str], 38 | ): 39 | self.disc_label = disc_label 40 | self.disc_size = disc_size 41 | self.protection = protection 42 | self.playlist = playlist 43 | self.size = size 44 | self.length = length 45 | self.total_bitrate = total_bitrate 46 | self.video = video 47 | self.audio = audio 48 | self.languages = languages 49 | self.subtitles = subtitles 50 | 51 | @classmethod 52 | def from_bdinfo_string(cls, bd_info_string: str) -> 'BDInfo': 53 | """ 54 | Creates an instance of BDInfo from a formatted string. 55 | 56 | Args: 57 | bd_info_string (str): A string containing Blu-ray disc information formatted with labels and values. 58 | 59 | Returns: 60 | BDInfo: An instance of the BDInfo class with attributes populated from the input string. 61 | """ 62 | lines = bd_info_string.strip().split("\n") 63 | data = {"audio": [], "subtitles": []} 64 | 65 | for line in lines: 66 | # Parsing Audio 67 | if ": " in line: 68 | key, value = line.split(": ", 1) 69 | key = key.strip().replace(" ", "_").lower() 70 | 71 | if key == "audio": 72 | data["audio"].append(value.strip().lower()) 73 | elif key == "subtitle": 74 | data["subtitles"].append(value.strip().lower()) 75 | else: 76 | data[key] = value.strip() 77 | 78 | # Parsing Languages 79 | languages_parsed = [] 80 | for item in data["audio"]: 81 | item = item.replace("(", " ") 82 | item = item.replace(")", " ") 83 | item = item.split("/") 84 | languages_parsed.append(item[0].strip()) 85 | 86 | return cls( 87 | disc_label=data.get("disc_label"), 88 | disc_size=data.get("disc_size"), 89 | protection=data.get("protection"), 90 | playlist=data.get("playlist"), 91 | size=data.get("size"), 92 | length=data.get("length"), 93 | total_bitrate=data.get("total_bitrate"), 94 | video=data.get("video"), 95 | audio=data["audio"], 96 | languages=languages_parsed, 97 | subtitles=data["subtitles"], 98 | ) 99 | -------------------------------------------------------------------------------- /common/bittorrent.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from dataclasses import dataclass 4 | from unit3dup.pvtTorrent import Mytorrent 5 | from unit3dup.media import Media 6 | 7 | 8 | @dataclass 9 | class BittorrentData: 10 | tracker_response: str 11 | torrent_response: Mytorrent | None 12 | content: Media 13 | tracker_message: dict 14 | archive_path: str 15 | -------------------------------------------------------------------------------- /common/command.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import os 4 | import argparse 5 | from common.utility import System 6 | from common.settings import Load 7 | 8 | class CommandLine: 9 | """ 10 | Class to handle user input from the command line 11 | """ 12 | 13 | def __init__(self): 14 | 15 | config = Load().load_config() 16 | 17 | parser = argparse.ArgumentParser( 18 | description="Manage torrents, uploads, and config checks." 19 | ) 20 | 21 | # Config files 22 | parser.add_argument( 23 | "-check", "--check", action="store_true", help="Check config" 24 | ) 25 | 26 | # Main flags 27 | parser.add_argument("-u", "--upload", type=str, help="Upload path") 28 | parser.add_argument("-f", "--folder", type=str, help="Upload folder") 29 | parser.add_argument("-scan", "--scan", type=str, help="Scan folder") 30 | 31 | parser.add_argument("-reseed", "--reseed", action="store_true", help="reseed folder") 32 | parser.add_argument("-gentitle", "--gentitle", action="store_true", help="") 33 | parser.add_argument("-watcher", "--watcher", action="store_true", help="Start watcher") 34 | 35 | parser.add_argument("-notitle", "--notitle", type=str, help="") 36 | parser.add_argument("-tracker", "--tracker", type=str, default=config.tracker_config.MULTI_TRACKER[0], 37 | help="Upload to single tracker") 38 | 39 | parser.add_argument("-mt", "--mt", action="store_true", help="") 40 | parser.add_argument('-force', nargs='?', const="movie", type=str, default=None) 41 | parser.add_argument("-noseed", "--noseed", action="store_true", help="No seeding after upload") 42 | parser.add_argument("-noup", "--noup", action="store_true", help="Torrent only. No upload") 43 | parser.add_argument("-duplicate", "--duplicate", action="store_true", help="Find duplicates") 44 | parser.add_argument("-personal", "--personal", action="store_true", help="Set to personal release") 45 | 46 | parser.add_argument("-pw", "--pw", type=str, help="") 47 | parser.add_argument("-ftp", "--ftp", action="store_true", help="Connect to FTP") 48 | 49 | # optional 50 | parser.add_argument("-dump", "--dump", type=str, help="Download all torrent files") 51 | parser.add_argument("-s", "--search", type=str, help="Search for torrent") 52 | parser.add_argument("-i", "--info", type=str, help="Get info on torrent") 53 | parser.add_argument("-up", "--uploader", type=str, help="Search by uploader") 54 | parser.add_argument("-desc", "--description", type=str, help="Search by description") 55 | parser.add_argument("-bdinfo", "--bdinfo", type=str, help="Show BDInfo") 56 | parser.add_argument("-m", "--mediainfo", type=str, help="Show MediaInfo") 57 | parser.add_argument("-st", "--startyear", type=str, help="Start year") 58 | parser.add_argument("-en", "--endyear", type=str, help="End year") 59 | parser.add_argument("-type", "--type", type=str, help="Filter by type") 60 | parser.add_argument("-res", "--resolution", type=str, help="Filter by resolution") 61 | parser.add_argument("-file", "--filename", type=str, help="Search by filename") 62 | parser.add_argument("-se", "--season", type=str, help="Season number") 63 | parser.add_argument("-ep", "--episode", type=str, help="Episode number") 64 | parser.add_argument("-tmdb", "--tmdb_id", type=str, help="TMDB ID") 65 | parser.add_argument("-imdb", "--imdb_id", type=str, help="IMDB ID") 66 | parser.add_argument("-tvdb", "--tvdb_id", type=int, help="TVDB ID") 67 | parser.add_argument("-mal", "--mal_id", type=str, help="MAL ID") 68 | parser.add_argument("-playid", "--playlist_id", type=str, help="Playlist ID") 69 | parser.add_argument("-coll", "--collection_id", type=str, help="Collection ID") 70 | parser.add_argument("-free", "--freelech", type=str, help="Freelech discount") 71 | parser.add_argument("-a", "--alive", action="store_true", help="Alive torrent") 72 | parser.add_argument("-d", "--dead", action="store_true", help="Dead torrent") 73 | parser.add_argument("-dy", "--dying", action="store_true", help="Dying torrent") 74 | 75 | parser.add_argument( 76 | "-du", "--doubleup", action="store_true", help="DoubleUp torrent" 77 | ) 78 | parser.add_argument( 79 | "-fe", "--featured", action="store_true", help="Featured torrent" 80 | ) 81 | parser.add_argument( 82 | "-re", "--refundable", action="store_true", help="Refundable torrent" 83 | ) 84 | parser.add_argument( 85 | "-str", "--stream", action="store_true", help="Stream torrent" 86 | ) 87 | parser.add_argument( 88 | "-sd", "--standard", action="store_true", help="Standard definition torrent" 89 | ) 90 | parser.add_argument( 91 | "-hs", "--highspeed", action="store_true", help="Highspeed torrent" 92 | ) 93 | parser.add_argument( 94 | "-int", "--internal", action="store_true", help="Internal torrent" 95 | ) 96 | 97 | parser.add_argument( 98 | "-pr", "--prelease", action="store_true", help="Personal release torrent" 99 | ) 100 | 101 | 102 | 103 | # Parsing the User cli 104 | self.args: parser = parser.parse_args() 105 | 106 | # Test user scan path 107 | self.is_dir = os.path.isdir(self.args.scan) if self.args.scan else None 108 | 109 | # Test the upload path. Expand path home_path with tilde 110 | if self.args.upload: 111 | self.args.upload = os.path.expanduser(self.args.upload) 112 | 113 | # Test -force flag 114 | if self.args.force: 115 | self.args.force = self.args.force[:10] 116 | if self.args.force.lower() not in [ System.category_list[System.MOVIE], 117 | System.category_list[System.GAME], 118 | System.category_list[System.TV_SHOW], 119 | System.category_list[System.DOCUMENTARY]]: 120 | self.args.force = None 121 | print("Invalid -force category") 122 | exit() 123 | -------------------------------------------------------------------------------- /common/constants.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | language_dict = { 4 | "IT": "Italian", 5 | "ES": "Spanish", 6 | "DE": "German", 7 | "FR": "French", 8 | "EN": "English", 9 | "TR": "Turkish", 10 | "PT": "Portuguese", 11 | "JA": "Japanese", 12 | "BG": "Bulgarian" 13 | } 14 | 15 | 16 | def my_language(iso639_1: str)-> str | None: 17 | return language_dict.get(iso639_1.upper(), '') 18 | -------------------------------------------------------------------------------- /common/database.py: -------------------------------------------------------------------------------- 1 | import json 2 | import sqlite3 3 | 4 | # Torrent attributes 5 | create_table_sql = ('\n' 6 | 'CREATE TABLE IF NOT EXISTS torrents (\n' 7 | ' id INTEGER PRIMARY KEY AUTOINCREMENT,\n' 8 | ' name TEXT,\n' 9 | ' category TEXT,\n' 10 | ' category_id INTEGER,\n' 11 | ' created_at TEXT,\n' 12 | ' description TEXT,\n' 13 | ' details_link TEXT,\n' 14 | ' download_link TEXT,\n' 15 | ' double_upload BOOLEAN,\n' 16 | ' featured BOOLEAN,\n' 17 | ' freeleech TEXT,\n' 18 | ' igdb_id INTEGER,\n' 19 | ' imdb_id TEXT,\n' 20 | ' info_hash TEXT,\n' 21 | ' internal BOOLEAN,\n' 22 | ' leechers INTEGER,\n' 23 | ' magnet_link TEXT,\n' 24 | ' mal_id INTEGER,\n' 25 | ' media_info TEXT,\n' 26 | ' release_year INTEGER,\n' 27 | ' resolution TEXT,\n' 28 | ' resolution_id INTEGER,\n' 29 | ' seeders INTEGER,\n' 30 | ' size INTEGER,\n' 31 | ' times_completed INTEGER,\n' 32 | ' tmdb_id INTEGER,\n' 33 | ' tvdb_id INTEGER,\n' 34 | ' type TEXT,\n' 35 | ' type_id INTEGER,\n' 36 | ' uploader TEXT,\n' 37 | ' personal_release BOOLEAN,\n' 38 | ' refundable BOOLEAN,\n' 39 | ' num_file INTEGER,\n' 40 | ' bd_info TEXT,\n' 41 | ' genres TEXT,\n' 42 | ' poster TEXT,\n' 43 | ' meta TEXT,\n' 44 | ' files TEXT\n' 45 | ')\n') 46 | 47 | 48 | class Database: 49 | """ 50 | Create a new database and populate it with torrents attributes 51 | Search torrents based on attributes 52 | """ 53 | 54 | def __init__(self, db_file): 55 | self.filename = db_file 56 | self.database = sqlite3.connect(f"{db_file}.db") 57 | self.cursor = self.database.cursor() 58 | self.build() 59 | 60 | def build(self): 61 | self.cursor.execute(create_table_sql) 62 | self.database.commit() 63 | 64 | def write(self, data: dict): 65 | for key, value in data.items(): 66 | if isinstance(value, (dict,list)): 67 | data[key] = json.dumps(value) 68 | 69 | keys = ', '.join(data.keys()) 70 | placeholders = ', '.join(['?'] * len(data)) 71 | values = tuple(data.values()) 72 | 73 | sql = f'''INSERT INTO torrents ({keys}) VALUES ({placeholders})''' 74 | self.cursor.execute(sql, values) 75 | self.database.commit() 76 | 77 | def search(self, query: str): 78 | # Search a substring in 'name' 79 | self.cursor.execute("SELECT name FROM torrents WHERE name LIKE ?", ('%' + query + '%',)) 80 | results = self.cursor.fetchall() 81 | # print the results 82 | for r in results: 83 | print(f"[database]{r}") 84 | input("[DATABASE] Press Enter to continue...") 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | -------------------------------------------------------------------------------- /common/external_services/Pw/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/31December99/Unit3Dup/d544838bb67662e0896fffd50f670961d3248ddd/common/external_services/Pw/__init__.py -------------------------------------------------------------------------------- /common/external_services/Pw/core/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/31December99/Unit3Dup/d544838bb67662e0896fffd50f670961d3248ddd/common/external_services/Pw/core/__init__.py -------------------------------------------------------------------------------- /common/external_services/Pw/core/models/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/31December99/Unit3Dup/d544838bb67662e0896fffd50f670961d3248ddd/common/external_services/Pw/core/models/__init__.py -------------------------------------------------------------------------------- /common/external_services/Pw/core/models/indexers.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from dataclasses import dataclass, field 4 | 5 | 6 | @dataclass 7 | class SubCategory: 8 | id: int = 0 9 | name: str = '' 10 | subCategories: list[any] = field(default_factory=list) 11 | 12 | 13 | @dataclass 14 | class Category: 15 | id: int = 0 16 | name: str = '' 17 | subCategories: list[SubCategory] = field(default_factory=list) 18 | 19 | 20 | @dataclass 21 | class Capability: 22 | bookSearchParams: list[str] = field(default_factory=list) 23 | categories: list[Category] = field(default_factory=list) 24 | 25 | 26 | @dataclass 27 | class FieldOption: 28 | hint: str = '' 29 | name: str = '' 30 | order: int = 0 31 | value: int = 0 32 | 33 | 34 | @dataclass 35 | class Field: 36 | advanced: bool = False 37 | hidden: str = '' 38 | isFloat: bool = False 39 | name: str = '' 40 | order: int = 0 41 | privacy: str = '' 42 | type: str = '' 43 | value: str = '' 44 | helpText: str = '' 45 | label: str = '' 46 | selectOptionsProviderAction: str = '' 47 | selectOptions: list[FieldOption] = field(default_factory=list) 48 | unit: str = '' 49 | 50 | 51 | @dataclass 52 | class Indexer: 53 | added: str = '' 54 | appProfileId: int = 0 55 | capabilities: Capability = field(default_factory=Capability) 56 | configContract: str = '' 57 | definitionName: str = '' 58 | description: str = '' 59 | downloadClientId: int = 0 60 | enable: bool = True 61 | encoding: str = '' 62 | fields: list[Field] = field(default_factory=list) 63 | id: int = 0 64 | implementation: str = '' 65 | implementationName: str = '' 66 | indexerUrls: list[str] = field(default_factory=list) 67 | infoLink: str = '' 68 | language: str = '' 69 | legacyUrls: list[str] = field(default_factory=list) 70 | name: str = '' 71 | priority: int = 0 72 | privacy: str = '' 73 | protocol: str = '' 74 | redirect: bool = False 75 | sortName: str = '' 76 | supportsPagination: bool = False 77 | supportsRedirect: bool = False 78 | supportsRss: bool = False 79 | supportsSearch: bool = False 80 | tags: list[int] = field(default_factory=list) 81 | -------------------------------------------------------------------------------- /common/external_services/Pw/core/models/search.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from dataclasses import dataclass, field 4 | 5 | 6 | @dataclass 7 | class Search: 8 | """ 9 | Get a result from the endpoint /search?query 10 | 11 | """ 12 | 13 | age: int = 0 14 | ageHours: float = 0.0 15 | ageMinutes: float = 0.0 16 | categories: list[dict[str, any]] = field(default_factory=list) 17 | downloadUrl: None | str = None 18 | fileName: None | str = None 19 | guid: None | str = None 20 | imdbId: int = 0 21 | indexer: None | str = None 22 | indexerFlags: list[str] = field(default_factory=list) 23 | indexerId: int = 0 24 | infoUrl: None | str = None 25 | leechers: int = 0 26 | protocol: str = "torrent" 27 | publishDate: None | str = None 28 | seeders: int = 0 29 | size: int = 0 30 | sortTitle: None | str = None 31 | title: None | str = None 32 | tmdbId: int = 0 33 | tvMazeId: int = 0 34 | tvdbId: int = 0 35 | files: int | None = None 36 | grabs: int | None = None 37 | posterUrl: str | None = None 38 | infoHash: str | None = None 39 | 40 | -------------------------------------------------------------------------------- /common/external_services/Pw/core/models/torrent_client_config.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from dataclasses import dataclass, field 4 | 5 | 6 | @dataclass 7 | class SelectOption: 8 | hint: str 9 | name: str 10 | order: int 11 | value: int 12 | 13 | 14 | @dataclass 15 | class Field: 16 | advanced: bool = False 17 | isFloat: bool = False 18 | label: str = "" 19 | name: str = "" 20 | order: int = 0 21 | privacy: str = "normal" 22 | type: str = "textbox" 23 | value: str | int | bool | None = None 24 | helpText: str | None = None 25 | selectOptions: list[SelectOption] | None = None 26 | 27 | 28 | @dataclass 29 | class TorrentClientConfig: 30 | categories: list[str] = field(default_factory=list) 31 | configContract: str | None = None 32 | enable: bool = True 33 | fields: list[Field] = field(default_factory=list) 34 | id: int = 0 35 | implementation: str | None = None 36 | implementationName: str | None = None 37 | infoLink: str | None = None 38 | name: str | None = None 39 | priority: int = 0 40 | protocol: str | None = None 41 | supportsCategories: bool = False 42 | tags: list[str] = field(default_factory=list) 43 | -------------------------------------------------------------------------------- /common/external_services/Pw/core/pw_api.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import os.path 3 | 4 | import httpx 5 | 6 | from common.external_services.Pw.core.models.torrent_client_config import ( 7 | TorrentClientConfig, 8 | ) 9 | from common.external_services.Pw.core.models.indexers import Indexer 10 | from common.external_services.Pw.core.models.search import Search 11 | from common.external_services.sessions.session import MyHttp 12 | from common.external_services.sessions.agents import Agent 13 | from common import config_settings 14 | 15 | from view import custom_console 16 | 17 | class PwAPI(MyHttp): 18 | 19 | def __init__(self): 20 | """ 21 | Initialize the PwApi instance 22 | """ 23 | headers = Agent.headers() 24 | headers.update( 25 | {"X-Api-Key": config_settings.options.PW_API_KEY, "Content-Type": "application/json"} 26 | ) 27 | 28 | super().__init__(headers) 29 | self.http_client = self.session 30 | self.base_url = config_settings.options.PW_URL 31 | self.api_key = config_settings.options.PW_API_KEY 32 | self.dataclass = {f"{self.base_url}/indexer": Indexer} 33 | 34 | if not config_settings.options.PW_URL: 35 | custom_console.bot_question_log("No PW_URL provided\n") 36 | exit(1) 37 | 38 | if not config_settings.options.PW_API_KEY: 39 | custom_console.bot_question_log("No PW_API_KEY provided\n") 40 | exit(1) 41 | 42 | def get_indexers(self) -> list[type[[Indexer]]]: 43 | """Get all indexers.""" 44 | 45 | response = self.get_url(url=f"{self.base_url}/indexer", params={}) 46 | 47 | if response.status_code == 200: 48 | indexers_list = response.json() 49 | return [Indexer(**indexer) for indexer in indexers_list] 50 | else: 51 | return [Indexer] 52 | 53 | def get_torrent_url(self, url: str, filename: str)-> httpx.Response : 54 | return self.get_url(url=url) 55 | 56 | def search(self, query: str) -> list[Search] | None: 57 | """Get search queue.""" 58 | 59 | params = {"query": query} 60 | url = f"{self.base_url}/search?" 61 | 62 | response = self.get_url(url=url, params=params) 63 | if response: 64 | if response.status_code == 200: 65 | results_list = response.json() 66 | return [Search(**result) for result in results_list] 67 | else: 68 | return [] 69 | return None 70 | 71 | def get_torrent_client_ids(self) -> list["TorrentClientConfig"]: 72 | """Get a list of torrent client configurations""" 73 | 74 | url = f"{self.base_url}/downloadclient" 75 | response = self.get_url(url=url, params={}) 76 | 77 | if response.status_code == 200: 78 | configurations_list = response.json() 79 | return [ 80 | TorrentClientConfig(**client_config) 81 | for client_config in configurations_list 82 | ] 83 | else: 84 | return [] 85 | 86 | def send_torrent_to_client(self, payload): 87 | """send torrent to client""" 88 | 89 | url = f"{self.base_url}/downloadclient/1" 90 | response = self.get_url(url=url, body=payload, get_method=False) 91 | 92 | # TODO: Test again - get_url() updated 21/09/2024 93 | if response.status_code == 202 or response.status_code == 200: 94 | result = response.json() 95 | else: 96 | return [] 97 | -------------------------------------------------------------------------------- /common/external_services/Pw/pw_manager.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import argparse 4 | import os 5 | 6 | from common.external_services.Pw.pw_service import PwService 7 | from common.utility import ManageTitles 8 | from common.database import Database 9 | from common import config_settings 10 | from unit3dup.media import Media 11 | 12 | 13 | from qbittorrent import Client 14 | from view import custom_console 15 | 16 | class PwManager: 17 | 18 | def __init__(self,cli: argparse.Namespace): 19 | # Keyword 20 | self.search = cli.pw 21 | 22 | # filename for the new download 23 | self.filename = ManageTitles.normalize_filename(self.search) 24 | 25 | # Select the tracker database 26 | self.database = Database(db_file=cli.tracker) 27 | 28 | 29 | def process(self): 30 | 31 | # a new qbittorrent instance 32 | qb = Client(f"http://{config_settings.torrent_client_config.QBIT_HOST}:{config_settings.torrent_client_config.QBIT_PORT}/") 33 | # a new pw instance 34 | pw_service = PwService() 35 | # Query the indexers 36 | search = pw_service.search(query=self.search) 37 | 38 | content = [] 39 | if search: 40 | for index, s in enumerate(search): 41 | if s.seeders > 0: 42 | category = s.categories[0]['name'] 43 | if category in ['Movies','TV','TV/HD']: 44 | content.append(s) 45 | custom_console.bot_process_table_pw(content=content) 46 | 47 | 48 | for c in content: 49 | test = Media(folder=f"c:\\test\\{c.fileName}", subfolder=f"c:\\test\\{c.fileName}") 50 | print(f"[Prowlarr] {c.fileName}") 51 | self.database.search(test.guess_title) 52 | 53 | 54 | """" 55 | qb.login(username=config_settings.torrent_client_config.QBIT_USER, 56 | password=config_settings.torrent_client_config.QBIT_PASS) 57 | 58 | for torrent in content: 59 | filename = str(os.path.join(config_settings.options.PW_TORRENT_ARCHIVE_PATH,torrent.fileName)) 60 | print(filename) 61 | magnet = pw_service.get_torrent_from_pw(torrent_url=torrent.downloadUrl,download_filename=filename) 62 | qb.download_from_link(magnet, savepath=config_settings.options.PW_DOWNLOAD_PATH) 63 | """ 64 | -------------------------------------------------------------------------------- /common/external_services/Pw/pw_service.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from common.external_services.Pw.core.pw_api import PwAPI 4 | from common.external_services.Pw.core.models.indexers import Indexer 5 | from common.external_services.Pw.core.models.search import Search 6 | from common.external_services.Pw.core.models.torrent_client_config import TorrentClientConfig 7 | 8 | 9 | class PwService: 10 | 11 | def __init__(self): 12 | self.pw_api = PwAPI() 13 | 14 | def get_indexers(self) -> [Indexer]: 15 | return self.pw_api.get_indexers() 16 | 17 | def search(self, query: str) -> list[Search]: 18 | return self.pw_api.search(query=query) 19 | 20 | def get_torrent_client_ids(self) -> list[TorrentClientConfig]: 21 | return self.pw_api.get_torrent_client_ids() 22 | 23 | def send_torrent_to_client(self, payload): 24 | return self.pw_api.send_torrent_to_client(payload) 25 | 26 | def get_torrent_from_pw(self, torrent_url: str, download_filename: str)-> str | None: 27 | response = self.pw_api.get_torrent_url(url=torrent_url, filename=download_filename) 28 | # Redirect (PW) 29 | if response.status_code == 301: 30 | return response.headers.get('Location') 31 | return None 32 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /common/external_services/__init__.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | logging.basicConfig( 4 | level=logging.INFO, 5 | format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', 6 | ) 7 | 8 | logger = logging.getLogger(__name__) 9 | 10 | -------------------------------------------------------------------------------- /common/external_services/ftpx/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/31December99/Unit3Dup/d544838bb67662e0896fffd50f670961d3248ddd/common/external_services/ftpx/__init__.py -------------------------------------------------------------------------------- /common/external_services/ftpx/core/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/31December99/Unit3Dup/d544838bb67662e0896fffd50f670961d3248ddd/common/external_services/ftpx/core/__init__.py -------------------------------------------------------------------------------- /common/external_services/ftpx/core/ftpx_service.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import os 3 | from datetime import datetime 4 | 5 | from common.external_services.ftpx.core.ftpx_session import FtpXCmds 6 | from common.external_services.ftpx.core.models.list import FTPDirectory 7 | from common.utility import MyString 8 | 9 | 10 | class FtpX(FtpXCmds): 11 | def __init__(self): 12 | super().__init__() 13 | 14 | def get_list(self, remote_path=".") -> ["FTPDirectory"]: 15 | # Get a list from the FTP server 16 | file_list = self._list(path=remote_path) 17 | if not file_list: 18 | return [] 19 | 20 | folder = [ 21 | FTPDirectory( 22 | permissions=parts[0], 23 | type="Folder" if parts[0][:1] == "d" else "File", 24 | links=int(parts[1]), 25 | owner=parts[2], 26 | group=parts[3], 27 | size=int(parts[4]), 28 | date=date_time.date(), 29 | time=date_time.time(), 30 | name=parts[8], 31 | ) 32 | for file in file_list 33 | if (parts := file.split()) and "total" not in parts 34 | if ( 35 | date_time := MyString.parse_date(file) 36 | ) # Assign date_time only if its valid 37 | ] 38 | 39 | # The first in list is the most recent 40 | sorted_folder = sorted( 41 | folder, 42 | key=lambda entry: datetime.combine(entry.date, entry.time), 43 | reverse=True, 44 | ) 45 | 46 | return sorted_folder 47 | 48 | def current_path(self): 49 | return self._pwd() 50 | 51 | def change_dir(self, new_path: str): 52 | return self.cwd(dirname=new_path) 53 | 54 | def download_file(self, remote_path: str, local_path: str): 55 | """Download a file from the ftp server""" 56 | 57 | # format the path. Replace '\' to '/' from Windows OS to linux 58 | remote_path = remote_path.replace("\\", "/") 59 | 60 | # Create the local folder if it does not exist 61 | local_dir = os.path.dirname(local_path) 62 | if not os.path.exists(local_dir): 63 | os.makedirs(local_dir) 64 | 65 | # Download... 66 | self._retr( 67 | remote_path=remote_path, 68 | local_path=local_path, 69 | size=self.file_size(remote_path), 70 | ) 71 | 72 | def file_size(self, file_path: str): 73 | return self._size(file_path=file_path) 74 | 75 | def syst(self): 76 | return self._syst() 77 | -------------------------------------------------------------------------------- /common/external_services/ftpx/core/ftpx_session.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import ftplib 3 | import threading 4 | import time 5 | from rich.progress import Progress 6 | 7 | from ftplib import FTP_TLS, all_errors 8 | from common import config_settings 9 | from view import custom_console 10 | 11 | class FtpXCmds(FTP_TLS): 12 | def __init__(self): 13 | super().__init__() 14 | self.is_quitting = False 15 | if config_settings.options.FTPX_KEEP_ALIVE: 16 | # Time interval to keep the connection alive (in seconds) 17 | self.keep_alive_interval = 10 18 | # flag to stop the keep-alive thread 19 | self.keep_alive_stop_flag = threading.Event() 20 | # Separate thread to run noop to the server 21 | self.keep_alive_thread = threading.Thread(target=self._keep_alive) 22 | # Daemon mode 23 | self.keep_alive_thread.daemon = True 24 | self.keep_alive_thread.start() 25 | 26 | def _keep_alive(self): 27 | while not self.keep_alive_stop_flag.is_set(): 28 | time.sleep(self.keep_alive_interval) 29 | try: 30 | self.voidcmd("NOOP") 31 | except Exception as e: 32 | print(f"Error during keep-alive: {e}") 33 | if not self.is_quitting: 34 | self.quit() 35 | 36 | @staticmethod 37 | def _execute_command(command_fn, *args, **kwargs): 38 | try: 39 | return command_fn(*args, **kwargs) 40 | except Exception as e: 41 | custom_console.bot_error_log( 42 | f"Error during {command_fn.__name__} command: {e}" 43 | ) 44 | exit(1) 45 | 46 | def quit(self): 47 | custom_console.bot_question_log("Exiting... Please wait for the app to close\n") 48 | if self.is_quitting: 49 | return 50 | if config_settings.options.FTPX_KEEP_ALIVE: 51 | # Set the stop flag for the keep-alive thread 52 | self.keep_alive_stop_flag.set() 53 | if self.keep_alive_thread.is_alive(): 54 | # Wait for the thread to finish 55 | self.keep_alive_thread.join() 56 | self.is_quitting = True 57 | 58 | try: 59 | super().quit() 60 | except ftplib.error_temp as e: 61 | custom_console.bot_error_log(e) 62 | 63 | @classmethod 64 | def new( 65 | cls, 66 | host=config_settings.options.FTPX_IP, 67 | port=int(config_settings.options.FTPX_PORT), 68 | user=config_settings.options.FTPX_USER, 69 | passwd=config_settings.options.FTPX_PASS, 70 | ): 71 | 72 | validate_ftpx_config = True 73 | if not config_settings.options.FTPX_IP: 74 | custom_console.bot_question_log("No FTPX_IP provided\n") 75 | validate_ftpx_config = False 76 | 77 | if not config_settings.options.FTPX_PORT: 78 | custom_console.bot_question_log("No FTPX_PORT provided\n") 79 | validate_ftpx_config = False 80 | 81 | if not config_settings.options.FTPX_USER: 82 | custom_console.bot_question_log("No FTPX_USER provided\n") 83 | validate_ftpx_config = False 84 | 85 | if not config_settings.options.FTPX_PASS: 86 | custom_console.bot_question_log("No FTPX_PASS provided\n") 87 | validate_ftpx_config = False 88 | 89 | if not config_settings.options.FTPX_LOCAL_PATH: 90 | custom_console.bot_question_log("No FTPX_LOCAL_PATH provided\n") 91 | validate_ftpx_config = False 92 | 93 | if not validate_ftpx_config: 94 | custom_console.bot_error_log(f"Please check your config file or verify if the FTP server is online") 95 | exit(1) 96 | 97 | """Create an instance of FtpXCmds and handle connection and login""" 98 | ftp = cls() 99 | try: 100 | ftp.connect(host, port) 101 | ftp.login(user=user, passwd=passwd) 102 | ftp.prot_p() # Enable SSL 103 | except all_errors as e: 104 | custom_console.bot_error_log(f"\nFTP Server Error: {e}") 105 | custom_console.bot_error_log( 106 | f"Please check your config file or verify if the FTP server is online" 107 | ) 108 | exit(1) 109 | return ftp 110 | 111 | def _send_pret(self, command): 112 | """Send PRET command to the FTP server""" 113 | return self.sendcmd(f"PRET {command}") 114 | 115 | def _list(self, path="") -> list: 116 | """Run LIST with PRET and return list of files and directories""" 117 | output_lines = [] 118 | 119 | def collect_line(line): 120 | output_lines.append(line) 121 | 122 | self._execute_command(self._send_pret, "LIST") 123 | self._execute_command(self.retrlines, f"LIST {path}", callback=collect_line) 124 | return output_lines 125 | 126 | def _retr(self, remote_path, local_path: str, size: int) -> bool: 127 | def download(): 128 | with open(local_path, "wb") as local_file: 129 | with Progress() as progress: 130 | task = progress.add_task( 131 | "[cyan]Downloading...", total=size, visible=True 132 | ) 133 | 134 | def write_chunk(chunk): 135 | local_file.write(chunk) 136 | progress.update(task, advance=len(chunk)) 137 | 138 | self.retrbinary(f"RETR {remote_path}", write_chunk) 139 | 140 | return self._execute_command( 141 | self._send_pret, f"RETR {remote_path}" 142 | ) and self._execute_command(download) 143 | 144 | def _cwd(self, path): 145 | # Change the working directory on the FTP server using PRET 146 | self._execute_command(self._send_pret, f"CWD {path}") 147 | self._execute_command(self.cwd, path) 148 | 149 | def _pwd(self) -> str: 150 | # Get the current directory on the FTP server 151 | return self._execute_command(self.pwd) 152 | 153 | def _size(self, file_path: str): 154 | # Get the size of a file on the FTP server 155 | return self._execute_command(self.size, filename=file_path) 156 | 157 | def _syst(self): 158 | # Send the syst command to get system info from the server 159 | return self._execute_command(self.sendcmd, "SYST") 160 | -------------------------------------------------------------------------------- /common/external_services/ftpx/core/menu.py: -------------------------------------------------------------------------------- 1 | from rich.console import Console 2 | 3 | 4 | class Menu: 5 | """ 6 | Create a new Menu. 7 | VIEW 8 | """ 9 | 10 | def __init__(self): 11 | self.console = Console() 12 | 13 | def show(self, table) -> None: 14 | """ 15 | Displays the current page. 16 | """ 17 | self.console.print(table) 18 | -------------------------------------------------------------------------------- /common/external_services/ftpx/core/models/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/31December99/Unit3Dup/d544838bb67662e0896fffd50f670961d3248ddd/common/external_services/ftpx/core/models/__init__.py -------------------------------------------------------------------------------- /common/external_services/ftpx/core/models/list.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from dataclasses import dataclass 4 | import datetime 5 | 6 | 7 | @dataclass 8 | class FTPDirectory: 9 | permissions: str | None = None 10 | type: str | None = None 11 | links: int | None = None 12 | owner: str | None = None 13 | group: str | None = None 14 | size: int | None = None 15 | date: datetime.date | None = None 16 | time: datetime.time | None = None 17 | name: str | None = None 18 | -------------------------------------------------------------------------------- /common/external_services/igdb/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/31December99/Unit3Dup/d544838bb67662e0896fffd50f670961d3248ddd/common/external_services/igdb/__init__.py -------------------------------------------------------------------------------- /common/external_services/igdb/core/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/31December99/Unit3Dup/d544838bb67662e0896fffd50f670961d3248ddd/common/external_services/igdb/core/__init__.py -------------------------------------------------------------------------------- /common/external_services/igdb/core/api.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from urllib.parse import urljoin 4 | from common.external_services.sessions.session import MyHttp 5 | from common import config_settings 6 | from view import custom_console 7 | 8 | class IGDBapi: 9 | 10 | params = { 11 | "client_id": config_settings.tracker_config.IGDB_CLIENT_ID, 12 | "client_secret": config_settings.tracker_config.IGDB_ID_SECRET, 13 | "grant_type": "client_credentials", 14 | } 15 | 16 | base_request_url = "https://api.igdb.com/v4/" 17 | oauth = "https://id.twitch.tv/oauth2/token" 18 | 19 | def __init__(self): 20 | self.access_header = None 21 | self.header_access = None 22 | self.http_client = None 23 | 24 | 25 | def login(self)-> bool: 26 | if not config_settings.tracker_config.IGDB_CLIENT_ID: 27 | custom_console.bot_question_log("No IGDB_CLIENT_ID provided\n") 28 | return False 29 | 30 | if not config_settings.tracker_config.IGDB_ID_SECRET: 31 | custom_console.bot_question_log("No IGDB_ID_SECRET provided\n") 32 | return False 33 | 34 | self.http_client = MyHttp({ 35 | "User-Agent": "Unit3D-up/0.0 (Linux 5.10.0-23-amd64)", 36 | "Accept": "application/json", 37 | }) 38 | 39 | 40 | response = self.http_client.post(self.oauth, params = { 41 | "client_id": config_settings.tracker_config.IGDB_CLIENT_ID, 42 | "client_secret": config_settings.tracker_config.IGDB_ID_SECRET, 43 | "grant_type": "client_credentials", 44 | }) 45 | 46 | if response: 47 | authentication = response.json() 48 | access_token = authentication["access_token"] 49 | expires = authentication["expires_in"] 50 | token_type = authentication["token_type"] 51 | custom_console.bot_log("IGDB Login successful!") 52 | 53 | self.access_header = { 54 | "Client-ID": config_settings.tracker_config.IGDB_CLIENT_ID, 55 | "Authorization": f"Bearer {access_token}", 56 | "Content-Type": "application/json", 57 | } 58 | return True 59 | else: 60 | custom_console.bot_error_log("Failed to authenticate with IGDB.\n") 61 | custom_console.bot_error_log("IGDB Login failed. Please check your credentials") 62 | return False 63 | 64 | 65 | def request(self, query:str , endpoint:str)-> list: 66 | build_request = urljoin(self.base_request_url, endpoint) 67 | response = self.http_client.post(build_request, headers=self.access_header, data=query) 68 | if response: 69 | return response.json() -------------------------------------------------------------------------------- /common/external_services/igdb/core/models/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/31December99/Unit3Dup/d544838bb67662e0896fffd50f670961d3248ddd/common/external_services/igdb/core/models/__init__.py -------------------------------------------------------------------------------- /common/external_services/igdb/core/models/search.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from dataclasses import dataclass, field 4 | 5 | @dataclass 6 | class Game: 7 | id: int 8 | name: str 9 | summary: str 10 | videos: list 11 | url: str 12 | description: str = field(default="", init=False) 13 | 14 | -------------------------------------------------------------------------------- /common/external_services/igdb/core/platformid.py: -------------------------------------------------------------------------------- 1 | platform_id = { 2 | "LIN": 3, # Linux 3 | "LINUX": 3, # Linux 4 | "PC": 6, # PC (Microsoft Windows) 5 | "WINDOWS": 6, 6 | "XBX": 11, # Xbox 7 | "PS4": 48, # PlayStation 4 8 | "PSN": 45, # PlayStation Network 9 | "VC": 47, # Virtual Console (Nintendo) 10 | "XBLA": 36, # Xbox Live Arcade 11 | "IOS": 39, # iOS 12 | "MAC": 14, # Mac 13 | "MACOS": 14, # Mac 14 | "XONE": 49, # Xbox One 15 | "XBO": 49, # Xbox One 16 | "MOB": 55, # Mobile 17 | "NSW": 130, # Nintendo Switch 18 | "NINTENDO": 130, 19 | "STEAM": 92, # SteamOS 20 | "OCULUS": 162, # Oculus VR 21 | "STEAMVR": 163, # SteamVR 22 | "PSVR": 165, # PlayStation VR 23 | "PS5": 167, # PS5 24 | } 25 | -------------------------------------------------------------------------------- /common/external_services/igdb/core/tags.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | crew_patterns = [ 3 | "NOGRP", "LIGHTFORCE", "SUXXORS", "KAOS", "GOG", "TENOKE", "I_KNOW", "RAZOR1911", "RUNE", "FITGIRL", 4 | "ELAMIGOS", "RAZORDOX", "RAZOR", "SKIDROW", "DINOBYTES", "TINYISO", "FCKDRM", "FLT","SKIDROW", 5 | "CPY", "RELOADED", "HIVE", "VACE", "CZW", "PHOENIX", "JAGUAR", "CRIMSON", "TURBO", "DUPLEX", "CODEX" 6 | ] 7 | 8 | platform_patterns = ["IOS", "LIN", "MACOS", "NINTENDO", "OCULUS", "PC", "PS4", "PS5", "PSN", "PSVR", "STEAM", 9 | "STEAMVR", "XBX", "XONE", "NSW"] 10 | 11 | additions = ["build", "complete", "definitive", "dlc", "edition", "episode", "pack", "patch", "remake", "remaster", 12 | "season", "update", "ultimate", "version"] 13 | 14 | -------------------------------------------------------------------------------- /common/external_services/imdb.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from imdb import Cinemagoer 4 | from view import custom_console 5 | from rich.table import Table 6 | from rich.align import Align 7 | 8 | from common.utility import ManageTitles 9 | from common import title 10 | 11 | 12 | class IMDB: 13 | 14 | def __init__(self): 15 | self.api = Cinemagoer() 16 | 17 | def search(self, query: str)-> int | None: 18 | # Search for a tv or movie 19 | movies = self.api.search_movie(query) 20 | for movie in movies: 21 | if ManageTitles.fuzzyit(str1=query, str2=movie.data['title']) > 95: 22 | return movie.movieID 23 | 24 | -------------------------------------------------------------------------------- /common/external_services/mediaresult.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from datetime import datetime 4 | 5 | class MediaResult: 6 | def __init__(self, result=None, video_id: int = 0, imdb_id = None, trailer_key: str = None, 7 | keywords_list: str = None, season_title = None): 8 | self.result = result 9 | self.trailer_key = trailer_key 10 | self.keywords_list = keywords_list 11 | self.video_id = video_id 12 | self.imdb_id = imdb_id 13 | self.season_title = season_title 14 | self.year = None 15 | 16 | if result: 17 | try: 18 | self.year = datetime.strptime(result.get_date(), '%Y-%m-%d').year 19 | except ValueError: 20 | pass 21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /common/external_services/sessions/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/31December99/Unit3Dup/d544838bb67662e0896fffd50f670961d3248ddd/common/external_services/sessions/__init__.py -------------------------------------------------------------------------------- /common/external_services/sessions/exceptions.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import httpx 4 | from functools import wraps 5 | from typing import Callable, Any 6 | from view import custom_console 7 | 8 | 9 | class HttpError(Exception): 10 | """Base class for all exceptions raised by the HTTP client.""" 11 | 12 | def __init__(self, message: str): 13 | super().__init__(message) 14 | self.message = message 15 | 16 | 17 | # passare httpx anche agli altri 18 | class ConnectError(httpx.ConnectError, HttpError): 19 | """Custom exception raised for connection errors.""" 20 | def __init__(self, message: str = "Connection error"): 21 | super().__init__(message) 22 | 23 | 24 | class HttpAuthError(HttpError): 25 | """Exception raised for authentication errors.""" 26 | 27 | def __init__(self, message: str = "Authentication failed"): 28 | super().__init__(message) 29 | 30 | 31 | class HttpNotFoundError(HttpError): 32 | """Exception raised when a requested resource is not found.""" 33 | 34 | def __init__(self, message: str = "Resource not found"): 35 | super().__init__(message) 36 | 37 | 38 | class HttpRateLimitError(HttpError): 39 | """Exception raised when the API rate limit is exceeded.""" 40 | 41 | def __init__(self, message: str = "Rate limit exceeded"): 42 | super().__init__(message) 43 | 44 | 45 | class HttpRequestError(HttpError): 46 | """Exception raised for general request errors.""" 47 | 48 | def __init__(self, message: str = "Request failed"): 49 | super().__init__(message) 50 | 51 | 52 | def exception_handler(log_errors: bool = True) -> Callable[..., Any]: 53 | """ 54 | Decorator for handling exceptions and optionally logging them. 55 | 56 | Args: 57 | log_errors (bool): Whether to log the errors. If True, errors will be logged. 58 | 59 | Returns: 60 | Callable[..., Any]: The decorator that handles exceptions. 61 | """ 62 | 63 | def decorator(func: Callable[..., Any]) -> Callable[..., Any]: 64 | @wraps(func) 65 | def wrapper(*args: Any, **kwargs: Any) -> Any: 66 | try: 67 | response = func(*args, **kwargs) 68 | if response.status_code == 404: 69 | raise HttpNotFoundError() 70 | elif response.status_code == 401: 71 | raise HttpAuthError() 72 | elif response.status_code == 429: 73 | raise HttpRateLimitError() 74 | elif response.status_code >= 400: 75 | raise HttpRequestError() 76 | return response 77 | 78 | except httpx.ConnectError as e: 79 | if log_errors: 80 | custom_console.bot_error_log(f"Connection Error: {e}") 81 | 82 | except HttpAuthError as e: 83 | if log_errors: 84 | custom_console.bot_error_log(f"Authentication Error: {e}") 85 | 86 | except HttpNotFoundError as e: 87 | if log_errors: 88 | custom_console.bot_error_log(f"Not Found Error: {e}") 89 | 90 | except HttpRateLimitError as e: 91 | if log_errors: 92 | custom_console.bot_error_log(f"Rate Limit Error: {e}") 93 | 94 | except HttpRequestError as e: 95 | if log_errors: 96 | custom_console.bot_error_log(f"Request Error: {e}") 97 | 98 | return wrapper 99 | 100 | return decorator 101 | -------------------------------------------------------------------------------- /common/external_services/sessions/session.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import json 3 | 4 | import httpx 5 | import diskcache as dc 6 | import logging 7 | from common.external_services.sessions.exceptions import exception_handler 8 | from view import custom_console 9 | 10 | ENABLE_LOG = False 11 | logging.getLogger("httpx").setLevel(logging.CRITICAL) 12 | 13 | 14 | class MyHttp: 15 | """Class to handle HTTP requests""" 16 | 17 | def __init__(self, headers: dict, cache_dir: str = "http_cache"): 18 | self.session = httpx.Client( 19 | timeout=httpx.Timeout(30), headers=headers, verify=False 20 | ) 21 | self.headers = headers 22 | self.cache = dc.Cache(cache_dir) 23 | 24 | def get_session(self) -> httpx.Client: 25 | """Returns the HTTP session""" 26 | return self.session 27 | 28 | @staticmethod 29 | def create_cache_key(url: str, params: dict) -> str: 30 | """Generates the cache key based on the URL and query parameters (otherwise the resource is not updated in 31 | the cache.)""" 32 | 33 | # Add the query to the cached endpoint 34 | # Sorted params to avoid duplicate 35 | if params: 36 | params = "&".join(f"{key}={val}" for key, val in sorted(params.items())) 37 | return f"{url}?{params}" 38 | 39 | @exception_handler(log_errors=ENABLE_LOG) 40 | def get_url( 41 | self, 42 | url: str, 43 | params=None, 44 | headers=None, 45 | data=None, 46 | body: json = json, 47 | use_cache: bool = False, 48 | get_method: bool = True, 49 | ) -> httpx.Response: 50 | """ 51 | GET request to the specified URL 52 | 53 | Args: 54 | url (str): The URL to request 55 | use_cache (bool): Whether to use cached response if available 56 | params (dict): The query parameters for the request 57 | get_method (bool): Defines the type of HTTP request (GET, POST) True= Get , False = Post 58 | body (JSON): Defines the body of the PUT request 59 | 60 | Returns: 61 | httpx.Response: The response object from the GET request 62 | """ 63 | cache_key = self.create_cache_key(url, params) 64 | 65 | if use_cache and cache_key in self.cache: 66 | response_data = self.cache[cache_key] 67 | response = httpx.Response( 68 | status_code=response_data["status_code"], 69 | headers=response_data["headers"], 70 | content=response_data["content"], 71 | ) 72 | return response 73 | 74 | if get_method: 75 | # GET 76 | response = self.session.get(url, params=params) 77 | else: 78 | # POST ! 79 | response = self.session.post(url, params=params, headers=headers, data=data) 80 | 81 | if use_cache: 82 | self.cache[cache_key] = { 83 | "status_code": response.status_code, 84 | "headers": dict(response.headers), 85 | "content": response.content, 86 | } 87 | 88 | return response 89 | 90 | @exception_handler(log_errors=ENABLE_LOG) 91 | def post( 92 | self, 93 | url: str, 94 | params=None, 95 | headers=None, 96 | data=None, 97 | use_cache: bool = False, 98 | ) -> httpx.Response: 99 | 100 | """ 101 | GET request to the specified URL 102 | 103 | Args: 104 | url (str): The URL to request 105 | use_cache (bool): Whether to use cached response if available 106 | params (dict): The query parameters for the request 107 | Returns: 108 | httpx.Response: The response object from the Post request 109 | """ 110 | cache_key = self.create_cache_key(url, params) 111 | 112 | if use_cache and cache_key in self.cache: 113 | response_data = self.cache[cache_key] 114 | response = httpx.Response( 115 | status_code=response_data["status_code"], 116 | headers=response_data["headers"], 117 | content=response_data["content"], 118 | ) 119 | return response 120 | 121 | try: 122 | response = self.session.post(url, params=params, headers=headers, data=data) 123 | except KeyboardInterrupt: 124 | custom_console.bot_error_log("\nOperation cancelled") 125 | exit(0) 126 | 127 | if response.status_code==200: 128 | if use_cache: 129 | self.cache[cache_key] = { 130 | "status_code": response.status_code, 131 | "headers": dict(response.headers), 132 | "content": response.content, 133 | } 134 | return response 135 | else: 136 | custom_console.bot_error_log(f"{self.__class__.__name__}: {response.content}") 137 | return response 138 | 139 | 140 | 141 | def clear_cache(self): 142 | """Clears the HTTP response cache""" 143 | self.cache.clear() 144 | 145 | def close(self): 146 | """Closes the HTTP client session""" 147 | if self.session: 148 | self.session.close() 149 | self.cache.close() 150 | -------------------------------------------------------------------------------- /common/external_services/theMovieDB/__init__.py: -------------------------------------------------------------------------------- 1 | from common.settings import Load 2 | 3 | config = Load().load_config().tracker_config 4 | -------------------------------------------------------------------------------- /common/external_services/theMovieDB/core/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /common/external_services/theMovieDB/core/keywords.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from dataclasses import dataclass 3 | 4 | @dataclass 5 | class Keyword: 6 | id: int 7 | name: str 8 | -------------------------------------------------------------------------------- /common/external_services/theMovieDB/core/models/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/31December99/Unit3Dup/d544838bb67662e0896fffd50f670961d3248ddd/common/external_services/theMovieDB/core/models/__init__.py -------------------------------------------------------------------------------- /common/external_services/theMovieDB/core/models/movie/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/31December99/Unit3Dup/d544838bb67662e0896fffd50f670961d3248ddd/common/external_services/theMovieDB/core/models/movie/__init__.py -------------------------------------------------------------------------------- /common/external_services/theMovieDB/core/models/movie/alternative_titles.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from dataclasses import dataclass, field 4 | from common.external_services import logger 5 | import json 6 | 7 | 8 | @dataclass 9 | class Title: 10 | iso_3166_1: str 11 | title: str 12 | type: str | None = None 13 | logger: logger = field(init=False, repr=False) 14 | 15 | def __post_init__(self): 16 | """ 17 | method invoked right after the __init__ method ! 18 | """ 19 | self.logger = logger.getChild(self.__class__.__name__) 20 | 21 | @staticmethod 22 | def from_data(data: dict[str, any]) -> "Title | None": 23 | try: 24 | return Title( 25 | iso_3166_1=data["iso_3166_1"], 26 | title=data["title"], 27 | type=data.get("type"), 28 | ) 29 | except KeyError as e: 30 | logger.debug(f"Missing key in Title data: {e}") 31 | return None 32 | except TypeError as e: 33 | logger.debug(f"Type error in Title data: {e}") 34 | return None 35 | 36 | 37 | @dataclass 38 | class AltTitle: 39 | id: int 40 | titles: list[Title] 41 | 42 | @classmethod 43 | def validate(cls, json_str: str) -> "AltTitle": 44 | data = json.loads(json_str) 45 | id_ = data.get("id", 0) 46 | titles_data = data.get("titles", []) 47 | titles = [ 48 | Title.from_data(title_data) 49 | for title_data in titles_data 50 | if Title.from_data(title_data) is not None 51 | ] 52 | return cls(id=id_, titles=titles) 53 | -------------------------------------------------------------------------------- /common/external_services/theMovieDB/core/models/movie/details.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # Tempus fugit 4 | 5 | from dataclasses import dataclass 6 | from abc import ABC, abstractmethod 7 | 8 | class Media(ABC): 9 | 10 | @abstractmethod 11 | def get_title(self) -> str: 12 | pass 13 | 14 | @abstractmethod 15 | def get_original(self) -> str: 16 | pass 17 | 18 | @abstractmethod 19 | def get_date(self) -> str: 20 | pass 21 | 22 | @dataclass 23 | class Genre: 24 | id: int 25 | name: str 26 | 27 | @dataclass 28 | class ProductionCompany: 29 | id: int 30 | logo_path: str | None 31 | name: str 32 | origin_country: str 33 | 34 | @dataclass 35 | class ProductionCountry: 36 | iso_3166_1: str 37 | name: str 38 | 39 | @dataclass 40 | class SpokenLanguage: 41 | english_name: str 42 | iso_639_1: str 43 | name: str 44 | 45 | @dataclass 46 | class MovieDetails(Media): 47 | adult: bool 48 | backdrop_path: str | None 49 | belongs_to_collection: str | None 50 | budget: int 51 | genres: list[Genre] 52 | homepage: str 53 | id: int 54 | imdb_id: str 55 | origin_country: list[str] 56 | original_language: str 57 | original_title: str 58 | overview: str 59 | popularity: float 60 | poster_path: str | None 61 | production_companies: list[ProductionCompany] 62 | production_countries: list[ProductionCountry] 63 | release_date: str 64 | revenue: int 65 | runtime: int 66 | spoken_languages: list[SpokenLanguage] 67 | status: str 68 | tagline: str 69 | title: str 70 | video: bool 71 | vote_average: float 72 | vote_count: int 73 | 74 | def get_title(self) -> str: 75 | return self.title 76 | 77 | def get_original(self) -> str: 78 | return self.original_title 79 | 80 | def get_date(self) -> str: 81 | return self.release_date 82 | -------------------------------------------------------------------------------- /common/external_services/theMovieDB/core/models/movie/movie.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from dataclasses import dataclass, field 4 | from abc import ABC, abstractmethod 5 | 6 | class Media(ABC): 7 | 8 | @abstractmethod 9 | def get_title(self) -> str: 10 | pass 11 | 12 | @abstractmethod 13 | def get_original(self) -> str: 14 | pass 15 | 16 | @abstractmethod 17 | def get_date(self) -> str: 18 | pass 19 | 20 | @dataclass 21 | class Movie(Media): 22 | """ 23 | A movie object for the search endpoint 24 | """ 25 | adult: bool = False 26 | backdrop_path: str = '' 27 | genre_ids: list[int] = field(default_factory=list) 28 | id: int = 0 29 | original_language: str = '' 30 | original_title: str = '' 31 | overview: str = '' 32 | popularity: float = 0.0 33 | poster_path: str = '' 34 | release_date: str = '' 35 | title: str = '' 36 | video: bool = False 37 | vote_average: float = 0.0 38 | vote_count: int = 0 39 | 40 | def get_title(self) -> str: 41 | return self.title 42 | 43 | def get_original(self) -> str: 44 | return self.original_title 45 | 46 | def get_date(self) -> str: 47 | return self.release_date 48 | -------------------------------------------------------------------------------- /common/external_services/theMovieDB/core/models/movie/nowplaying.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from .release_info import MovieReleaseInfo 4 | from dataclasses import dataclass, field 5 | from common.external_services import logger 6 | 7 | 8 | @dataclass 9 | class NowPlaying: 10 | """ 11 | Represents Nowplaying attributes 12 | """ 13 | adult: bool | None = None 14 | backdrop_path: str | None = None 15 | genre_ids: list[int] = field(default_factory=list) 16 | id: int | None = None 17 | original_language: str | None = None 18 | original_title: str | None = None 19 | overview: str | None = None 20 | popularity: float | None = None 21 | poster_path: str | None = None 22 | release_date: str | None = None 23 | title: str | None = None 24 | video: bool | None = None 25 | vote_average: float | None = None 26 | vote_count: int | None = None 27 | 28 | def __repr__(self): 29 | """Returns a string """ 30 | return f"" 31 | 32 | 33 | @dataclass 34 | class NowPlayingByCountry(NowPlaying): 35 | """ 36 | Represents a combined movie object NowPlayIng by Country code 37 | """ 38 | iso_3166_1: str | None = None 39 | release_dates: list[dict[str, str]] = field(default_factory=list) 40 | 41 | def __post_init__(self): 42 | """Validate data """ 43 | if self.iso_3166_1 and (len(self.iso_3166_1) != 2 or not self.iso_3166_1.isalpha()): 44 | logger.debug(f"Warning: Invalid iso_3166_1 code '{self.iso_3166_1}'. It must be a two-letter country code.") 45 | self.iso_3166_1 = None 46 | 47 | @staticmethod 48 | def from_data(now_playing: NowPlaying, release_info: "MovieReleaseInfo") -> "NowPlayingByCountry": 49 | """ 50 | Creates a NowPlayingByCountry instance from NowPlaying and MovieReleaseInfo instances. 51 | """ 52 | return NowPlayingByCountry( 53 | adult=now_playing.adult, 54 | backdrop_path=now_playing.backdrop_path, 55 | genre_ids=now_playing.genre_ids, 56 | id=now_playing.id, 57 | original_language=now_playing.original_language, 58 | original_title=now_playing.original_title, 59 | overview=now_playing.overview, 60 | popularity=now_playing.popularity, 61 | poster_path=now_playing.poster_path, 62 | release_date=now_playing.release_date, 63 | title=now_playing.title, 64 | video=now_playing.video, 65 | vote_average=now_playing.vote_average, 66 | vote_count=now_playing.vote_count, 67 | iso_3166_1=release_info.iso_3166_1, 68 | release_dates=release_info.release_dates, 69 | ) 70 | -------------------------------------------------------------------------------- /common/external_services/theMovieDB/core/models/movie/release_info.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass, field 2 | from common.external_services import logger 3 | 4 | 5 | @dataclass 6 | class MovieReleaseInfo: 7 | """ 8 | Represents release information for a movie in a specific country. 9 | """ 10 | 11 | iso_3166_1: str | None = None 12 | release_dates: list[dict[str, any]] = field(default_factory=list) 13 | 14 | def __repr__(self) -> str: 15 | """ 16 | Returns the MovieReleaseInfo string 17 | """ 18 | return f"" 19 | 20 | @classmethod 21 | def validate(cls, data: dict) -> 'MovieReleaseInfo | None': 22 | """ 23 | Validates the data; return None if it's invalid 24 | """ 25 | 26 | iso_3166_1 = data.get("iso_3166_1") 27 | release_dates = data.get("release_dates", {}) 28 | 29 | # Validate country code 30 | if iso_3166_1 is not None: 31 | if ( 32 | not isinstance(iso_3166_1, str) 33 | or len(iso_3166_1) != 2 34 | or not iso_3166_1.isupper() 35 | ): 36 | logger.error(f"Invalid ISO 3166-1 code: {iso_3166_1}") 37 | return None 38 | 39 | # Validate release_dates 40 | if not isinstance(release_dates, list): 41 | logger.error("release_dates must be a list.") 42 | return None 43 | 44 | for item in release_dates: 45 | if not isinstance(item, dict): 46 | logger.error(f"Invalid item in release_dates list: {item}") 47 | return None 48 | 49 | return cls(iso_3166_1=iso_3166_1, release_dates=release_dates) 50 | -------------------------------------------------------------------------------- /common/external_services/theMovieDB/core/models/tvshow/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/31December99/Unit3Dup/d544838bb67662e0896fffd50f670961d3248ddd/common/external_services/theMovieDB/core/models/tvshow/__init__.py -------------------------------------------------------------------------------- /common/external_services/theMovieDB/core/models/tvshow/alternative.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from dataclasses import dataclass 4 | 5 | @dataclass 6 | class Alternative: 7 | iso_3166_1: str 8 | title: str 9 | type: str 10 | 11 | @dataclass 12 | class DataResponse: 13 | id: int 14 | results: list[Alternative] 15 | -------------------------------------------------------------------------------- /common/external_services/theMovieDB/core/models/tvshow/details.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from dataclasses import dataclass, field 4 | from abc import ABC, abstractmethod 5 | 6 | # Tempus fugit 7 | 8 | class Media(ABC): 9 | 10 | @abstractmethod 11 | def get_title(self) -> str: 12 | pass 13 | 14 | @abstractmethod 15 | def get_original(self) -> str: 16 | pass 17 | 18 | @abstractmethod 19 | def get_date(self) -> str: 20 | pass 21 | 22 | @dataclass 23 | class CreatedBy: 24 | credit_id: str 25 | gender: int 26 | id: int 27 | name: str 28 | original_name: str 29 | profile_path: str | None = None 30 | 31 | 32 | @dataclass 33 | class Genre: 34 | id: int 35 | name: str 36 | 37 | 38 | @dataclass 39 | class LastEpisodeToAir: 40 | air_date: str 41 | episode_number: int 42 | episode_type: str 43 | id: int 44 | name: str 45 | overview: str 46 | production_code: str 47 | runtime: int 48 | season_number: int 49 | show_id: int 50 | vote_average: float 51 | vote_count: int 52 | still_path: str | None = None 53 | 54 | 55 | @dataclass 56 | class Network: 57 | id: int 58 | logo_path: str 59 | name: str 60 | origin_country: str 61 | 62 | 63 | @dataclass 64 | class ProductionCompany: 65 | id: int 66 | name: str 67 | origin_country: str 68 | logo_path: str | None = None 69 | 70 | 71 | @dataclass 72 | class ProductionCountry: 73 | iso_3166_1: str 74 | name: str 75 | 76 | 77 | @dataclass 78 | class Season: 79 | episode_count: int 80 | id: int 81 | name: str 82 | overview: str 83 | season_number: int 84 | vote_average: float 85 | air_date: str | None = None 86 | poster_path: str | None = None 87 | 88 | 89 | @dataclass 90 | class SpokenLanguage: 91 | english_name: str 92 | iso_639_1: str 93 | name: str 94 | 95 | 96 | @dataclass 97 | class TVShowDetails(Media): 98 | adult: bool 99 | first_air_date: str 100 | homepage: str 101 | id: int 102 | in_production: bool 103 | last_air_date: str 104 | last_episode_to_air: LastEpisodeToAir 105 | name: str 106 | number_of_episodes: int 107 | number_of_seasons: int 108 | original_language: str 109 | original_name: str 110 | overview: str 111 | popularity: float 112 | poster_path: str 113 | status: str 114 | tagline: str 115 | type: str 116 | vote_average: float 117 | vote_count: int 118 | languages: list[str] = field(default_factory=list) 119 | genres: list[Genre] = field(default_factory=list) 120 | backdrop_path: str | None = None 121 | created_by: list[CreatedBy] = field(default_factory=list) 122 | episode_run_time: list[int] = field(default_factory=list) 123 | networks: list[Network] = field(default_factory=list) 124 | next_episode_to_air: LastEpisodeToAir | None = None 125 | production_companies: list[ProductionCompany] = field(default_factory=list) 126 | production_countries: list[ProductionCountry] = field(default_factory=list) 127 | seasons: list[Season] = field(default_factory=list) 128 | spoken_languages: list[SpokenLanguage] = field(default_factory=list) 129 | origin_country: list[str] = field(default_factory=list) 130 | 131 | def get_title(self) -> str: 132 | return self.name 133 | 134 | def get_original(self) -> str: 135 | return self.original_name 136 | 137 | def get_date(self) -> str: 138 | return self.first_air_date -------------------------------------------------------------------------------- /common/external_services/theMovieDB/core/models/tvshow/on_the_air.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from dataclasses import dataclass 3 | 4 | 5 | # 05/09/2024 6 | # Automatically creates __init__, __repr__, and other methods 7 | # 8 | @dataclass(frozen=True) 9 | class OnTheAir: 10 | adult: bool | None = None 11 | backdrop_path: str | None = None 12 | genre_ids: list[int] | None = None 13 | id: int | None = None 14 | original_language: str | None = None 15 | original_title: str | None = None 16 | overview: str | None = None 17 | popularity: float | None = None 18 | poster_path: str | None = None 19 | release_date: str | None = None 20 | title: str | None = None 21 | video: bool | None = None 22 | vote_average: float | None = None 23 | vote_count: int | None = None 24 | origin_country: list[str] | None = None 25 | original_name: str | None = None 26 | first_air_date: str | None = None 27 | name: str | None = None 28 | -------------------------------------------------------------------------------- /common/external_services/theMovieDB/core/models/tvshow/translations.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from dataclasses import dataclass 4 | 5 | 6 | @dataclass 7 | class Translation: 8 | """ 9 | Represents a TV show translation 10 | """ 11 | iso_639_1: str 12 | """ 13 | Language code of the translation 14 | """ 15 | english_name: str 16 | """ 17 | English name of the language 18 | """ 19 | name: str 20 | """ 21 | Name of the translation 22 | """ 23 | url: str | None 24 | """ 25 | Optional URL associated with the translation 26 | """ 27 | tagline: str | None 28 | """ 29 | Optional tagline for the translation 30 | """ 31 | description: str | None 32 | """ 33 | Optional description for the translation 34 | """ 35 | country: str | None 36 | """ 37 | Optional country code related to the translation 38 | """ 39 | 40 | 41 | @dataclass 42 | class TranslationsResponse: 43 | """ 44 | Contains a list of TV show translations 45 | """ 46 | translations: list[Translation] 47 | """ 48 | List of Translation objects 49 | """ 50 | -------------------------------------------------------------------------------- /common/external_services/theMovieDB/core/models/tvshow/tvshow.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from abc import abstractmethod, ABC 3 | from dataclasses import dataclass, field 4 | 5 | 6 | class Media(ABC): 7 | 8 | @abstractmethod 9 | def get_title(self) -> str: 10 | pass 11 | 12 | @abstractmethod 13 | def get_original(self) -> str: 14 | pass 15 | 16 | @abstractmethod 17 | def get_date(self) -> str: 18 | pass 19 | 20 | 21 | @dataclass 22 | class TvShow(Media): 23 | """ 24 | A tv object for the search endpoint 25 | """ 26 | 27 | id: int 28 | name: str 29 | first_air_date: str 30 | overview: str 31 | popularity: float 32 | vote_average: float 33 | vote_count: int 34 | genre_ids: list[int] = field(default_factory=list) 35 | origin_country: list[str] = field(default_factory=list) 36 | original_language: str = '' 37 | original_name: str = '' 38 | backdrop_path: str | None = None 39 | poster_path: str | None = None 40 | adult: bool = False 41 | 42 | def get_title(self) -> str: 43 | return self.name 44 | 45 | def get_original(self) -> str: 46 | return self.original_name 47 | 48 | def get_date(self) -> str: 49 | return self.first_air_date -------------------------------------------------------------------------------- /common/external_services/theMovieDB/core/videos.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from dataclasses import dataclass 4 | 5 | @dataclass 6 | class Videos: 7 | id: str 8 | iso_3166_1: str 9 | iso_639_1: str 10 | key: str 11 | name: str 12 | official: bool 13 | published_at: str 14 | site: str 15 | size: int 16 | type: str 17 | 18 | @dataclass 19 | class Data: 20 | id: int 21 | results: list[Videos] 22 | -------------------------------------------------------------------------------- /common/external_services/trailers/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /common/external_services/trailers/api.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import requests 4 | from .response import YouTubeSearchResponse, Thumbnails, Id, Item, PageInfo, Snippet 5 | 6 | from common import config_settings 7 | from view import custom_console 8 | 9 | class YtTrailer: 10 | url = 'https://www.googleapis.com/youtube/v3/search' 11 | 12 | def __init__(self, title: str): 13 | self.title = title 14 | self.params = { 15 | 'part': 'snippet', 16 | 'q': f'{title} trailer', 17 | 'type': 'video', 18 | 'key': config_settings.tracker_config.YOUTUBE_KEY, 19 | 'channelId': '', 20 | 'maxResults': 3, 21 | } 22 | 23 | def get_trailer_link(self) -> list[YouTubeSearchResponse] | None: 24 | 25 | # Use a favorite channel if the flag is True 26 | # Otherwise use a global YouTube search 27 | if config_settings.user_preferences.YOUTUBE_CHANNEL_ENABLE: 28 | self.params['channelId'] = config_settings.user_preferences.YOUTUBE_FAV_CHANNEL_ID 29 | 30 | response = requests.get(self.url, params=self.params) 31 | 32 | if response.status_code == 200: 33 | response_data = response.json() 34 | youtube_responses = [] 35 | 36 | if response_data['items']: 37 | for result in response_data['items']: 38 | thumbnails_data = result['snippet']['thumbnails'] 39 | thumbnails = Thumbnails( 40 | default=thumbnails_data['default'], 41 | high=thumbnails_data['high'], 42 | medium=thumbnails_data['medium'] 43 | ) 44 | 45 | snippet_data = result['snippet'] 46 | snippet = Snippet( 47 | channelId=snippet_data['channelId'], 48 | channelTitle=snippet_data['channelTitle'], 49 | description=snippet_data['description'], 50 | liveBroadcastContent=snippet_data['liveBroadcastContent'], 51 | publishTime=snippet_data['publishTime'], 52 | publishedAt=snippet_data['publishedAt'], 53 | title=snippet_data['title'], 54 | thumbnails=thumbnails 55 | ) 56 | 57 | video_id = None 58 | # Sometimes it returns a chennelId and not only the video id 59 | if 'id' in result: 60 | id_data = result['id'] 61 | 62 | # Check if the result is a video or a channel 63 | if id_data['kind'] == 'youtube#video': 64 | video_id = Id( 65 | kind=id_data.get('kind', ''), 66 | videoId=id_data.get('videoId', '') 67 | ) 68 | elif id_data['kind'] == 'youtube#channel': 69 | # Get video_di from the result 70 | video_id = Id( 71 | kind=id_data.get('kind', ''), 72 | videoId=id_data.get('channelId', '') 73 | ) 74 | else: 75 | # Fail 76 | pass 77 | 78 | item = Item( 79 | etag=result['etag'], 80 | id=video_id, 81 | kind=result['kind'], 82 | snippet=snippet 83 | ) 84 | 85 | page_info = PageInfo(**response_data['pageInfo']) 86 | youtube_response = YouTubeSearchResponse( 87 | etag=response_data['etag'], 88 | items=[item], 89 | kind=response_data['kind'], 90 | pageInfo=page_info, 91 | regionCode=response_data['regionCode'] 92 | ) 93 | youtube_responses.append(youtube_response) 94 | 95 | 96 | return youtube_responses 97 | else: 98 | return None 99 | else: 100 | custom_console.wait_for_user_confirmation("No response from YouTube. Check your API_KEY\n" 101 | "Press Enter to continue or Ctrl-C to exit") 102 | return None -------------------------------------------------------------------------------- /common/external_services/trailers/response.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from dataclasses import dataclass 4 | 5 | @dataclass 6 | class Thumbnails: 7 | default: dict[str, int] 8 | high: dict[str, int] 9 | medium: dict[str, int] 10 | 11 | 12 | @dataclass 13 | class Snippet: 14 | channelId: str 15 | channelTitle: str 16 | description: str 17 | liveBroadcastContent: str 18 | publishTime: str 19 | publishedAt: str 20 | title: str 21 | thumbnails: Thumbnails 22 | 23 | 24 | @dataclass 25 | class Id: 26 | kind: str 27 | videoId: str 28 | 29 | 30 | @dataclass 31 | class Item: 32 | etag: str 33 | id: Id 34 | kind: str 35 | snippet: Snippet 36 | 37 | 38 | @dataclass 39 | class PageInfo: 40 | resultsPerPage: int 41 | totalResults: int 42 | 43 | 44 | @dataclass 45 | class YouTubeSearchResponse: 46 | etag: str 47 | items: list[Item] 48 | kind: str 49 | pageInfo: PageInfo 50 | regionCode: str 51 | 52 | -------------------------------------------------------------------------------- /common/extractor.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import os 3 | import re 4 | 5 | import patoolib 6 | import logging 7 | 8 | from rich.progress import Progress, SpinnerColumn 9 | from view import custom_console 10 | 11 | # Turn off INFO 12 | logging.getLogger("patool").setLevel(logging.ERROR) 13 | 14 | 15 | class Extractor: 16 | 17 | def __init__(self, compressed_media__path: str): 18 | # the Root folder 19 | self.path = compressed_media__path 20 | 21 | def delete_old_rar(self, rar_volumes_list: list): 22 | 23 | # Manual mode for delete old files 24 | manual_mode = True 25 | 26 | # Construct the original archive with the path 27 | files_to_delete = [] 28 | for file_rar in rar_volumes_list: 29 | files_to_delete.append(os.path.join(self.path, file_rar)) 30 | 31 | custom_console.bot_log( 32 | f"There are {len(files_to_delete)} old files to delete.." 33 | ) 34 | for index, old_file in enumerate(files_to_delete): 35 | custom_console.bot_log(f"Delete {index} - Old file: {old_file}") 36 | 37 | if not manual_mode: 38 | # Remove each file without user confirm ! 39 | os.remove(old_file) 40 | 41 | # Ask user for each file 42 | while manual_mode: 43 | delete_choice = input("Delete the old file ? (Y/N/All) Q=quit ") 44 | # Wait for an answer 45 | if delete_choice: 46 | # Only letters 47 | if delete_choice.isalpha(): 48 | if delete_choice.upper() == "Y": 49 | print("Your choice: Yes") 50 | print(f"Deleted {old_file}") 51 | # Remove 52 | os.remove(old_file) 53 | break 54 | if delete_choice.upper() == "N": 55 | # Continue to next file 56 | print("Your choice: No") 57 | return 58 | if delete_choice.upper() == "ALL": 59 | # Remove each file without user confirm 60 | print("Your choice: All") 61 | # Remove the current file 62 | os.remove(old_file) 63 | # Automatic mode 64 | manual_mode = False 65 | break 66 | if delete_choice.upper() == "Q": 67 | # Exit.. 68 | print("Your choice: Quit") 69 | exit(1) 70 | 71 | @staticmethod 72 | def list_rar_files_old(subfolder: str) -> ["str"]: 73 | folder_list = os.listdir(subfolder) 74 | # Filter by *.rar and sorted 75 | return sorted([file for file in folder_list if file.lower().endswith(".rar")]) 76 | 77 | @staticmethod 78 | def list_rar_files(subfolder: str) -> list[str]: 79 | folder_list = os.listdir(subfolder) 80 | # Filter by *.rar and *.rxx (sorted) 81 | rar_pattern = re.compile(r"\.rar$|\.r\d{2}$", re.IGNORECASE) 82 | return sorted( 83 | [file for file in folder_list if rar_pattern.search(file.lower())] 84 | ) 85 | 86 | def unrar(self) -> bool | None: 87 | # only -f option 88 | if not os.path.isdir(self.path): 89 | return 90 | 91 | with Progress( 92 | SpinnerColumn(spinner_name="earth"), console=custom_console, transient=True 93 | ) as progress: 94 | progress.add_task("Working...", total=100) 95 | 96 | # Get the list of *.rar files sorted 97 | folder_list = self.list_rar_files(self.path) 98 | 99 | # Is not empty 100 | if folder_list: 101 | custom_console.bot_error_log( 102 | "[is_rar] Found an RAR archive ! Decompressing... Wait.." 103 | ) 104 | else: 105 | return None 106 | 107 | # Build the First Volume filename or use the only file present 108 | first_part = os.path.join(self.path, folder_list[0]) 109 | 110 | # Run Extract 111 | try: 112 | patoolib.extract_archive( 113 | first_part, 114 | outdir=self.path, 115 | verbosity=-1, 116 | ) 117 | except patoolib.util.PatoolError as e: 118 | custom_console.bot_error_log(f"{e}\nError remove old file if necessary..") 119 | return False 120 | custom_console.bot_log("[is_rar] Decompression complete") 121 | 122 | # Remove the original archive or the original multipart archive 123 | self.delete_old_rar(rar_volumes_list=folder_list) 124 | return True 125 | -------------------------------------------------------------------------------- /common/mediainfo.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import re 3 | import os 4 | 5 | from pymediainfo import MediaInfo 6 | from common.utility import ManageTitles 7 | 8 | class MediaFile: 9 | """ 10 | Get attributes from mediainfo 11 | """ 12 | def __init__(self, file_path: str): 13 | self.file_path = file_path 14 | 15 | self._video_info: list = [] 16 | self._general_track: dict = {} 17 | self._audio_info: list = [] 18 | 19 | try: 20 | self.media_info = MediaInfo.parse(self.file_path) 21 | except OSError as e: 22 | if os.name != 'nt': 23 | print(f"{e} Try to install: sudo apt-get install -y libmediainfo-dev") 24 | exit(1) 25 | 26 | 27 | @property 28 | def general_track(self)-> dict: 29 | """Returns general information""" 30 | if not self._general_track: 31 | for track in self.media_info.to_data().get("tracks", []): 32 | if track.get("track_type") == "General": 33 | return self._general_track 34 | self._general_track = {} 35 | return self._general_track 36 | 37 | @property 38 | def video_track(self) -> list: 39 | """Returns video information""" 40 | if not self._video_info: 41 | for track in self.media_info.tracks: 42 | if track.track_type == "Video": 43 | self._video_info.append(track.to_data()) 44 | 45 | return self._video_info 46 | 47 | @property 48 | def audio_track(self) -> list: 49 | """Returns audio information""" 50 | if not self._audio_info: 51 | for track in self.media_info.tracks: 52 | if track.track_type == "Audio": 53 | self._audio_info.append(track.to_data()) 54 | return self._audio_info 55 | 56 | @property 57 | def codec_id(self) -> str: 58 | """Returns the codec_id of the first video track""" 59 | video = self.video_track 60 | if video: 61 | return video[0].get("codec_id", "Unknown") 62 | return "Unknown" 63 | 64 | @property 65 | def video_width(self) -> str: 66 | """Returns the width of the video""" 67 | video = self.video_track 68 | if video: 69 | return video[0].get("width", "Unknown") 70 | return "Unknown" 71 | 72 | @property 73 | def video_height(self) -> str | None: 74 | """Returns the height of the video""" 75 | video = self.video_track 76 | if video: 77 | return video[0].get("height", None) 78 | return None 79 | 80 | @property 81 | def video_scan_type(self) -> str | None: 82 | """Returns the scan type""" 83 | video = self.video_track 84 | if video: 85 | return video[0].get("scan_type", None) 86 | return None 87 | 88 | @property 89 | def video_aspect_ratio(self) -> str: 90 | """Returns the aspect ratio of the video""" 91 | video = self.video_track 92 | if video: 93 | return video[0].get("display_aspect_ratio", "Unknown") 94 | return "Unknown" 95 | 96 | @property 97 | def video_frame_rate(self) -> str: 98 | """Returns the frame rate of the video""" 99 | video = self.video_track 100 | if video: 101 | return video[0].get("frame_rate", "Unknown") 102 | return "Unknown" 103 | 104 | @property 105 | def video_bit_depth(self) -> str: 106 | """Returns the bit depth of the video""" 107 | video = self.video_track 108 | if video: 109 | return video[0].get("bit_depth", "Unknown") 110 | return "Unknown" 111 | 112 | @property 113 | def audio_codec_id(self) -> str: 114 | """Returns the codec_id of the first audio track""" 115 | audio = self.audio_track 116 | if audio: 117 | return audio[0].get("codec_id", "Unknown") 118 | return "Unknown" 119 | 120 | @property 121 | def audio_bit_rate(self) -> str: 122 | """Returns the bit rate of the audio""" 123 | audio = self.audio_track 124 | if audio: 125 | return audio[0].get("bit_rate", "Unknown") 126 | return "Unknown" 127 | 128 | @property 129 | def audio_channels(self) -> str: 130 | """Returns the number of audio channels""" 131 | audio = self.audio_track 132 | if audio: 133 | return audio[0].get("channels", "Unknown") 134 | return "Unknown" 135 | 136 | @property 137 | def audio_sampling_rate(self) -> str: 138 | """Returns the sampling rate of the audio""" 139 | audio = self.audio_track 140 | if audio: 141 | return audio[0].get("sampling_rate", "Unknown") 142 | return "Unknown" 143 | 144 | @property 145 | def subtitle_track(self) -> list: 146 | """Get subtitle track""" 147 | subtitle_info = [] 148 | for track in self.media_info.tracks: 149 | if track.track_type == "Text": 150 | subtitle_info.append(track.to_data()) 151 | return subtitle_info 152 | 153 | @property 154 | def available_languages(self) -> list: 155 | """Get available languages from audio and subtitle tracks""" 156 | languages = set() 157 | 158 | for track in self.audio_track: # + self.subtitle_track: 159 | lang = track.get("language", "Unknown") 160 | if lang != "Unknown": 161 | languages.add(ManageTitles.convert_iso(lang)) 162 | return list(languages) if len(languages) > 0 else ["not found"] 163 | 164 | @property 165 | def file_size(self) -> str: 166 | """Get the file size""" 167 | general = self.general_track 168 | if general: 169 | return general.get("file_size", "Unknown") 170 | return "Unknown" 171 | 172 | @property 173 | def info(self): 174 | return MediaInfo.parse(self.file_path, output="STRING", full=False) 175 | 176 | @property 177 | def is_interlaced(self) -> int | None: 178 | video = self.video_track 179 | if video: 180 | encoding_settings = video[0].get("encoding_settings", None) 181 | if encoding_settings: 182 | match = re.search(r"interlaced=(\d)", encoding_settings) 183 | if match: 184 | return int(match.group(1)) 185 | 186 | return None 187 | 188 | def generate(self, guess_title: str, resolution: str)-> str | None: 189 | if self.video_track: 190 | video_format = self.video_track[0].get("format", "") 191 | audio_format = self.audio_track[0].get("format", "") 192 | _, file_ext =os.path.splitext(self.file_path) 193 | 194 | return f"{guess_title}.web-dl.{video_format}.{resolution}.{audio_format}.{file_ext}" 195 | return None -------------------------------------------------------------------------------- /common/mediainfo_string.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import re 4 | from dataclasses import dataclass 5 | 6 | 7 | @dataclass 8 | class MediainfoAudioFormat: 9 | """Create a new object with the audio attributes""" 10 | 11 | id: str 12 | format: str 13 | format_info: str 14 | commercial_name: str 15 | codec_id: str 16 | duration: str 17 | bit_rate_mode: str 18 | bit_rate: str 19 | channels: str 20 | channel_layout: str 21 | sampling_rate: str 22 | frame_rate: str 23 | compression_mode: str 24 | stream_size: str 25 | title: str 26 | language: str 27 | service_kind: str 28 | default: str 29 | forced: str 30 | max_bit_rate: str 31 | delay_relative_to_video: str 32 | 33 | @staticmethod 34 | def from_mediainfo_string(audio_info: dict[str, str]) -> "MediainfoAudioFormat": 35 | """Create an instance from a dictionary""" 36 | return MediainfoAudioFormat( 37 | id=audio_info.get("ID", ""), 38 | format=audio_info.get("Format", ""), 39 | format_info=audio_info.get("Format/Info", ""), 40 | commercial_name=audio_info.get("Commercial name", ""), 41 | codec_id=audio_info.get("Codec ID", ""), 42 | duration=audio_info.get("Duration", ""), 43 | bit_rate_mode=audio_info.get("Bit rate mode", ""), 44 | bit_rate=audio_info.get("Bit rate", ""), 45 | channels=audio_info.get("Channel(s)", ""), 46 | channel_layout=audio_info.get("Channel layout", ""), 47 | sampling_rate=audio_info.get("Sampling rate", ""), 48 | frame_rate=audio_info.get("Frame rate", ""), 49 | compression_mode=audio_info.get("Compression mode", ""), 50 | stream_size=audio_info.get("Stream size", ""), 51 | title=audio_info.get("Title", ""), 52 | language=audio_info.get("Language", ""), 53 | service_kind=audio_info.get("Service kind", ""), 54 | default=audio_info.get("Default", ""), 55 | forced=audio_info.get("Forced", ""), 56 | max_bit_rate=audio_info.get("Maximum bit rate", ""), 57 | delay_relative_to_video=audio_info.get("Delay relative to video", ""), 58 | ) 59 | 60 | 61 | class MediaInfo: 62 | def __init__(self, media_info: str): 63 | # Mediainfo.parsed() output string from the tracker 64 | self.media_info = media_info 65 | 66 | def audio_sections(self) -> list[dict[str, str]] | None: 67 | """Get each Audio section from the Mediainfo.parsed() output string""" 68 | mediainfo_audio_sections_regex = ( 69 | r"Audio #\d+\n([\s\S]*?)(?=\nAudio #\d+|\n\n|$)" 70 | ) 71 | audio_sections = re.findall(mediainfo_audio_sections_regex, self.media_info) 72 | 73 | # One audio track only 74 | if not audio_sections: 75 | mediainfo_audio_sections_regex = r"Audio([\s\S]*?)(?=\nAudio #\d+|\n\n|$)" 76 | audio_sections = re.findall(mediainfo_audio_sections_regex, self.media_info) 77 | 78 | audio_info_list = [] 79 | 80 | # For each audio section 81 | for i, audio_section in enumerate(audio_sections, 1): 82 | audio_info = {} 83 | audio_lines = audio_section.strip().split("\n") 84 | for line in audio_lines: 85 | # Get key and value when a section is found 86 | if ":" in line: 87 | key, value = line.split(":", 1) 88 | # Create a dictionary with key (left) and value(right) string 89 | audio_info[key.strip()] = value.strip() 90 | # Add each section to list 91 | audio_info_list.append(audio_info) 92 | return audio_info_list 93 | 94 | def get_audio_formats(self) -> list[MediainfoAudioFormat] | None: 95 | """Get list of Audio sections and return MediainfoAudioFormat objects""" 96 | audio_info_list = self.audio_sections() 97 | if audio_info_list: 98 | # return a new object AudioFormat for each section 99 | return [ 100 | MediainfoAudioFormat.from_mediainfo_string(info) 101 | for info in audio_info_list 102 | ] 103 | return None 104 | -------------------------------------------------------------------------------- /common/title.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import guessit 3 | from common.utility import ManageTitles 4 | 5 | 6 | class Guessit: 7 | 8 | def __init__(self, filename: str): 9 | temp_name = ManageTitles.replace(filename) 10 | self.guessit = guessit.guessit(temp_name) 11 | self.filename = filename 12 | 13 | @property 14 | def guessit_title(self) -> str: 15 | """ 16 | Estrae la stringa con il titolo dal nome del file film_title o title(serie ?) 17 | :return: 18 | """ 19 | # 20 | # Se fallisce guessit ad esempio in questo caso : 21 | # titolo : 1923 ( nessun altra informazione nel titolo) 22 | # MatchesDict([('year', 1923), ('type', 'movie')]) 23 | # dove non trova ne title e film_title e erroneamente lo credo un movie.. 24 | # bypass guessit e ritorna filename alla ricerca di tmdb 25 | return self.guessit.get("film_title", self.guessit.get("title", self.filename)) 26 | 27 | @property 28 | def guessit_alternative(self) -> str: 29 | """ 30 | Estrae la stringa con il titolo dal nome del file film_title o title(serie ?) 31 | :return: 32 | """ 33 | return self.guessit.get( 34 | "alternative_title", self.guessit.get("title", self.filename) 35 | ) 36 | 37 | @property 38 | def guessit_year(self) -> str | None: 39 | """ 40 | Estrae l'anno di pubblicazione dal titolo 41 | :return: 42 | """ 43 | return self.guessit["year"] if "year" in self.guessit else None 44 | 45 | @property 46 | def guessit_episode(self) -> str | None: 47 | """ 48 | Estrae il numero di episodio dal titolo 49 | :return: 50 | """ 51 | return self.guessit["episode"] if "episode" in self.guessit else None 52 | 53 | @property 54 | def guessit_season(self) -> str | None: 55 | """ 56 | Estrae il numero di stagione dal titolo 57 | :return: 58 | """ 59 | # return int(self.guessit['season']) if 'season' in self.guessit else None 60 | return self.guessit["season"] if "season" in self.guessit else None 61 | 62 | @property 63 | def guessit_episode_title(self) -> str: 64 | """ 65 | Get the episode title 66 | :return: 67 | """ 68 | return guessit.guessit(self.filename, {"excludes": "part"}).get("episode_title", "") 69 | 70 | 71 | @property 72 | def type(self) -> str | None: 73 | """ 74 | Determina se è una serie verificando la presenza di un numero di stagione 75 | :return: 76 | """ 77 | return self.guessit["type"] if "type" in self.guessit else None 78 | 79 | @property 80 | def source(self) -> str | None: 81 | """ 82 | Grab the source 83 | :return: 84 | """ 85 | return self.guessit["source"] if "source" in self.guessit else None 86 | 87 | @property 88 | def other(self) -> str | None: 89 | """ 90 | Grab the 'other' info 91 | :return: 92 | """ 93 | return self.guessit["other"] if "other" in self.guessit else None 94 | 95 | @property 96 | def audio_codec(self) -> str | None: 97 | """ 98 | Grab the 'other' info 99 | :return: 100 | """ 101 | return self.guessit["audio_codec"] if "audio_codec" in self.guessit else None 102 | 103 | @property 104 | def subtitle(self) -> str | None: 105 | """ 106 | Grab the 'other' subtitle 107 | :return: 108 | """ 109 | return self.guessit["subtitle"] if "subtitle" in self.guessit else None 110 | 111 | @property 112 | def release_group(self) -> str | None: 113 | """ 114 | Grab the 'release_group' 115 | :return: 116 | """ 117 | return ( 118 | self.guessit["release_group"] if "release_group" in self.guessit else None 119 | ) 120 | 121 | @property 122 | def screen_size(self) -> str | None: 123 | """ 124 | Grab the 'screen_size' 125 | :return: 126 | """ 127 | return self.guessit["screen_size"] if "screen_size" in self.guessit else None 128 | -------------------------------------------------------------------------------- /common/trackers/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from common.trackers.itt import itt_data 4 | from common.trackers.sis import sis_data 5 | 6 | tracker_list = {'ITT': itt_data, 'SIS': sis_data} 7 | -------------------------------------------------------------------------------- /common/trackers/data.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from common import config_settings 4 | 5 | 6 | trackers_api_data = { 7 | 'ITT': 8 | { 9 | "url": config_settings.tracker_config.ITT_URL, 10 | "api_key": config_settings.tracker_config.ITT_APIKEY, 11 | "pass_key": config_settings.tracker_config.ITT_PID, 12 | "announce": f"{config_settings.tracker_config.ITT_URL}/announce/{config_settings.tracker_config.ITT_PID}", 13 | "source": "ItaTorrents", 14 | } 15 | , 16 | 'SIS': 17 | { 18 | "url": config_settings.tracker_config.SIS_URL, 19 | "api_key": config_settings.tracker_config.SIS_APIKEY, 20 | "pass_key": config_settings.tracker_config.SIS_PID, 21 | "announce": f"{config_settings.tracker_config.SIS_URL}/announce/{config_settings.tracker_config.SIS_PID}", 22 | "source": "ShareIsland", 23 | } 24 | 25 | } 26 | 27 | -------------------------------------------------------------------------------- /common/trackers/itt.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | itt_data = { 4 | "CATEGORY":{ "movie": 1, 5 | "tv": 2, 6 | "edicola": 6, 7 | "game": 4}, 8 | 9 | "FREELECH":{ "size20": 100, 10 | "size15": 75, 11 | "size10": 50, 12 | "size5": 25}, 13 | 14 | "TYPE_ID":{ "full-disc": 1, 15 | "remux": 2, 16 | "bdremux": 2, 17 | "vh": 2, 18 | "untouched": 2, 19 | "bd-untouched": 2, 20 | "encode": 3, 21 | "bluray": 3, 22 | "fullhd": 3, 23 | "hevc": 3, 24 | "hdrip": 3, 25 | "vu": 2, 26 | "web-dl": 4, 27 | "webdl": 4, 28 | "web": 4, 29 | "web-dlmux": 4, 30 | "webrip": 5, 31 | "hdtv": 6, 32 | "mac": 12, 33 | "macos": 12, 34 | "windows": 13, 35 | "pc": 13, 36 | "cinema-md": 14, 37 | "hdts": 14, 38 | "wrs": 14, 39 | "md": 14, 40 | "altro": 15, 41 | "pdf": 16, 42 | "nintendo": 17, 43 | "nsw": 17, 44 | "ps4": 18, 45 | "psn": 18, 46 | "epub": 19, 47 | "mp4": 20, 48 | "pack": 22, 49 | "avi": 23, 50 | "dvdrip": 24, 51 | "bdrip": 25, 52 | "webmux": 26, 53 | "dlmux": 27, 54 | "bdmux": 29, 55 | "3d": 32, 56 | "cbr-cbz": 33, 57 | "ps5": 35, 58 | "psvr": 35, 59 | }, 60 | "TYPE_ID_AUDIO":{ "flac": 7, 61 | "alac": 8, 62 | "ac3": 9, 63 | "aac": 10, 64 | "mp3": 11, 65 | }, 66 | "TAGS":{ 67 | "SD": 1, 68 | "HD": 0, 69 | }, 70 | "RESOLUTION": { 71 | "4320p": 1, 72 | "2160p": 2, 73 | "1080p": 3, 74 | "1080i": 4, 75 | "720p": 5, 76 | "576p": 6, 77 | "576i": 7, 78 | "480p": 8, 79 | "480i": 9, 80 | "altro": 10, 81 | }, 82 | "CODEC": [ 83 | "h261", 84 | "h262", 85 | "h263", 86 | "h264", 87 | "x264", 88 | "x265", 89 | "avc", 90 | "h265", 91 | "hevc", 92 | "vp8", 93 | "vp9", 94 | "av1", 95 | "mpeg-1", 96 | "mpeg-4", 97 | "wmv", 98 | "theora", 99 | "divx", 100 | "xvid", 101 | "prores", 102 | "dnxhd", 103 | "cinepak", 104 | "indeo", 105 | "dv", 106 | "ffv1", 107 | "sorenson", 108 | "rv40", 109 | "cineform", 110 | "huffyuv", 111 | "mjpeg", 112 | "lagarith", 113 | "msu", 114 | "rle", 115 | "dirac", 116 | "wmv3", 117 | "vorbis", 118 | "smpte", 119 | "mjpeg", 120 | "ffvhuff", 121 | "v210", 122 | "yuv4:2:2", 123 | "yuv4:4:4", 124 | "hap", 125 | "sheervideo", 126 | "ut", 127 | "quicktime", 128 | "rududu", 129 | "h.266", 130 | "vvc", 131 | "mjpeg 4:2:0", 132 | "h.263+", 133 | "h.263++", 134 | "vp4", 135 | "vp5", 136 | "vp6", 137 | "vp7", 138 | "vp8", 139 | "vp9", 140 | "vp10", 141 | "vp11", 142 | "vp12", 143 | "vp3", 144 | "vp2", 145 | "vp1", 146 | "amv", 147 | "daala", 148 | "gecko", 149 | "nvenc", 150 | "bluray", 151 | ], 152 | } 153 | -------------------------------------------------------------------------------- /common/trackers/sis.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | sis_data = { 4 | "CATEGORY": {"movie": 1, 5 | "tv": 2, 6 | "ebook":15, 7 | "edicola": 17, 8 | "xxx":19, 9 | "Music": 3, 10 | "game": 7, 11 | "software": 23, 12 | "eventi sportivi": 24, 13 | "misc": 25}, 14 | 15 | "FREELECH": {"size20": 100, 16 | "size15": 75, 17 | "size10": 50, 18 | "size5": 25}, 19 | 20 | 21 | "TYPE_ID": { 22 | "encode": 15, 23 | "web": 27, 24 | "web-dl": 27, 25 | "webrip": 27, 26 | "remux": 7, 27 | "disk": 26, 28 | "cinemanews": 42, 29 | "hdtv": 33, 30 | "windows": 31, 31 | "pc": 31, 32 | "linux": 39, 33 | "android": 38, 34 | "appleos": 32, 35 | "mp3": 25, 36 | "flac": 24, 37 | "epub": 48, 38 | "pdf": 49, 39 | "altro": 47, 40 | 41 | }, 42 | "TYPE_ID_AUDIO": { 43 | "flac": 24, 44 | "alac": 8, 45 | "ac3": 9, 46 | "aac": 10, 47 | "mp3": 25, 48 | }, 49 | "TAGS": { 50 | "SD": 1, 51 | "HD": 0, 52 | }, 53 | "RESOLUTION": { 54 | "4320p": 1, 55 | "2160p": 2, 56 | '1440p': 3, 57 | "1080p": 3, 58 | "1080i": 4, 59 | "720p": 5, 60 | "576p": 6, 61 | "576i": 7, 62 | "480p": 8, 63 | "480i": 9, 64 | "altro": 11, 65 | }, 66 | "CODEC": [ 67 | "h261", 68 | "h262", 69 | "h263", 70 | "h264", 71 | "x264", 72 | "x265", 73 | "avc", 74 | "h265", 75 | "hevc", 76 | "vp8", 77 | "vp9", 78 | "av1", 79 | "mpeg-1", 80 | "mpeg-4", 81 | "wmv", 82 | "theora", 83 | "divx", 84 | "xvid", 85 | "prores", 86 | "dnxhd", 87 | "cinepak", 88 | "indeo", 89 | "dv", 90 | "ffv1", 91 | "sorenson", 92 | "rv40", 93 | "cineform", 94 | "huffyuv", 95 | "mjpeg", 96 | "lagarith", 97 | "msu", 98 | "rle", 99 | "dirac", 100 | "wmv3", 101 | "vorbis", 102 | "smpte", 103 | "mjpeg", 104 | "ffvhuff", 105 | "v210", 106 | "yuv4:2:2", 107 | "yuv4:4:4", 108 | "hap", 109 | "sheervideo", 110 | "ut", 111 | "quicktime", 112 | "rududu", 113 | "h.266", 114 | "vvc", 115 | "mjpeg 4:2:0", 116 | "h.263+", 117 | "h.263++", 118 | "vp4", 119 | "vp5", 120 | "vp6", 121 | "vp7", 122 | "vp8", 123 | "vp9", 124 | "vp10", 125 | "vp11", 126 | "vp12", 127 | "vp3", 128 | "vp2", 129 | "vp1", 130 | "amv", 131 | "daala", 132 | "gecko", 133 | "nvenc", 134 | "bluray", 135 | ], 136 | } 137 | -------------------------------------------------------------------------------- /common/trackers/trackers.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from dataclasses import dataclass 4 | from common.utility import ManageTitles 5 | from . import tracker_list 6 | 7 | @dataclass 8 | class TRACKData: 9 | category: dict[str, int] 10 | freelech: dict[str, int] 11 | type_id: dict[str, int] 12 | resolution: dict[str, int] 13 | codec: list 14 | 15 | @classmethod 16 | def load_from_module(cls, tracker_name: str) -> "TRACKData": 17 | """ 18 | Load tracker data from module 19 | """ 20 | tracker_data= tracker_list[tracker_name.upper()] 21 | 22 | return cls( 23 | category=tracker_data.get("CATEGORY"), 24 | freelech=tracker_data.get("FREELECH"), 25 | type_id=tracker_data.get("TYPE_ID"), 26 | resolution=tracker_data.get("RESOLUTION"), 27 | codec=tracker_data.get("CODEC"), 28 | ) 29 | 30 | def filter_type(self, file_name: str) -> int: 31 | 32 | file_name = ManageTitles.clean(file_name) 33 | # >Clean the releaser sign 34 | file_name = file_name.replace("-", " ") 35 | word_list = file_name.lower().strip().split(" ") 36 | 37 | # Caso 1: Cerca un TYPE_ID nel nome del file 38 | for word in word_list: 39 | if word in self.type_id: 40 | return self.type_id[word] 41 | 42 | # Caso 2: Se non trova un TYPE_ID, cerca un codec e ritorna 'encode' 43 | for word in word_list: 44 | if word in self.codec: 45 | return self.type_id.get("encode", -1) 46 | return self.type_id.get("altro", -1) 47 | -------------------------------------------------------------------------------- /docs/.nojekyll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/31December99/Unit3Dup/d544838bb67662e0896fffd50f670961d3248ddd/docs/.nojekyll -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | language = 'en' 2 | project = 'Unit3D Bot' 3 | copyright = '2025, Parzival' 4 | author = 'Parzival' 5 | version = '0.07.10' 6 | release = '0.07.10' 7 | 8 | exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] 9 | html_theme = 'sphinx_rtd_theme' 10 | pygments_style = 'sphinx' 11 | -------------------------------------------------------------------------------- /docs/config.rst: -------------------------------------------------------------------------------- 1 | Configuration file 2 | ################## 3 | 4 | The file config is a json file created the first time you run the Unit3Dup 5 | 6 | it's named Unit3Dup.json 7 | 8 | Windows 9 | ******* 10 | 11 | The file is created in 12 | 13 | .. code-block:: python 14 | 15 | C:\\Users\\[USER]\\AppData\\Local\\Unit3Dup_config 16 | 17 | Debian/Ubuntu 18 | ************* 19 | 20 | The file is created in 21 | 22 | .. code-block:: python 23 | 24 | /home/[user] 25 | 26 | 27 | 28 | 29 | Settings Overview 30 | ***************** 31 | 32 | MINIMAL 33 | ======= 34 | 35 | .. code-block:: python 36 | 37 | ITT_URL: Tracker URL 38 | ITT_APIKEY: Trackr APIKEY 39 | ITT_PID: Tracker PASSKEY 40 | TMDB_APIKEY: The Movie DB APIKEY 41 | 42 | 43 | At least one: 44 | 45 | .. code-block:: python 46 | 47 | IMGBB_KEY: Host APIKEY 48 | FREE_IMAGE_KEY: Host APIKEY 49 | LENSDUMP_KEY: Host APIKEY 50 | PTSCREENS_KEY: Host APIKEY 51 | IMGFI_KEY: Host APIKEY 52 | 53 | 54 | At least one client 55 | 56 | .. code-block:: python 57 | 58 | QBIT_USER: admin 59 | QBIT_PASS: password 60 | QBIT_HOST: 127.0.0.1 61 | QBIT_PORT: 8080 62 | 63 | TRASM_USER: admin 64 | TRASM_PASS: password 65 | TRASM_HOST: 127.0.0.1 66 | TRASM_PORT: 9091 67 | 68 | RTORR_HOST: 192.168.1.41 69 | RTORR_PASS: password 70 | RTORR_PORT: 5000 71 | RTORR_USER: admin 72 | 73 | TORRENT_CLIENT: qbittorrent or Transmission or rTorrent 74 | 75 | 76 | 77 | Preferences 78 | =========== 79 | 80 | Game ID and media 81 | 82 | .. code-block:: python 83 | 84 | IGDB_CLIENT_ID: Client ID used to fetch game media from the IGDB database. 85 | IGDB_ID_SECRET: Secret ID 86 | 87 | 88 | Torrent Clients 89 | 90 | .. code-block:: python 91 | 92 | SHARED_QBIT_PATH: Set this if you're running the bot on Linux but seeding with qBittorrent on Windows 93 | or viceversa 94 | SHARED_RTORR_PATH: Like above but for rTorrent 95 | TORRENT_ARCHIVE_PATH: Set the path for the torrent file created by the bot 96 | TORRENT_COMMENT: Add a comment to your torrent file 97 | 98 | Trailers 99 | 100 | .. code-block:: python 101 | 102 | YOUTUBE_KEY: YouTube API key used to fetch a trailer if TMDb does not provide one 103 | YOUTUBE_FAV_CHANNEL_ID: When enabled, forces the bot to search trailers only within this YouTube channel instead of using a global search 104 | YOUTUBE_CHANNEL_ENABLE: Enable youtube channel 105 | 106 | Duplicate 107 | 108 | .. code-block:: python 109 | 110 | DUPLICATE_ON: Search for a title, check the release year, size, and episode information for duplicates 111 | SKIP_DUPLICATE: Automatically skip upload if a duplicate is found 112 | SIZE_TH: Set the acceptable size delta between your title and the one present on the tracker 113 | SKIP_TMDB: Automatically skip if no TMDb ID is found for the title 114 | 115 | 116 | Screenshots 117 | Host image priority. Tries the next one if the current fails (1:5). 1 = highest priority 118 | 119 | .. code-block:: python 120 | 121 | NUMBER_OF_SCREENSHOTS: 3 122 | PTSCREENS_PRIORITY: 2 123 | LENSDUMP_PRIORITY: 3 124 | FREE_IMAGE_PRIORITY: 1 125 | IMGBB_PRIORITY: 4 126 | IMGFI_PRIORITY: 5 127 | COMPRESS_SCSHOT: Compression level for screenshots (0:9) 9 = max 128 | RESIZE_SCSHOT: Enable screenshot resizing while preserving aspect ratio 129 | 130 | 131 | General 132 | 133 | .. code-block:: python 134 | 135 | PREFERRED_LANG: Choose your preferred language (eg ITA-ENG) Skip if the video does not match your selected language 136 | ANON: Anonymity 137 | PERSONAL_RELEASE: Set the flag personal release 138 | WEBP_ENABLED: In addition to the screenshot create an animated one 139 | 140 | Cache 141 | 142 | .. code-block:: python 143 | 144 | CACHE_SCR: Activate cache for the screenshots 145 | CACHE_PATH: Set the main path for storing cache files 146 | CACHE_DBONLINE: Activate cache for the TMBD o IMDB search -------------------------------------------------------------------------------- /docs/docker.rst: -------------------------------------------------------------------------------- 1 | Docker 2 | ###### 3 | 4 | You can run containers on both Windows and Linux 5 | 6 | Linux 7 | """"" 8 | 1) Install the Docker Engine and run the "Hello World!" test. 9 | https://docs.docker.com/engine/install/ubuntu/ 10 | 11 | 2) Open the dockerfile and set your username inside the Dockerfile. 12 | 13 | .. code-block:: ini 14 | 15 | The username must be the same on both the host and the container 16 | The owner must be the same on both the host and the container 17 | Sometimes files appear to be missing in Docker due to permission problems 18 | 19 | 20 | 21 | 3) Build the container 22 | 23 | cd in the \Unit3Dup\Docker folder and run: 24 | 25 | docker build -t unit3dup . 26 | 27 | 4) Run the bot using the unit3dup.sh script: 28 | 29 | chmod +x unit3du.sh 30 | 31 | ./unit3du.sh <-f> <-u> <-scan> 32 | 33 | Windows 34 | """"""" 35 | 36 | Install Desktop Docker 37 | 38 | Like above except you need the unit3du.ps1 script 39 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | Documentazione di **Unit3Dup** 2 | =============================================== 3 | 4 | .. toctree:: 5 | :maxdepth: 2 6 | :caption: Contenuti: 7 | 8 | main 9 | watcher 10 | config 11 | docker 12 | 13 | .. include:: ../README.rst 14 | :start-line: 1 15 | -------------------------------------------------------------------------------- /docs/main.rst: -------------------------------------------------------------------------------- 1 | Start.py 2 | ######## 3 | 4 | Always use **python start.py** when you want to send a command to the Unit3Dup 5 | 6 | - Open the console window and navigate to the Unit3Dup folder 7 | 8 | Create and load 9 | ******************** 10 | 11 | - [-scan] If you want to create a torrent for a single file: 12 | 13 | .. code-block:: python 14 | 15 | python start.py -u "C:\Archive\The Movie 01.mkv" 16 | 17 | - [-f] Single folder: 18 | 19 | .. code-block:: python 20 | 21 | python start.py -f "C:\Archive\The Movies" 22 | 23 | 24 | - [-scan] For one or more folders 25 | 26 | .. code-block:: python 27 | 28 | python start.py -scan "C:\Archive" 29 | 30 | 31 | - [-noup] if you want to create torrent but do not upload 32 | 33 | .. code-block:: python 34 | 35 | python start.py -noup -u "C:\Archive\The Movie 01.mkv" 36 | 37 | - [-reseed] If you think you have the file for reseeding an old torrent with zero seeds (dead) 38 | 39 | .. code-block:: python 40 | 41 | python start.py -reseed -u "C:\Archive\The Movie 01.mkv" 42 | python start.py -reseed -f "C:\Archive\The Movie 01" 43 | python start.py -reseed -scan "C:\Archive" 44 | 45 | - [-force] If you want to set the category 46 | 47 | .. code-block:: python 48 | 49 | python start.py -force movie -u "C:\Archive\Highlander.mkv" 50 | python start.py -force tv -u "C:\Archive\Highlander" 51 | python start.py -force game -u "C:\Archive\Highlander" 52 | python start.py -force edicola -u "C:\Archive\Highlander.pdf" 53 | 54 | 55 | - [-noseed] if you don't want to seed the torrent after creating it 56 | 57 | .. code-block:: python 58 | 59 | python start.py -noseed -u "C:\Archive\The Movie 01.mkv" 60 | python start.py -noseed -f "C:\Archive\The Movie 01" 61 | python start.py -noseed -scan "C:\Archive" 62 | 63 | 64 | Search 65 | ******************** 66 | 67 | 68 | - [-s] if you want to perform a search using a word 69 | 70 | .. code-block:: python 71 | 72 | python start.py -s Oblivion 73 | 74 | - [-tmdb] if you want to perform a search using the TMDB ID 75 | 76 | .. code-block:: python 77 | 78 | python start.py -tmdb 8009 79 | python start.py -imdb ... 80 | python start.py -mal ... 81 | python start.py -tvdb ... 82 | 83 | or IMDB MAL TVDB 84 | 85 | - [-res] if you want to perform a search using Resolution 86 | 87 | .. code-block:: python 88 | 89 | python start.py -res 1080p 90 | 91 | - [-tmdb -res] if you want to perform a search using TMDB ID and Resolution 92 | 93 | .. code-block:: python 94 | 95 | python start.py -tmdb 8009 -res 1080p 96 | 97 | - [-up] if you want to perform a search using the username 98 | 99 | .. code-block:: python 100 | 101 | python start.py -up Joi 102 | 103 | 104 | - [-st] if you want to perform a search from the a specific start.date 105 | 106 | .. code-block:: python 107 | 108 | python start.py -st 1999 109 | 110 | 111 | - [-en] if you want to perform a search up to a specific end.date 112 | 113 | .. code-block:: python 114 | 115 | python start.py -en 1999 116 | 117 | 118 | - [-file] if you want to perform a search using the filename 119 | 120 | .. code-block:: python 121 | 122 | python start.py -file "F.s. 1080p H265 Ita Ac3 Eng DTS 5.1 Eng.mkv" 123 | 124 | - [-type] if you want to perform a search using Type 125 | 126 | .. code-block:: python 127 | 128 | python start.py -type remux 129 | 130 | 131 | 132 | working in progress.... :) -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=. 11 | set BUILDDIR=_build 12 | 13 | %SPHINXBUILD% >NUL 2>NUL 14 | if errorlevel 9009 ( 15 | echo. 16 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 17 | echo.installed, then set the SPHINXBUILD environment variable to point 18 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 19 | echo.may add the Sphinx directory to PATH. 20 | echo. 21 | echo.If you don't have Sphinx installed, grab it from 22 | echo.https://www.sphinx-doc.org/ 23 | exit /b 1 24 | ) 25 | 26 | if "%1" == "" goto help 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | sphinx 2 | sphinx_rtd_theme 3 | -------------------------------------------------------------------------------- /docs/watcher.rst: -------------------------------------------------------------------------------- 1 | Flag watcher 2 | ############ 3 | 4 | `-watcher` it reads the contents of a folder and moves them to another destination folder, then uploads everything to the tracker 5 | 6 | How to use watcher 7 | ============================== 8 | 9 | The flag does not accept parameters 10 | 11 | .. code-block:: python 12 | 13 | python start.py -watcher 14 | 15 | The default folders are: 16 | 17 | 'watcher_path' -> The location where you download your files 18 | 19 | 'watcher_destination_path' -> The location where Unit3Dup moves the files and uploads them 20 | 21 | How to configure the watcher 22 | ============================== 23 | 24 | Open the `Unit3D.json` file with a text editor and set the attribute: 25 | 26 | WATCHER_INTERVAL 27 | 28 | Every WATCHER_INTERVAL (seconds), it checks `watcher_path`, then moves everything to `watcher_destination_path`, and uploads it to the tracker 29 | 30 | 31 | How to create a service with the watcher 32 | ======================================== 33 | sudo nano /etc/systemd/system/Unit3Dup.service 34 | 35 | .. code-block:: ini 36 | 37 | [Unit] 38 | Description=Unit3D bot uploader 39 | After=network.target 40 | 41 | [Service] 42 | Type=simple 43 | WorkingDirectory=/home/parzival/Unit3Dup 44 | ExecStart=/usr/bin/python3 /home/parzival/Unit3Dup/start.py -watcher 45 | StandardOutput=file:/home/parzival/Unit3Dup.log 46 | StandardError=file:/home/parzival/Unit3Dup_error.log 47 | User=parzival 48 | 49 | [Install] 50 | WantedBy=multi-user.target 51 | 52 | .. code-block:: ini 53 | 54 | parzival@parivalsrv:~/Unit3Dup$ sudo systemctl daemon-reload 55 | parzival@parivalsrv:~/Unit3Dup$ sudo systemctl enable Unit3Dup.service 56 | parzival@parivalsrv:~/Unit3Dup$ sudo systemctl restart Unit3Dup.service 57 | parzival@parivalsrv:~/Unit3Dup$ sudo systemctl status Unit3Dup.service 58 | ● unit3dupbot.service - Unit3D bot uploader 59 | Loaded: loaded (/etc/systemd/system/Unit3Dup.service; enabled; vendor preset: enabled) 60 | Active: active (running) since Mon 2025-04-28 15:34:29 UTC; 4s ago 61 | Main PID: 3093 (python3) 62 | Tasks: 1 (limit: 9350) 63 | Memory: 46.6M 64 | CPU: 420ms 65 | CGroup: /system.slice/Unit3Dup.service 66 | └─3093 /usr/bin/python3 /home/parzival/Unit3Dup/start.py -watcher 67 | 68 | 69 | -------------------------------------------------------------------------------- /mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | python_version = 3.10 3 | files = common/ 4 | disallow_untyped_defs = True 5 | ignore_missing_imports = True 6 | exclude = ^(tests|build|docs|dist|http_cache|Pw) 7 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=61.0", "wheel"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | dynamic = ["dependencies"] 7 | name = "Unit3Dup" 8 | version = "0.8.13" 9 | description = "An uploader for the Unit3D torrent tracker" 10 | readme = "README.rst" 11 | requires-python = ">=3.10" 12 | license = "MIT" 13 | 14 | authors = [ 15 | { name = "Parzival" } 16 | ] 17 | 18 | [project.urls] 19 | Homepage = "https://github.com/31December99/Unit3Dup" 20 | 21 | 22 | [tool.setuptools.packages.find] 23 | where = ["."] 24 | exclude = ["tests", "docs","http_cache","venv*"] 25 | 26 | [tool.setuptools.dynamic] 27 | dependencies = {file = ["requirements.txt"]} 28 | 29 | 30 | [project.scripts] 31 | unit3dup = "unit3dup.__main__:main" 32 | 33 | 34 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | guessit==3.8.0 2 | pymediainfo==6.1.0 3 | python-qbittorrent==0.4.3 4 | pydantic==2.10.6 5 | requests==2.32.3 6 | rich==13.7.1 7 | torf==4.2.7 8 | tqdm==4.66.5 9 | thefuzz==0.22.1 10 | unidecode==1.3.8 11 | pillow==10.4.0 12 | patool==2.4.0 13 | diskcache==5.6.3 14 | httpx==0.27.2 15 | transmission-rpc==7.0.11 16 | cinemagoer==2023.5.1 17 | pathvalidate==3.2.3 18 | bencode2==0.3.24 19 | rtorrent-rpc==0.9.4 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import argparse 4 | from common.torrent_clients import TransmissionClient, QbittorrentClient 5 | from common.external_services.theMovieDB.core.api import DbOnline 6 | from common.external_services.igdb.client import IGDBClient 7 | from common.trackers.trackers import TRACKData 8 | from common.mediainfo import MediaFile 9 | from common.command import CommandLine 10 | from common.settings import Load 11 | from common.utility import System 12 | 13 | 14 | from unit3dup.media_manager.ContentManager import ContentManager 15 | from unit3dup.media_manager.common import UserContent 16 | from unit3dup.upload import UploadBot 17 | from unit3dup.pvtVideo import Video 18 | from unit3dup.bot import Bot 19 | 20 | 21 | from view import custom_console 22 | 23 | cli = CommandLine() 24 | config = Load.load_config() 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /tests/contents.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import tests 4 | 5 | 6 | 7 | def test_content_manager(): 8 | 9 | test_content_movie = r"C:\test_tmp\Australian Dreams WEB-DL 1080p AC3 E-AC3 ITA SPA SUB-LF.mkv" 10 | cli_scan = tests.argparse.Namespace( 11 | watcher=False, 12 | torrent=False, 13 | duplicate=False, 14 | noseed=False, 15 | tracker=None, 16 | force=False, 17 | notitle=None, 18 | 19 | ) 20 | 21 | tracker_data = tests.TRACKData.load_from_module(tracker_name='ITT') 22 | content_manager = tests.ContentManager(path=test_content_movie, mode='man', cli=cli_scan) 23 | 24 | contents = content_manager.process() 25 | tests.custom_console.bot_warning_log("\n- TVSHOW -") 26 | for content in contents: 27 | 28 | """ TMDB """ 29 | db_online = tests.DbOnline(media=content, category=content.category,no_title=cli_scan.notitle) 30 | result = db_online.media_result 31 | 32 | """ VIDEO INFO """ 33 | video_info = tests.Video(content, tmdb_id=result.video_id, trailer_key=result.trailer_key) 34 | video_info.build_info() 35 | 36 | if content.mediafile: 37 | content.generate_title = content.mediafile.generate(content.guess_title, content.resolution) 38 | tests.custom_console.bot_log(f"FileName {content.file_name}") 39 | tests.custom_console.bot_log(f"Display Name {content.display_name}") 40 | tests.custom_console.bot_log(f"tmdb {result.video_id} imdb:{result.imdb_id if result.imdb_id else 0}") 41 | tests.custom_console.bot_log(f"Generate Name {content.generate_title}") 42 | tests.custom_console.bot_log(f"Category {content.category}") 43 | tests.custom_console.bot_log(f"category_id {tracker_data.category.get(content.category)}") 44 | tests.custom_console.bot_log(f"anonymous {int(tests.config.user_preferences.ANON)}") 45 | tests.custom_console.bot_log(f"sd {video_info.is_hd}") 46 | tests.custom_console.bot_log(f"type_id {tracker_data.filter_type(content.file_name)}") 47 | tests.custom_console.bot_log(f"Folder {content.folder}") 48 | tests.custom_console.bot_log(f"Torrent Name {content.torrent_name}") 49 | tests.custom_console.bot_log(f"AudioLang {content.audio_languages}") 50 | tests.custom_console.bot_log(f"Resolution {content.resolution}") 51 | 52 | resolution_id = tracker_data.resolution[content.screen_size]\ 53 | if content.screen_size else tracker_data.resolution[content.resolution] 54 | tests.custom_console.bot_log(f"Resolution_id {resolution_id}") 55 | tests.custom_console.bot_log(f"season_number {content.guess_season}") 56 | tests.custom_console.bot_log(f"episode_number {content.guess_episode if not content.torrent_pack else 0}") 57 | tests.custom_console.rule() 58 | 59 | assert content 60 | -------------------------------------------------------------------------------- /tests/dbonline.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import tests 4 | from common.external_services.theMovieDB.core.api import DbOnline 5 | 6 | def test_content_manager(): 7 | test_content_movie = r"C:\test_folder_dbonline" 8 | 9 | cli_scan = tests.argparse.Namespace( 10 | watcher=False, 11 | torrent=False, 12 | duplicate=False, 13 | noseed=False, 14 | tracker=None, 15 | force=False, 16 | notitle=None, 17 | ) 18 | content_manager = tests.ContentManager(path=test_content_movie, mode='auto', cli=cli_scan) 19 | 20 | contents = content_manager.process() 21 | assert len(contents) > 0 22 | 23 | tests.custom_console.bot_warning_log("\n- CONTENT -") 24 | for content in contents: 25 | 26 | db_online = DbOnline(media=content, category=content.category,no_title=cli_scan.notitle) 27 | if db:= db_online.media_result: 28 | assert hasattr(db, 'video_id') and hasattr(db, 'keywords_list') and hasattr(db, 'trailer_key') 29 | 30 | tests.custom_console.bot_log(f"Display Name {content.display_name}") 31 | 32 | tests.custom_console.bot_log(f"Video ID {db.video_id}") 33 | assert db.video_id == 41201 34 | 35 | tests.custom_console.bot_log(f"KeyWords {db.keywords_list}") 36 | assert isinstance(db.keywords_list, str) 37 | 38 | tests.custom_console.bot_log(f"Trailer Key {db.trailer_key}") 39 | assert isinstance(db.trailer_key, str) 40 | 41 | tests.custom_console.bot_log(f"IMDB {db.imdb_id}") 42 | assert db.imdb_id == 0 43 | 44 | tests.custom_console.rule() 45 | -------------------------------------------------------------------------------- /tests/faketitle.py: -------------------------------------------------------------------------------- 1 | import os 2 | import random 3 | 4 | 5 | # Fake titles for testing purposes 6 | OUTPUT_DIR = "test_titles" 7 | os.makedirs(OUTPUT_DIR, exist_ok=True) 8 | 9 | 10 | names = ["The Matrix", "Inception", "Breaking Bad", "Il Padrino", "La Casa di Carta"] 11 | years = [str(y) for y in range(1990, 2025)] 12 | episodes = ["S01E01", "S02E05", "S03E10", "S01E01E02", "S04-07", "S01 Extras"] 13 | cuts = ["Director's Cut", "Extended", "Uncut", "Special Edition", "Unrated"] 14 | repack_flags = ["", "REPACK"] 15 | resolutions = ["480p", "576p", "720p", "1080p", "2160p", "4320p"] 16 | editions = ["Remastered", "4K Remaster", "Criterion Collection", "Limited"] 17 | regions = ["Region EUR", "Region USA", "Region JAP"] 18 | three_d = ["", "3D"] 19 | sources = ["Blu-ray", "UHD Blu-ray", "WEB-DL", "WEBRip", "HDTV", "BDRip", "WEBMux", "DVDMux"] 20 | types = ["", "REMUX", "WEB-DL", "WEBRip"] 21 | hi10p = ["", "Hi10P"] 22 | hdrs = ["", "HDR", "HDR10+", "DV", "HLG"] 23 | vcodecs = ["x264", "x265", "AVC", "HEVC"] 24 | dubs = ["ITA", "ENG", "SPA", "GER"] 25 | acodecs = ["DTS", "DTS-HD MA", "AC3", "AAC", "FLAC"] 26 | channels = ["2.0", "5.1", "7.1"] 27 | objects = ["", "Atmos", "Auro3D"] 28 | tags = ["RLSGROUP", "Username", "SceneTeam", "iTTRG"] 29 | 30 | def generate_title(): 31 | parts = [ 32 | random.choice(names), 33 | random.choice(years), 34 | random.choice(episodes), 35 | random.choice(cuts), 36 | random.choice(repack_flags), 37 | random.choice(resolutions), 38 | random.choice(editions), 39 | random.choice(regions), 40 | random.choice(three_d), 41 | random.choice(sources), 42 | random.choice(types), 43 | random.choice(hi10p), 44 | random.choice(hdrs), 45 | random.choice(vcodecs), 46 | random.choice(dubs), 47 | random.choice(acodecs), 48 | random.choice(channels), 49 | random.choice(objects), 50 | random.choice(tags) 51 | ] 52 | return ' '.join([p for p in parts if p]) 53 | 54 | def write_fake_mkv_files(count=10, size_bytes=1024): 55 | for i in range(count): 56 | title = generate_title() 57 | safe_title = title.replace(" ", ".").replace(":", "").replace("/", "-")[:150] # evita problemi filesystem 58 | filename = f"{safe_title}.mkv" 59 | filepath = os.path.join(OUTPUT_DIR, filename) 60 | with open(filepath, "wb") as f: 61 | # dummy 62 | f.write(os.urandom(size_bytes - 4)) 63 | print(f"{count} file .mkv fittizi creati in: {OUTPUT_DIR}/") 64 | 65 | # Esegui 66 | if __name__ == "__main__": 67 | write_fake_mkv_files(count=10, size_bytes=2048) 68 | -------------------------------------------------------------------------------- /tests/game.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from urllib.parse import urlparse 3 | 4 | import tests 5 | from common.external_services.igdb.core.models.search import Game 6 | from common.external_services.igdb.client import IGDBClient 7 | 8 | def validate_url(value: str) -> bool: 9 | """ 10 | Validates URL 11 | """ 12 | parsed_url = urlparse(value) 13 | if not (parsed_url.scheme and parsed_url.netloc) or parsed_url.scheme not in ["http", "https"]: 14 | return False 15 | return True 16 | 17 | 18 | def test_game(): 19 | test_content_movie = r"C:\test_folder_game" 20 | cli_scan = tests.argparse.Namespace( 21 | watcher=False, 22 | torrent=False, 23 | duplicate=False, 24 | noseed=False, 25 | tracker=None, 26 | force=False, 27 | notitle=None, 28 | ) 29 | content_manager = tests.ContentManager(path=test_content_movie, mode='auto', cli=cli_scan) 30 | 31 | contents = content_manager.process() 32 | assert len(contents) > 0 33 | 34 | igdb = IGDBClient() 35 | assert isinstance(igdb, IGDBClient) 36 | 37 | login = igdb.connect() 38 | assert login 39 | 40 | tests.custom_console.bot_warning_log("\n- GAME CONTENT -") 41 | for content in contents: 42 | 43 | game = igdb.game(content=content) 44 | assert isinstance(game, Game) 45 | 46 | tests.custom_console.bot_log(f"Display Game Name {content.display_name}") 47 | assert content.display_name is not None 48 | 49 | tests.custom_console.bot_log(f"Videos {game.videos}") 50 | assert game.videos == [55378, 50928, 53892, 46772] 51 | 52 | tests.custom_console.bot_log(f"Description {game.description}") 53 | assert isinstance(game.description, str) 54 | 55 | tests.custom_console.bot_log(f"Game ID {game.id}") 56 | assert game.id == 144765 57 | 58 | tests.custom_console.bot_log(f"Game Name '{game.name}'") 59 | assert len(game.name) > 0 60 | 61 | tests.custom_console.bot_log(f"Game URL {game.url}") 62 | assert game.url and validate_url(game.url) 63 | 64 | tests.custom_console.bot_log(f"Game Summary {game.summary}") 65 | assert isinstance(game.summary,str) and len(game.summary) > 0 66 | 67 | tests.custom_console.rule() 68 | -------------------------------------------------------------------------------- /tests/movieshow.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import pytest 3 | import tests 4 | 5 | for tracker in tests.config.tracker_config.MULTI_TRACKER: 6 | tracker_data = tests.TRACKData.load_from_module(tracker) 7 | assert isinstance(tracker_data.category, dict) 8 | 9 | 10 | def test_tmdb(): 11 | 12 | cli_scan = tests.argparse.Namespace( 13 | watcher=True, 14 | torrent=False, 15 | duplicate=False, 16 | tracker=None, 17 | force=False, 18 | noup=False, 19 | noseed=False, 20 | mt=False, 21 | notitle=None, 22 | 23 | ) 24 | 25 | test_content_movie = r"C:\test_folder" 26 | content_manager = tests.ContentManager(path=test_content_movie, mode='auto', cli=cli_scan) 27 | contents = content_manager.process() 28 | 29 | TRACKER_TEST = 'ITT' 30 | tracker_data = tests.TRACKData.load_from_module(TRACKER_TEST) 31 | 32 | # // Print list 33 | for item in contents: 34 | tests.custom_console.bot_warning_log(f"{item.title}, '{item.category}'") 35 | 36 | input("Press Enter to continue...") 37 | 38 | for content in contents: 39 | 40 | """ DUPLICATE """ 41 | assert isinstance(tests.UserContent.is_duplicate(content=content, tracker_name=TRACKER_TEST),bool) 42 | 43 | """ VIDEO INFO """ 44 | # TMDB 45 | tests.custom_console.bot_log(f"FileName = {content.file_name}") 46 | db_online = tests.DbOnline(media=content, category=content.category) 47 | if db:= db_online.media_result: 48 | assert hasattr(db, 'video_id') and hasattr(db, 'keywords_list') and hasattr(db, 'trailer_key') 49 | 50 | video_info = tests.Video(content.file_name, tmdb_id=db.video_id, trailer_key=db.trailer_key) 51 | video_info.build_info() 52 | assert video_info.video_frames 53 | assert video_info.mediainfo is not None 54 | 55 | if media_info:= tests.MediaFile(content.file_name): 56 | assert all(value is not None for value in vars(media_info).values()) 57 | 58 | # """ TRACKER DATA""" 59 | tests.custom_console.bot_log(f"name = {content.display_name}") 60 | tests.custom_console.bot_log(f"tmdb = {db.video_id}") 61 | tests.custom_console.bot_log(f"keywords = {db.keywords_list}") 62 | tests.custom_console.bot_log(f"category_id = {content.category}") 63 | resolution_id = tracker_data.resolution[content.screen_size]\ 64 | if content.screen_size else tracker_data.resolution[content.resolution] 65 | tests.custom_console.bot_log(f"resolution_id = {resolution_id}") 66 | # tests.custom_console.bot_log(f"mediainfo = {video_info.mediainfo}") 67 | tests.custom_console.bot_log(f"description = {video_info.description}") 68 | tests.custom_console.bot_log(f"sd = {video_info.is_hd}") 69 | tests.custom_console.bot_log(f"type_id = {tracker_data.filter_type(content.file_name)}") 70 | tests.custom_console.bot_log(f"season_number = {content.guess_season}") 71 | tests.custom_console.bot_log(f"episode_number = {content.guess_episode if not content.torrent_pack else 0}") 72 | 73 | """ TORRENT INFO """ 74 | tracker_name_list = [] 75 | if torrent_response:=tests.UserContent.torrent(content=content, trackers=tracker_name_list): 76 | assert all(value is not None for value in vars(torrent_response).values()) 77 | 78 | """ UPLOAD """ 79 | # Tracker Bot 80 | unit3d_up = tests.UploadBot(content=content, tracker_name=TRACKER_TEST) 81 | 82 | 83 | # Send data to the tracker 84 | tracker_response, tracker_message = unit3d_up.send(show_id=db.video_id, imdb_id = db.imdb_id, 85 | show_keywords_list=db.keywords_list, 86 | video_info=video_info) 87 | 88 | 89 | tests.custom_console.bot_log(f"TRACKER RESPONSE {tracker_response}") 90 | 91 | if not tracker_response: 92 | tests.custom_console.bot_error_log(f"NO TRACKER RESPONSE {tracker_message}") 93 | input("Press Enter to continue...") 94 | continue 95 | 96 | """ TRANSMISSION """ 97 | transmission = tests.TransmissionClient() 98 | transmission.connect() 99 | transmission.send_to_client( 100 | tracker_data_response=tracker_response, 101 | torrent=torrent_response, 102 | content=content 103 | ) 104 | 105 | """ QBITTORRENT """ 106 | qbittorrent = tests.QbittorrentClient() 107 | qbittorrent.connect() 108 | qbittorrent.send_to_client( 109 | tracker_data_response=tracker_response, 110 | torrent=torrent_response, 111 | content=content 112 | ) 113 | tests.custom_console.bot_log("Done.") 114 | tests.custom_console.rule() 115 | -------------------------------------------------------------------------------- /tests/video.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import tests 4 | from common.external_services.theMovieDB.core.api import DbOnline 5 | from unit3dup.pvtVideo import Video 6 | from common.trackers.itt import itt_data 7 | from view import custom_console 8 | 9 | 10 | for tracker in tests.config.tracker_config.MULTI_TRACKER: 11 | tracker_data = tests.TRACKData.load_from_module(tracker) 12 | assert isinstance(tracker_data.category, dict) 13 | 14 | 15 | def get_type_id(value): 16 | # get key based on value 17 | for itt in itt_data['TYPE_ID'].items(): 18 | if itt[1] == value: 19 | return itt[0] 20 | 21 | def get_category(value): 22 | # get key based on value 23 | for itt in itt_data['CATEGORY'].items(): 24 | if itt[1] == value: 25 | return itt[0] 26 | 27 | def get_tags(value): 28 | # get key based on value 29 | for itt in itt_data['TAGS'].items(): 30 | if itt[1] == value: 31 | print(itt[0]) 32 | return itt[0] 33 | 34 | 35 | def test_content_manager(): 36 | test_content_movie = r"C:\vm_share\Australian.dreams.2019.WEB-DL.1080p.EAC3.2.0.ITA.x264-ZioRip.mkv" 37 | cli_scan = tests.argparse.Namespace( 38 | watcher=False, 39 | torrent=False, 40 | duplicate=False, 41 | noseed=False, 42 | tracker=None, 43 | force=False, 44 | notitle=None, 45 | ) 46 | content_manager = tests.ContentManager(path=test_content_movie, mode='man', cli=cli_scan) 47 | 48 | # Load the constant tracker 49 | custom_console.bot_log(f"Available trackers {tests.config.tracker_config.MULTI_TRACKER}") 50 | tracker_data = tests.TRACKData.load_from_module(tracker_name=tests.config.tracker_config.MULTI_TRACKER[0]) 51 | 52 | contents = content_manager.process() 53 | tests.custom_console.bot_warning_log("\n- CONTENT -") 54 | for content in contents: 55 | 56 | db_online = DbOnline(media=content, category=content.category,no_title=cli_scan.notitle) 57 | if db:= db_online.media_result: 58 | assert hasattr(db, 'video_id') and hasattr(db, 'keywords_list') and hasattr(db, 'trailer_key') 59 | 60 | # not found in TMDB 61 | assert db.video_id == 1451126 62 | assert db.keywords_list != [] 63 | assert db.trailer_key is not None 64 | # SET imdb when tmdb is not available 65 | assert db.imdb_id == 0 66 | 67 | """ VIDEO INFO """ 68 | video_info = Video(content, tmdb_id=db.video_id, trailer_key=db.trailer_key) 69 | video_info.build_info() 70 | 71 | assert video_info.mediainfo is not None 72 | 73 | assert video_info.description is not None 74 | 75 | tests.custom_console.bot_log(f"Display Name {content.display_name}") 76 | assert content.display_name == "Australian dreams 2019 WEB-DL 1080p EAC3 2 0 ITA x264-ZioRip" 77 | 78 | tests.custom_console.bot_log(f"IS HD {video_info.is_hd}") 79 | assert get_tags(video_info.is_hd) == 'HD' 80 | 81 | tests.custom_console.bot_log(f"Category {content.category}") 82 | assert content.category == 'movie' 83 | 84 | tests.custom_console.bot_log(f"{tracker_data.resolution[content.screen_size] if content.screen_size else tracker_data.resolution[content.resolution]}") 85 | assert tracker_data.resolution[content.screen_size] if content.screen_size else tracker_data.resolution[content.resolution] == '3' 86 | 87 | tests.custom_console.bot_log(f"Type_id {get_type_id(tracker_data.filter_type(content.file_name))}") 88 | assert get_type_id(tracker_data.filter_type(content.file_name)) == 'web-dl' 89 | 90 | tests.custom_console.bot_log(f"Season {content.guess_season}") 91 | assert not content.guess_season 92 | 93 | tests.custom_console.bot_log(f"Episode {content.guess_episode}") 94 | assert not content.guess_episode 95 | 96 | tests.custom_console.rule() 97 | -------------------------------------------------------------------------------- /tests/watcher.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import tests 4 | # /* ----------------------------------------------------------------------------------------------- */ 5 | force_media = 0 6 | 7 | def test_cli_watcher(): 8 | cli_scan = tests.argparse.Namespace( 9 | watcher=True, 10 | torrent=False, 11 | duplicate=False, 12 | tracker=None, 13 | force=False, 14 | noup=False, 15 | noseed=False, 16 | cross=False, 17 | upload=False, 18 | mt=False, 19 | notitle=None, 20 | reseed=False, 21 | ) 22 | 23 | tests.cli.args = cli_scan 24 | bot = tests.Bot( 25 | path=r"", # /**/ 26 | cli=tests.cli.args, 27 | mode="auto", 28 | trackers_name_list= ['ITT'] 29 | ) 30 | assert bot.watcher(duration=tests.config.user_preferences.WATCHER_INTERVAL, 31 | watcher_path=tests.config.user_preferences.WATCHER_PATH, 32 | destination_path=tests.config.user_preferences.WATCHER_DESTINATION_PATH) == True 33 | 34 | -------------------------------------------------------------------------------- /unit3dup/__init__.py: -------------------------------------------------------------------------------- 1 | from common.settings import Load 2 | 3 | config_settings = Load.load_config() 4 | 5 | 6 | -------------------------------------------------------------------------------- /unit3dup/automode.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import os 4 | from common.utility import ManageTitles 5 | from view import custom_console 6 | 7 | from unit3dup.media import Media 8 | 9 | class Auto: 10 | """ 11 | A class for managing and processing video files and directories based on a given mode 12 | """ 13 | 14 | def __init__(self, path: str, mode="auto"): 15 | """ 16 | Initialize the Auto instance with path, tracker configuration, and mode. 17 | 18 | Args: 19 | path (str): The path to the directory or file to be processed. 20 | mode (str): The mode of operation, either 'auto', 'man', or 'folder'. Default is 'auto'. 21 | """ 22 | 23 | self.series = None 24 | self.movies = None 25 | self.path = path 26 | self.is_dir = os.path.isdir(self.path) 27 | self.auto = mode 28 | 29 | def upload(self): 30 | """ 31 | Handles the upload process based on the specified mode. 32 | 33 | If the path is a directory, it processes video files and directories according to the mode: 34 | - 'man': Single file or scan each file in the folder 35 | - 'folder': Single folder series or 'saga' 36 | If the path is not a directory, it processes the path as a single file 37 | """ 38 | if self.is_dir: 39 | series_path = self.list_video_files(self.path) 40 | 41 | if self.auto == "man": 42 | # -u command (single file or scan each file in the folder) 43 | return self._lists(files_path=[], subfolders_path=series_path) 44 | 45 | if self.auto == "folder": 46 | # -f command (single folder series or 'saga') 47 | return self._lists(files_path=[], subfolders_path=[self.path]) 48 | else: 49 | return self._lists(files_path=[self.path], subfolders_path=[]) 50 | 51 | def scan(self): 52 | """ 53 | Scans the directory for video files and subdirectories. 54 | 55 | If the path is a file, logs an error since scanning requires a folder. Otherwise, scans 56 | the folder and subfolders, sorting files and subdirectories, and processes them 57 | """ 58 | files_path = [] 59 | subfolders_path = [] 60 | 61 | if not self.is_dir: 62 | custom_console.bot_error_log("We can't scan a file.") 63 | else: 64 | # Scan folder and subfolders 65 | for path, sub_dirs, files in os.walk(self.path): 66 | # Sort subdirs 67 | sub_dirs.sort(reverse=False) 68 | # Sort files 69 | files.sort(reverse=False) 70 | 71 | # Get the files path from the self.path 72 | if path == self.path: 73 | files_path = [ 74 | os.path.join(self.path, file) 75 | for file in files 76 | if ManageTitles.filter_ext(file) 77 | ] 78 | # Get the subfolders path from the self.path 79 | if sub_dirs: 80 | # Maximum level of subfolder depth = 1 81 | if self.depth_walker(path) < 1: 82 | subfolders_path = [ 83 | os.path.join(self.path, subdir) for subdir in sub_dirs 84 | ] 85 | return self._lists(files_path=files_path, subfolders_path=subfolders_path) 86 | 87 | def _lists(self, files_path: list, subfolders_path: list): 88 | 89 | return [ 90 | result 91 | for media_path in files_path + subfolders_path 92 | if (result := Media(folder=self.path,subfolder=media_path)) is not None 93 | ] 94 | 95 | 96 | def depth_walker(self, path) -> int: 97 | """ 98 | Calculates the depth of a given path relative to the base path. 99 | 100 | Args: 101 | path (str): The path to evaluate. 102 | 103 | Returns: 104 | int: The depth level of the path, where depth < 1 indicates a maximum of one level of subfolders 105 | """ 106 | return path[len(self.path):].count(os.sep) 107 | 108 | @staticmethod 109 | def list_video_files(manual_path: str) -> list: 110 | """ 111 | Lists all video files in a given directory based on their extension 112 | 113 | Args: 114 | manual_path (str): The directory path to scan for video files 115 | 116 | Returns: 117 | list: A list of video files in the directory that match the video file extensions 118 | """ 119 | return [ 120 | file for file in os.listdir(manual_path) if ManageTitles.filter_ext(file) 121 | ] 122 | -------------------------------------------------------------------------------- /unit3dup/exceptions.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from functools import wraps 3 | from typing import Callable, Any 4 | from view import custom_console 5 | 6 | 7 | class Unit3DError(Exception): 8 | """Base class for all exceptions raised by the Unit3D API package""" 9 | 10 | def __init__(self, message: str): 11 | super().__init__(message) 12 | self.message = message 13 | 14 | 15 | class Unit3DBadRequestError(Unit3DError): 16 | """Exception raised for bad requests to the Unit3D API.""" 17 | 18 | def __init__(self, message: str = "Bad request"): 19 | super().__init__(message) 20 | 21 | 22 | class Unit3DAuthError(Unit3DError): 23 | """Exception raised for authentication errors with the Unit3D API.""" 24 | 25 | def __init__(self, message: str = "Authentication failed"): 26 | super().__init__(message) 27 | 28 | 29 | class Unit3DForbiddenError(Unit3DError): 30 | """Exception raised for forbidden access to resources.""" 31 | 32 | def __init__(self, message: str = "Access forbidden"): 33 | super().__init__(message) 34 | 35 | 36 | class Unit3DNotFoundError(Unit3DError): 37 | """Exception raised when a requested resource is not found.""" 38 | 39 | def __init__(self, message: str = "Resource not found"): 40 | super().__init__(message) 41 | 42 | 43 | class Unit3DConflictError(Unit3DError): 44 | """Exception raised for conflicts in the request.""" 45 | 46 | def __init__(self, message: str = "Request conflict"): 47 | super().__init__(message) 48 | 49 | 50 | class Unit3DRateLimitError(Unit3DError): 51 | """Exception raised when the Unit3D API rate limit is exceeded.""" 52 | 53 | def __init__(self, message: str = "Rate limit exceeded"): 54 | super().__init__(message) 55 | 56 | 57 | class Unit3DServerError(Unit3DError): 58 | """Exception raised for internal server errors.""" 59 | 60 | def __init__(self, message: str = "Internal server error"): 61 | super().__init__(message) 62 | 63 | 64 | class Unit3DServiceUnavailableError(Unit3DError): 65 | """Exception raised when the service is unavailable.""" 66 | 67 | def __init__(self, message: str = "Service unavailable"): 68 | super().__init__(message) 69 | 70 | 71 | class Unit3DRequestError(Unit3DError): 72 | """Exception raised for general request errors.""" 73 | 74 | def __init__(self, status_code: int, message: str = "Request failed"): 75 | super().__init__(message) 76 | self.status_code = status_code 77 | 78 | def __str__(self): 79 | return f"Unit3DRequestError: {self.message} (status code: {self.status_code})" 80 | 81 | 82 | class BotConfigError(Unit3DError): 83 | """Exception raised for general request errors.""" 84 | 85 | def __init__(self, message: str = "Loading failed"): 86 | super().__init__(message) 87 | 88 | def __str__(self): 89 | return f"BotConfigError: {self.message}" 90 | 91 | 92 | def exception_handler(func: Callable[..., Any]) -> Callable[..., Any]: 93 | @wraps(func) 94 | def wrapper(*args, **kwargs): 95 | try: 96 | return func(*args, **kwargs) 97 | except Unit3DBadRequestError as e: 98 | custom_console.bot_error_log(f"Bad Request Error: {e}") 99 | except Unit3DAuthError as e: 100 | custom_console.bot_error_log(f"Authentication Error: {e}") 101 | except Unit3DForbiddenError as e: 102 | custom_console.bot_error_log(f"Forbidden Error: {e}") 103 | except Unit3DNotFoundError as e: 104 | custom_console.bot_error_log(f"Not Found Error: {e}") 105 | except Unit3DConflictError as e: 106 | custom_console.bot_error_log(f"Conflict Error: {e}") 107 | except Unit3DRateLimitError as e: 108 | custom_console.bot_error_log(f"Rate Limit Error: {e}") 109 | except Unit3DServerError as e: 110 | custom_console.bot_error_log(f"Server Error: {e}") 111 | except Unit3DServiceUnavailableError as e: 112 | custom_console.bot_error_log(f"Service Unavailable Error: {e}") 113 | except Unit3DRequestError as e: 114 | custom_console.bot_error_log(f"Request Error: {e}") 115 | except BotConfigError as e: 116 | custom_console.bot_error_log(f" {e}") 117 | exit(1) 118 | 119 | except Exception as e: 120 | custom_console.bot_error_log(f"An unexpected error occurred: '{e}'") 121 | 122 | return wrapper 123 | -------------------------------------------------------------------------------- /unit3dup/media_manager/DocuManager.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import argparse 3 | import os 4 | 5 | from common.bittorrent import BittorrentData 6 | 7 | from unit3dup.media_manager.common import UserContent 8 | from unit3dup.pvtDocu import PdfImages 9 | from unit3dup.upload import UploadBot 10 | from unit3dup import config_settings 11 | from unit3dup.media import Media 12 | 13 | from view import custom_console 14 | 15 | class DocuManager: 16 | 17 | def __init__(self, contents: list[Media], cli: argparse.Namespace): 18 | self._my_tmdb = None 19 | self.contents: list['Media'] = contents 20 | self.cli: argparse = cli 21 | 22 | def process(self, selected_tracker: str, tracker_name_list: list, tracker_archive: str) -> list[BittorrentData]: 23 | 24 | # -multi : no announce_list . One announce for multi tracker 25 | if self.cli.mt: 26 | tracker_name_list = [selected_tracker.upper()] 27 | 28 | # Init the torrent list 29 | bittorrent_list = [] 30 | for content in self.contents: 31 | # get the archive path 32 | archive = os.path.join(tracker_archive, selected_tracker) 33 | os.makedirs(archive, exist_ok=True) 34 | torrent_filepath = os.path.join(tracker_archive,selected_tracker, f"{content.torrent_name}.torrent") 35 | 36 | if self.cli.watcher: 37 | if os.path.exists(content.torrent_path): 38 | custom_console.bot_log(f"Watcher Active.. skip the old upload '{content.file_name}'") 39 | continue 40 | 41 | torrent_response = UserContent.torrent(content=content, tracker_name_list=tracker_name_list, 42 | selected_tracker=selected_tracker, this_path=torrent_filepath) 43 | 44 | # Skip if it is a duplicate 45 | if ((self.cli.duplicate or config_settings.user_preferences.DUPLICATE_ON) 46 | and UserContent.is_duplicate(content=content, tracker_name=selected_tracker, cli=self.cli)): 47 | continue 48 | 49 | # print the title will be shown on the torrent page 50 | custom_console.bot_log(f"'DISPLAYNAME'...{{{content.display_name}}}\n") 51 | 52 | # Don't upload if -noup is set to True 53 | if self.cli.noup: 54 | custom_console.bot_warning_log(f"No Upload active. Done.") 55 | continue 56 | 57 | # Get the cover image 58 | docu_info = PdfImages(content.file_name) 59 | docu_info.build_info() 60 | 61 | 62 | # Tracker payload 63 | unit3d_up = UploadBot(content=content, tracker_name=selected_tracker, cli = self.cli) 64 | 65 | # Upload 66 | unit3d_up.data_docu(document_info=docu_info) 67 | 68 | # Get the data 69 | tracker_response, tracker_message = unit3d_up.send(torrent_archive=torrent_filepath) 70 | 71 | bittorrent_list.append( 72 | BittorrentData( 73 | tracker_response=tracker_response, 74 | torrent_response=torrent_response, 75 | content=content, 76 | tracker_message=tracker_message, 77 | archive_path=torrent_filepath, 78 | )) 79 | 80 | return bittorrent_list -------------------------------------------------------------------------------- /unit3dup/media_manager/GameManager.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import argparse 3 | import os 4 | 5 | from common.external_services.igdb.client import IGDBClient 6 | from common.bittorrent import BittorrentData 7 | 8 | from unit3dup.media_manager.common import UserContent 9 | from unit3dup.upload import UploadBot 10 | from unit3dup import config_settings 11 | from unit3dup.media import Media 12 | 13 | from view import custom_console 14 | 15 | class GameManager: 16 | 17 | def __init__(self, contents: list["Media"], cli: argparse.Namespace): 18 | """ 19 | Initialize the GameManager with the given contents 20 | 21 | Args: 22 | contents (list): List of content media objects 23 | cli (argparse.Namespace): user flag Command line 24 | """ 25 | self.contents: list[Media] = contents 26 | self.cli: argparse = cli 27 | self.igdb = IGDBClient() 28 | 29 | def process(self, selected_tracker: str, tracker_name_list: list, tracker_archive: str) -> list[BittorrentData]: 30 | """ 31 | Process the game contents to filter duplicates and create torrents 32 | 33 | Returns: 34 | list: List of Bittorrent objects created for each content 35 | """ 36 | 37 | login = self.igdb.connect() 38 | if not login: 39 | exit(1) 40 | 41 | # -multi : no announce_list . One announce for multi tracker 42 | if self.cli.mt: 43 | tracker_name_list = [selected_tracker.upper()] 44 | 45 | if self.cli.upload: 46 | custom_console.bot_error_log("Game upload works only with the '-f' flag.You need to specify a folder name.") 47 | return [] 48 | 49 | 50 | # Init the torrent list 51 | bittorrent_list = [] 52 | for content in self.contents: 53 | # get the archive path 54 | archive = os.path.join(tracker_archive, selected_tracker) 55 | os.makedirs(archive, exist_ok=True) 56 | torrent_filepath = os.path.join(tracker_archive,selected_tracker, f"{content.torrent_name}.torrent") 57 | 58 | # Filter contents based on existing torrents or duplicates 59 | if self.cli.watcher: 60 | if os.path.exists(content.torrent_path): 61 | custom_console.bot_log(f"Watcher Active.. skip the old upload '{content.file_name}'") 62 | continue 63 | 64 | torrent_response = UserContent.torrent(content=content, tracker_name_list=tracker_name_list, 65 | selected_tracker=selected_tracker, this_path=torrent_filepath) 66 | 67 | 68 | # Skip if it is a duplicate 69 | if ((self.cli.duplicate or config_settings.user_preferences.DUPLICATE_ON) 70 | and UserContent.is_duplicate(content=content, tracker_name=selected_tracker, cli=self.cli)): 71 | continue 72 | 73 | # Search for the game on IGDB using the content's title and platform tags 74 | game_data_results = self.igdb.game(content=content) 75 | # print the title will be shown on the torrent page 76 | custom_console.bot_log(f"'DISPLAYNAME'...{{{content.display_name}}}\n") 77 | 78 | # Skip the upload if there is no valid IGDB 79 | if not game_data_results: 80 | continue 81 | 82 | # Tracker instance 83 | unit3d_up = UploadBot(content=content, tracker_name=selected_tracker, cli = self.cli) 84 | 85 | # Get the data 86 | unit3d_up.data_game(igdb=game_data_results) 87 | 88 | # Don't upload if -noup is set to True 89 | if self.cli.noup: 90 | custom_console.bot_warning_log(f"No Upload active. Done.") 91 | continue 92 | 93 | # Send to the tracker 94 | tracker_response, tracker_message = unit3d_up.send(torrent_archive=torrent_filepath, nfo_path=content.game_nfo) 95 | 96 | bittorrent_list.append( 97 | BittorrentData( 98 | tracker_response=tracker_response, 99 | torrent_response=torrent_response, 100 | content=content, 101 | tracker_message=tracker_message, 102 | archive_path = torrent_filepath, 103 | )) 104 | return bittorrent_list 105 | 106 | -------------------------------------------------------------------------------- /unit3dup/media_manager/MediaInfoManager.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from typing import Optional 3 | 4 | from common.mediainfo_string import MediaInfo 5 | from common.bdinfo_string import BDInfo 6 | 7 | 8 | class MediaInfoManager: 9 | 10 | def __init__(self, media_info_output: dict): 11 | 12 | self.languages = 'n/a' 13 | 14 | if media_info_output['media_info']: 15 | self.parser = MediaInfo(media_info=media_info_output['media_info']) 16 | self.audio = self.parser.get_audio_formats() 17 | audio_languages = [audio.language.lower() for audio in self.audio if audio.language] if self.audio else [] 18 | self.languages = ','.join(set(audio_languages)) if audio_languages else 'n/a' 19 | 20 | if media_info_output['bd_info']: 21 | self.parser = BDInfo.from_bdinfo_string(media_info_output['bd_info']) 22 | audio_languages = self.parser.languages 23 | self.languages = ','.join(set(audio_languages)) if audio_languages else 'n/a' 24 | 25 | def search_language(self, language: str) -> Optional[bool]: 26 | return language.lower() in self.languages.lower() if self.languages else None 27 | -------------------------------------------------------------------------------- /unit3dup/media_manager/SeedManager.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import argparse 4 | import os 5 | 6 | from common.external_services.theMovieDB.core.api import DbOnline 7 | from common.bittorrent import BittorrentData 8 | 9 | from unit3dup.media_manager.common import UserContent 10 | from unit3dup.media import Media 11 | 12 | class SeedManager: 13 | def __init__(self, contents: list[Media], cli: argparse.Namespace): 14 | 15 | self.contents = contents 16 | # Command line 17 | self.cli = cli 18 | 19 | def process(self, selected_tracker: str, trackers_name_list: list, tracker_archive: str) -> list[BittorrentData] | None: 20 | 21 | # Data list for the torrent client 22 | bittorrent_list = [] 23 | 24 | # Iterate user content 25 | if self.contents: 26 | for content in self.contents: 27 | # get the archive path 28 | archive = os.path.join(tracker_archive, selected_tracker) 29 | # Build the path for downloading 30 | os.makedirs(archive, exist_ok=True) 31 | torrent_filepath = os.path.join(tracker_archive, selected_tracker, f"{content.torrent_name}.torrent") 32 | # Search for tmdb ID 33 | db_online = DbOnline(media=content, category=content.category, no_title=self.cli.notitle) 34 | db = db_online.media_result 35 | 36 | torrents = UserContent.can_ressed(content=content, tracker_name=selected_tracker,cli=self.cli, 37 | tmdb_id=db.video_id) 38 | 39 | for t in torrents: 40 | bittorrent_list.append(BittorrentData( 41 | tracker_response=t['attributes']['download_link'], 42 | torrent_response=None, 43 | content=content, 44 | tracker_message={}, 45 | archive_path=torrent_filepath, 46 | )) 47 | 48 | return bittorrent_list 49 | return None 50 | -------------------------------------------------------------------------------- /unit3dup/media_manager/TorrentManager.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import argparse 4 | 5 | import requests 6 | 7 | from unit3dup.media_manager.VideoManager import VideoManager 8 | from unit3dup.media_manager.GameManager import GameManager 9 | from unit3dup.media_manager.DocuManager import DocuManager 10 | from unit3dup.media_manager.SeedManager import SeedManager 11 | 12 | from unit3dup import config_settings 13 | from unit3dup.media import Media 14 | 15 | from common.bittorrent import BittorrentData 16 | from common.constants import my_language 17 | from common.utility import System 18 | 19 | from unit3dup.media_manager.common import UserContent 20 | from view import custom_console 21 | 22 | 23 | class TorrentManager: 24 | def __init__(self, cli: argparse.Namespace, tracker_archive: str): 25 | 26 | self.preferred_lang = my_language(config_settings.user_preferences.PREFERRED_LANG) 27 | self.tracker_archive = tracker_archive 28 | self.videos: list[Media] = [] 29 | self.games: list[Media] = [] 30 | self.doc: list[Media] = [] 31 | self.cli = cli 32 | self.fast_load = config_settings.user_preferences.FAST_LOAD 33 | if self.fast_load < 1 or self.fast_load > 150: 34 | # full list 35 | self.fast_load = None 36 | 37 | 38 | def process(self, contents: list) -> None: 39 | """ 40 | Send content to each selected tracker with the trackers_name_list. 41 | trackers_name_list can be a list of tracker names or the current tracker for the upload process 42 | 43 | Args: 44 | contents: torrent contents 45 | Returns: 46 | NOne 47 | """ 48 | # // Build a GAME list 49 | self.games = [ 50 | content for content in contents if content.category == System.category_list.get(System.GAME) 51 | ] 52 | 53 | if self.games: 54 | if 'no_key' in config_settings.tracker_config.IGDB_CLIENT_ID: 55 | custom_console.bot_warning_log("Skipping game upload, no IGDB credentials provided") 56 | self.games = [] 57 | 58 | # // Build a VIDEO list 59 | self.videos = [ 60 | content 61 | for content in contents 62 | if content.category in {System.category_list.get(System.MOVIE), System.category_list.get(System.TV_SHOW)} 63 | ] 64 | 65 | 66 | # // Build a Doc list 67 | self.doc = [ 68 | content for content in contents if content.category == System.category_list.get(System.DOCUMENTARY) 69 | ] 70 | 71 | def run(self, trackers_name_list: list): 72 | """ 73 | 74 | Args: 75 | trackers_name_list: list of tracker names to update the torrent file ( -cross or -tracker) 76 | Returns: 77 | 78 | """ 79 | 80 | game_process_results: list[BittorrentData] = [] 81 | video_process_results: list[BittorrentData] = [] 82 | docu_process_results: list[BittorrentData] = [] 83 | 84 | for selected_tracker in trackers_name_list: 85 | # Build the torrent file and upload each GAME to the tracker 86 | if self.games: 87 | game_manager = GameManager(contents=self.games[:self.fast_load], 88 | cli=self.cli) 89 | game_process_results = game_manager.process(selected_tracker=selected_tracker, 90 | tracker_name_list=trackers_name_list, 91 | tracker_archive=self.tracker_archive) 92 | 93 | # Build the torrent file and upload each VIDEO to the trackers 94 | if self.videos: 95 | video_manager = VideoManager(contents=self.videos[:self.fast_load], 96 | cli=self.cli) 97 | video_process_results = video_manager.process(selected_tracker=selected_tracker, 98 | tracker_name_list=trackers_name_list, 99 | tracker_archive=self.tracker_archive) 100 | 101 | # Build the torrent file and upload each DOC to the tracker 102 | if self.doc and not self.cli.reseed: 103 | docu_manager = DocuManager(contents=self.doc[:self.fast_load], 104 | cli=self.cli) 105 | docu_process_results = docu_manager.process(selected_tracker=selected_tracker, 106 | tracker_name_list=trackers_name_list, 107 | tracker_archive=self.tracker_archive) 108 | 109 | # No seeding 110 | if self.cli.noseed or self.cli.noup: 111 | custom_console.bot_warning_log(f"No seeding active. Done.") 112 | custom_console.rule() 113 | continue 114 | 115 | 116 | if game_process_results: 117 | UserContent.send_to_bittorrent(game_process_results, 'GAME') 118 | 119 | if video_process_results: 120 | UserContent.send_to_bittorrent(video_process_results, 'VIDEO') 121 | 122 | if docu_process_results: 123 | UserContent.send_to_bittorrent(docu_process_results, 'DOCUMENTARY') 124 | custom_console.bot_log(f"Tracker '{selected_tracker}' Done.") 125 | custom_console.rule() 126 | 127 | custom_console.bot_log(f"Done.") 128 | custom_console.rule() 129 | 130 | def reseed(self, trackers_name_list: list) -> None: 131 | """ 132 | 133 | Reseed : compare local file with remote tracker file. Download if found 134 | 135 | Args: 136 | trackers_name_list: list of tracker names 137 | Returns: 138 | 139 | """ 140 | 141 | for selected_tracker in trackers_name_list: 142 | # From the contents 143 | if self.videos: 144 | # Instance 145 | seed_manager = SeedManager(contents=self.videos, cli=self.cli) 146 | # Search your content to see if there is a title present in the tracker 147 | seed_manager_results = seed_manager.process(selected_tracker=selected_tracker, 148 | trackers_name_list=trackers_name_list, 149 | tracker_archive=self.tracker_archive) 150 | 151 | #if so download the torrent files from the tracker for seeding 152 | if seed_manager_results: 153 | for result in seed_manager_results: 154 | UserContent.download_file(url=result.tracker_response, destination_path=result.archive_path) 155 | # Send the data to the torrent client 156 | UserContent.send_to_bittorrent([result], 'VIDEO') 157 | -------------------------------------------------------------------------------- /unit3dup/media_manager/VideoManager.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import argparse 3 | import os 4 | 5 | from common.external_services.theMovieDB.core.api import DbOnline 6 | from common.bittorrent import BittorrentData 7 | 8 | from unit3dup.media_manager.common import UserContent 9 | from unit3dup.upload import UploadBot 10 | from unit3dup import config_settings 11 | from unit3dup.pvtVideo import Video 12 | from unit3dup.media import Media 13 | 14 | from view import custom_console 15 | 16 | class VideoManager: 17 | 18 | def __init__(self, contents: list[Media], cli: argparse.Namespace): 19 | """ 20 | Initialize the VideoManager with the given contents 21 | 22 | Args: 23 | contents (list): List of content media objects 24 | cli (argparse.Namespace): user flag Command line 25 | """ 26 | 27 | self.torrent_found:bool = False 28 | self.contents: list[Media] = contents 29 | self.cli: argparse = cli 30 | 31 | def process(self, selected_tracker: str, tracker_name_list: list, tracker_archive: str) -> list[BittorrentData]: 32 | """ 33 | Process the video contents to filter duplicates and create torrents 34 | 35 | Returns: 36 | list: List of Bittorrent objects created for each content 37 | """ 38 | 39 | # -multi : no announce_list . One announce for multi tracker 40 | if self.cli.mt: 41 | tracker_name_list = [selected_tracker.upper()] 42 | 43 | # Init the torrent list 44 | bittorrent_list = [] 45 | for content in self.contents : 46 | 47 | # get the archive path 48 | archive = os.path.join(tracker_archive, selected_tracker) 49 | os.makedirs(archive, exist_ok=True) 50 | torrent_filepath = os.path.join(tracker_archive,selected_tracker, f"{content.torrent_name}.torrent") 51 | 52 | # Filter contents based on existing torrents or duplicates 53 | if UserContent.is_preferred_language(content=content): 54 | 55 | if self.cli.watcher: 56 | if os.path.exists(torrent_filepath): 57 | custom_console.bot_log(f"Watcher Active.. skip the old upload '{content.file_name}'") 58 | continue 59 | 60 | torrent_response = UserContent.torrent(content=content, tracker_name_list=tracker_name_list, 61 | selected_tracker=selected_tracker, this_path=torrent_filepath) 62 | 63 | # Skip(S) if it is a duplicate or let the user choose to continue (C) 64 | if (self.cli.duplicate or config_settings.user_preferences.DUPLICATE_ON 65 | and UserContent.is_duplicate(content=content, tracker_name=selected_tracker, 66 | cli=self.cli)): 67 | continue 68 | 69 | # Search for VIDEO ID 70 | db_online = DbOnline(media=content,category=content.category, no_title=self.cli.notitle) 71 | db = db_online.media_result 72 | 73 | # If it is 'None' we skipped the imdb search (-notitle) 74 | if not db: 75 | continue 76 | 77 | # Update display name with Serie Title when requested by the user (-notitle) 78 | if self.cli.notitle: 79 | # Add generated metadata to the display_title 80 | if self.cli.gentitle: 81 | content.display_name = (f"{db_online.media_result.result.get_title()} " 82 | f"{db_online.media_result.year} ") 83 | content.display_name+= " " + content.generate_title 84 | else: 85 | # otherwise keep the old meta_data and add the new display_title to it 86 | print() 87 | content.display_name = (f"{db_online.media_result.result.get_title()}" 88 | f" {db_online.media_result.year} {content.guess_title}") 89 | 90 | # Get meta from the media video 91 | video_info = Video(media=content, tmdb_id=db.video_id, trailer_key=db.trailer_key) 92 | video_info.build_info() 93 | # print the title will be shown on the torrent page 94 | custom_console.bot_log(f"'DISPLAYNAME'...{{{content.display_name}}}\n") 95 | 96 | # Tracker instance 97 | unit3d_up = UploadBot(content=content, tracker_name=selected_tracker, cli = self.cli) 98 | 99 | # Get the data 100 | unit3d_up.data(show_id=db.video_id, imdb_id=db.imdb_id, show_keywords_list=db.keywords_list, 101 | video_info=video_info) 102 | 103 | # Don't upload if -noup is set to True 104 | if self.cli.noup: 105 | custom_console.bot_warning_log(f"No Upload active. Done.") 106 | continue 107 | 108 | # Send to the tracker 109 | tracker_response, tracker_message = unit3d_up.send(torrent_archive=torrent_filepath) 110 | 111 | 112 | # Store response for the torrent clients 113 | bittorrent_list.append( 114 | BittorrentData( 115 | tracker_response=tracker_response, 116 | torrent_response=torrent_response, 117 | content=content, 118 | tracker_message = tracker_message, 119 | archive_path=torrent_filepath, 120 | )) 121 | 122 | # // end content 123 | return bittorrent_list 124 | -------------------------------------------------------------------------------- /unit3dup/media_manager/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/31December99/Unit3Dup/d544838bb67662e0896fffd50f670961d3248ddd/unit3dup/media_manager/__init__.py -------------------------------------------------------------------------------- /unit3dup/pvtDocu.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import os 3 | 4 | import diskcache 5 | import subprocess 6 | import unicodedata 7 | 8 | from common.external_services.imageHost import Build 9 | from view import custom_console 10 | from unit3dup import config_settings 11 | from PIL import Image 12 | 13 | class PdfImages: 14 | """ 15 | - Generate screenshots for each Document provided 16 | """ 17 | 18 | def __init__(self, file_name: str): 19 | 20 | # File name 21 | self.file_name: str = file_name 22 | 23 | # Screenshots samples 24 | samples_n: int = config_settings.user_preferences.NUMBER_OF_SCREENSHOTS\ 25 | if 2 <= config_settings.user_preferences.NUMBER_OF_SCREENSHOTS <= 10 else 4 26 | 27 | # Description 28 | self.description: str = '' 29 | 30 | # description cache 31 | self.docu_cache = diskcache.Cache(str(os.path.join(config_settings.user_preferences.CACHE_PATH, "covers.cache"))) 32 | 33 | @staticmethod 34 | def sanitize_filename(filename: str) -> str: 35 | # normalize ! 36 | normalized_filename = unicodedata.normalize('NFKD', filename).encode('ascii', 'ignore').decode('ascii') 37 | # replace special chars with those in list 38 | sanitized_filename = "".join(c if c.isalnum() or c in ['.', '-', '_'] else "_" for c in normalized_filename) 39 | return sanitized_filename 40 | 41 | 42 | def extract(self) -> list['Image']: 43 | images = [] 44 | 45 | # Sanitize the input file name 46 | sanitized_file_name = self.sanitize_filename(os.path.basename(self.file_name)) 47 | path = os.path.dirname(self.file_name) 48 | output_name = os.path.join(path, sanitized_file_name) 49 | 50 | command = [ 51 | "pdftocairo", 52 | "-q", # Silent 53 | "-singlefile", # do not add digit 54 | "-png", 55 | "-f", "1", 56 | "-l", "1", 57 | self.file_name, 58 | output_name, 59 | ] 60 | try: 61 | subprocess.run(command, capture_output=True, check=True, timeout=20) 62 | except subprocess.CalledProcessError: 63 | custom_console.bot_error_log(f"It was not possible to extract any page from '{self.file_name}'") 64 | exit(1) 65 | except FileNotFoundError: 66 | custom_console.bot_error_log(f"It was not possible to find 'xpdf'. Please check your system PATH or install it.") 67 | exit(1) 68 | 69 | output_name+='.png' 70 | with open(output_name, "rb") as img_file: 71 | img_data = img_file.read() 72 | images.append(img_data) 73 | if os.path.exists(output_name): 74 | os.remove(output_name) 75 | 76 | return images 77 | 78 | def build_info(self): 79 | """Build the information to send to the tracker""" 80 | 81 | # If cache is enabled and the title is already cached 82 | if config_settings.user_preferences.CACHE_SCR: 83 | description = self.load_cache(self.file_name) 84 | if isinstance(description, dict): 85 | self.description = description['description'] 86 | if not self.description: 87 | custom_console.bot_warning_log(f"" 88 | f"[{self.__class__.__name__}] The description in the cache is empty") 89 | else: 90 | self.description = None 91 | 92 | if not self.description: 93 | # If there is no cache available 94 | custom_console.bot_log(f"GENERATING PAGES..") 95 | extracted_frames = self.extract() 96 | custom_console.bot_log("Done.") 97 | # Create a new description 98 | build_description = Build(extracted_frames=extracted_frames, filename = self.file_name) 99 | self.description = build_description.description() 100 | 101 | # Write the new description to the cache 102 | if config_settings.user_preferences.CACHE_SCR: 103 | self.docu_cache[self.file_name] = {'description' : self.description} 104 | 105 | 106 | def load_cache(self, file_name: str): 107 | # Check if the item is in the cache 108 | if file_name not in self.docu_cache: 109 | return False 110 | 111 | custom_console.bot_warning_log(f"** {self.__class__.__name__} **: Using cached Description!") 112 | 113 | try: 114 | # Try to get the video from the cache 115 | cover = self.docu_cache[file_name] 116 | except KeyError: 117 | # Handle the case where the video is missing or the cache is corrupted 118 | custom_console.bot_error_log("Cached frame not found or cache file corrupted") 119 | custom_console.bot_error_log("Proceed to extract the screenshot again. Please wait..") 120 | return False 121 | 122 | # // OK 123 | return cover 124 | -------------------------------------------------------------------------------- /unit3dup/pvtTorrent.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import json 4 | import os 5 | import torf 6 | from tqdm import tqdm 7 | 8 | from common.trackers.data import trackers_api_data 9 | from unit3dup.media import Media 10 | from unit3dup import config_settings 11 | 12 | from view import custom_console 13 | 14 | class HashProgressBar(tqdm): 15 | def callback(self, mytorr, path, current_num_hashed, total_pieces): 16 | progress_percentage = (current_num_hashed / total_pieces) * 100 17 | self.total = 100 18 | self.update(int(progress_percentage) - self.n) 19 | 20 | class Mytorrent: 21 | 22 | def __init__(self, contents: Media, meta: str, trackers_list = None): 23 | 24 | self.torrent_path = contents.torrent_path 25 | self.trackers_list = trackers_list 26 | 27 | announces = [] 28 | # one tracker at time 29 | for tracker_name in trackers_list: 30 | announce = trackers_api_data[tracker_name.upper()]['announce'] if tracker_name else None 31 | announces.append([announce]) 32 | 33 | self.metainfo = json.loads(meta) 34 | self.mytorr = torf.Torrent(path=contents.torrent_path, trackers=announces) 35 | self.mytorr.comment = config_settings.user_preferences.TORRENT_COMMENT 36 | self.mytorr.name = contents.torrent_name 37 | self.mytorr.created_by = "https://github.com/31December99/Unit3Dup" 38 | self.mytorr.private = True 39 | self.mytorr.source= trackers_api_data[trackers_list[0]]['source'] 40 | self.mytorr.segments = 16 * 1024 * 1024 41 | 42 | 43 | def hash(self): 44 | # Calculate the torrent size 45 | size = round(self.mytorr.size / (1024 ** 3), 2) 46 | # Print a message for the user 47 | custom_console.print(f"\n{self.trackers_list} {self.mytorr.name} - {size} GB") 48 | # Hashing 49 | with HashProgressBar() as progress: 50 | try: 51 | self.mytorr.generate(threads=4, callback=progress.callback, interval=0) 52 | except torf.TorfError as e: 53 | custom_console.bot_error_log(e) 54 | exit(1) 55 | 56 | def write(self, overwrite: bool, full_path: str) -> bool: 57 | try: 58 | if overwrite: 59 | os.remove(full_path) 60 | self.mytorr.write(full_path) 61 | return True 62 | except torf.TorfError as e: 63 | if "File exists" in str(e): 64 | custom_console.bot_error_log(f"This torrent file already exists: {full_path}") 65 | return False 66 | except FileNotFoundError as e: 67 | custom_console.bot_error_log(f"Trying to update torrent but it does not exist: {full_path}") 68 | return False 69 | -------------------------------------------------------------------------------- /unit3dup/pvtVideo.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import hashlib 3 | import os.path 4 | 5 | import diskcache 6 | 7 | from common.external_services.imageHost import Build 8 | from common.mediainfo import MediaFile 9 | from common.frames import VideoFrame 10 | 11 | from view import custom_console 12 | from unit3dup import config_settings 13 | from unit3dup.media import Media 14 | 15 | 16 | class Video: 17 | """ Build a description for the torrent page: screenshots, mediainfo, trailers, metadata """ 18 | 19 | def __init__(self, media: Media, tmdb_id: int, trailer_key=None): 20 | self.file_name: str = media.file_name 21 | self.display_name: str = media.display_name 22 | 23 | self.tmdb_id: int = tmdb_id 24 | self.trailer_key: int = trailer_key 25 | self.cache = diskcache.Cache(str(config_settings.user_preferences.CACHE_PATH)) 26 | 27 | # Create a cache key for tmdb_id 28 | self.key = f"{self.tmdb_id}.{self.display_name}" 29 | self.cache_key = self.hash_key(self.key) 30 | 31 | # Load the video frames 32 | # if web_enabled is off set the number of screenshots to an even number 33 | if not config_settings.user_preferences.WEBP_ENABLED: 34 | if config_settings.user_preferences.NUMBER_OF_SCREENSHOTS % 2 != 0: 35 | config_settings.user_preferences.NUMBER_OF_SCREENSHOTS += 1 36 | 37 | samples_n = max(2, min(config_settings.user_preferences.NUMBER_OF_SCREENSHOTS, 10)) 38 | self.video_frames: VideoFrame = VideoFrame(self.file_name, num_screenshots=samples_n) 39 | 40 | # Init 41 | self.is_hd: int = 0 42 | self.description: str = '' 43 | self.mediainfo: str = '' 44 | 45 | @staticmethod 46 | def hash_key(key: str) -> str: 47 | """ Generate a hashkey for the cache index """ 48 | return hashlib.md5(key.encode('utf-8')).hexdigest() 49 | 50 | def build_info(self): 51 | """Build the information to send to the tracker""" 52 | 53 | # media_info 54 | media_info = MediaFile(self.file_name) 55 | self.mediainfo = media_info.info 56 | 57 | if config_settings.user_preferences.CACHE_SCR: 58 | description = self.cache.get(self.cache_key) 59 | if description: 60 | custom_console.bot_warning_log(f"\n<> Using cached images for '{self.key}'") 61 | self.description = description.get('description', '') 62 | self.is_hd = description.get('is_hd', 0) 63 | 64 | if not self.description: 65 | # If no description found generate it 66 | custom_console.bot_log(f"\n[GENERATING IMAGES..] [HD {'ON' if self.is_hd == 0 else 'OFF'}]") 67 | # Extract the frames 68 | extracted_frames, is_hd = self.video_frames.create() 69 | # Create a webp file if it's enabled in the config json 70 | extracted_frames_webp = [] 71 | if config_settings.user_preferences.WEBP_ENABLED: 72 | extracted_frames_webp = self.video_frames.create_webp_from_video(video_path=self.file_name, 73 | start_time=90, 74 | duration=10, 75 | output_path= 76 | os.path.join(config_settings.user_preferences.CACHE_PATH,"file.webp")) 77 | custom_console.bot_log("Done.") 78 | 79 | # Build the description 80 | build_description = Build(extracted_frames=extracted_frames_webp+extracted_frames, filename= self.display_name) 81 | self.description = build_description.description() 82 | self.description += (f"[b][spoiler=Spoiler: PLAY TRAILER][center][youtube]{self.trailer_key}[/youtube]" 83 | f"[/center][/spoiler][/b]") 84 | self.is_hd = is_hd 85 | 86 | # Caching 87 | if config_settings.user_preferences.CACHE_SCR: 88 | self.cache[self.cache_key] = {'tmdb_id': self.tmdb_id, 'description': self.description, 'is_hd': self.is_hd} -------------------------------------------------------------------------------- /unit3dup/upload.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import json 3 | import requests 4 | 5 | from common.external_services.igdb.core.models.search import Game 6 | from common.trackers.trackers import TRACKData 7 | 8 | from unit3dup.pvtTracker import Unit3d 9 | from unit3dup.pvtDocu import PdfImages 10 | from unit3dup import config_settings, Load 11 | from unit3dup.pvtVideo import Video 12 | from unit3dup.media import Media 13 | 14 | from view import custom_console 15 | 16 | class UploadBot: 17 | def __init__(self, content: Media, tracker_name: str, cli: argparse): 18 | 19 | self.API_TOKEN = config_settings.tracker_config.ITT_APIKEY 20 | self.BASE_URL = config_settings.tracker_config.ITT_URL 21 | self.cli = cli 22 | self.content = content 23 | self.tracker_name = tracker_name 24 | self.tracker_data = TRACKData.load_from_module(tracker_name=tracker_name) 25 | self.tracker = Unit3d(tracker_name=tracker_name) 26 | self.sign = (f"[url=https://github.com/31December99/Unit3Dup][code][color=#00BFFF][size=14]Uploaded with Unit3Dup" 27 | f" {Load.version}[/size][/color][/code][/url]") 28 | 29 | def message(self,tracker_response: requests.Response) -> (requests, dict): 30 | 31 | name_error = '' 32 | info_hash_error = '' 33 | _message = json.loads(tracker_response.text) 34 | if 'data' in _message: 35 | _message = _message['data'] 36 | 37 | if tracker_response.status_code == 200: 38 | tracker_response_body = json.loads(tracker_response.text) 39 | custom_console.bot_log(f"\n[RESPONSE]-> '{self.tracker_name}'.....{tracker_response_body['message'].upper()}\n\n") 40 | custom_console.rule() 41 | return tracker_response_body["data"],{} 42 | 43 | elif tracker_response.status_code == 401: 44 | custom_console.bot_error_log(_message) 45 | exit(_message['message']) 46 | 47 | elif tracker_response.status_code == 404: 48 | if _message.get("type_id",None): 49 | name_error = _message["type_id"] 50 | else: 51 | name_error = _message 52 | error_message = f"{self.__class__.__name__} - {name_error}" 53 | else: 54 | if _message.get("name",None): 55 | name_error = _message["name"][0] 56 | if _message.get("info_hash",None): 57 | info_hash_error = _message["info_hash"][0] 58 | error_message =f"{self.__class__.__name__} - {name_error} : {info_hash_error}" 59 | 60 | custom_console.bot_error_log(f"\n[RESPONSE]-> '{error_message}\n\n") 61 | custom_console.rule() 62 | return {}, error_message 63 | 64 | def data(self,show_id: int , imdb_id: int, show_keywords_list: str, video_info: Video) -> Unit3d | None: 65 | 66 | self.tracker.data["name"] = self.content.display_name 67 | self.tracker.data["tmdb"] = show_id 68 | self.tracker.data["imdb"] = imdb_id if imdb_id else 0 69 | self.tracker.data["keywords"] = show_keywords_list 70 | self.tracker.data["category_id"] = self.tracker_data.category.get(self.content.category) 71 | self.tracker.data["anonymous"] = int(config_settings.user_preferences.ANON) 72 | self.tracker.data["resolution_id"] = self.tracker_data.resolution[self.content.screen_size]\ 73 | if self.content.screen_size else self.tracker_data.resolution[self.content.resolution] 74 | self.tracker.data["mediainfo"] = video_info.mediainfo 75 | self.tracker.data["description"] = video_info.description + self.sign 76 | self.tracker.data["sd"] = video_info.is_hd 77 | self.tracker.data["type_id"] = self.tracker_data.filter_type(self.content.file_name) 78 | self.tracker.data["season_number"] = self.content.guess_season 79 | self.tracker.data["episode_number"] = (self.content.guess_episode if not self.content.torrent_pack else 0) 80 | self.tracker.data["personal_release"] = (int(config_settings.user_preferences.PERSONAL_RELEASE) 81 | or int(self.cli.personal)) 82 | return self.tracker 83 | 84 | def data_game(self,igdb: Game) -> Unit3d | None: 85 | 86 | igdb_platform = self.content.platform_list[0].lower() if self.content.platform_list else '' 87 | self.tracker.data["name"] = self.content.display_name 88 | self.tracker.data["tmdb"] = 0 89 | self.tracker.data["category_id"] = self.tracker_data.category.get(self.content.category) 90 | self.tracker.data["anonymous"] = int(config_settings.user_preferences.ANON) 91 | self.tracker.data["description"] = igdb.description + self.sign if igdb else "Sorry, there is no valid IGDB" 92 | self.tracker.data["type_id"] = self.tracker_data.type_id.get(igdb_platform) if igdb_platform else 1 93 | self.tracker.data["igdb"] = igdb.id if igdb else 1, # need zero not one ( fix tracker) 94 | self.tracker.data["personal_release"] = (int(config_settings.user_preferences.PERSONAL_RELEASE) 95 | or int(self.cli.personal)) 96 | return self.tracker 97 | 98 | def data_docu(self, document_info: PdfImages) -> Unit3d | None: 99 | 100 | self.tracker.data["name"] = self.content.display_name 101 | self.tracker.data["tmdb"] = 0 102 | self.tracker.data["category_id"] = self.tracker_data.category.get(self.content.category) 103 | self.tracker.data["anonymous"] = int(config_settings.user_preferences.ANON) 104 | self.tracker.data["description"] = document_info.description + self.sign 105 | self.tracker.data["type_id"] = self.tracker_data.filter_type(self.content.file_name) 106 | self.tracker.data["resolution_id"] = "" 107 | self.tracker.data["personal_release"] = (int(config_settings.user_preferences.PERSONAL_RELEASE) 108 | or int(self.cli.personal)) 109 | return self.tracker 110 | 111 | def send(self, torrent_archive: str, nfo_path = None) -> (requests, dict): 112 | 113 | tracker_response=self.tracker.upload_t(data=self.tracker.data, torrent_path=self.content.torrent_path, 114 | torrent_archive_path = torrent_archive,nfo_path=nfo_path) 115 | return self.message(tracker_response) 116 | 117 | -------------------------------------------------------------------------------- /view/__init__.py: -------------------------------------------------------------------------------- 1 | from view.custom_console import CustomConsole 2 | 3 | custom_console = CustomConsole() -------------------------------------------------------------------------------- /view/custom_console.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from rich.align import Align 3 | from rich.console import Console 4 | from rich.panel import Panel 5 | from rich.text import Text 6 | from rich.table import Table 7 | 8 | from common import config_settings 9 | 10 | class CustomConsole(Console): 11 | def __init__(self): 12 | super().__init__(log_path=False) 13 | 14 | def welcome_message(self): 15 | title_panel = Panel( 16 | Text(f"UNIT3Dup - An uploader for the Unit3D torrent tracker -\n{config_settings.console_options.WELCOME_MESSAGE}", 17 | style=config_settings.console_options.WELCOME_MESSAGE_COLOR, justify="center"), 18 | border_style=config_settings.console_options.WELCOME_MESSAGE_BORDER_COLOR, 19 | title_align="center", 20 | ) 21 | self.print(title_panel) 22 | 23 | def panel_message(self, message: str): 24 | title_panel = Panel( 25 | Text(message, style=config_settings.console_options.PANEL_MESSAGE_COLOR, justify="center"), 26 | border_style=config_settings.console_options.PANEL_MESSAGE_BORDER_COLOR, 27 | title_align="center", 28 | expand=False, 29 | ) 30 | self.print(title_panel, justify="center") 31 | 32 | def bot_log(self, message: str): 33 | self.log(message, style=config_settings.console_options.NORMAL_COLOR) 34 | 35 | def bot_error_log(self, message: str): 36 | self.log(message, style=config_settings.console_options.ERROR_COLOR) 37 | 38 | def bot_warning_log(self, message: str): 39 | self.log(message, style=config_settings.console_options.QUESTION_MESSAGE_COLOR) 40 | 41 | def bot_input_log(self, message: str): 42 | self.print(f"{message} ", end="", style=config_settings.console_options.NORMAL_COLOR) 43 | 44 | def bot_question_log(self, message: str): 45 | self.print(message, end="", style=config_settings.console_options.QUESTION_MESSAGE_COLOR) 46 | 47 | def bot_counter_log(self, message: str): 48 | self.print(message, end="\r", style=config_settings.console_options.QUESTION_MESSAGE_COLOR) 49 | 50 | def bot_process_table_log(self, content: list): 51 | 52 | table = Table( 53 | title="Here is your files list" if content else "There are no files here", 54 | border_style="bold blue", 55 | header_style="red blue", 56 | ) 57 | 58 | table.add_column("Torrent Pack", style="dim") 59 | table.add_column("Media", justify="left", style="bold green") 60 | table.add_column("Path", justify="left", style="bold green") 61 | 62 | for item in content: 63 | pack = "Yes" if item.torrent_pack else "No" 64 | table.add_row( 65 | pack, 66 | item.category, 67 | item.torrent_path, 68 | ) 69 | 70 | self.print(Align.center(table)) 71 | 72 | def bot_process_table_pw(self, content: list): 73 | 74 | table = Table( 75 | title="Here is your files list" if content else "There are no files here", 76 | border_style="bold blue", 77 | header_style="red blue", 78 | ) 79 | 80 | table.add_column("Category", style="dim") 81 | table.add_column("Indexer", justify="left", style="bold green") 82 | table.add_column("Title", justify="left", style="bold green") 83 | table.add_column("Size", justify="left", style="bold green") 84 | table.add_column("Seeders", justify="left", style="bold green") 85 | 86 | for item in content: 87 | table.add_row( 88 | item.categories[0]['name'], 89 | item.indexer, 90 | item.title, 91 | str(item.size), 92 | str(item.seeders), 93 | ) 94 | 95 | self.print(Align.center(table)) 96 | 97 | def bot_tmdb_table_log(self, result, title: str, media_info_language: str): 98 | 99 | self.print("\n") 100 | media_info_audio_languages = (",".join(media_info_language)).upper() 101 | self.panel_message(f"\nResults for {title.upper()}") 102 | 103 | table = Table(border_style="bold blue") 104 | table.add_column("TMDB ID", style="dim") 105 | table.add_column("LANGUAGE", style="dim") 106 | table.add_column("TMDB POSTER", justify="left", style="bold green") 107 | table.add_column("TMDB BACKDROP", justify="left", style="bold green") 108 | # table.add_column("TMDB KEYWORDS", justify="left", style="bold green") 109 | table.add_row( 110 | str(result.video_id), 111 | media_info_audio_languages, 112 | result.poster_path, 113 | result.backdrop_path, 114 | ) 115 | self.print(Align.center(table)) 116 | 117 | def wait_for_user_confirmation(self, message: str): 118 | # Wait for user confirmation in case of validation failure 119 | try: 120 | self.bot_error_log(message=message) 121 | input("> ") 122 | except KeyboardInterrupt: 123 | self.bot_error_log("\nOperation cancelled.Please update your config file") 124 | exit(0) 125 | 126 | def user_input(self,message: str)-> int: 127 | try: 128 | while True: 129 | self.bot_input_log(message=message) 130 | user_tmdb_id = input() 131 | if user_tmdb_id.isdigit(): 132 | user_tmdb_id = int(user_tmdb_id) 133 | return user_tmdb_id if user_tmdb_id < 9999999 else 0 134 | except KeyboardInterrupt: 135 | self.bot_error_log("\nOperation cancelled. Bye !") 136 | exit(0) 137 | 138 | def user_input_str(self,message: str)-> str: 139 | try: 140 | while True: 141 | self.bot_input_log(message=message) 142 | user_ = input() 143 | return user_ if user_ else '0' 144 | except KeyboardInterrupt: 145 | self.bot_error_log("\nOperation cancelled. Bye !") 146 | exit(0) 147 | 148 | --------------------------------------------------------------------------------