├── eMulerrStalledChecker ├── requirements.txt ├── Dockerfile ├── docker-compose.yml └── README.md ├── AudioMediaChecker ├── requirements.txt ├── Dockerfile ├── README.md └── AudioMediaChecker.py ├── .github └── workflows │ ├── build-audiomedia-checker.yml │ └── build-emulerr-stalled.yml ├── LICENSE ├── README.md ├── TransmissionRemoveCompleteTorrent.sh.readme.md ├── clean_samba_recycle.sh ├── AddTransmissionTrackers ├── AddTransmissionTrackers.sh.readme.md └── AddTransmissionTrackers.sh ├── AddqBittorrentTrackers ├── AddqBittorrentTrackers.sh.readme.md ├── AddqBittorrentTrackers.py └── AddqBittorrentTrackers.sh ├── radarr_cleanup_packed_torrent.sh ├── sonarr_cleanup_packed_torrent.sh ├── qBittorrentHardlinksChecker ├── qBittorrentHardlinksChecker.sh.readme.md ├── README.md ├── qBittorrentHardlinksChecker.sh └── qBittorrentHardlinksChecker.py └── TransmissionRemoveCompleteTorrent.sh /eMulerrStalledChecker/requirements.txt: -------------------------------------------------------------------------------- 1 | requests>=2.31.0 2 | apprise>=1.7.0 3 | -------------------------------------------------------------------------------- /AudioMediaChecker/requirements.txt: -------------------------------------------------------------------------------- 1 | faster-whisper 2 | mutagen 3 | pydub 4 | ffmpeg-python 5 | pycountry 6 | psutil 7 | tqdm -------------------------------------------------------------------------------- /eMulerrStalledChecker/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.11-alpine 2 | 3 | WORKDIR /app 4 | COPY requirements.txt . 5 | COPY eMulerr_Stalled_Checker.py . 6 | 7 | RUN pip install -r requirements.txt 8 | 9 | CMD ["python", "eMulerr_Stalled_Checker.py"] -------------------------------------------------------------------------------- /AudioMediaChecker/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM nvidia/cuda:12.3.2-cudnn9-runtime-ubuntu22.04 2 | 3 | # Setup Python e dipendenze di sistema 4 | RUN apt-get update && apt-get install -y \ 5 | python3 \ 6 | python3-pip \ 7 | python3-dev \ 8 | gcc \ 9 | g++ \ 10 | mkvtoolnix \ 11 | ffmpeg \ 12 | && rm -rf /var/lib/apt/lists/* 13 | 14 | # Installazione delle dipendenze Python 15 | COPY requirements.txt . 16 | RUN pip install --no-cache-dir -r requirements.txt 17 | 18 | WORKDIR /app 19 | COPY . . 20 | 21 | RUN ln -s /usr/bin/python3 /usr/bin/python 22 | 23 | ENTRYPOINT ["python3", "AudioMediaChecker.py"] 24 | -------------------------------------------------------------------------------- /.github/workflows/build-audiomedia-checker.yml: -------------------------------------------------------------------------------- 1 | name: Build AudioMediaChecker 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | paths: 7 | - 'AudioMediaChecker/**' 8 | - '.github/workflows/build-audiomedia-checker.yml' 9 | workflow_dispatch: 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v4 16 | 17 | - uses: docker/setup-buildx-action@v3 18 | 19 | - uses: docker/login-action@v3 20 | with: 21 | username: ${{ secrets.DOCKERHUB_USERNAME }} 22 | password: ${{ secrets.DOCKERHUB_TOKEN }} 23 | 24 | - uses: docker/build-push-action@v5 25 | with: 26 | context: ./AudioMediaChecker 27 | platforms: linux/amd64,linux/arm64 28 | push: true 29 | tags: | 30 | ${{ secrets.DOCKERHUB_USERNAME }}/audiomedia-checker:latest 31 | ${{ secrets.DOCKERHUB_USERNAME }}/audiomedia-checker:${{ github.sha }} 32 | cache-from: type=gha 33 | cache-to: type=gha,mode=max 34 | -------------------------------------------------------------------------------- /.github/workflows/build-emulerr-stalled.yml: -------------------------------------------------------------------------------- 1 | name: Build eMulerrStalledChecker 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | paths: 7 | - 'eMulerrStalledChecker/**' 8 | - '.github/workflows/build-emulerr-stalled.yml' 9 | workflow_dispatch: 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v4 16 | 17 | - uses: docker/setup-buildx-action@v3 18 | 19 | - uses: docker/login-action@v3 20 | with: 21 | username: ${{ secrets.DOCKERHUB_USERNAME }} 22 | password: ${{ secrets.DOCKERHUB_TOKEN }} 23 | 24 | - uses: docker/build-push-action@v5 25 | with: 26 | context: ./eMulerrStalledChecker 27 | platforms: linux/amd64,linux/arm64 28 | push: true 29 | tags: | 30 | ${{ secrets.DOCKERHUB_USERNAME }}/emulerr-stalled-checker:latest 31 | ${{ secrets.DOCKERHUB_USERNAME }}/emulerr-stalled-checker:${{ github.sha }} 32 | cache-from: type=gha 33 | cache-to: type=gha,mode=max 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Jorman 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.md: -------------------------------------------------------------------------------- 1 | # qBittorrent Helper Scripts 2 | 3 | This small, but useful collection of scripts are a fantastic way to help you manage, maintain and prune your Torrent file lists. 4 | 5 | They automate some of the tasks that become a bit boring after clicking buttons from within the client itself for the 350th time... 6 | 7 | There are FOUR main scripts and several sub-scripts that are specific purpose. 8 | 9 | Download the lot, review and modify them to suit your needs. 10 | 11 | ### AddqBittorrentTrackers.sh 12 | This script injects trackers into your **qBittorrent downloads**. 13 | 14 | 15 | ### AddTransmissionTrackers.sh 16 | This script injects trakers inside the **Transmission torrent** 17 | 18 | 19 | ### TransmissionRemoveCompleteTorrent.sh 20 | This script removes completed torrents. 21 | 22 | 23 | ### qBittorrentHardlinksChecker.sh 24 | This script checks qBittorrents Hard Links. 25 | 26 | 27 | # FEEDBACK and ERRORS 28 | 29 | Please star this project - if you find a problem, please do report it. 30 | 31 | While the scripts do not change often, the project is regualrly reviewed. The world of Torrenting is well established, but tweaks, tricks and usage evolves. 32 | 33 | As things evolve, these scripts will be updated. If you would like to add, suggest ideas or propose new methods, please do open either an Issue or pop me a message. 34 | 35 | 36 | 37 | # TODO 38 | Make some good expalation on how to use these scripts - see the Wiki (top) 39 | -------------------------------------------------------------------------------- /TransmissionRemoveCompleteTorrent.sh.readme.md: -------------------------------------------------------------------------------- 1 | # TransmissionRemoveCompleteTorrent.sh 2 | 3 | The best way is to use it is to cronize it. 4 | 5 | This script uses `transmission-remote`, normally this is already installed if you use transmission. 6 | 7 | * First make sure your Radarr/Sonarr user can execute the script with someting like this: 8 | * `chown USER:USER TransmissionRemoveCompleteTorrent.sh` 9 | * Then ensure it is executable: `chmod +x TransmissionRemoveCompleteTorrent.sh` 10 | 11 | * Modify the scripts `########## CONFIGURATIONS ##########` section: 12 | * `t_username`, `t_password`, `t_host` and `t_port` are all Transmission related. Set them accordingly. 13 | * `t_log` is to enable the logfile. If set to 1 the logfile will be written to `t_log_path`. 14 | * The most important setting is `automatic_folder`. This is the folder that contains all the **automatic downloads** 15 | * I use this folder structure for automatic downloads that came from Radarr/Sonarr: 16 | - download 17 | - automatic 18 | - movie 19 | - tv_show 20 | 21 | * Within the files configuration example, I've set `automatic` for `automatic_folder` option. 22 | * `max_days_seed` is the maximum seed time. 23 | * `remove_normal`. Pay attention if you set this to true, because this enables a kind of **force** option that also checks all non-automatic downloads. 24 | 25 | * Lastly, consider using cron for the script. Add this to your cron scheduler with something like this (varies according to your own Linux installs cron manager): 26 | * `30 01 * * * /PATHOFTHESCRIPT/TransmissionRemoveCompleteTorrent.sh >/dev/null 2>&1` 27 | * this example will execute the script at 01:30 every day. 28 | -------------------------------------------------------------------------------- /clean_samba_recycle.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # cleanup recycle dir: 4 | # delete all files with last access time 5 | # older than a specific number of days and 6 | # remove all empty subdirs afterwards. 7 | # 8 | # in your smb.conf: 9 | # make sure you set recycle:touch = yes 10 | # in order to periodically delete old files, you can 11 | # cron the script with something like 00 01 * * * clean_samba_recycle.sh 12 | 13 | # set vars 14 | recycle_dir1='/dir1' 15 | recycle_dir2='/dir2' 16 | recycle_dir3='/dir3' 17 | recycle_dir4='/dir4' 18 | recycle_dir5='/dir5' 19 | recycle_dir6='/dir6' 20 | lastaccess_50_maxdays=50 21 | lastaccess_10_maxdays=10 22 | lastaccess_5_maxdays=5 23 | 24 | # execute commands for recycle_dir1 25 | find $recycle_dir1 -atime +$lastaccess_10_maxdays -type f -delete 26 | find $recycle_dir1 -type d ! -path $recycle_dir1 -empty -delete 27 | 28 | # execute commands for recycle_dir2 29 | find $recycle_dir2 -atime +$lastaccess_50_maxdays -type f -delete 30 | find $recycle_dir2 -type d ! -path $recycle_dir2 -empty -delete 31 | 32 | # execute commands for recycle_dir3 33 | find $recycle_dir3 -atime +$lastaccess_50_maxdays -type f -delete 34 | find $recycle_dir3 -type d ! -path $recycle_dir3 -empty -delete 35 | 36 | # execute commands for recycle_dir4 37 | find $recycle_dir4 -atime +$lastaccess_5_maxdays -type f -delete 38 | find $recycle_dir4 -type d ! -path $recycle_dir4 -empty -delete 39 | 40 | # execute commands for recycle_dir5 41 | find $recycle_dir5 -atime +$lastaccess_10_maxdays -type f -delete 42 | find $recycle_dir5 -type d ! -path $recycle_dir5 -empty -delete 43 | 44 | # execute commands for recycle_dir6 45 | find $recycle_dir6 -atime +$lastaccess_10_maxdays -type f -delete 46 | find $recycle_dir6 -type d ! -path $recycle_dir6 -empty -delete 47 | -------------------------------------------------------------------------------- /AddTransmissionTrackers/AddTransmissionTrackers.sh.readme.md: -------------------------------------------------------------------------------- 1 | # AddTransmissionTrackers.sh 2 | 3 | The purpose of this script is to inject trakers inside the **Transmission torrent** 4 | 5 | This can be used manually, or with Radarr/Sonarr automatically. To run the script manually, simply run the script `./AddTransmissionTrackers.sh` and see all the possible options. 6 | 7 | When Radarr and/or Sonarr grabs a new torrent *and if the torrent is not from a private tracker*, the script is triggered and the custom tracker list populated to the torrent. 8 | 9 | In the latest version, I've inserted a new way to call the script, with many options to inject trackers inside torrents. 10 | 11 | N.B for those updating to the latest script, `Transmission-remote` is no longer needed. All commands have been switched to directly use `/rpc`. This is the very first release with this method. 12 | 13 | 14 | 15 | I've also included the possibility to call the script and specify the name and/or ID where one adds trackers: 16 | 17 | * First ensure your Radarr/Sonarr user can execute the script with something like this: 18 | * Take ownership with: `chown USER:USER AddTransmissionTrackers.sh` where `USER:GROUP` is the same user and group as Transmission. 19 | * Ensure it is executable: `chmod +x AddTransmissionTrackers.sh` 20 | 21 | * Modify the scripts `########## CONFIGURATIONS ##########` section: 22 | * `transmission_username`, `transmission_password`, `transmission_host` and `transmission_port`. These are all the same as your Transmission config. 23 | * `live_trackers_list_url`, is the URL where the trackers list is obtained. This is the default list. You may specify more than one URL, just follow the example in the file. 24 | * The script will automatically check if the torrent is private or public. 25 | 26 | The configuration is complete. 27 | 28 | 29 | If you are a **Radarr and/or Sonarr user**, personally I: 30 | 1. Create a custom script (settings -> connect -> add notification -> Custom Script). 31 | 2. The name is not important. I use Add Transmission Trackers, you can use any name you like. 32 | 3. Set "On Grab". 33 | 4. Inside Path field, point to the `AddTransmissionTrackers.sh` script. 34 | 5. Save the custom script. 35 | 36 | 37 | 38 | One note about configuration and using the script manually. Before use you MUST configure the username, password, host and port within the script. Otherwise I would have to insert four new options to be called every time for manual user input, or "complicate" it by having a configuration file saved somewhere. If it's necessary I will do it, but for now I think it is easier to keep only the necessary options. 39 | 40 | -------------------------------------------------------------------------------- /eMulerrStalledChecker/docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | emulerr-stalled-checker: 3 | build: . 4 | container_name: emulerr-stalled-checker 5 | environment: 6 | - TZ=Europe/Rome # set your timezone 7 | - CHECK_INTERVAL=10 # in minutes 8 | - EMULERR_HOST=http://10.0.0.100:3000 9 | - STALL_CHECKS=15 # number of checks before marking as stalled 10 | - STALL_DAYS=15 # days if the download has been never seen completed 11 | - RECENT_DOWNLOAD_GRACE_PERIOD=30 # how many minutes wait before checking if the download, apply only if the download is recent 12 | - DELETE_IF_UNMONITORED_SERIE=false # delete if the serie is not monitored 13 | - DELETE_IF_UNMONITORED_SEASON=false # delete if the season is not monitored 14 | - DELETE_IF_UNMONITORED_EPISODE=true # delete if the episode is not monitored 15 | - DELETE_IF_UNMONITORED_MOVIE=true # delete if the movie is not monitored 16 | - DELETE_IF_ONLY_ON_EMULERR=false # delete if the download is only on eMulerr but not inside *Arr, apply only for downloads with *Arr eMulerr category 17 | 18 | # Notification configuration using Apprise (supports 70+ services) 19 | # Recommended: Use APPRISE_URLS for maximum flexibility 20 | # Format examples: 21 | # Pushover: pover://user_key@app_token 22 | # Telegram: tgram://bot_token/chat_id 23 | # Discord: discord://webhook_id/webhook_token 24 | # Multiple: pover://key@token discord://webhook/id (space-separated) 25 | - APPRISE_URLS=pover://your_user_key@your_app_token 26 | 27 | # Legacy Pushover support (auto-converted to Apprise internally) 28 | # You can still use these instead of APPRISE_URLS if you prefer 29 | # - PUSHOVER_USER_KEY=your_pushover_user_key 30 | # - PUSHOVER_APP_TOKEN=your_pushover_app_token 31 | 32 | - LOG_LEVEL=info # or debug, warning, error, critical 33 | - LOG_TO_FILE=/path/to/logfile # optional, by default no log file is created, if set, the file will be created in the path specified, note volume must be mounted to the path 34 | - DRY_RUN=false 35 | - DOWNLOAD_CLIENT=emulerr # name of the download client inside *Arr 36 | - RADARR_HOST=http://10.0.0.100:7878 37 | - RADARR_API_KEY=*** 38 | - RADARR_CATEGORY=radarr-eMulerr # Radarr category for eMulerr 39 | - SONARR_HOST=http://10.0.0.100:8989 40 | - SONARR_API_KEY=*** 41 | - SONARR_CATEGORY=tv-sonarr-eMulerr # Sonarr category for eMulerr 42 | restart: unless-stopped 43 | healthcheck: 44 | test: ["CMD", "wget", "--spider", "http://10.0.0.100:3000"] 45 | interval: 1m 46 | timeout: 10s 47 | retries: 3 48 | -------------------------------------------------------------------------------- /AddqBittorrentTrackers/AddqBittorrentTrackers.sh.readme.md: -------------------------------------------------------------------------------- 1 | # AddqBittorrentTrackers.sh 2 | 3 | The purpose of this script is to inject trackers into your **qBittorrent downloads**. 4 | 5 | It may be executed manually or automatically with Radarr/Sonarr. 6 | 7 | This script works with the qBittorrent v4.1+ API. It may work with lower versions, but must be checked. Let me know if you use an earlier version and it works, so I can expand the version compatability. 8 | 9 | To use this script you'll need: 10 | * [jq](https://stedolan.github.io/jq/). Check if `jq` is available for your distro with `sudo apt install jq` (or the appropriate package management tool) 11 | * Curl. Install it with `sudo apt install curl` 12 | 13 | * First make sure your Radarr/Sonarr user can execute the script with a process similar to this: 14 | * `chown USER:GROUP AddqBittorrentTrackers.sh` where `USER:GROUP` is the same user and group as qBittorrent. 15 | * Then be sure it is executable with: `chmod +x AddqBittorrentTrackers.sh` 16 | 17 | * Modify the scripts `########## CONFIGURATIONS ##########` section: 18 | * `qbt_username` -> username to access to qBittorrent Web UI. 19 | * `qbt_password` -> password to access to qBittorrent Web UI. **Password MUST BE url encoded**, otherwise any special characters will break the curl request. 20 | * Note that if the script runs on the same device that runs qBittorrent, you can set `Bypass authentication for clients on localhost`. With this option set and when the script runs, the username and password are not required. 21 | * `qbt_host` -> if the script is on the same device as qBittorrent `http://localhost`, otherwise, set this to the remote device. 22 | * `qbt_port` -> is the Web UI port. 23 | * `live_trackers_list_url`, is the url from where the trackers list is obtained. These lists are automatically generated. You can specify more than one url, just follow the example in the file. 24 | * The script will automatically check if the torrent is private or public. 25 | 26 | Configuration is now complete. 27 | 28 | 29 | If you are a **Radarr and/or Sonarr user**, personally I: 30 | 1. Create a custom script (settings -> connect -> add notification -> Custom Script). 31 | 2. The name is not important. I use Add qBitTorrent Trackers, you can use any name you like. 32 | 3. Set "On Grab". 33 | 4. Inside Path field, point to the `AddqBittorrentTrackers.sh` script. 34 | 5. Save the custom script. 35 | 36 | Now, when _Radarr and/or Sonarr_ grabs a new torrent, the script will be automatically triggered and a custom tracker list will be added to the torrent. This is true only if the torrent is _not_ from a private tracker. 37 | 38 | To run the script manually, simply run `./AddqBittorrentTrackers.sh`. All the possible options will be shown. When calling the script, there are many options to add trackers to torrents. 39 | 40 | One note about configuration, if you want to use it manually, you must configure the username, password, host and port within the file. This is for simplicity. Otherwise I would have to insert four new options to be called every time manually, or "complicate" the script by checking for the possibility of a configuration file to be saved somewhere. If it is necessary I will do it, but for now I think it is easier to keep the necessary options hard coded. 41 | 42 | 43 | -------------------------------------------------------------------------------- /radarr_cleanup_packed_torrent.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Examples for testing 4 | # radarr_moviefile_sourcefolder="/data/torrent/movies/Justice.League.2017.1080p.HDRip.X264.AAC-m2g" radarr_moviefile_sourcepath="/data/torrent/movies/Justice.League.2017.1080p.HDRip.X264.AAC-m2g/Justice.League.2017.1080p.HDRip.X264.AAC-m2g.mkv" 5 | 6 | # Instructions 7 | # Put this script somewhere on your file system like /usr/local/bin and make it executable. 8 | # 9 | # In Radarr, Settings -> Connect add a Custom Script 10 | # On Grab: No 11 | # On Download: Yes 12 | # On Upgrade: Yes 13 | # On Rename: No 14 | # Path: /path/to/where/script/is/radarr_cleanup_packed_torrent.sh 15 | # Arguments: 16 | 17 | # Tune values below to protect your torrents w/ small rar files or non-torrent download client. 18 | 19 | # In *bytes*, the biggest rar file size limit to prevent video deletion from torrents with unrelated rar files (like subs) 20 | # 25 * 1024 * 1024 21 | rar_min_size=26214400 22 | 23 | # Seconds to wait between size checks for in progress unpack 24 | unpack_time=5 25 | 26 | # The final base directory torrents end up in, for example "movies" from /data/torrents/movies 27 | radarr_final_dir="Film" 28 | 29 | # Identifiable portion of path to torrents, so it will only run on torrents. 30 | # For example, with a path of "/data/torrents/movies", "torrents" is a good choice. 31 | torrent_path_portion="Automatici" 32 | 33 | # Test that this is a download event, so we don't run on grab or rename. 34 | # shellcheck disable=SC2154 35 | if [[ "${radarr_eventtype}" != "Download" ]]; then 36 | echo "[Torrent Cleanup] Sonarr Event Type is NOT Download, exiting." 37 | exit 38 | fi 39 | 40 | # Test this file exists, no point running on a file that isn't there. 41 | # shellcheck disable=SC2154 42 | if ! [[ -f "${radarr_moviefile_sourcepath}" ]]; then 43 | echo "[Torrent Cleanup] File ${radarr_moviefile_sourcepath} does not exist, exiting." 44 | exit 45 | fi 46 | 47 | # Test that this is a torrent, so we don't run on usenet downloads. 48 | # shellcheck disable=SC2154 49 | if ! [[ "${radarr_moviefile_sourcepath}" =~ ${torrent_path_portion} ]]; then 50 | echo "[Torrent Cleanup] Path ${radarr_moviefile_sourcepath} does not contain \"torrent\", exiting." 51 | exit 52 | fi 53 | 54 | # Test that this is a multi-file torrent, so we don't run on single file torrents. 55 | # shellcheck disable=SC2154 56 | base_dir=$( basename "${radarr_moviefile_sourcefolder}" ) 57 | if [ "${base_dir}" == "${radarr_final_dir}" ]; then 58 | echo "[Torrent Cleanup] Single file torrent, exiting." 59 | exit 60 | fi 61 | 62 | # We might run while the unpack is still happening, so wait for that before removing. 63 | echo "[Torrent Cleanup] Starting wait for ${radarr_moviefile_sourcepath} unpacking..." 64 | file_size_start=$( stat --printf="%s" "${radarr_moviefile_sourcepath}" ) 65 | sleep ${unpack_time} 66 | file_size_end=$( stat --printf="%s" "${radarr_moviefile_sourcepath}" ) 67 | until [[ ${file_size_start} -eq ${file_size_end} ]]; do 68 | file_size_start=$( stat --printf="%s" "${radarr_moviefile_sourcepath}" ) 69 | sleep ${unpack_time} 70 | file_size_end=$( stat --printf="%s" "${radarr_moviefile_sourcepath}" ) 71 | done 72 | echo "[Torrent Cleanup] Finished wait for ${radarr_moviefile_sourcepath} unpacking..." 73 | 74 | # Test for rar and r## files and check the *size* of the biggest one so we don't run due to packed subs or something. 75 | # shellcheck disable=SC2154 76 | if find "${radarr_moviefile_sourcefolder}" -type f -iregex '.*\.r[0-9a][0-9r]$' | grep -Eq '.*'; then 77 | # shellcheck disable=SC2154 78 | rar_size="$( find "${radarr_moviefile_sourcefolder}" -type f -iregex '.*\.r[0-9a][0-9r]$' -ls | sort -nk 7 | tail -1 | awk '{ print $7 }' )" 79 | if [[ ${rar_size} -gt ${rar_min_size} ]]; then 80 | echo "[Torrent Cleanup] Rar file size ${rar_size} exceeds minimum of ${rar_min_size}, deleting video file." 81 | rm "${radarr_moviefile_sourcepath}" 82 | else 83 | echo "[Torrent Cleanup] Rar file size ${rar_size} DOES NOT MEET minimum of ${rar_min_size}, skipping deletion of video file." 84 | fi 85 | else 86 | echo "[Torrent Cleanup] No rar files, exiting." 87 | fi 88 | -------------------------------------------------------------------------------- /sonarr_cleanup_packed_torrent.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Examples for testing 4 | # sonarr_episodefile_sourcefolder="/data/torrent/tv/Penny.Dreadful.S01E01.720p.HDTV.x264-2HD" sonarr_episodefile_sourcepath="/data/torrent/tv/Penny.Dreadful.S01E01.720p.HDTV.x264-2HD/penny.dreadful.s01e01.720p.hdtv.x264-2hd.mkv" 5 | 6 | # Instructions 7 | # Put this script somewhere on your file system like /usr/local/bin and make it executable. 8 | # 9 | # In Sonarr, Settings -> Connect add a Custom Script 10 | # On Grab: No 11 | # On Download: Yes 12 | # On Upgrade: Yes 13 | # On Rename: No 14 | # Path: /path/to/where/script/is/sonarr_cleanup_packed_torrent.sh 15 | # Arguments: 16 | 17 | # Tune values below to protect your torrents w/ small rar files or non-torrent download client. 18 | 19 | # In *bytes*, the biggest rar file size limit to prevent video deletion from torrents with unrelated rar files (like subs) 20 | # 25 * 1024 * 1024 21 | rar_min_size=26214400 22 | 23 | # Seconds to wait between size checks for in progress unpack 24 | unpack_time=5 25 | 26 | # The final base directory torrents end up in, for example "tv" from /data/torrents/tv 27 | sonarr_final_dir="Serie_Tv" 28 | 29 | # Identifiable portion of path to torrents, so it will only run on torrents. 30 | # For example, a path of "/data/torrents/tv", "torrents" is a good choice. 31 | torrent_path_portion="Automatici" 32 | 33 | # Test that this is a download event, so we don't run on grab or rename. 34 | # shellcheck disable=SC2154 35 | if [[ "${sonarr_eventtype}" != "Download" ]]; then 36 | echo "[Torrent Cleanup] Sonarr Event Type is NOT Download, exiting." 37 | exit 38 | fi 39 | 40 | # Test this file exists, no point running on a file that isn't there. 41 | # shellcheck disable=SC2154 42 | if ! [[ -f "${sonarr_episodefile_sourcepath}" ]]; then 43 | echo "[Torrent Cleanup] File ${sonarr_episodefile_sourcepath} does not exist, exiting." 44 | exit 45 | fi 46 | 47 | # Test that this is a torrent, so we don't run on usenet downloads. 48 | # shellcheck disable=SC2154 49 | if ! [[ "${sonarr_episodefile_sourcepath}" =~ ${torrent_path_portion} ]]; then 50 | echo "[Torrent Cleanup] Path ${sonarr_episodefile_sourcepath} does not contain \"torrent\", exiting." 51 | exit 52 | fi 53 | 54 | # Test that this is a multi-file torrent, so we don't run on single file torrents. 55 | # shellcheck disable=SC2154 56 | base_dir=$( basename "${sonarr_episodefile_sourcefolder}" ) 57 | if [[ "${base_dir}" == "${sonarr_final_dir}" ]]; then 58 | echo "[Torrent Cleanup] Single file torrent, exiting." 59 | exit 60 | fi 61 | 62 | # We might run while the unpack is still happening, so wait for that before removing. 63 | echo "[Torrent Cleanup] Starting wait for ${sonarr_episodefile_sourcepath} unpacking..." 64 | file_size_start=$( stat --printf="%s" "${sonarr_episodefile_sourcepath}" ) 65 | sleep ${unpack_time} 66 | file_size_end=$( stat --printf="%s" "${sonarr_episodefile_sourcepath}" ) 67 | until [[ ${file_size_start} -eq ${file_size_end} ]]; do 68 | file_size_start=$( stat --printf="%s" "${sonarr_episodefile_sourcepath}" ) 69 | sleep ${unpack_time} 70 | file_size_end=$( stat --printf="%s" "${sonarr_episodefile_sourcepath}" ) 71 | done 72 | echo "[Torrent Cleanup] Finished wait for ${sonarr_episodefile_sourcepath} unpacking..." 73 | 74 | # Test for rar and r## files and check the *size* of the biggest one so we don't run due to packed subs or something. 75 | # shellcheck disable=SC2154 76 | if find "${sonarr_episodefile_sourcefolder}" -type f -iregex '.*\.r[0-9a][0-9r]$' | grep -Eq '.*'; then 77 | # shellcheck disable=SC2154 78 | rar_size="$( find "${sonarr_episodefile_sourcefolder}" -type f -iregex '.*\.r[0-9a][0-9r]$' -ls | sort -nk 7 | tail -1 | awk '{ print $7 }' )" 79 | if [[ ${rar_size} -gt ${rar_min_size} ]]; then 80 | echo "[Torrent Cleanup] Rar file size ${rar_size} exceeds minimum of ${rar_min_size}, deleting video file." 81 | rm "${sonarr_episodefile_sourcepath}" 82 | else 83 | echo "[Torrent Cleanup] Rar file size ${rar_size} DOES NOT MEET minimum of ${rar_min_size}, skipping deletion of video file." 84 | fi 85 | else 86 | echo "[Torrent Cleanup] No rar files, exiting." 87 | fi 88 | -------------------------------------------------------------------------------- /qBittorrentHardlinksChecker/qBittorrentHardlinksChecker.sh.readme.md: -------------------------------------------------------------------------------- 1 | # qBittorrentHardlinksChecker.sh 2 | 3 | The idea of this script is very simple, it **checks qBittorrents Hard Links**. 4 | 5 | In my case it helps, judge for yourself if it helps you. 6 | 7 | For managing the seed times of automatic downloads from the various `*Arr`, I normally use [autoremove-torrent](https://github.com/jerrymakesjelly/autoremove-torrents). It is a very complete and useful script that allows me to pick and choose category by category, tracker by tracker, the various torrent removal settings. This is because my space available is not infinite. So I am forced to do a regular cleanup of the various downloads. I always respect the rules of the various private trackers! 8 | 9 | **But let's come to the idea:** Very simply, if the configuration within the automatic downloading programs `*Arr` is set to generate hardlinks, then it means that _until I have deleted both the file from the torrent client and the linked file that is managed automatically_, the space occupied on the disk will be the same. This means that as long as I haven't watched and deleted that movie (etc), I could safely keep the shared downloaded file, because it no longer takes up disk space, being a hardlink. 10 | 11 | With this script, for the categories you set, you can check each download. If there are _two or more_ hardlinks the file will not be deleted from qBittorrent. If on the other hand the file has _only one hardlink_, then the script will consider whether or not to delete the file by checking the minimum seed time that has been set. 12 | 13 | **Here is an example of usage:** Downloads that only end up in the automatic categories, e.g. `movie` for Radarr (or whatever your category is) rather than `tv_show` for Sonarr (or whatever your category is), **before** running [autoremove-torrent](https://github.com/jerrymakesjelly/autoremove-torrents) (which is appropriately configured previously)... I run this script and by doing so I make sure that any "duplicates" are not deleted and remain in seed. This helps me with the share ratio and minimum seed time. 14 | 15 | 16 | **How to use:** 17 | * First make sure your Radarr/Sonarr user can execute the script with something like this: 18 | * `chown USER:GROUP qBittorrentHardlinksChecker.sh` where `USER:GROUP` is the user and group of Radarr/Sonarr. 19 | * Then be sure it is executable: `chmod +x AddqBittorrentTrackers.sh` 20 | 21 | **Note:** not being a script that is called from `*Arr` it's not strictly necessary to change user and group, just make sure that the script can be executed by the user concerned. 22 | 23 | * Modify the scripts `########## CONFIGURATIONS ##########` section: 24 | * `qbt_username` -> username to access to qBittorrent Web UI. 25 | * `qbt_password` -> username to access to qBittorrent Web UI. 26 | * Note that if the script runs on the same device that runs qBittorrent, you can set `Bypass authentication for clients on localhost`. When the script executes, the username and password are not required. 27 | * `qbt_host` -> if the script is on the same device as qBittorrent use `http://localhost`, otherwise, set this to the remote device. 28 | * `qbt_port` -> is the Web UI port. 29 | * `category_list` -> is the list of categories upon which the script performs the check. 30 | * `min_seeding_time` -> is the minimum seed time expressed in seconds. 31 | * `only_private` -> if true, the script will only check the torrents that are from private trackers. In this way you can set [autoremove-torrent](https://github.com/jerrymakesjelly/autoremove-torrents) in order to remove only the remaining public trackers. This help the share ratio and helps you to find and remove torrents from public trackers with your own rules. 32 | * `private_torrents_check_orphan` -> This is only for private trackers. If `true`, check the torrent and if is not registered, it will be deleted. 33 | * `public_torrent_check_bad_trackers` -> Only for public torrents. If `true`, check the trackers and the bad one/s will be eliminated, but _not_ the torrent itself, _only_ the trackers. Be patient, this can be a "slow" function during the deleting/ion phase. 34 | 35 | I recommend you use this script with cron or create a timer for `systemd`. I personally use it via timer so runs right after [autoremove-torrent](https://github.com/jerrymakesjelly/autoremove-torrents) 36 | -------------------------------------------------------------------------------- /TransmissionRemoveCompleteTorrent.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | ########## CONFIGURATIONS ########## 4 | # Access Information for Transmission 5 | t_username= 6 | t_password= 7 | # Host where transmission runs 8 | t_host=localhost 9 | # Transmission port 10 | t_port=9091 11 | t_remote="$(command -v transmission-remote)" 12 | # Log 0 for disable | 1 for enable 13 | t_log=1 14 | t_log_path="/data/Varie" 15 | # Folder for automatic download? Be carefull must be different and not included into the default Download folder of transmission 16 | # for example if your download go to /data/download and your automatic download from transmission go to /data/download/automatic 17 | # you have to point to that automatic folder, because when the script run will search for automatic 18 | automatic_folder="Automatici" 19 | # If more than 0 this will indicate the max seed time (in days) for the automatic torrents. If reached the torrent will be deleted 20 | max_days_seed=7 21 | # If true, this will also delete data for non automatic torrent 22 | remove_normal=true 23 | ########## CONFIGURATIONS ########## 24 | 25 | if [[ "$t_log" == "1" ]]; then 26 | if [[ ! -w "$t_log_path/${0##*/}.log" ]]; then 27 | touch "$t_log_path/${0##*/}.log" 28 | fi 29 | fi 30 | 31 | [[ "$t_log" == "1" ]] && echo "########## $(date) ##########" >> "$t_log_path/${0##*/}.log" 32 | 33 | # use transmission-remote to get torrent list from transmission-remote list 34 | torrent_list=`$t_remote $t_host:$t_port -n=$t_username:$t_password -l | awk '{print $1}' | grep -o '[0-9]*'` 35 | # for each torrent in the list 36 | for torrent_id in $torrent_list; do 37 | torrent_name=`$t_remote $t_host:$t_port -n=$t_username:$t_password -t $torrent_id -i | grep Name: | sed -e 's/\s\sName:\s//'` 38 | [[ "$t_log" == "1" ]] && echo "* * * * * Checking torrent Nr. $torrent_id -> $torrent_name * * * * *" >> "$t_log_path/${0##*/}.log" 39 | 40 | # check if torrent download is completed 41 | percent_done=`$t_remote $t_host:$t_port -n=$t_username:$t_password -t $torrent_id -i | grep 'Percent Done' | awk '{print $3}' | sed 's/.$//'` 42 | done_auto=`$t_remote $t_host:$t_port -n=$t_username:$t_password -t $torrent_id -i | grep Location | awk '{print $2}' | grep "$automatic_folder"` 43 | done_seed=`$t_remote $t_host:$t_port -n=$t_username:$t_password -t $torrent_id -i | grep Seeding | awk -F'[()]' '{print $2}' | grep -o '[[:digit:]]*'` 44 | 45 | # check torrents current state is "Stopped", "Finished", or "Idle" 46 | state_stopped=`$t_remote $t_host:$t_port -n=$t_username:$t_password -t $torrent_id -i | grep "State: Stopped\|Finished"` 47 | 48 | if [ "$percent_done" == "100" ]; then # torrent complete 49 | [[ "$t_log" == "1" ]] && echo " Torrent done at $percent_done%" >> "$t_log_path/${0##*/}.log" 50 | if [ "$done_auto" != "" ]; then # automatic torrent 51 | [[ "$t_log" == "1" ]] && echo " Torrent is under automatic folder ..." >> "$t_log_path/${0##*/}.log" 52 | if [ "$state_stopped" != "" ]; then # transmission stopped the torrent 53 | [[ "$t_log" == "1" ]] && echo " Torrent is stopped, I'll remove torrent and data!" >> "$t_log_path/${0##*/}.log" 54 | $t_remote $t_host:$t_port -n=$t_username:$t_password -t $torrent_id -rad 55 | elif [ $(( done_seed / 60)) -gt $(( max_days_seed * 60 * 24)) ] && [ $max_days_seed -gt 0 ]; then # maximum seed time reached 56 | [[ "$t_log" == "1" ]] && echo " Torrent have a good seed time ($(( done_seed / 60))/$(( max_days_seed * 60 * 24)) minutes). I'll also remove the data!" >> "$t_log_path/${0##*/}.log" 57 | $t_remote $t_host:$t_port -n=$t_username:$t_password -t $torrent_id -rad 58 | else 59 | [[ "$t_log" == "1" ]] && echo " Torrent not yet fully finished. Seed time ($(( done_seed / 60))/$(( max_days_seed * 60 * 24)) minutes)" >> "$t_log_path/${0##*/}.log" 60 | fi 61 | else # not automatic torrent 62 | [[ "$t_log" == "1" ]] && echo " This's a normal torrent ..." >> "$t_log_path/${0##*/}.log" 63 | if [ "$state_stopped" != "" ]; then # transmission stopped the torrent 64 | [[ "$t_log" == "1" ]] && echo " Torrent is stopped" >> "$t_log_path/${0##*/}.log" 65 | if [[ "$remove_normal" == "true" ]]; then 66 | [[ "$t_log" == "1" ]] && echo " Also remove normal torrent is active, I'll remove torrent and data!" >> "$t_log_path/${0##*/}.log" 67 | $t_remote $t_host:$t_port -n=$t_username:$t_password -t $torrent_id -rad 68 | else 69 | $t_remote $t_host:$t_port -n=$t_username:$t_password -t $torrent_id -r 70 | fi 71 | elif [ $(( done_seed / 60 / 60 / 24)) -gt $max_days_seed ] && [ $max_days_seed -gt 0 ]; then # maximum seed time reached 72 | [[ "$t_log" == "1" ]] && echo " Torrent have a good seed time ($(( done_seed / 60))/$(( max_days_seed * 60 * 24)) minutes)" >> "$t_log_path/${0##*/}.log" 73 | if [[ "$remove_normal" == "true" ]]; then 74 | [[ "$t_log" == "1" ]] && echo " Also remove normal torrent is active, I'll remove torrent and data!" >> "$t_log_path/${0##*/}.log" 75 | $t_remote $t_host:$t_port -n=$t_username:$t_password -t $torrent_id -rad 76 | else 77 | $t_remote $t_host:$t_port -n=$t_username:$t_password -t $torrent_id -r 78 | fi 79 | else 80 | [[ "$t_log" == "1" ]] && echo " Torrent not yet fully finished. Seed time ($(( done_seed / 60))/$(( max_days_seed * 60 * 24)) minutes)" >> "$t_log_path/${0##*/}.log" 81 | fi 82 | fi 83 | elif [ "$percent_done" == "99.9" ] && [ "$state_stopped" != "" ]; then # torrent stalled 84 | [[ "$t_log" == "1" ]] && echo " Seems that torrent Nr. #$torrent_id is stalled, I'll try to restart it!" >> "$t_log_path/${0##*/}.log" 85 | $t_remote $t_host:$t_port -n=$t_username:$t_password -t $torrent_id -s 86 | elif [ "$percent_done" == "nan" ]; then # torrent not yet started 87 | [[ "$t_log" == "1" ]] && echo " Torrent not yet started" >> "$t_log_path/${0##*/}.log" 88 | else # torrent not complete 89 | [[ "$t_log" == "1" ]] && echo " Torrent not yet finished done at $percent_done%" >> "$t_log_path/${0##*/}.log" 90 | fi 91 | [[ "$t_log" == "1" ]] && echo -e "* * * * * Checking torrent Nr. $torrent_id complete. * * * * *\n" >> "$t_log_path/${0##*/}.log" 92 | done -------------------------------------------------------------------------------- /AudioMediaChecker/README.md: -------------------------------------------------------------------------------- 1 | # AudioMedia Checker 2 | 3 | ![Docker Pulls](https://img.shields.io/docker/pulls/chryses/audiomedia-checker) 4 | ![Docker Image Size](https://img.shields.io/docker/image-size/chryses/audiomedia-checker) 5 | ![GitHub](https://img.shields.io/github/license/Jorman/Scripts) 6 | 7 | > Automatic audio track language detection and tagging for video files using OpenAI Whisper 8 | 9 | ## Overview 10 | AudioMedia Checker is a Docker-based CLI tool that automatically detects the language of audio tracks in video files and corrects language tags using OpenAI's Whisper AI model. 11 | It's designed as a disposable container (`docker run --rm`) that can be integrated into automation scripts without requiring any local installation. The tool analyzes audio tracks without language tags (or with undefined tags) and updates MKV file metadata accordingly. For non-MKV formats, it performs read-only analysis in dry-run mode, ensuring safe operation. 12 | 13 | --- 14 | 15 | ## ✨ Features 16 | - **AI-Powered Detection** — Uses OpenAI Whisper for accurate language identification 17 | - **Automatic Tagging** — Updates language metadata in MKV files 18 | - **Flexible Analysis** — Single file or recursive folder processing 19 | - **Confidence Control** — Adjustable threshold (default: 65%) 20 | - **Force Override** — Manual language assignment when detection fails 21 | - **GPU Acceleration** — Optional CUDA support for faster processing 22 | - **Docker-Native** — No local dependencies, run-and-forget design 23 | - **Dry-Run Mode** — Safe testing without file modifications 24 | - **Selective Analysis** — Process only untagged tracks or all tracks 25 | - 📦 **Model Cache (recommended)** — Persist Whisper models under `/models` to avoid re-downloads between runs 26 | 27 | --- 28 | 29 | ## Quick Start 30 | 31 | ### 1) Prepare a persistent model cache (recommended) 32 | Create a directory on the host to persist Whisper model files and mount it to `/models` inside the container: 33 | ```bash 34 | sudo mkdir -p /opt/audiomedia-models 35 | sudo chown -R $(id -u):$(id -g) /opt/audiomedia-models 36 | ``` 37 | 38 | > Why: the application downloads model files to `/models`. Persisting this directory makes repeated runs much faster and saves bandwidth. 39 | 40 | ### 2) Analyze a Single File (CPU) 41 | ```bash 42 | docker run --rm \ 43 | -v /path/to/movies:/data \ 44 | -v /opt/audiomedia-models:/models \ 45 | chryses/audiomedia-checker:latest \ 46 | --file "/data/Movie.mkv" 47 | ``` 48 | 49 | ### 3) Analyze Folder Recursively (GPU) 50 | ```bash 51 | docker run --rm --gpus all \ 52 | -v /path/to/movies:/data \ 53 | -v /opt/audiomedia-models:/models \ 54 | chryses/audiomedia-checker:latest \ 55 | --gpu \ 56 | --folder "/data" \ 57 | --recursive 58 | ``` 59 | 60 | ### 4) Dry-Run Test (Safe Mode) 61 | ```bash 62 | docker run --rm \ 63 | -v /path/to/movies:/data \ 64 | -v /opt/audiomedia-models:/models \ 65 | chryses/audiomedia-checker:latest \ 66 | --dry-run \ 67 | --folder "/data/Movies" \ 68 | --verbose 69 | ``` 70 | 71 | --- 72 | 73 | ## Command-Line Arguments 74 | | Argument | Type | Default | Description | 75 | |----------|------|---------|-------------| 76 | | `--file` | string | - | Path to a single file to analyze | 77 | | `--folder` | string | - | Directory path to process | 78 | | `--recursive` | int | - | Depth levels (0 = unlimited, >0 = specific depth) | 79 | | `--check-all-tracks` | flag | false | Analyze all tracks, not just untagged ones | 80 | | `--verbose` | flag | false | Enable detailed logging | 81 | | `--dry-run` | flag | false | Simulate operations without modifying files | 82 | | `--force-language` | string | - | Force specific language (ISO 639-2, 3 letters) | 83 | | `--confidence` | int | 65 | Detection confidence threshold (0-100) | 84 | | `--model` | string | base | Whisper model size (see below) | 85 | | `--gpu` | flag | false | Use GPU acceleration (requires NVIDIA GPU) | 86 | | `--help-languages` | flag | false | Show available language codes | 87 | 88 | --- 89 | 90 | ### Whisper Models 91 | | Model | Size | Speed | Accuracy | Recommended For | 92 | |-------|------|-------|----------|-----------------| 93 | | `tiny` | ~39 MB | ⚡⚡⚡ | ⭐⭐ | Quick tests | 94 | | `base` | ~74 MB | ⚡⚡ | ⭐⭐⭐ | **Default - Best balance** | 95 | | `small` | ~244 MB | ⚡ | ⭐⭐⭐⭐ | Better accuracy | 96 | | `medium` | ~769 MB | | ⭐⭐⭐⭐⭐ | High accuracy needed | 97 | | `large` | ~1550 MB | | ⭐⭐⭐⭐⭐ | Maximum accuracy | 98 | | `large-v3` | ~1550 MB | | ⭐⭐⭐⭐⭐ | Latest version | 99 | 100 | > Tip: `base` model provides excellent results for most use cases. Use larger models only if detection fails. 101 | 102 | --- 103 | 104 | ## Usage Examples 105 | 106 | ### Basic File Analysis (verbose) 107 | ```bash 108 | docker run --rm \ 109 | -v /media/movies:/data \ 110 | -v /opt/audiomedia-models:/models \ 111 | chryses/audiomedia-checker:latest \ 112 | --file "/data/MyMovie.mkv" \ 113 | --verbose 114 | ``` 115 | 116 | ### Recursive Folder with Custom Confidence and Model 117 | ```bash 118 | docker run --rm \ 119 | -v /media/library:/library \ 120 | -v /opt/audiomedia-models:/models \ 121 | chryses/audiomedia-checker:latest \ 122 | --folder "/library" \ 123 | --recursive 0 \ 124 | --confidence 70 \ 125 | --model small 126 | ``` 127 | 128 | ### Force Italian Language (fallback) 129 | ```bash 130 | docker run --rm \ 131 | -v /media/movies:/data \ 132 | -v /opt/audiomedia-models:/models \ 133 | chryses/audiomedia-checker:latest \ 134 | --folder "/data/Italian_Films" \ 135 | --force-language ita \ 136 | --recursive 137 | ``` 138 | 139 | ### GPU-Accelerated Processing (medium model) 140 | ```bash 141 | docker run --rm --gpus all \ 142 | -v /media/library:/data \ 143 | -v /opt/audiomedia-models:/models \ 144 | chryses/audiomedia-checker:latest \ 145 | --gpu \ 146 | --folder "/data" \ 147 | --recursive \ 148 | --model medium 149 | ``` 150 | 151 | ### Dry-Run on Mixed Formats (read-only) 152 | ```bash 153 | docker run --rm \ 154 | -v /media/downloads:/downloads \ 155 | -v /opt/audiomedia-models:/models \ 156 | chryses/audiomedia-checker:latest \ 157 | --dry-run \ 158 | --folder "/downloads" \ 159 | --check-all-tracks \ 160 | --verbose 161 | ``` 162 | 163 | --- 164 | 165 | ## How It Works 166 | 167 | ### Detection Logic 168 | 1. **Scans** MKV files (or all video formats in dry-run mode) 169 | 2. **Identifies** audio tracks without language tags 170 | 3. **Extracts** 30-second audio sample 171 | 4. **Analyzes** with Whisper AI model 172 | 5. **Updates** MKV metadata if confidence ≥ threshold 173 | 6. **Skips** modification for non-MKV formats (analysis only) 174 | 175 | > Note: models are downloaded to `/models`. Mount a persistent volume to avoid re-downloading on each run. 176 | 177 | --- 178 | 179 | ### File Format Support 180 | | Format | Detection | Tag Update | Notes | 181 | |--------|-----------|------------|-------| 182 | | `.mkv` | ✅ | ✅ | Fully supported | 183 | | `.mp4` | ✅ | ❌ | Dry-run only | 184 | | `.avi` | ✅ | ❌ | Dry-run only | 185 | | `.mov` | ✅ | ❌ | Dry-run only | 186 | | `.m4v` | ✅ | ❌ | Dry-run only | 187 | | `.flv` | ✅ | ❌ | Dry-run only | 188 | | `.wmv` | ✅ | ❌ | Dry-run only | 189 | | `.webm` | ✅ | ❌ | Dry-run only | 190 | 191 | > Safety: Non-MKV files are automatically analyzed in read-only mode to prevent accidental modifications. 192 | 193 | --- 194 | 195 | ### Language Support 196 | All languages supported by OpenAI Whisper: 197 | - **100+ languages** detected automatically 198 | - Tags use **ISO 639-2** format (3-letter codes) 199 | - Use `--help-languages` to see full list 200 | 201 | Common examples: `eng` (English), `ita` (Italian), `fra` (French), `spa` (Spanish), `deu` (German), `jpn` (Japanese), `kor` (Korean), `rus` (Russian), `chi` (Chinese) 202 | 203 | --- 204 | 205 | ## 🖥️ GPU Acceleration 206 | 207 | ### Requirements 208 | - NVIDIA GPU with CUDA support 209 | - [NVIDIA Container Toolkit](https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/install-guide.html) installed 210 | - Docker `--gpus` flag support 211 | 212 | ### Installation (Ubuntu/Debian) 213 | ```bash 214 | # Install NVIDIA Container Toolkit 215 | distribution=$(. /etc/os-release;echo $ID$VERSION_ID) 216 | curl -s -L https://nvidia.github.io/nvidia-docker/gpgkey | sudo apt-key add - 217 | curl -s -L https://nvidia.github.io/nvidia-docker/$distribution/nvidia-docker.list | \ 218 | sudo tee /etc/apt/sources.list.d/nvidia-docker.list 219 | sudo apt-get update && sudo apt-get install -y nvidia-container-toolkit 220 | sudo systemctl restart docker 221 | ``` 222 | 223 | --- 224 | 225 | ## ⚠️ Important Notes 226 | 227 | ### Modifications & Backups 228 | - ✅ **MKV files are modified in-place** (no backup created) 229 | - ✅ **Original video/audio streams untouched** (only metadata changes) 230 | - ⚠️ **No undo feature** — test with `--dry-run` first 231 | - Recommendation: Backup important files before first run 232 | 233 | ### Force Language Behavior 234 | > ⚠️ Current Limitation: `--force-language` applies to ALL tracks that either: 235 | > - Have no language tag 236 | > - Have confidence score below threshold 237 | 238 | Use only when you're certain all tracks share the same language. 239 | 240 | ### Recursive Depth 241 | ```bash 242 | --recursive # Unlimited depth (all subdirectories) 243 | --recursive 0 # Same as above 244 | --recursive 1 # Only immediate subdirectories 245 | --recursive 2 # Up to 2 levels deep 246 | ``` 247 | 248 | --- 249 | 250 | ## Integration Examples 251 | 252 | ### Automated Post-Processing Script 253 | ```bash 254 | #!/bin/bash 255 | # Process new downloads automatically 256 | DOWNLOAD_DIR="/media/downloads" 257 | LIBRARY_DIR="/media/library" 258 | 259 | # Analyze and tag 260 | docker run --rm \ 261 | -v "$DOWNLOAD_DIR:/data" \ 262 | -v /opt/audiomedia-models:/models \ 263 | chryses/audiomedia-checker:latest \ 264 | --folder "/data" \ 265 | --confidence 70 \ 266 | --model base 267 | 268 | # Move to library after tagging 269 | mv "$DOWNLOAD_DIR"/*.mkv "$LIBRARY_DIR/" 2>/dev/null || true 270 | ``` 271 | 272 | ### Cron Job (Daily Library Scan) 273 | ```bash 274 | # /etc/cron.daily/audiomedia-checker 275 | #!/bin/bash 276 | docker run --rm \ 277 | -v /media/library:/library \ 278 | -v /opt/audiomedia-models:/models \ 279 | chryses/audiomedia-checker:latest \ 280 | --folder "/library" \ 281 | --recursive \ 282 | --confidence 75 \ 283 | >> /var/log/audiomedia-checker.log 2>&1 284 | ``` 285 | 286 | ### Sonarr/Radarr Custom Script 287 | ```bash 288 | #!/bin/bash 289 | # Save as: /scripts/tag-audio.sh 290 | FILE_PATH="$1" # Passed by Sonarr/Radarr 291 | docker run --rm \ 292 | -v "$(dirname "$FILE_PATH"):/data" \ 293 | -v /opt/audiomedia-models:/models \ 294 | chryses/audiomedia-checker:latest \ 295 | --file "/data/$(basename "$FILE_PATH")" \ 296 | --model base 297 | ``` 298 | 299 | --- 300 | 301 | ## Docker Hub 302 | **Repository:** [chryses/audiomedia-checker](https://hub.docker.com/r/chryses/audiomedia-checker) 303 | 304 | ### Available Tags 305 | - `latest` — Latest stable release (recommended) 306 | - `[commit-sha]` — Specific commit builds for testing/rollback 307 | 308 | ### Supported Architectures 309 | - ✅ `linux/amd64` (x86_64) 310 | - ✅ `linux/arm64` (ARM 64-bit) 311 | 312 | ### Auto-Build 313 | Images are automatically built on every push to the `master` branch via GitHub Actions. 314 | 315 | --- 316 | 317 | ## Troubleshooting 318 | 319 | ### "GPU not detected" in Docker 320 | ```bash 321 | # Test GPU availability 322 | docker run --rm --gpus all nvidia/cuda:11.8.0-base-ubuntu22.04 nvidia-smi 323 | # If it fails, (re)install NVIDIA Container Toolkit and restart Docker 324 | ``` 325 | 326 | ### "Permission denied" on files 327 | Ensure your user has read/write access to mounted volumes: 328 | ```bash 329 | # Option 1: run as your user 330 | docker run --rm --user $(id -u):$(id -g) \ 331 | -v /media:/data \ 332 | -v /opt/audiomedia-models:/models \ 333 | chryses/audiomedia-checker:latest ... 334 | 335 | # Option 2: fix host permissions 336 | sudo chown -R $USER:$USER /media/library 337 | ``` 338 | 339 | ### Model cache not persistent 340 | - Make sure you mount a persistent volume: `-v /opt/audiomedia-models:/models` 341 | - Verify permissions on the host directory 342 | 343 | ### Low confidence scores 344 | 1) Try a larger model: `--model medium` 345 | 2) Lower threshold: `--confidence 50` 346 | 3) Ensure audio is clear (not corrupted) 347 | 4) Use `--force-language` as last resort 348 | 349 | ### High memory usage 350 | Large models require significant RAM: 351 | 352 | | Model | RAM Required | 353 | |-------|--------------| 354 | | tiny/base | ~2 GB | 355 | | small | ~4 GB | 356 | | medium | ~8 GB | 357 | | large | ~16 GB | 358 | 359 | Use smaller models on limited hardware. 360 | 361 | --- 362 | 363 | ## Contributing 364 | Contributions are welcome! Please feel free to submit a Pull Request. 365 | 366 | ### How to Contribute 367 | 1. Fork the repository 368 | 2. Create a feature branch (`git checkout -b feature/AmazingFeature`) 369 | 3. Commit your changes (`git commit -m 'Add some AmazingFeature'`) 370 | 4. Push to the branch (`git push origin feature/AmazingFeature`) 371 | 5. Open a Pull Request 372 | 373 | --- 374 | 375 | ## Support 376 | - **Bug Reports:** [GitHub Issues](https://github.com/Jorman/Scripts/issues) 377 | - **Discussions:** [GitHub Discussions](https://github.com/Jorman/Scripts/discussions) 378 | - **Docker Hub:** [chryses/audiomedia-checker](https://hub.docker.com/r/chryses/audiomedia-checker) 379 | 380 | --- 381 | 382 | ## Acknowledgments 383 | - **[OpenAI Whisper](https://github.com/openai/whisper)** — AI-powered speech recognition 384 | - **[MKVToolNix](https://mkvtoolnix.download/)** — MKV file manipulation 385 | - **[FFmpeg](https://ffmpeg.org/)** — Multimedia processing 386 | 387 | --- 388 | 389 | ## License 390 | This project is licensed under the **GNU General Public License v3.0** — see the [LICENSE](https://www.gnu.org/licenses/gpl-3.0.en.html) file for details. 391 | 392 | --- 393 | 394 | ## ⭐ Show Your Support 395 | If you find this project useful, please consider: 396 | - ⭐ Starring the repository on GitHub 397 | - Pulling the Docker image 398 | - Sharing with the media automation community 399 | 400 | --- 401 | **Made with ❤️ for audio perfectionists** 402 | **Powered by OpenAI Whisper** | **Source:** [GitHub](https://github.com/Jorman/Scripts) 403 | -------------------------------------------------------------------------------- /qBittorrentHardlinksChecker/README.md: -------------------------------------------------------------------------------- 1 | # qBittorrent Hardlinks Checker 2 | 3 | [![Python Version](https://img.shields.io/badge/python-3.6+-blue.svg)](https://www.python.org/downloads/) 4 | [![License](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE) 5 | 6 | Advanced Python script for automated torrent management in qBittorrent, with a special focus on hardlink checking to optimize disk space in environments with *Arr arrays (Sonarr, Radarr, etc.). 7 | 8 | ## 🌟 Key Features 9 | 10 | ### 🔗 Intelligent Hardlink Management 11 | The script checks if torrent files have multiple hardlinks. This is particularly useful when using *Arr arrays that import files via hardlinks: 12 | - **Hardlink present** → File has been imported, keeps the torrent even if seeding time exceeded 13 | - **No hardlink** → File occupies real space, can be removed after seeding 14 | 15 | ### 🔍 Automatic Checks 16 | - **Automatic recheck**: Identifies and forces recheck of torrents with file errors 17 | - **Non-working tracker removal**: Cleans dead trackers from public torrents 18 | - **Orphan torrent detection**: Identifies and removes torrents deleted from trackers (private only) 19 | - **Seeding time management**: Automatically removes torrents that completed minimum seeding 20 | - **Tracker updates**: Optional integration with external scripts for tracker updates 21 | 22 | ### ⚙️ Flexible Configuration 23 | - YAML configuration file for every usage scenario 24 | - Support for multiple configurations with separate files 25 | - Filters by category, torrent type (public/private) 26 | - Path mapping for Docker environments 27 | 28 | ## 📋 Requirements 29 | 30 | - Python 3.6 or higher 31 | - qBittorrent with Web UI enabled 32 | 33 | ### Python Dependencies 34 | ```bash 35 | pip install requests pyyaml colorama 36 | ``` 37 | 38 | ## 🚀 Installation 39 | 40 | 1. **Download the script** 41 | ```bash 42 | wget https://raw.githubusercontent.com/Jorman/Scripts/master/qBittorrentHardlinksChecker/qBittorrentHardlinksChecker.py 43 | chmod +x qBittorrentHardlinksChecker.py 44 | ``` 45 | 46 | 2. **Create configuration file** 47 | ```bash 48 | python qBittorrentHardlinksChecker.py --create-config 49 | ``` 50 | 51 | 3. **Edit configuration** (see Configuration section) 52 | 53 | ## ⚙️ Configuration 54 | 55 | ### Creating Configuration File 56 | 57 | The default configuration file is named `qBittorrentHardlinksChecker_config.yaml` and is automatically created with: 58 | 59 | ```bash 60 | python qBittorrentHardlinksChecker.py --create-config 61 | ``` 62 | 63 | ### Complete Configuration Example 64 | 65 | ```yaml 66 | # qBittorrent server configuration 67 | qbt_host: "http://10.0.0.100" # Server address (with http/https) 68 | qbt_port: "8081" # Web UI Port 69 | qbt_username: "admin" # Web UI Username 70 | qbt_password: "adminadmin" # Web UI Password 71 | 72 | # Options to automatically invoke the tracker update script 73 | enable_auto-update_trackers: true # Call up script to update trackers 74 | auto-update_trackers_script: "/utilities/scripts/AddqBittorrentTrackers.py" # Path of the script 75 | 76 | # Torrent management configuration 77 | # Minimum seeding time in seconds (e.g., 259200 = 3 days) 78 | # Set to 0 if you want to disable the min_seeding_time check 79 | min_seeding_time: 864000 80 | 81 | # List of categories to be processed 82 | # Use ["All"] for all categories 83 | # Use ["Uncategorized"] for torrents without category 84 | # Or specify categories: ["movies", "tv", "books"] 85 | categories: 86 | - "All" 87 | 88 | # Type of torrents to process: 89 | # "" = all torrents (public + private) 90 | # "public" = only public torrents 91 | # "private" = only private torrents 92 | torrent_type: "" 93 | 94 | # Enable/disable specific checks 95 | enable_recheck: true # Check and recheck torrents with errors 96 | enable_orphan_check: true # Check for orphaned torrents (private only) 97 | 98 | # States that identify a torrent as orphaned 99 | orphan_states: 100 | - "unregistered" 101 | - "not registered" 102 | - "not found" 103 | - "not working" 104 | - "torrent has been deleted" 105 | 106 | # Minimum number of peers before considering a torrent orphaned 107 | # Default: 1 108 | min_peers: 1 109 | 110 | # Path mapping (useful for Docker) 111 | # If qBittorrent sees paths different from the real system 112 | # virtual_path: "/downloads" # Path in qBittorrent 113 | # real_path: "/mnt/storage/torrents" # Real path on system 114 | ``` 115 | 116 | ### Configuration Parameters Explained 117 | 118 | #### qBittorrent Connection 119 | - `qbt_host`: qBittorrent server address (include `http://` or `https://`) 120 | - `qbt_port`: Web UI port (default: 8080) 121 | - `qbt_username`: Web UI username 122 | - `qbt_password`: Web UI password 123 | 124 | #### Tracker Updates 125 | - `enable_auto-update_trackers`: Enable call to external script for tracker updates 126 | - `auto-update_trackers_script`: Full path to the tracker update script 127 | 128 | **Note**: This script integrates with [AddqBittorrentTrackers](https://github.com/Jorman/Scripts/tree/master/AddqBittorrentTrackers) for automatic tracker updates. The script is called with the torrent name using the `-n` parameter. See the dedicated documentation for setup and configuration. 129 | 130 | **Configuration example:** 131 | ```yaml 132 | enable_auto-update_trackers: true 133 | auto-update_trackers_script: "/utilities/scripts/AddqBittorrentTrackers.py" 134 | ``` 135 | 136 | #### Seeding Management 137 | - `min_seeding_time`: Minimum seeding time in seconds before removal 138 | - `259200` = 3 days 139 | - `604800` = 7 days 140 | - `864000` = 10 days 141 | - `0` = disable time-based removal 142 | 143 | #### Category Filters 144 | - `["All"]`: Process all categories 145 | - `["Uncategorized"]`: Only torrents without category 146 | - `["movies", "tv"]`: Specific categories 147 | 148 | #### Torrent Type 149 | - `""`: All torrents (public + private) 150 | - `"public"`: Only public torrents 151 | - `"private"`: Only private torrents 152 | 153 | #### Checks 154 | - `enable_recheck`: Enable automatic recheck for torrents with errors 155 | - `enable_orphan_check`: Enable detection of orphaned torrents (works only on private torrents) 156 | 157 | #### Orphan Detection 158 | - `orphan_states`: List of tracker messages that identify an orphaned torrent 159 | - `min_peers`: Minimum number of peers before considering a torrent orphaned 160 | 161 | #### Path Mapping 162 | Useful when qBittorrent runs in Docker and sees different paths: 163 | ```yaml 164 | virtual_path: "/downloads" # Path as seen by qBittorrent 165 | real_path: "/mnt/storage/torrents" # Real path on host system 166 | ``` 167 | 168 | ## 🎯 Usage 169 | 170 | ### Basic Commands 171 | 172 | ```bash 173 | # Run with default configuration 174 | python qBittorrentHardlinksChecker.py 175 | 176 | # Run with custom configuration file 177 | python qBittorrentHardlinksChecker.py -c my_config.yaml 178 | 179 | # Dry-run mode (no actual changes, only simulation) 180 | python qBittorrentHardlinksChecker.py --dry-run 181 | 182 | # Create default configuration file 183 | python qBittorrentHardlinksChecker.py --create-config 184 | 185 | # Create custom configuration file 186 | python qBittorrentHardlinksChecker.py --create-config -c custom_config.yaml 187 | ``` 188 | 189 | ### Multiple Configurations 190 | 191 | You can create different configurations for different scenarios: 192 | 193 | ```bash 194 | # Configuration for movies 195 | python qBittorrentHardlinksChecker.py -c movies_config.yaml 196 | 197 | # Configuration for TV series 198 | python qBittorrentHardlinksChecker.py -c tv_config.yaml 199 | 200 | # Configuration for public torrents only 201 | python qBittorrentHardlinksChecker.py -c public_config.yaml 202 | ``` 203 | 204 | ### Command Line Arguments 205 | 206 | - `-c, --config`: Path to YAML configuration file (default: `qBittorrentHardlinksChecker_config.yaml`) 207 | - `--dry-run`: Run in simulation mode without making actual changes 208 | - `--create-config`: Create a default configuration file 209 | 210 | ## 🔄 How It Works 211 | 212 | ### 1. Initial Checks 213 | - ✅ Recheck torrents with file errors (if enabled) 214 | - ✅ Verify hardlinks for each torrent file 215 | - ✅ Display hardlink statistics 216 | 217 | ### 2. Public Torrent Management 218 | - ✅ Check and remove non-working trackers 219 | - ✅ Tracker updates (if enabled via [AddqBittorrentTrackers](https://github.com/Jorman/Scripts/tree/master/AddqBittorrentTrackers)) 220 | - ✅ Seeding time management 221 | - ✅ Hardlink verification 222 | 223 | ### 3. Private Torrent Management 224 | - ✅ Orphan torrent detection 225 | - ✅ Seeding time management 226 | - ✅ Hardlink verification 227 | 228 | ### 4. Final Report 229 | - 📊 Total torrents processed 230 | - 📊 Torrents removed 231 | - 📊 Torrents with hardlinks preserved 232 | - 📊 Tracker updates performed 233 | 234 | ## 🐳 Docker Usage 235 | 236 | ### docker-compose.yml Example 237 | 238 | ```yaml 239 | version: '3' 240 | 241 | services: 242 | qbittorrent-manager: 243 | image: python:3.9-slim 244 | container_name: qbittorrent-manager 245 | volumes: 246 | - ./qBittorrentHardlinksChecker.py:/app/qBittorrentHardlinksChecker.py 247 | - ./config.yaml:/app/config.yaml 248 | - /path/to/torrents:/data # Same mount as qBittorrent 249 | working_dir: /app 250 | command: > 251 | sh -c "pip install requests pyyaml colorama && 252 | python qBittorrentHardlinksChecker.py -c config.yaml" 253 | restart: "no" 254 | ``` 255 | 256 | ### Important for Docker 257 | 258 | 1. **Volume Mapping**: Mount the same torrent directories as qBittorrent 259 | 2. **Path Mapping**: Configure `virtual_path` and `real_path` if needed 260 | 3. **Network**: Ensure the container can reach qBittorrent 261 | 262 | ## ⏰ Automation with Cron 263 | 264 | ### Example Crontab 265 | 266 | ```bash 267 | # Edit crontab 268 | crontab -e 269 | 270 | # Run every 6 hours 271 | 0 */6 * * * /usr/bin/python3 /path/to/qBittorrentHardlinksChecker.py -c /path/to/config.yaml >> /var/log/qbt_manager.log 2>&1 272 | 273 | # Run daily at 3 AM 274 | 0 3 * * * /usr/bin/python3 /path/to/qBittorrentHardlinksChecker.py -c /path/to/config.yaml 275 | 276 | # Multiple configurations at different times 277 | 0 2 * * * /usr/bin/python3 /path/to/qBittorrentHardlinksChecker.py -c /path/to/movies_config.yaml 278 | 0 4 * * * /usr/bin/python3 /path/to/qBittorrentHardlinksChecker.py -c /path/to/tv_config.yaml 279 | ``` 280 | 281 | ## 🔐 Security Best Practices 282 | 283 | 1. **Protect configuration file** 284 | ```bash 285 | chmod 600 *_config.yaml 286 | ``` 287 | 288 | 2. **Use environment variables for credentials** (future feature) 289 | 290 | 3. **Add configuration files to .gitignore** 291 | 292 | 4. **Always test with --dry-run** before production use 293 | 294 | ### .gitignore File 295 | ``` 296 | *_config.yaml 297 | *.yaml 298 | config.yaml 299 | ``` 300 | 301 | ## 📖 Usage Examples 302 | 303 | ### Scenario 1: Initial Setup with *Arr 304 | ```yaml 305 | # Conservative configuration to start 306 | min_seeding_time: 604800 # 7 days 307 | categories: ["All"] 308 | torrent_type: "" 309 | enable_recheck: true 310 | enable_orphan_check: true 311 | ``` 312 | 313 | ```bash 314 | # First run in dry-run mode 315 | python qBittorrentHardlinksChecker.py --dry-run 316 | 317 | # If everything is ok, real execution 318 | python qBittorrentHardlinksChecker.py 319 | ``` 320 | 321 | ### Scenario 2: Public Tracker Cleanup Only 322 | ```yaml 323 | min_seeding_time: 0 # Disable time-based removal 324 | categories: ["All"] 325 | torrent_type: "public" 326 | enable_recheck: false 327 | enable_orphan_check: false 328 | enable_auto-update_trackers: true 329 | ``` 330 | 331 | ### Scenario 3: Aggressive Space Management 332 | ```yaml 333 | min_seeding_time: 259200 # 3 days 334 | categories: ["tv", "movies"] 335 | torrent_type: "" 336 | enable_recheck: true 337 | enable_orphan_check: true 338 | ``` 339 | 340 | ### Scenario 4: Private Trackers Only 341 | ```yaml 342 | min_seeding_time: 864000 # 10 days 343 | categories: ["All"] 344 | torrent_type: "private" 345 | enable_recheck: true 346 | enable_orphan_check: true 347 | ``` 348 | 349 | ## 🔍 Troubleshooting 350 | 351 | ### Script Cannot Find Files 352 | **Problem**: Incorrect path mapping (common with Docker) 353 | 354 | **Solution**: 355 | ```yaml 356 | # Check paths in qBittorrent Web UI 357 | # Then map correctly: 358 | virtual_path: "/downloads" # Path in qBittorrent 359 | real_path: "/mnt/storage/torrents" # Real path on system 360 | ``` 361 | 362 | ### Torrents with Hardlinks Are Being Removed 363 | **Problem**: Script not detecting hardlinks 364 | 365 | **Solution**: 366 | 1. Verify that permissions allow reading files 367 | 2. Check that path mapping is correct 368 | 3. Run with `--dry-run` to see hardlink count 369 | 370 | ### qBittorrent Connection Error 371 | **Solution**: 372 | ```yaml 373 | # Verify configuration 374 | qbt_host: "http://CORRECT_IP" # Don't forget http:// 375 | qbt_port: "CORRECT_PORT" # Check in qBittorrent > Options > Web UI 376 | ``` 377 | 378 | ### Orphan Torrents Not Being Detected 379 | **Cause**: Orphan check only works on **private** torrents 380 | 381 | **Verify**: 382 | ```yaml 383 | enable_orphan_check: true 384 | torrent_type: "" # or "private" 385 | ``` 386 | 387 | ### Tracker Update Script Not Working 388 | **Problem**: AddqBittorrentTrackers script not found or not executable 389 | 390 | **Solution**: 391 | 1. Verify the script path is correct 392 | 2. Ensure the script is executable: `chmod +x /path/to/AddqBittorrentTrackers.py` 393 | 3. Check the [AddqBittorrentTrackers documentation](https://github.com/Jorman/Scripts/tree/master/AddqBittorrentTrackers) 394 | 395 | ## 🤝 Contributing 396 | 397 | Contributions are welcome! Feel free to: 398 | - Open issues for bugs or feature requests 399 | - Propose pull requests 400 | - Improve documentation 401 | 402 | ## 📄 License 403 | 404 | This project is released under the MIT License. 405 | 406 | ## 🙏 Acknowledgments 407 | 408 | - qBittorrent Community 409 | - *Arr array developers 410 | - All contributors 411 | 412 | ## 📞 Support 413 | 414 | For issues or questions: 415 | - Open an [Issue](https://github.com/Jorman/Scripts/issues) 416 | - Start a [Discussion](https://github.com/Jorman/Scripts/discussions) 417 | 418 | --- 419 | 420 | **Note**: This script is provided "as is". Always test it in a development environment before production use. 421 | -------------------------------------------------------------------------------- /qBittorrentHardlinksChecker/qBittorrentHardlinksChecker.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | ########## CONFIGURATIONS ########## 4 | # Host on which qBittorrent runs 5 | qbt_host="http://10.0.0.100" 6 | # Port -> the same port that is inside qBittorrent option -> Web UI -> Web User Interface 7 | qbt_port="8081" 8 | # Username to access to Web UI 9 | qbt_username="admin" 10 | # Password to access to Web UI 11 | qbt_password="adminadmin" 12 | 13 | # Configure here your categories, comma separated, like -> movie,tv_show 14 | categories='Serie_Tv,Film' 15 | 16 | # Minimum seed time before deletion, expressed in seconds, for example 864000 means 10 days 17 | min_seeding_time=864000 18 | 19 | # Using docker it may happen that the path is different from the real one, this allows to replace part of qBittorrent path, turning it into the real one 20 | # In this example, if a download within qBittorren has /oldpath/film as its path, the script will interpret it as /new/path/film 21 | # This allows volumes within qBittorrent to be mounted differently from the actual path on disk 22 | # Leave empty if not needed 23 | virtual_path="oldpath" # The qBittorrent path, or the part you want to change 24 | real_path="new/path" # The new part of the path 25 | 26 | # Check only private torrents? if not true (lowercase) will check all torrents in given categories not only the private one 27 | only_private=true 28 | 29 | # If true, only for private tracker, check the torrent and if is not registered will be deleted 30 | private_torrents_check_orphan=true 31 | 32 | # If true, only for public torrent, check the trackers and the bad one will be eliminated, not the torrent only the trackers 33 | public_torrent_check_bad_trackers=true 34 | 35 | # If true, if there's some torrent in error, a force recheck is actuaded, this try to start again the torrent 36 | receck_erroring_torrent=true 37 | ########## CONFIGURATIONS ########## 38 | 39 | jq_executable="$(command -v jq)" 40 | curl_executable="$(command -v curl)" 41 | 42 | if [[ -z $jq_executable ]]; then 43 | echo -e "\n\e[0;91;1mFail on jq. Aborting.\n\e[0m" 44 | echo "You can find it here: https://stedolan.github.io/jq/" 45 | echo "Or you can install it with -> sudo apt install jq" 46 | exit 1 47 | fi 48 | 49 | if [[ -z $curl_executable ]]; then 50 | echo -e "\n\e[0;91;1mFail on curl. Aborting.\n\e[0m" 51 | echo "You can install it with -> sudo apt install curl" 52 | exit 2 53 | fi 54 | 55 | if [[ "${qbt_host,,}" == *"https"* ]] ;then 56 | curl_executable="${curl_executable} --insecure" 57 | fi 58 | 59 | # Variable to keep track of dryrun mode 60 | dryrun=false 61 | 62 | if [ "$1" == "test" ]; then 63 | dryrun=true 64 | echo "Dryrun mode turned on." 65 | echo "" 66 | fi 67 | 68 | ########## FUNCTIONS ########## 69 | url_encode() { 70 | local string="${1}" 71 | 72 | # Check if xxd is available 73 | if command -v xxd >/dev/null 2>&1; then 74 | # If xxd is available, use xxd for encoding 75 | printf '%s' "$string" | xxd -p | sed 's/\(..\)/%\1/g' | tr -d '\n' 76 | else 77 | # If jq is available, use jq for encoding 78 | jq -nr --arg s "$string" '$s|@uri' 79 | fi 80 | } 81 | 82 | get_cookie () { 83 | encoded_username=$(url_encode "$qbt_username") 84 | encoded_password=$(url_encode "$qbt_password") 85 | 86 | # If encoding fails, exit the function 87 | if [ $? -ne 0 ]; then 88 | echo "Error during URL encoding" >&2 89 | return 1 90 | fi 91 | 92 | qbt_cookie=$($curl_executable --silent --fail --show-error \ 93 | --header "Referer: ${qbt_host}:${qbt_port}" \ 94 | --cookie-jar - \ 95 | --data "username=${encoded_username}&password=${encoded_password}" ${qbt_host}:${qbt_port}/api/v2/auth/login) 96 | } 97 | 98 | get_torrent_list () { 99 | [[ -z "$qbt_cookie" ]] && get_cookie 100 | torrent_list=$(echo "$qbt_cookie" | $curl_executable --silent --fail --show-error \ 101 | --cookie - \ 102 | --request GET "${qbt_host}:${qbt_port}/api/v2/torrents/info") 103 | } 104 | 105 | delete_torrent () { 106 | hash="$1" 107 | echo "$qbt_cookie" | $curl_executable --silent --fail --show-error \ 108 | -d "hashes=${hash}&deleteFiles=true" \ 109 | --cookie - \ 110 | --request POST "${qbt_host}:${qbt_port}/api/v2/torrents/delete" 111 | echo "Deleted" 112 | } 113 | 114 | recheck_torrent () { 115 | hash="$1" 116 | echo "$qbt_cookie" | $curl_executable --silent --fail --show-error \ 117 | -d "hashes=${hash}" \ 118 | --cookie - \ 119 | --request POST "${qbt_host}:${qbt_port}/api/v2/torrents/recheck" 120 | echo "Command executed" 121 | } 122 | 123 | reannounce_torrent () { 124 | hash="$1" 125 | echo "$qbt_cookie" | $curl_executable --silent --fail --show-error \ 126 | -d "hashes=${hash}" \ 127 | --cookie - \ 128 | --request POST "${qbt_host}:${qbt_port}/api/v2/torrents/reannounce" 129 | } 130 | 131 | remove_bad_tracker () { 132 | hash="$1" 133 | single_url="$2" 134 | echo "$qbt_cookie" | $curl_executable --silent --fail --show-error \ 135 | -d "hash=${hash}&urls=${single_url}" \ 136 | --cookie - \ 137 | --request POST "${qbt_host}:${qbt_port}/api/v2/torrents/removeTrackers" 138 | } 139 | 140 | unset_array () { 141 | array_element="$1" 142 | unset torrent_name_array[$array_element] 143 | unset torrent_hash_array[$array_element] 144 | unset torrent_path_array[$array_element] 145 | unset torrent_seeding_time_array[$array_element] 146 | unset torrent_progress_array[$array_element] 147 | unset private_torrent_array[$array_element] 148 | unset torrent_trackers_array[$array_element] 149 | unset torrent_category_array[$array_element] 150 | } 151 | 152 | wait() { 153 | w=$1 154 | echo "I'll wait ${w}s to be sure the reannunce going well..." 155 | while [ $w -gt 0 ]; do 156 | echo -ne "$w\033[0K\r" 157 | sleep 1 158 | w=$((w-1)) 159 | done 160 | } 161 | 162 | check_hardlinks() { 163 | local path="$1" 164 | local more_hard_links=false 165 | 166 | if [ -d "$path" ]; then 167 | # È una directory, controlla i file all'interno 168 | while IFS= read -r -d $'\0' file; do 169 | if [ "$(stat -c %h "$file")" -gt 1 ]; then 170 | more_hard_links=true 171 | break 172 | fi 173 | done < <(find "$path" -type f -print0) 174 | else 175 | # È un file 176 | if [ "$(stat -c %h "$path")" -gt 1 ]; then 177 | more_hard_links=true 178 | fi 179 | fi 180 | 181 | echo "$more_hard_links" 182 | } 183 | ########## FUNCTIONS ########## 184 | 185 | get_torrent_list 186 | 187 | if [ -z "$torrent_list" ]; then 188 | echo "No torrents founds to check" 189 | exit 190 | fi 191 | 192 | echo "Collecting data from qBittorrent, wait..." 193 | 194 | torrent_name_array=() 195 | torrent_hash_array=() 196 | torrent_path_array=() 197 | torrent_seeding_time_array=() 198 | torrent_progress_array=() 199 | private_torrent_array=() 200 | torrent_trackers_array=() 201 | torrent_category_array=() 202 | 203 | while IFS= read -r line; do 204 | torrent_name_array+=("$line") 205 | done < <(echo $torrent_list | $jq_executable --raw-output '.[] | .name') 206 | 207 | while IFS= read -r line; do 208 | torrent_hash_array+=("$line") 209 | done < <(echo $torrent_list | $jq_executable --raw-output '.[] | .hash') 210 | 211 | while IFS= read -r line; do 212 | torrent_path_array+=("$line") 213 | done < <(echo $torrent_list | $jq_executable --raw-output '.[] | .content_path') 214 | 215 | while IFS= read -r line; do 216 | torrent_seeding_time_array+=("$line") 217 | done < <(echo $torrent_list | $jq_executable --raw-output '.[] | .seeding_time') 218 | 219 | while IFS= read -r line; do 220 | torrent_progress_array+=("$line") 221 | done < <(echo $torrent_list | $jq_executable --raw-output '.[] | .progress') 222 | 223 | while IFS= read -r line; do 224 | torrent_category_array+=("$line") 225 | done < <(echo $torrent_list | $jq_executable --raw-output '.[] | .category') 226 | 227 | while IFS= read -r line; do 228 | torrent_state_array+=("$line") 229 | done < <(echo $torrent_list | $jq_executable --raw-output '.[] | .state') 230 | 231 | for i in "${!torrent_hash_array[@]}"; do 232 | torrent_trackers_array[$i]=$(echo "$qbt_cookie" | $curl_executable --silent --fail --show-error \ 233 | --cookie - \ 234 | --request GET "${qbt_host}:${qbt_port}/api/v2/torrents/trackers?hash=${torrent_hash_array[$i]}") 235 | done 236 | 237 | for i in "${!torrent_hash_array[@]}"; do 238 | private_torrent_array[$i]=$(echo "$qbt_cookie" | $curl_executable --silent --fail --show-error \ 239 | --cookie - \ 240 | --request GET "${qbt_host}:${qbt_port}/api/v2/torrents/properties?hash=${torrent_hash_array[$i]}" | $jq_executable --raw-output '.is_private') 241 | done 242 | 243 | if [ -n "$categories" ]; then 244 | echo "Checking hardlinks:" 245 | for j in ${categories//,/ }; do 246 | test=$(echo "$torrent_list" | $jq_executable --raw-output --arg tosearch "$j" '.[] | select(.category == "\($tosearch)") | .name') 247 | 248 | if [[ -z "$test" ]]; then 249 | echo "There's no categories named ${j} or is empty" 250 | continue 251 | else 252 | echo "#####################################" 253 | echo "Checking category ${j}:" 254 | echo "#####################################" 255 | echo "" 256 | 257 | for i in "${!torrent_hash_array[@]}"; do 258 | if [[ $only_private == true ]]; then 259 | if [[ ${torrent_category_array[$i]} == ${j} ]] && [[ ${private_torrent_array[$i]} == true ]]; then 260 | echo "Analyzing torrent -> ${torrent_name_array[$i]}" 261 | 262 | if awk "BEGIN {exit !(${torrent_progress_array[$i]} < 1)}rent"; then 263 | printf "Torrent incomplete, nothing to do -> %0.3g%%\n" $(awk -v var="${torrent_progress_array[$i]}" 'BEGIN{print var * 100}') 264 | else 265 | 266 | if [ -z "$virtual_path" ] || [ -z "$real_path" ]; then 267 | result="${torrent_path_array[$i]}" 268 | else 269 | result=$(echo "${torrent_path_array[$i]/$virtual_path/"$real_path"}") 270 | fi 271 | 272 | more_hard_links=$(check_hardlinks "$result") 273 | 274 | if [ "$more_hard_links" = true ]; then 275 | echo "More than 1 hardlinks found in $result" 276 | else 277 | echo "No additional hardlinks found in $result" 278 | fi 279 | 280 | if [[ $more_hard_links == false ]]; then 281 | echo "Found 1 hardlinks, checking seeding time:" 282 | if [ ${torrent_seeding_time_array[$i]} -gt $min_seeding_time ]; then 283 | echo "I can delete this torrent, seeding time more than $min_seeding_time seconds" 284 | 285 | if [[ $dryrun == true ]]; then 286 | echo "Simulation (dryrun)..." 287 | echo "reannounce torrent" 288 | echo "wait 15 seconds..." 289 | echo "delete torrent ${torrent_name_array[$i]}" 290 | unset_array $i 291 | else 292 | reannounce_torrent ${torrent_hash_array[$i]} 293 | wait 15 294 | delete_torrent ${torrent_hash_array[$i]} 295 | unset_array $i 296 | fi 297 | 298 | else 299 | echo "I can't delete this torrent, seeding time not meet -> ${torrent_seeding_time_array[$i]}/${min_seeding_time}" 300 | fi 301 | else 302 | echo "More than 1 hardlinks found, nothing to do" 303 | fi 304 | fi 305 | echo "------------------------------" 306 | fi 307 | else 308 | if [[ ${torrent_category_array[$i]} == ${j} ]]; then 309 | echo "Analyzing torrent -> ${torrent_name_array[$i]}" 310 | 311 | if awk "BEGIN {exit !(${torrent_progress_array[$i]} < 1)}rent"; then 312 | printf "Torrent incomplete, nothing to do -> %0.3g%%\n" $(awk -v var="${torrent_progress_array[$i]}" 'BEGIN{print var * 100}') 313 | else 314 | 315 | if [ -z "$virtual_path" ] || [ -z "$real_path" ]; then 316 | result="${torrent_path_array[$i]}" 317 | else 318 | result=$(echo "${torrent_path_array[$i]/$virtual_path/"$real_path"}") 319 | fi 320 | 321 | more_hard_links=$(check_hardlinks "$result") 322 | 323 | if [ "$more_hard_links" = true ]; then 324 | echo "More than 1 hardlinks found in $result" 325 | else 326 | echo "No additional hardlinks found in $result" 327 | fi 328 | 329 | if [[ $more_hard_links == false ]]; then 330 | echo "Found 1 hardlinks, checking seeding time:" 331 | if [ ${torrent_seeding_time_array[$i]} -gt $min_seeding_time ]; then 332 | echo "I can delete this torrent, seeding time more than $min_seeding_time seconds" 333 | 334 | if [[ $dryrun == true ]]; then 335 | echo "Simulation (dryrun)..." 336 | echo "reannounce torrent" 337 | echo "wait 15 seconds..." 338 | echo "delete torrent ${torrent_name_array[$i]}" 339 | unset_array $i 340 | else 341 | reannounce_torrent ${torrent_hash_array[$i]} 342 | wait 15 343 | delete_torrent ${torrent_hash_array[$i]} 344 | unset_array $i 345 | fi 346 | else 347 | echo "I can't delete this torrent, seeding time not meet -> ${torrent_seeding_time_array[$i]}/${min_seeding_time}" 348 | fi 349 | else 350 | echo "More than 1 hardlinks found, nothing to do" 351 | fi 352 | fi 353 | echo "------------------------------" 354 | fi 355 | fi 356 | done 357 | fi 358 | done 359 | echo "Harklinks check completed" 360 | echo "------------------------------" 361 | else 362 | echo "Categories list empty" 363 | echo "------------------------------" 364 | fi 365 | 366 | if [[ $private_torrents_check_orphan == true ]]; then 367 | echo "Checking for orphan torrents:" 368 | 369 | for i in "${!torrent_hash_array[@]}"; do 370 | orphan_torrent=$(echo ${torrent_trackers_array[$i]} | $jq_executable --raw-output '.[] | select((.status == 4) and (.num_peers < 1) and ((.msg|test("unregistered"; "i")) or (.msg|test("not registered"; "i")))) | any') 371 | if [[ $orphan_torrent == true ]]; then 372 | echo "Found orphan torrent -> ${torrent_name_array[$i]}, deleting" 373 | 374 | if [[ $dryrun == true ]]; then 375 | echo "Simulation (dryrun)..." 376 | echo "delete torrent ${torrent_name_array[$i]}" 377 | unset_array $i 378 | else 379 | delete_torrent ${torrent_hash_array[$i]} 380 | unset_array $i 381 | fi 382 | 383 | echo "------------------------------" 384 | fi 385 | done 386 | echo "Orphan check completed" 387 | echo "------------------------------" 388 | fi 389 | 390 | if [[ $public_torrent_check_bad_trackers == true ]]; then 391 | echo "Checking for bad trackers:" 392 | 393 | for i in "${!torrent_hash_array[@]}"; do 394 | if [[ ${private_torrent_array[$i]} != true ]]; then 395 | url_list=$(echo ${torrent_trackers_array[$i]} | $jq_executable --raw-output '.[] | select((.status == 4) and (.num_peers < 1)) .url') 396 | 397 | if [[ ! -z "$url_list" ]]; then 398 | echo "Some problem found on -> ${torrent_name_array[$i]}" 399 | echo "fixing..." 400 | 401 | if [[ $dryrun == true ]]; then 402 | echo "Simulation (dryrun)..." 403 | echo "removing bad tracker for torrent ${torrent_name_array[$i]}" 404 | else 405 | remove_bad_tracker ${torrent_hash_array[$i]} $(echo $url_list | tr '\n' ' ' | tr ' ' '|' | rev | cut -c2- | rev) 406 | fi 407 | 408 | echo "------------------------------" 409 | else 410 | continue 411 | fi 412 | fi 413 | done 414 | echo "Bad trackers check completed" 415 | echo "------------------------------" 416 | fi 417 | 418 | if [[ $receck_erroring_torrent == true ]]; then 419 | echo "Checking for errored torrent:" 420 | 421 | for i in "${!torrent_hash_array[@]}"; do 422 | if [[ ${torrent_state_array[$i]} == "error" ]]; then 423 | echo "Found erroring torrent -> ${torrent_name_array[$i]}, I'll recheck it" 424 | 425 | if [[ $dryrun == true ]]; then 426 | echo "Simulation (dryrun)..." 427 | echo "checking torrent ${torrent_name_array[$i]}" 428 | else 429 | recheck_torrent ${torrent_hash_array[$i]} 430 | fi 431 | 432 | echo "------------------------------" 433 | fi 434 | done 435 | echo "Error check completed" 436 | echo "------------------------------" 437 | fi -------------------------------------------------------------------------------- /AddTransmissionTrackers/AddTransmissionTrackers.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | ########## CONFIGURATIONS ########## 4 | # Host on which qBittorrent runs 5 | transmission_host="http://10.0.0.100" 6 | # Port -> the same port that is inside qBittorrent option -> Web UI -> Web User Interface 7 | transmission_port="9091" 8 | # Username to access to Web UI 9 | transmission_username="transmission" 10 | # Password to access to Web UI 11 | transmission_password="transmission" 12 | 13 | # If true (lowercase) the script will inject trackers inside private torrent too (not a good idea) 14 | ignore_private=false 15 | 16 | # Configure here your trackers list 17 | declare -a live_trackers_list_urls=( 18 | "https://newtrackon.com/api/stable" 19 | "https://trackerslist.com/best.txt" 20 | "https://trackerslist.com/http.txt" 21 | "https://raw.githubusercontent.com/ngosang/trackerslist/master/trackers_best.txt" 22 | ) 23 | ########## CONFIGURATIONS ########## 24 | 25 | jq_executable="$(command -v jq)" 26 | curl_executable="$(command -v curl)" 27 | auto_tor_grab=0 28 | test_in_progress=0 29 | applytheforce=0 30 | all_torrent=0 31 | 32 | if [[ -z $jq_executable ]]; then 33 | echo -e "\n\e[0;91;1mFail on jq. Aborting.\n\e[0m" 34 | echo "You can find it here: https://stedolan.github.io/jq/" 35 | echo "Or you can install it with -> sudo apt install jq" 36 | exit 1 37 | fi 38 | 39 | if [[ -z $curl_executable ]]; then 40 | echo -e "\n\e[0;91;1mFail on curl. Aborting.\n\e[0m" 41 | echo "You can install it with -> sudo apt install curl" 42 | exit 2 43 | fi 44 | 45 | ########## FUNCTIONS ########## 46 | generate_trackers_list () { 47 | for j in "${live_trackers_list_urls[@]}"; do 48 | tmp_trackers_list+=$($curl_executable -sS $j) 49 | tmp_trackers_list+=$'\n' 50 | done 51 | 52 | trackers_list=$(echo "$tmp_trackers_list" | awk '{for (i=1;i<=NF;i++) if (!a[$i]++) printf("%s%s",$i,FS)}{printf("\n")}' | xargs | tr ' ' '\n') 53 | if [[ $? -ne 0 ]]; then 54 | echo "I can't download the list, I'll use a static one" 55 | cat >"${trackers_list}" <<'EOL' 56 | udp://tracker.coppersurfer.tk:6969/announce 57 | http://tracker.internetwarriors.net:1337/announce 58 | udp://tracker.internetwarriors.net:1337/announce 59 | udp://tracker.opentrackr.org:1337/announce 60 | udp://9.rarbg.to:2710/announce 61 | udp://exodus.desync.com:6969/announce 62 | udp://explodie.org:6969/announce 63 | http://explodie.org:6969/announce 64 | udp://public.popcorn-tracker.org:6969/announce 65 | udp://tracker.vanitycore.co:6969/announce 66 | http://tracker.vanitycore.co:6969/announce 67 | udp://tracker1.itzmx.com:8080/announce 68 | http://tracker1.itzmx.com:8080/announce 69 | udp://ipv4.tracker.harry.lu:80/announce 70 | udp://tracker.torrent.eu.org:451/announce 71 | udp://tracker.tiny-vps.com:6969/announce 72 | udp://tracker.port443.xyz:6969/announce 73 | udp://open.stealth.si:80/announce 74 | udp://open.demonii.si:1337/announce 75 | udp://denis.stalker.upeer.me:6969/announce 76 | udp://bt.xxx-tracker.com:2710/announce 77 | http://tracker.port443.xyz:6969/announce 78 | udp://tracker2.itzmx.com:6961/announce 79 | udp://retracker.lanta-net.ru:2710/announce 80 | http://tracker2.itzmx.com:6961/announce 81 | http://tracker4.itzmx.com:2710/announce 82 | http://tracker3.itzmx.com:6961/announce 83 | http://tracker.city9x.com:2710/announce 84 | http://torrent.nwps.ws:80/announce 85 | http://retracker.telecom.by:80/announce 86 | http://open.acgnxtracker.com:80/announce 87 | wss://ltrackr.iamhansen.xyz:443/announce 88 | udp://zephir.monocul.us:6969/announce 89 | udp://tracker.toss.li:6969/announce 90 | http://opentracker.xyz:80/announce 91 | http://open.trackerlist.xyz:80/announce 92 | udp://tracker.swateam.org.uk:2710/announce 93 | udp://tracker.kamigami.org:2710/announce 94 | udp://tracker.iamhansen.xyz:2000/announce 95 | udp://tracker.ds.is:6969/announce 96 | udp://pubt.in:2710/announce 97 | https://tracker.fastdownload.xyz:443/announce 98 | https://opentracker.xyz:443/announce 99 | http://tracker.torrentyorg.pl:80/announce 100 | http://t.nyaatracker.com:80/announce 101 | http://open.acgtracker.com:1096/announce 102 | wss://tracker.openwebtorrent.com:443/announce 103 | wss://tracker.fastcast.nz:443/announce 104 | wss://tracker.btorrent.xyz:443/announce 105 | udp://tracker.justseed.it:1337/announce 106 | udp://thetracker.org:80/announce 107 | udp://packages.crunchbangplusplus.org:6969/announce 108 | https://1337.abcvg.info:443/announce 109 | http://tracker.tfile.me:80/announce.php 110 | http://tracker.tfile.me:80/announce 111 | http://tracker.tfile.co:80/announce 112 | http://retracker.mgts.by:80/announce 113 | http://peersteers.org:80/announce 114 | http://fxtt.ru:80/announce 115 | EOL 116 | fi 117 | number_of_trackers_in_list=$(echo "$trackers_list" | wc -l) 118 | } 119 | 120 | inject_trackers () { 121 | start=1 122 | while read tracker; do 123 | if [ -n "$tracker" ]; then 124 | echo -ne "\e[0;36;1m$start/$number_of_trackers_in_list - Adding tracker $tracker\e[0;36m" 125 | $curl_executable --silent --fail --show-error --anyauth \ 126 | --user ${transmission_username}:${transmission_password} --header "$qbt_cookie" "${transmission_host}:${transmission_port}/transmission/rpc/" \ 127 | -d "{\"method\":\"torrent-set\",\"arguments\": {\"fields\":[\"ids\",\"trackerAdd\"],\"ids\":[$1],\"trackerAdd\":[\"$tracker\"]}}" 128 | 129 | if [ $? -eq 0 ]; then 130 | echo -e " -> \e[32mSuccess! " 131 | else 132 | echo -e " - \e[31m< Failed > " 133 | fi 134 | fi 135 | start=$((start+1)) 136 | done <<< "$trackers_list" 137 | echo "Done!" 138 | } 139 | 140 | get_torrent_list () { 141 | get_cookie 142 | torrent_list=$($curl_executable --silent --anyauth \ 143 | --user ${transmission_username}:${transmission_password} --header "$qbt_cookie" "${transmission_host}:${transmission_port}/transmission/rpc/" \ 144 | -d "{\"method\":\"torrent-get\",\"arguments\": {\"fields\":[\"isPrivate\",\"id\",\"name\",\"hashString\",\"trackers\"]}}") 145 | } 146 | 147 | get_cookie () { 148 | qbt_cookie=$($curl_executable --silent --anyauth \ 149 | --user ${transmission_username}:${transmission_password} ${transmission_host}:${transmission_port}/transmission/rpc/ \ 150 | | sed 's/.*//g;s/<\/code>.*//g') 151 | } 152 | 153 | hash_check() { 154 | case $1 in 155 | ( *[!0-9A-Fa-f]* | "" ) return 1 ;; 156 | ( * ) 157 | case ${#1} in 158 | ( 32 | 40 ) return 0 ;; 159 | ( * ) return 1 ;; 160 | esac 161 | esac 162 | } 163 | 164 | wait() { 165 | w=$1 166 | echo "I'll wait ${w}s to be sure ..." 167 | while [ $w -gt 0 ]; do 168 | echo -ne "$w\033[0K\r" 169 | sleep 1 170 | w=$((w-1)) 171 | done 172 | } 173 | ########## FUNCTIONS ########## 174 | 175 | if [ -t 1 ] ; then 176 | if [[ ! $@ =~ ^\-.+ ]]; then 177 | echo "Arguments must be passed with - in front, like -n foo, or -i 5. Check the instructions" 178 | echo "" 179 | $0 -h 180 | exit 181 | fi 182 | 183 | [ $# -eq 0 ] && $0 -h 184 | 185 | if [ $# -eq 1 ] && [ $1 == "-f" ]; then 186 | echo "Don't use only -f, you need to specify also the torrent!" 187 | exit 188 | fi 189 | 190 | while getopts ":aflhn:i:" opt; do 191 | case ${opt} in 192 | a ) # If used inject trackers to all torrent. 193 | all_torrent=1 194 | ;; 195 | f ) # If used force the injection also in private trackers. 196 | applytheforce=1 197 | ;; 198 | l ) # Print the list of the torrent where you can inject trackers. 199 | list=1 200 | get_torrent_list 201 | echo -e "\n\e[0;32;1mCurrent torrents:\e[0;32m" 202 | 203 | while IFS= read -r line; do 204 | torrent_id_array+=("$line") 205 | done < <(echo $torrent_list | $jq_executable --raw-output '. | .arguments | .torrents | .[] | .id') 206 | 207 | while IFS= read -r line; do 208 | torrent_name_array+=("$line") 209 | done < <(echo $torrent_list | $jq_executable --raw-output '. | .arguments | .torrents | .[] | .name') 210 | 211 | for i in "${!torrent_name_array[@]}"; do 212 | echo "ID: ${torrent_id_array[$i]} ~~~ Name: ${torrent_name_array[$i]}" 213 | done 214 | exit 215 | ;; 216 | n ) # Specify the name of the torrent example -n foo or -n "foo bar", multiple -n can be used. 217 | tor_arg_names+=("$OPTARG") 218 | ;; 219 | i ) # Specify the id of the torrent example -i 5, multiple -i can be used. 220 | tor_arg_id+=("$OPTARG") 221 | ;; 222 | : ) 223 | echo "Invalid option: -${OPTARG} requires an argument" 1>&2 224 | exit 0 225 | ;; 226 | \? ) 227 | echo "Unknow option: -${OPTARG}" 1>&2 228 | exit 1 229 | ;; 230 | h | * ) # Display help. 231 | echo "Usage:" 232 | echo "$0 -a Inject trackers to all torrent in qBittorrent, this not require any extra information" 233 | echo "$0 -f Force the injection of the trackers inside the private torrent too, this not require any extra information" 234 | echo "$0 -l Print the list of the torrent where you can inject trackers, this not require any extra information" 235 | echo "$0 -n Specify the torrent name or part of it, for example -n foo or -n 'foo bar'" 236 | echo "$0 -i Specify the torrent id, for example -i 5" 237 | echo "$0 -h Display this help" 238 | echo "NOTE:" 239 | echo "It's possible to specify more than -n and -i in one single command, even combined" 240 | echo "Just remember that if you set -a in useless to add any extra -n, but -f can always be used" 241 | exit 2 242 | ;; 243 | esac 244 | done 245 | shift $((OPTIND -1)) 246 | else 247 | if [[ -n "${sonarr_download_id}" ]] || [[ -n "${radarr_download_id}" ]] || [[ -n "${lidarr_download_id}" ]] || [[ -n "${readarr_download_id}" ]]; then 248 | wait 5 249 | if [[ -n "${sonarr_download_id}" ]]; then 250 | echo "Sonarr varialbe found -> $sonarr_download_id" 251 | hash=$(echo "$sonarr_download_id" | awk '{print tolower($0)}') 252 | fi 253 | 254 | if [[ -n "${radarr_download_id}" ]]; then 255 | echo "Radarr varialbe found -> $radarr_download_id" 256 | hash=$(echo "$radarr_download_id" | awk '{print tolower($0)}') 257 | fi 258 | 259 | if [[ -n "${lidarr_download_id}" ]]; then 260 | echo "Lidarr varialbe found -> $lidarr_download_id" 261 | hash=$(echo "$lidarr_download_id" | awk '{print tolower($0)}') 262 | fi 263 | 264 | if [[ -n "${readarr_download_id}" ]]; then 265 | echo "Readarr varialbe found -> $readarr_download_id" 266 | hash=$(echo "$readarr_download_id" | awk '{print tolower($0)}') 267 | fi 268 | 269 | hash_check "${hash}" 270 | if [[ $? -ne 0 ]]; then 271 | echo "The download is not for a torrent client, I'll exit" 272 | exit 3 273 | fi 274 | auto_tor_grab="1" 275 | fi 276 | 277 | if [[ $sonarr_eventtype == "Test" ]] || [[ $radarr_eventtype == "Test" ]] || [[ $lidarr_eventtype == "Test" ]] || [[ $readarr_eventtype == "Test" ]]; then 278 | echo "Test in progress..." 279 | test_in_progress=1 280 | fi 281 | fi 282 | 283 | for i in "${tor_arg_names[@]}"; do 284 | if [[ -z "${i// }" ]]; then 285 | echo "one or more argument for -n not valid, try again" 286 | exit 287 | fi 288 | done 289 | 290 | if [ ${#tor_arg_names[@]} -eq 0 ] && [ ${#tor_arg_id[@]} -eq 0 ] && [ $all_torrent -eq 0 ] && [ -z $list ] && [ $auto_tor_grab -eq 0 ]; then 291 | echo "No name, no ID or no -a passed, exiting" 292 | exit 293 | fi 294 | 295 | if [ ${#tor_arg_id[@]} -gt 0 ]; then 296 | re='^[0-9]+$' 297 | 298 | for i in "${tor_arg_id[@]}"; do 299 | if ! [[ $i =~ $re ]] ; then 300 | echo "Error: parameter for -i ${i} is not a number" >&2; exit 1 301 | fi 302 | done 303 | fi 304 | 305 | if [ $test_in_progress -eq 1 ]; then 306 | echo "Good-bye!" 307 | elif [ $auto_tor_grab -eq 0 ]; then # manual run 308 | get_torrent_list 309 | 310 | if [ $all_torrent -eq 1 ]; then 311 | while IFS= read -r line; do 312 | torrent_id_array+=("$line") 313 | done < <(echo $torrent_list | $jq_executable --raw-output '. | .arguments | .torrents | .[] | .id') 314 | 315 | while IFS= read -r line; do 316 | torrent_name_array+=("$line") 317 | done < <(echo $torrent_list | $jq_executable --raw-output '. | .arguments | .torrents | .[] | .name') 318 | 319 | while IFS= read -r line; do 320 | torrent_private_array+=("$line") 321 | done < <(echo $torrent_list | $jq_executable --raw-output '. | .arguments | .torrents | .[] | .isPrivate') 322 | 323 | else 324 | for i in "${tor_arg_names[@]}"; do 325 | torrent_name_list=$(echo "$torrent_list" | $jq_executable --raw-output --arg tosearch "$i" '. | .arguments | .torrents | .[] | select(.name|test("\($tosearch)";"i")) .name') 326 | 327 | if [ -n "$torrent_name_list" ]; then # not empty 328 | torrent_name_check=1 329 | echo -e "\n\e[0;32;1mFor argument ### -n $i ###\e[0;32m" 330 | echo -e "\e[0;32;1mI found the following torrent:\e[0;32m" 331 | echo "$torrent_name_list" 332 | else 333 | torrent_name_check=0 334 | fi 335 | 336 | if [ $torrent_name_check -eq 0 ]; then 337 | echo -e "\e[0;31;1mI didn't find a torrent with the text: \e[21m$1\e[0m" 338 | shift 339 | continue 340 | else 341 | while read -r single_found; do 342 | torrent_name_array+=("$single_found") 343 | id=$(echo "$torrent_list" | $jq_executable --raw-output --arg tosearch "$single_found" '. | .arguments | .torrents | .[] | select(.name == "\($tosearch)") .id') 344 | torrent_id_array+=("$id") 345 | private=$(echo "$torrent_list" | $jq_executable --raw-output --arg tosearch "$single_found" '. | .arguments | .torrents | .[] | select(.name == "\($tosearch)") .isPrivate') 346 | torrent_private_array+=("$private") 347 | done <<< "$torrent_name_list" 348 | fi 349 | done 350 | 351 | for i in "${tor_arg_id[@]}"; do 352 | torrent_name_list=$(echo "$torrent_list" | $jq_executable --raw-output --argjson tosearch "$i" '. | .arguments | .torrents | .[] | select(.id == $tosearch) .name') 353 | 354 | if [ -n "$torrent_name_list" ]; then # not empty 355 | torrent_name_check=1 356 | echo -e "\n\e[0;32;1mFor argument ### -i $i ###\e[0;32m" 357 | echo -e "\e[0;32;1mI found the following torrent:\e[0;32m" 358 | echo "$torrent_name_list" 359 | else 360 | torrent_name_check=0 361 | fi 362 | 363 | if [ $torrent_name_check -eq 0 ]; then 364 | echo -e "\e[0;31;1mI didn't find a torrent with the text: \e[21m$1\e[0m" 365 | shift 366 | continue 367 | else 368 | while read -r single_found; do 369 | torrent_name_array+=("$single_found") 370 | id=$(echo "$torrent_list" | $jq_executable --raw-output --arg tosearch "$single_found" '. | .arguments | .torrents | .[] | select(.name == "\($tosearch)") .id') 371 | torrent_id_array+=("$id") 372 | private=$(echo "$torrent_list" | $jq_executable --raw-output --arg tosearch "$single_found" '. | .arguments | .torrents | .[] | select(.name == "\($tosearch)") .isPrivate') 373 | torrent_private_array+=("$private") 374 | done <<< "$torrent_name_list" 375 | fi 376 | done 377 | fi 378 | 379 | if [ ${#torrent_name_array[@]} -gt 0 ]; then 380 | echo "" 381 | for i in "${!torrent_name_array[@]}"; do 382 | echo -ne "\n\e[0;1;4;32mFor the Torrent: \e[0;4;32m" 383 | echo "${torrent_name_array[$i]}" 384 | 385 | if [[ $ignore_private == true ]] || [ $applytheforce -eq 1 ]; then # Inject anyway the trackers inside any torrent 386 | if [ $applytheforce -eq 1 ]; then 387 | echo -e "\e[0m\e[33mForce mode is active, I'll inject trackers anyway\e[0m" 388 | else 389 | echo -e "\e[0m\e[33mignore_private set to true, I'll inject trackers anyway\e[0m" 390 | fi 391 | [[ -z "$trackers_list" ]] && generate_trackers_list 392 | inject_trackers ${torrent_id_array[$i]} 393 | else 394 | if [[ ${torrent_private_array[$i]} == true ]]; then 395 | private_tracker_name=$(echo "$torrent_list" | $jq_executable --raw-output --argjson tosearch "${torrent_id_array[$i]}" '. | .arguments | .torrents | .[] | select(.id == $tosearch) .trackers | .[] | .announce' | sed -e 's/[^/]*\/\/\([^@]*@\)\?\([^:/]*\).*/\2/') 396 | echo -e "\e[31m< Private tracker found \e[0m\e[33m-> $private_tracker_name <- \e[0m\e[31mI'll not add any extra tracker >\e[0m" 397 | else 398 | echo -e "\e[0m\e[33mThe torrent is not private, I'll inject trackers on it\e[0m" 399 | [[ -z "$trackers_list" ]] && generate_trackers_list 400 | inject_trackers ${torrent_id_array[$i]} 401 | fi 402 | fi 403 | done 404 | else 405 | echo "No torrents found, exiting" 406 | fi 407 | else # auto_tor_grab active, so some *Arr 408 | wait 5 409 | get_torrent_list 410 | 411 | torrent_name=$(echo "$torrent_list" | $jq_executable --raw-output --arg tosearch "$hash" '. | .arguments | .torrents | .[] | select(.hashString == "\($tosearch)") .name') 412 | torrent_id=$(echo "$torrent_list" | $jq_executable --raw-output --arg tosearch "$hash" '. | .arguments | .torrents | .[] | select(.hashString == "\($tosearch)") .id') 413 | private_check=$(echo "$torrent_list" | $jq_executable --raw-output --arg tosearch "$hash" '. | .arguments | .torrents | .[] | select(.hashString == "\($tosearch)") .isPrivate') 414 | 415 | echo -ne "\n\e[0;1;4;32mFor the Torrent: \e[0;4;32m" 416 | echo "$torrent_name" 417 | 418 | if [[ $private_check == true ]]; then 419 | private_tracker_name=$(echo "$torrent_list" | $jq_executable --raw-output --argjson tosearch "$torrent_id" '. | .arguments | .torrents | .[] | select(.id == $tosearch) .trackers | .[] | .announce' | sed -e 's/[^/]*\/\/\([^@]*@\)\?\([^:/]*\).*/\2/') 420 | echo -e "\e[31m< Private tracker found \e[0m\e[33m-> $private_tracker_name <- \e[0m\e[31mI'll not add any extra tracker >\e[0m" 421 | else 422 | echo -e "\e[0m\e[33mThe torrent is not private, I'll inject trackers on it\e[0m" 423 | [[ -z "$trackers_list" ]] && generate_trackers_list 424 | inject_trackers $torrent_id 425 | fi 426 | fi 427 | -------------------------------------------------------------------------------- /eMulerrStalledChecker/README.md: -------------------------------------------------------------------------------- 1 | # eMulerr Stalled Checker 2 | ![Docker Pulls](https://img.shields.io/docker/pulls/chryses/emulerr-stalled-checker) 3 | ![Docker Image Size](https://img.shields.io/docker/image-size/chryses/emulerr-stalled-checker) 4 | ![GitHub](https://img.shields.io/github/license/Jorman/Scripts) 5 | 6 | > Automated monitoring and cleanup tool for stalled ed2k/Kad downloads in Sonarr/Radarr via eMulerr 7 | 8 | --- 9 | 10 | ## Overview 11 | 12 | eMulerr Stalled Checker is a Docker-based monitoring service that automatically detects and removes stalled or source-less downloads from [eMulerr](https://github.com/isc30/eMulerr), keeping your [Sonarr](https://github.com/Sonarr/Sonarr) and [Radarr](https://github.com/Radarr/Radarr) download queues clean and efficient. 13 | 14 | When eMulerr downloads (ed2k/Kad network) get stuck without sources or stall indefinitely, this tool identifies them through configurable health checks, removes them from eMulerr, marks them as failed in the respective *Arr application, and automatically triggers a new search. This ensures your media automation keeps running smoothly without manual intervention. 15 | 16 | The script intelligently handles downloads by category, respects monitoring status in Sonarr/Radarr, and can clean up orphaned downloads that exist only in eMulerr but not in your *Arr instances anymore. 17 | 18 | --- 19 | 20 | ## ✨ Features 21 | 22 | - 🧠 Smart Stall Detection — Configurable checks before marking downloads as stalled 23 | - 🧹 Automatic Cleanup — Removes stalled downloads and triggers new searches 24 | - 🗂️ Category-Based Management — Handles Sonarr and Radarr downloads separately via categories 25 | - 🧭 Orphan Detection — Removes downloads that exist only in eMulerr (optional) 26 | - 👀 Monitoring-Aware — Respects series/season/episode/movie monitoring status 27 | - ⏰ Grace Period — Configurable waiting time for recent downloads 28 | - 🔔 Apprise Notifications — Multi-service alerts (Telegram, Discord, Email, Slack, Pushover via Apprise, etc.). Pushover remains backward-compatible, but switching to Apprise is highly recommended 29 | - 🐳 Docker Native — Easy deployment and management 30 | - 🧪 Dry Run Mode — Test configuration without actual changes 31 | - 📜 Detailed Logging — Console and optional file logging with configurable levels 32 | 33 | --- 34 | 35 | ## Quick Start 36 | 37 | ### Prerequisites 38 | 39 | - Docker and Docker Compose installed 40 | - Running instances of: 41 | - [eMulerr](https://github.com/isc30/eMulerr) 42 | - [Sonarr](https://github.com/Sonarr/Sonarr) and/or [Radarr](https://github.com/Radarr/Radarr) 43 | - eMulerr configured as a download client in Sonarr/Radarr with specific categories 44 | 45 | ### Using Docker Run 46 | 47 | ```bash 48 | docker run -d \ 49 | --name emulerr-stalled-checker \ 50 | --restart unless-stopped \ 51 | -e TZ=Europe/Rome \ 52 | -e CHECK_INTERVAL=10 \ 53 | -e EMULERR_HOST=http://your-emulerr:3000 \ 54 | -e STALL_CHECKS=30 \ 55 | -e STALL_DAYS=20 \ 56 | -e RECENT_DOWNLOAD_GRACE_PERIOD=30 \ 57 | -e DELETE_IF_UNMONITORED_SERIE=false \ 58 | -e DELETE_IF_UNMONITORED_SEASON=false \ 59 | -e DELETE_IF_UNMONITORED_EPISODE=true \ 60 | -e DELETE_IF_UNMONITORED_MOVIE=true \ 61 | -e DELETE_IF_ONLY_ON_EMULERR=false \ 62 | -e DOWNLOAD_CLIENT=emulerr \ 63 | -e RADARR_HOST=http://your-radarr:7878 \ 64 | -e RADARR_API_KEY=your_radarr_api_key \ 65 | -e RADARR_CATEGORY=radarr-eMulerr \ 66 | -e SONARR_HOST=http://your-sonarr:8989 \ 67 | -e SONARR_API_KEY=your_sonarr_api_key \ 68 | -e SONARR_CATEGORY=tv-sonarr-eMulerr \ 69 | -e APPRISE_URLS="discord://webhook_id/webhook_token tgram://bot_token/chat_id" \ 70 | -e LOG_LEVEL=info \ 71 | -e LOG_TO_FILE=/logs \ 72 | -e DRY_RUN=false \ 73 | -v "$(pwd)/logs:/logs" \ 74 | chryses/emulerr-stalled-checker:latest 75 | ``` 76 | 77 | Notes: 78 | - EMULERR_HOST, RADARR_HOST and SONARR_HOST must start with http:// or https://. 79 | - LOG_TO_FILE expects a directory path; the file emulerr_stalled_checker.log will be created inside that directory. 80 | 81 | ### Using Docker Compose 82 | 83 | ```yaml 84 | version: '3.8' 85 | 86 | services: 87 | emulerr-stalled-checker: 88 | image: chryses/emulerr-stalled-checker:latest 89 | container_name: emulerr-stalled-checker 90 | restart: unless-stopped 91 | environment: 92 | - TZ=Europe/Rome 93 | - CHECK_INTERVAL=10 94 | - EMULERR_HOST=http://10.0.0.100:3000 95 | - STALL_CHECKS=30 96 | - STALL_DAYS=20 97 | - RECENT_DOWNLOAD_GRACE_PERIOD=30 98 | - DELETE_IF_UNMONITORED_SERIE=false 99 | - DELETE_IF_UNMONITORED_SEASON=false 100 | - DELETE_IF_UNMONITORED_EPISODE=true 101 | - DELETE_IF_UNMONITORED_MOVIE=true 102 | - DELETE_IF_ONLY_ON_EMULERR=false 103 | # 🔔 Notifications via Apprise (recommended). Multiple URLs separated by space or comma. 104 | # Examples: 105 | # - Pushover (via Apprise): pover://user_key@app_token 106 | # - Telegram: tgram://bot_token/chat_id 107 | # - Discord: discord://webhook_id/webhook_token 108 | # - Multiple: pover://key@token discord://id/token 109 | - APPRISE_URLS=pover://your_user_key@your_app_token 110 | # 📲 Legacy Pushover (optional; auto-converted to Apprise if both are set) 111 | # - PUSHOVER_USER_KEY=your_pushover_user_key 112 | # - PUSHOVER_APP_TOKEN=your_pushover_app_token 113 | - LOG_LEVEL=info 114 | # 🪵 LOG_TO_FILE is a DIRECTORY; the file will be created as emulerr_stalled_checker.log 115 | - LOG_TO_FILE=/logs 116 | - DRY_RUN=false 117 | - DOWNLOAD_CLIENT=emulerr 118 | - RADARR_HOST=http://10.0.0.100:7878 119 | - RADARR_API_KEY=your_radarr_api_key 120 | - RADARR_CATEGORY=radarr-eMulerr 121 | - SONARR_HOST=http://10.0.0.100:8989 122 | - SONARR_API_KEY=your_sonarr_api_key 123 | - SONARR_CATEGORY=tv-sonarr-eMulerr 124 | volumes: 125 | - ./logs:/logs 126 | healthcheck: 127 | test: ["CMD", "wget", "--spider", "http://10.0.0.100:3000"] 128 | interval: 1m 129 | timeout: 10s 130 | retries: 3 131 | ``` 132 | 133 | --- 134 | 135 | ## ⚙️ Configuration 136 | 137 | ### Environment Variables 138 | 139 | #### Core Settings 140 | 141 | | Variable | Description | Default | Required | 142 | |----------|-------------|---------|----------| 143 | | `EMULERR_HOST` | eMulerr base URL (e.g., `http://10.0.0.100:3000`). Must start with `http://` or `https://` | — | ✅ Yes | 144 | | `CHECK_INTERVAL` | Minutes between stall checks | — | ✅ Yes | 145 | | `STALL_CHECKS` | Number of consecutive checks before marking as stalled | — | ✅ Yes | 146 | | `STALL_DAYS` | Days before a never-completed download is considered stalled | — | ✅ Yes | 147 | | `RECENT_DOWNLOAD_GRACE_PERIOD` | Minutes to wait before checking recent downloads | `30` | ✅ Yes | 148 | 149 | #### *Arr Integration (at least one required) 150 | 151 | | Variable | Description | Default | Required | 152 | |----------|-------------|---------|----------| 153 | | `DOWNLOAD_CLIENT` | Download client name configured in Sonarr/Radarr | — | ✅ Yes | 154 | | `RADARR_HOST` | Radarr base URL | `None` | ⚠️ Conditional | 155 | | `RADARR_API_KEY` | Radarr API key | `None` | ⚠️ Conditional | 156 | | `RADARR_CATEGORY` | eMulerr category for Radarr downloads | `None` | ⚠️ Conditional | 157 | | `SONARR_HOST` | Sonarr base URL | `None` | ⚠️ Conditional | 158 | | `SONARR_API_KEY` | Sonarr API key | `None` | ⚠️ Conditional | 159 | | `SONARR_CATEGORY` | eMulerr category for Sonarr downloads | `None` | ⚠️ Conditional | 160 | 161 | > You must configure at least one *Arr service (Radarr or Sonarr). 162 | > If you use both, configure both sets of variables. 163 | 164 | #### Monitoring & Cleanup Rules 165 | 166 | | Variable | Description | Default | Required | 167 | |----------|-------------|---------|----------| 168 | | `DELETE_IF_UNMONITORED_SERIE` | Remove downloads for unmonitored series (Sonarr) | `false` | ❌ No | 169 | | `DELETE_IF_UNMONITORED_SEASON` | Remove downloads for unmonitored seasons (Sonarr) | `false` | ❌ No | 170 | | `DELETE_IF_UNMONITORED_EPISODE` | Remove downloads for unmonitored episodes (Sonarr) | `false` | ❌ No | 171 | | `DELETE_IF_UNMONITORED_MOVIE` | Remove downloads for unmonitored movies (Radarr) | `false` | ❌ No | 172 | | `DELETE_IF_ONLY_ON_EMULERR` | Remove orphaned downloads (present only in eMulerr, not in *Arr) | `false` | ❌ No | 173 | 174 | #### 🔔 Notifications 175 | 176 | | Variable | Description | Default | Required | 177 | |----------|-------------|---------|----------| 178 | | `APPRISE_URLS` | One or more Apprise URLs (space or comma separated), e.g., `pover://user@app` | `None` | ❌ No | 179 | | `PUSHOVER_USER_KEY` | Legacy Pushover user key (auto-converted to Apprise if APP token is also set) | `None` | ❌ No | 180 | | `PUSHOVER_APP_TOKEN` | Legacy Pushover app token (auto-converted to Apprise if USER key is also set) | `None` | ❌ No | 181 | 182 | Recommendation: use `APPRISE_URLS` for maximum flexibility. If both `PUSHOVER_USER_KEY` and `PUSHOVER_APP_TOKEN` are set, the script auto-converts them to an Apprise URL transparently. 183 | 184 | #### 🪵 Logging & Debug 185 | 186 | | Variable | Description | Default | Required | 187 | |----------|-------------|---------|----------| 188 | | `LOG_LEVEL` | Logging level: `debug`, `info`, `warning`, `error`, `critical` | `info` | ❌ No | 189 | | `LOG_TO_FILE` | Directory path where the log file will be created as `emulerr_stalled_checker.log` (requires volume) | `None` | ❌ No | 190 | | `DRY_RUN` | Test mode — no actual deletions (`true`/`false`) | `false` | ❌ No | 191 | | `TZ` | Timezone (e.g., `Europe/Rome`) | `UTC` | ❌ No | 192 | 193 | --- 194 | 195 | ## How It Works 196 | 197 | ### Download Monitoring Workflow 198 | 199 | 1. Periodic Checks — Every `CHECK_INTERVAL` minutes, the script queries eMulerr for all downloads 200 | 2. Category Filtering — Identifies downloads matching configured Sonarr/Radarr categories 201 | 3. Health Assessment — For each download: 202 | - Checks if it's stalled (no progress, no sources) 203 | - Verifies against `STALL_CHECKS` threshold 204 | - Applies `RECENT_DOWNLOAD_GRACE_PERIOD` for new downloads 205 | - Checks `STALL_DAYS` for long-running incomplete downloads 206 | 4. Monitoring Status — Queries Sonarr/Radarr to verify if content is still monitored 207 | 5. Orphan Detection — Identifies downloads that exist only in eMulerr (optional) 208 | 6. Action Execution — If criteria are met: 209 | - Removes the download from eMulerr 210 | - Marks it as failed in the corresponding *Arr application 211 | - Triggers an automatic search for an alternative source 212 | - Sends a notification via Apprise (if configured) 213 | 214 | ### Stall Detection Logic 215 | 216 | A download is considered stalled when: 217 | - No Progress: it hasn't made progress for `STALL_CHECKS` consecutive checks 218 | - No Sources: it has no active sources/peers 219 | - Time-Based: incomplete download older than `STALL_DAYS` days 220 | - Grace Period: recent downloads (< `RECENT_DOWNLOAD_GRACE_PERIOD` minutes) are skipped 221 | 222 | ### Monitoring Checks 223 | 224 | Before removing any download, the script verifies: 225 | - Series/Season/Episode Monitoring (Sonarr): 226 | - `DELETE_IF_UNMONITORED_SERIE=true` → remove if the series is unmonitored 227 | - `DELETE_IF_UNMONITORED_SEASON=true` → remove if the season is unmonitored 228 | - `DELETE_IF_UNMONITORED_EPISODE=true` → remove if the episode is unmonitored 229 | - Movie Monitoring (Radarr): 230 | - `DELETE_IF_UNMONITORED_MOVIE=true` → remove if the movie is unmonitored 231 | - Orphan Cleanup: 232 | - `DELETE_IF_ONLY_ON_EMULERR=true` → remove downloads not present in *Arr anymore 233 | 234 | --- 235 | 236 | ## Advanced Usage 237 | 238 | ### Aggressive Cleanup Configuration 239 | 240 | ```yaml 241 | environment: 242 | - CHECK_INTERVAL=5 # Check every 5 minutes 243 | - STALL_CHECKS=15 # Mark stalled after 15 checks (~75 min) 244 | - STALL_DAYS=15 # Remove old incomplete downloads 245 | - DELETE_IF_UNMONITORED_SERIE=true 246 | - DELETE_IF_UNMONITORED_SEASON=true 247 | - DELETE_IF_UNMONITORED_EPISODE=true 248 | - DELETE_IF_UNMONITORED_MOVIE=true 249 | - DELETE_IF_ONLY_ON_EMULERR=true 250 | ``` 251 | 252 | ### Conservative Configuration 253 | 254 | ```yaml 255 | environment: 256 | - CHECK_INTERVAL=30 # Check every 30 minutes 257 | - STALL_CHECKS=48 # ~24 hours before marking as stalled 258 | - STALL_DAYS=30 # 30 days grace period 259 | - DELETE_IF_UNMONITORED_EPISODE=false 260 | - DELETE_IF_UNMONITORED_MOVIE=false 261 | - DELETE_IF_ONLY_ON_EMULERR=false 262 | - DRY_RUN=true # Test mode enabled 263 | ``` 264 | 265 | ### Testing Configuration (Dry Run) 266 | 267 | ```yaml 268 | environment: 269 | - DRY_RUN=true 270 | - LOG_LEVEL=debug 271 | - LOG_TO_FILE=/logs 272 | volumes: 273 | - ./logs:/logs 274 | ``` 275 | 276 | ### File Logging Setup 277 | 278 | ```yaml 279 | environment: 280 | - LOG_TO_FILE=/logs # directory mount 281 | - LOG_LEVEL=info 282 | volumes: 283 | - ./logs:/logs 284 | ``` 285 | 286 | ### Sonarr-Only Configuration 287 | 288 | ```yaml 289 | environment: 290 | - SONARR_HOST=http://10.0.0.100:8989 291 | - SONARR_API_KEY=your_sonarr_api_key 292 | - SONARR_CATEGORY=tv-sonarr-eMulerr 293 | # Radarr variables not needed 294 | ``` 295 | 296 | ### Radarr-Only Configuration 297 | 298 | ```yaml 299 | environment: 300 | - RADARR_HOST=http://10.0.0.100:7878 301 | - RADARR_API_KEY=your_radarr_api_key 302 | - RADARR_CATEGORY=radarr-eMulerr 303 | # Sonarr variables not needed 304 | ``` 305 | 306 | --- 307 | 308 | ## 🔔 Apprise Notifications 309 | 310 | eMulerr Stalled Checker uses [Apprise](https://github.com/caronc/apprise) to send notifications to many services (Telegram, Discord, Pushover, Slack, SMTP, etc.). Provide one or more URLs in Apprise format via `APPRISE_URLS`. 311 | 312 | - Basic configuration: 313 | ```yaml 314 | environment: 315 | - APPRISE_URLS=discord://webhook_id/webhook_token 316 | ``` 317 | - Multiple services (space- or comma-separated): 318 | ```yaml 319 | environment: 320 | - APPRISE_URLS=discord://id/token,tgram://bot_token/chat_id 321 | ``` 322 | - Pushover via Apprise (recommended): 323 | ```yaml 324 | environment: 325 | - APPRISE_URLS=pover://your_user_key@your_app_token 326 | ``` 327 | 328 | Official documentation and URL formats: https://github.com/caronc/apprise/wiki 329 | 330 | ### Pushover Backward Compatibility 331 | 332 | The script preserves compatibility with the legacy Pushover variables: 333 | - `PUSHOVER_USER_KEY` 334 | - `PUSHOVER_APP_TOKEN` 335 | 336 | If both variables are set, they are automatically converted to the corresponding Apprise URL. However, it is highly recommended to switch to `APPRISE_URLS` for clearer configuration and wider provider support. 337 | 338 | --- 339 | 340 | ## Troubleshooting 341 | 342 | ### Common Issues 343 | 344 | #### The tool doesn’t remove any downloads 345 | Possible causes: 346 | - `DRY_RUN=true` is enabled (check logs for “DRY RUN” messages) 347 | - `STALL_CHECKS` threshold not reached yet 348 | - The download is recent (`RECENT_DOWNLOAD_GRACE_PERIOD`) 349 | - The download is making progress 350 | 351 | Solution: 352 | ```bash 353 | # Check logs 354 | docker logs -f emulerr-stalled-checker 355 | # Enable debug in docker-compose: 356 | # environment: 357 | # - LOG_LEVEL=debug 358 | ``` 359 | 360 | #### Cannot connect to eMulerr/Sonarr/Radarr 361 | 362 | Solution: 363 | ```bash 364 | # Check reachability from the container 365 | docker exec emulerr-stalled-checker wget -O- http://your-emulerr:3000 366 | 367 | # Make sure the services share a Docker network 368 | docker network inspect bridge 369 | 370 | # Use host IPs instead of localhost inside containers 371 | # ✅ EMULERR_HOST=http://10.0.0.100:3000 372 | # ❌ EMULERR_HOST=http://localhost:3000 373 | ``` 374 | 375 | #### API Key errors 376 | 377 | Solution: 378 | - Verify API keys in Sonarr/Radarr: Settings → General → Security → API Key 379 | - Ensure there are no extra spaces in docker-compose.yml 380 | - Manual API test: 381 | ```bash 382 | curl -H "X-Api-Key: YOUR_KEY" http://your-sonarr:8989/api/v3/system/status 383 | ``` 384 | 385 | #### Downloads are removed but no new search is triggered 386 | 387 | Possible cause: Category mismatch 388 | 389 | Solution: 390 | - Ensure eMulerr categories in Sonarr/Radarr match the configuration: 391 | - Sonarr → Settings → Download Clients → eMulerr → Category 392 | - Values must match `SONARR_CATEGORY`/`RADARR_CATEGORY` exactly (case-sensitive) 393 | 394 | #### Healthcheck failing 395 | 396 | Solution: 397 | ```yaml 398 | healthcheck: 399 | test: ["CMD", "wget", "--no-check-certificate", "--spider", "http://your-emulerr-host:3000"] 400 | interval: 2m 401 | timeout: 30s 402 | retries: 5 403 | ``` 404 | 405 | --- 406 | 407 | ## 📜 Logs 408 | 409 | View logs: 410 | ```bash 411 | docker logs emulerr-stalled-checker 412 | ``` 413 | 414 | Follow in real time: 415 | ```bash 416 | docker logs -f emulerr-stalled-checker 417 | ``` 418 | 419 | Enable debug: 420 | ```yaml 421 | environment: 422 | - LOG_LEVEL=debug 423 | ``` 424 | 425 | Persist logs to file: 426 | ```yaml 427 | environment: 428 | - LOG_TO_FILE=/logs 429 | volumes: 430 | - ./logs:/logs 431 | ``` 432 | 433 | --- 434 | 435 | ## 🛠️ Building from Source 436 | 437 | ```bash 438 | git clone https://github.com/Jorman/Scripts.git 439 | cd Scripts/eMulerrStalledChecker 440 | docker build -t emulerr-stalled-checker . 441 | ``` 442 | 443 | Use the local image: 444 | ```yaml 445 | services: 446 | emulerr-stalled-checker: 447 | image: emulerr-stalled-checker # local image 448 | # ... rest of config 449 | ``` 450 | 451 | Note: The pre-built image `chryses/emulerr-stalled-checker` on Docker Hub is automatically built and tested via GitHub Actions. 452 | 453 | --- 454 | 455 | ## 🐳 Docker Hub 456 | 457 | **Official Image:** [`chryses/emulerr-stalled-checker`](https://hub.docker.com/r/chryses/emulerr-stalled-checker) 458 | 459 | ### Available Tags 460 | - `latest` — Latest stable release (recommended) 461 | 462 | ### Supported Architectures 463 | - ✅ `linux/amd64` (x86_64) 464 | - ✅ `linux/arm64` (ARM 64-bit) 465 | 466 | ### Auto-Build 467 | Images are automatically built on every push to the `master` branch via GitHub Actions. 468 | 469 | --- 470 | 471 | ## 🔗 Related Projects 472 | 473 | - 📡 **[eMulerr](https://github.com/isc30/eMulerr)** — ed2k/Kad integration for Sonarr/Radarr 474 | - 📺 **[Sonarr](https://github.com/Sonarr/Sonarr)** — Smart PVR for TV shows 475 | - 🎬 **[Radarr](https://github.com/Radarr/Radarr)** — Movie collection manager 476 | - 🔔 **[Apprise](https://github.com/caronc/apprise)** — Multi-service notification library (recommended) 477 | - 📲 **[Pushover](https://pushover.net)** — Legacy/optional notification provider 478 | 479 | --- 480 | 481 | ## 📜 License 482 | 483 | This project is licensed under the **GNU General Public License v3.0** — see the [LICENSE](https://www.gnu.org/licenses/gpl-3.0.en.html) file for details. 484 | 485 | --- 486 | 487 | ## 🤝 Contributing 488 | 489 | Contributions are welcome! Please feel free to submit a Pull Request. 490 | 491 | ### How to Contribute 492 | 493 | 1. Fork the repository 494 | 2. Create a feature branch (`git checkout -b feature/AmazingFeature`) 495 | 3. Commit your changes (`git commit -m 'Add some AmazingFeature'`) 496 | 4. Push to the branch (`git push origin feature/AmazingFeature`) 497 | 5. Open a Pull Request 498 | 499 | --- 500 | 501 | ## 🆘 Support 502 | 503 | - 🐞 Bug Reports: [GitHub Issues](https://github.com/Jorman/Scripts/issues) 504 | - 💬 Discussions: [GitHub Discussions](https://github.com/Jorman/Scripts/discussions) 505 | - 🐳 Docker Hub: [chryses/emulerr-stalled-checker](https://hub.docker.com/r/chryses/emulerr-stalled-checker) 506 | 507 | --- 508 | 509 | ## ⭐ Show Your Support 510 | 511 | If you find this project useful, please consider: 512 | - ⭐ Starring the repository on GitHub 513 | - 🐳 Pulling the Docker image 514 | - 🔁 Sharing with others in the homelab/selfhosted community 515 | 516 | --- 517 | 518 | Made with ❤️ for the *Arr community 519 | -------------------------------------------------------------------------------- /AddqBittorrentTrackers/AddqBittorrentTrackers.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import sys 4 | import importlib 5 | 6 | required_packages = [ 7 | 'requests', # Not included in Python standard library 8 | ] 9 | 10 | def check_dependencies(): 11 | missing_packages = [] 12 | for package in required_packages: 13 | try: 14 | importlib.import_module(package) 15 | except ImportError: 16 | missing_packages.append(package) 17 | 18 | if missing_packages: 19 | print("The following dependencies are missing:") 20 | for package in missing_packages: 21 | print(f"- {package}") 22 | print("\nTo install them, you can use the command:") 23 | print(f"pip install {' '.join(missing_packages)}") 24 | sys.exit(1) 25 | 26 | ########## CONFIGURATIONS ########## 27 | qbt_host = "http://10.0.0.100" 28 | qbt_port = "8081" 29 | qbt_username = "admin" 30 | qbt_password = "adminadmin" 31 | 32 | ignore_private = False 33 | clean_existing_trackers = False 34 | 35 | exclude_download_client = "emulerr" # If not empty, download clients to exclude must be comma separated 36 | 37 | live_trackers_list_urls = [ 38 | "https://newtrackon.com/api/stable", 39 | "https://trackerslist.com/best.txt", 40 | "https://trackerslist.com/http.txt", 41 | "https://raw.githubusercontent.com/ngosang/trackerslist/master/trackers_best.txt", 42 | ] 43 | 44 | version = "v1.1" 45 | 46 | STATIC_TRACKERS_LIST = """ 47 | udp://tracker.coppersurfer.tk:6969/announce 48 | http://tracker.internetwarriors.net:1337/announce 49 | udp://tracker.internetwarriors.net:1337/announce 50 | udp://tracker.opentrackr.org:1337/announce 51 | udp://9.rarbg.to:2710/announce 52 | udp://exodus.desync.com:6969/announce 53 | udp://explodie.org:6969/announce 54 | http://explodie.org:6969/announce 55 | udp://public.popcorn-tracker.org:6969/announce 56 | udp://tracker.vanitycore.co:6969/announce 57 | http://tracker.vanitycore.co:6969/announce 58 | udp://tracker1.itzmx.com:8080/announce 59 | http://tracker1.itzmx.com:8080/announce 60 | udp://ipv4.tracker.harry.lu:80/announce 61 | udp://tracker.torrent.eu.org:451/announce 62 | udp://tracker.tiny-vps.com:6969/announce 63 | udp://tracker.port443.xyz:6969/announce 64 | udp://open.stealth.si:80/announce 65 | udp://open.demonii.si:1337/announce 66 | udp://denis.stalker.upeer.me:6969/announce 67 | udp://bt.xxx-tracker.com:2710/announce 68 | http://tracker.port443.xyz:6969/announce 69 | udp://tracker2.itzmx.com:6961/announce 70 | udp://retracker.lanta-net.ru:2710/announce 71 | http://tracker2.itzmx.com:6961/announce 72 | http://tracker4.itzmx.com:2710/announce 73 | http://tracker3.itzmx.com:6961/announce 74 | http://tracker.city9x.com:2710/announce 75 | http://torrent.nwps.ws:80/announce 76 | http://retracker.telecom.by:80/announce 77 | http://open.acgnxtracker.com:80/announce 78 | wss://ltrackr.iamhansen.xyz:443/announce 79 | udp://zephir.monocul.us:6969/announce 80 | udp://tracker.toss.li:6969/announce 81 | http://opentracker.xyz:80/announce 82 | http://open.trackerlist.xyz:80/announce 83 | udp://tracker.swateam.org.uk:2710/announce 84 | udp://tracker.kamigami.org:2710/announce 85 | udp://tracker.iamhansen.xyz:2000/announce 86 | udp://tracker.ds.is:6969/announce 87 | udp://pubt.in:2710/announce 88 | https://tracker.fastdownload.xyz:443/announce 89 | https://opentracker.xyz:443/announce 90 | http://tracker.torrentyorg.pl:80/announce 91 | http://t.nyaatracker.com:80/announce 92 | http://open.acgtracker.com:1096/announce 93 | wss://tracker.openwebtorrent.com:443/announce 94 | wss://tracker.fastcast.nz:443/announce 95 | wss://tracker.btorrent.xyz:443/announce 96 | udp://tracker.justseed.it:1337/announce 97 | udp://thetracker.org:80/announce 98 | udp://packages.crunchbangplusplus.org:6969/announce 99 | https://1337.abcvg.info:443/announce 100 | http://tracker.tfile.me:80/announce.php 101 | http://tracker.tfile.me:80/announce 102 | http://tracker.tfile.co:80/announce 103 | http://retracker.mgts.by:80/announce 104 | http://peersteers.org:80/announce 105 | http://fxtt.ru:80/announce 106 | """ 107 | 108 | ########## FUNCTIONS ########## 109 | def generate_trackers_list(): 110 | # Use a function attribute to store the state (equivalent to a static variable) 111 | if not hasattr(generate_trackers_list, "trackers_list_cache"): 112 | generate_trackers_list.trackers_list_cache = None 113 | 114 | # Check to see if the list is already populated 115 | if generate_trackers_list.trackers_list_cache is not None: 116 | print("Trackers list already populated. Skipping generation.") 117 | return generate_trackers_list.trackers_list_cache 118 | 119 | # If the list is not populated, generate a new one 120 | if not live_trackers_list_urls or len(live_trackers_list_urls) == 0: 121 | print("The URL list is empty. Using the static tracker list.") 122 | generate_trackers_list.trackers_list_cache = STATIC_TRACKERS_LIST.strip().split("\n") 123 | return generate_trackers_list.trackers_list_cache 124 | 125 | trackers_list = "" 126 | errors_count = 0 127 | 128 | for url in live_trackers_list_urls: 129 | try: 130 | response = requests.get(url) 131 | response.raise_for_status() 132 | trackers_list += response.text + "\n" 133 | except requests.RequestException as e: 134 | errors_count += 1 135 | print(f"Error downloading from {url}: {e}") 136 | 137 | if errors_count == len(live_trackers_list_urls): 138 | print("All URLs failed. Using the static tracker list.") 139 | generate_trackers_list.trackers_list_cache = STATIC_TRACKERS_LIST.strip().split("\n") 140 | else: 141 | generate_trackers_list.trackers_list_cache = trackers_list.strip().split("\n") 142 | 143 | return generate_trackers_list.trackers_list_cache 144 | 145 | def get_qbittorrent_session(qbt_host, qbt_port, qbt_username, qbt_password): 146 | url = f"{qbt_host}:{qbt_port}" 147 | session = requests.Session() 148 | try: 149 | response = session.post(f'{url}/api/v2/auth/login', data={'username': qbt_username, 'password': qbt_password}) 150 | response.raise_for_status() 151 | return session 152 | except requests.exceptions.RequestException as e: 153 | print(f"Error during authentication: {e}") 154 | return None 155 | 156 | def get_torrent_trackers(session, hash): 157 | try: 158 | response = session.get( 159 | f"{qbt_host}:{qbt_port}/api/v2/torrents/trackers?hash={hash}", 160 | ) 161 | response.raise_for_status() 162 | return json.loads(response.text) 163 | except Exception as e: 164 | print(f"An error occurred while getting torrent trackers: {e}") 165 | return None 166 | 167 | def inject_trackers(hash, session): 168 | print("Injecting... ", end="") 169 | 170 | trackers_data = get_torrent_trackers(session, hash) 171 | if trackers_data is None: 172 | print(" Error getting torrent trackers... ") 173 | torrent_trackers = [tracker["url"] for tracker in trackers_data[3:]] 174 | 175 | remove_trackers(hash, torrent_trackers, session) 176 | 177 | trackers_list = generate_trackers_list() 178 | 179 | if clean_existing_trackers: 180 | print(" But before a quick cleaning the existing trackers... ") 181 | trackers_list = sorted(set(trackers_list)) 182 | else: 183 | trackers_list = sorted(set(trackers_list + torrent_trackers)) 184 | 185 | trackers_list = [tracker for tracker in trackers_list if tracker.strip()] 186 | 187 | number_of_trackers_in_list = len(trackers_list) 188 | 189 | # Format trackers into tiers 190 | formatted_trackers = "" 191 | for tracker in trackers_list: 192 | formatted_trackers += f"{tracker}\n\n" 193 | 194 | # Remove the last newlines if any 195 | formatted_trackers = formatted_trackers.rstrip("\n") 196 | 197 | response = session.post( 198 | f"{qbt_host}:{qbt_port}/api/v2/torrents/addTrackers", 199 | data={"hash": hash, "urls": formatted_trackers}, 200 | ) 201 | response.raise_for_status() 202 | 203 | print(f"done, injected {number_of_trackers_in_list} tracker{'s' if number_of_trackers_in_list > 1 else ''}!") 204 | 205 | def get_torrent_list(session): 206 | response = session.get( 207 | f"{qbt_host}:{qbt_port}/api/v2/torrents/info", 208 | ) 209 | response.raise_for_status() 210 | return json.loads(response.text) 211 | 212 | def hash_check(hash): 213 | if not hash or any(c not in '0123456789ABCDEFabcdef' for c in hash): 214 | return False 215 | return len(hash) in (32, 40) 216 | 217 | def remove_trackers(hash, urls, session): 218 | urls_string = "|".join(urls) 219 | response = session.post( 220 | f"{qbt_host}:{qbt_port}/api/v2/torrents/removeTrackers", 221 | data={"hash": hash, "urls": urls_string}, 222 | ) 223 | response.raise_for_status() 224 | 225 | def check_torrent_privacy(session, torrent_hash): 226 | try: 227 | response = session.get( 228 | f"{qbt_host}:{qbt_port}/api/v2/torrents/properties?hash={torrent_hash}", 229 | ) 230 | response.raise_for_status() 231 | private_check = json.loads(response.text)["is_private"] 232 | return private_check 233 | except Exception as e: 234 | print(f"An error occurred while checking torrent privacy: {e}") 235 | return None # Or any other value to signify an error 236 | 237 | def parse_arguments(): 238 | import argparse 239 | 240 | parser = argparse.ArgumentParser(description="How to Inject trackers into qBittorrent") 241 | parser.add_argument("-a", action="store_true", help="Inject trackers to all torrent in qBittorrent, this not require any extra information") 242 | parser.add_argument("-c", action="store_true", help="Clean all the existing trackers before the injection, this not require any extra information") 243 | parser.add_argument("-f", action="store_true", help="Force the injection of the trackers inside the private torrent too, this not require any extra information") 244 | parser.add_argument("-l", action="store_true", help="Print the list of the torrent where you can inject trackers, this not require any extra information") 245 | parser.add_argument("-n", action="append", help="Specify the torrent name or part of it, for example -n foo or -n 'foo bar'") 246 | parser.add_argument("-s", action="append", help="Specify the exact category name, for example -s foo or -s 'foo bar'. If -s is passed empty, \"\", the \"Uncategorized\" category will be used") 247 | 248 | args = parser.parse_args() 249 | 250 | if not sys.stdin.isatty() and not any(os.path.abspath('.').lower().startswith(p) for p in ["qbittorrent"]): 251 | if args.f and len(sys.argv) == 2: 252 | print("Don't use only -f, you need to specify also the torrent!") 253 | sys.exit(1) 254 | else: 255 | if not any(arg.startswith('-') for arg in sys.argv[1:]): 256 | print("Arguments must be passed with - in front, like -n foo. Check instructions") 257 | parser.print_help() 258 | sys.exit(1) 259 | 260 | if len(sys.argv) == 1: 261 | parser.print_help() 262 | sys.exit(0) 263 | 264 | if args.n: 265 | for name in args.n: 266 | if not name.strip(): 267 | print("One or more arguments for -n not valid, try again") 268 | sys.exit(1) 269 | 270 | return args 271 | 272 | ########## MAIN ########## 273 | if __name__ == "__main__": 274 | 275 | check_dependencies() 276 | 277 | import os 278 | import sys 279 | import requests 280 | import json 281 | import urllib.parse 282 | import time 283 | 284 | args = parse_arguments() 285 | 286 | all_torrent = args.a 287 | clean_existing_trackers = args.c 288 | applytheforce = args.f 289 | list_torrents = args.l 290 | tor_arg_names = args.n or [] 291 | tor_categories = args.s or [] 292 | 293 | if not sys.stdin.isatty() and not any(os.path.abspath('.').lower().startswith(p) for p in ["qbittorrent"]): 294 | event_types = ["sonarr_eventtype", "radarr_eventtype", "lidarr_eventtype", "readarr_eventtype"] 295 | download_clients = ["sonarr_download_client", "radarr_download_client", "lidarr_download_client", "readarr_download_client"] 296 | download_ids = ["sonarr_download_id", "radarr_download_id", "lidarr_download_id", "readarr_download_id"] 297 | 298 | if any(os.environ.get(event_type) == "Test" for event_type in event_types): 299 | print("Test in progress... Good-bye!") 300 | sys.exit(0) 301 | 302 | if exclude_download_client: 303 | exclude_clients = exclude_download_client.split(',') 304 | exclude_clients = [client.strip() for client in exclude_clients if client.strip()] 305 | 306 | # Check clients to exclude only if exclude_download_client is not empty 307 | for download_client in download_clients: 308 | client = os.environ.get(download_client) 309 | if client and client in exclude_clients: 310 | print(f"Exiting because {download_client} matches an excluded client: {client}") 311 | sys.exit(4) 312 | 313 | session = get_qbittorrent_session(qbt_host, qbt_port, qbt_username, qbt_password) 314 | 315 | if session: 316 | # Controlling download_ids 317 | for download_id in download_ids: 318 | hash = os.environ.get(download_id) 319 | if hash: 320 | print(f"{download_id.replace('_download_id', '').capitalize()} variable found -> {hash}") 321 | hash = hash.lower() 322 | if hash_check(hash): 323 | print(f"I'll wait 5s to be sure ...") 324 | time.sleep(5) 325 | 326 | torrent_list = get_torrent_list(session) 327 | 328 | private_check = check_torrent_privacy(session, hash) 329 | 330 | if private_check and not (ignore_private or applytheforce): 331 | trackers_data = get_torrent_trackers(session, hash) 332 | 333 | if trackers_data is None: 334 | print("Error getting torrent trackers.") 335 | else: 336 | private_tracker_name = trackers_data[3]["url"].split("//")[1].split("@")[-1].split(":")[0] 337 | print(f"< Private tracker found -> {private_tracker_name} <- I'll not add any extra tracker >") 338 | else: 339 | if ignore_private and not applytheforce: 340 | print("ignore_private set to true, I'll inject trackers anyway") 341 | elif applytheforce: 342 | print("Force mode is active, I'll inject trackers anyway") 343 | else: 344 | print("The torrent is not private, I'll inject trackers on it") 345 | inject_trackers(hash, session) 346 | break 347 | else: 348 | print("No valid hash found for the torrent, I'll exit") 349 | sys.exit(3) 350 | else: 351 | print("Failed to authenticate with qBittorrent.") 352 | else: 353 | session = get_qbittorrent_session(qbt_host, qbt_port, qbt_username, qbt_password) 354 | 355 | if session: 356 | if list_torrents: 357 | torrent_list = get_torrent_list(session) 358 | print(f"\n{len(torrent_list)} active torrent{'s' if len(torrent_list) > 1 else ''}:") 359 | for torrent in torrent_list: 360 | print(f"Name: {torrent['name']}, Category: {torrent['category'] if torrent['category'] else 'Uncategorized'}") 361 | sys.exit(0) 362 | 363 | torrent_list = get_torrent_list(session) 364 | 365 | torrent_name_array = [] 366 | torrent_hash_array = [] 367 | 368 | if all_torrent: 369 | for torrent in torrent_list: 370 | torrent_name_array.append(torrent["name"]) 371 | torrent_hash_array.append(torrent["hash"]) 372 | else: 373 | if tor_arg_names and tor_categories: 374 | for name in tor_arg_names: 375 | for category in tor_categories: 376 | filtered_torrents = [t for t in torrent_list if t["category"].lower() == category.lower() and name.lower() in t["name"].lower()] 377 | if filtered_torrents: 378 | print(f"\nFor the name ### {name} ### in category ### {'Uncategorized' if category == '' else category} ###") 379 | print(f"I found {len(filtered_torrents)} torrent{'s' if len(filtered_torrents) > 1 else ''}:") 380 | for torrent in filtered_torrents: 381 | print(torrent["name"]) 382 | torrent_name_array.append(torrent["name"]) 383 | torrent_hash_array.append(torrent["hash"]) 384 | else: 385 | print(f"\nI didn't find a torrent with name ### {name} ### in category ### {'Uncategorized' if category == '' else category} ###") 386 | elif tor_arg_names: 387 | for name in tor_arg_names: 388 | filtered_torrents = [t for t in torrent_list if name.lower() in t["name"].lower()] 389 | if filtered_torrents: 390 | print(f"\nFor the name ### {name} ###") 391 | print(f"I found {len(filtered_torrents)} torrent{'s' if len(filtered_torrents) > 1 else ''}:") 392 | for torrent in filtered_torrents: 393 | print(torrent["name"]) 394 | torrent_name_array.append(torrent["name"]) 395 | torrent_hash_array.append(torrent["hash"]) 396 | else: 397 | print(f"\nI didn't find a torrent with this part of the text: {name}") 398 | else: 399 | for category in tor_categories: 400 | filtered_torrents = [t for t in torrent_list if t["category"].lower() == category.lower()] 401 | if filtered_torrents: 402 | print(f"\nFor category ### {'Uncategorized' if category == '' else category} ###") 403 | print(f"I found {len(filtered_torrents)} torrent{'s' if len(filtered_torrents) > 1 else ''}:") 404 | for torrent in filtered_torrents: 405 | print(torrent["name"]) 406 | torrent_name_array.append(torrent["name"]) 407 | torrent_hash_array.append(torrent["hash"]) 408 | else: 409 | print(f"\nI didn't find a torrent in the category: {'Uncategorized' if category == '' else category}") 410 | 411 | if torrent_name_array: 412 | for i, name in enumerate(torrent_name_array): 413 | print(f"\nFor the Torrent: {name}") 414 | 415 | if ignore_private or applytheforce: 416 | if applytheforce: 417 | print("Force mode is active, I'll inject trackers anyway") 418 | else: 419 | print("ignore_private set to true, I'll inject trackers anyway") 420 | inject_trackers(torrent_hash_array[i], session) 421 | else: 422 | private_check = check_torrent_privacy(session, torrent_hash_array[i]) 423 | if private_check: 424 | trackers_data = get_torrent_trackers(session, torrent_hash_array[i]) 425 | if trackers_data is None: 426 | print("Error getting torrent trackers.") 427 | else: 428 | private_tracker_name = trackers_data[3]["url"].split("//")[1].split("@")[-1].split(":")[0] 429 | print(f"< Private tracker found -> {private_tracker_name} <- I'll not add any extra tracker >") 430 | else: 431 | print("The torrent is not private, I'll inject trackers on it") 432 | inject_trackers(torrent_hash_array[i], session) 433 | else: 434 | print("Exiting") 435 | sys.exit(1) 436 | else: 437 | print("Failed to authenticate with qBittorrent.") 438 | -------------------------------------------------------------------------------- /qBittorrentHardlinksChecker/qBittorrentHardlinksChecker.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import os 4 | import sys 5 | import argparse 6 | import json 7 | import requests 8 | import yaml 9 | import time 10 | from typing import Dict, Any, Optional, List, Tuple 11 | from urllib.parse import urljoin 12 | from pathlib import Path 13 | from dataclasses import dataclass 14 | from colorama import init, Fore, Style 15 | 16 | init() 17 | 18 | class QBittorrentManager: 19 | def __init__(self, config_file: str, dry_run: bool = False): 20 | self.dry_run = dry_run 21 | self._load_config(config_file) 22 | self._setup_session() 23 | 24 | def _load_config(self, config_file: str) -> None: 25 | """Load and validate the configuration""" 26 | with open(config_file) as f: 27 | config = yaml.safe_load(f) 28 | 29 | # Basic configuration required 30 | self.host = config['qbt_host'] 31 | self.port = config['qbt_port'] 32 | self.username = config['qbt_username'] 33 | self.password = config['qbt_password'] 34 | self.min_seeding_time = config['min_seeding_time'] 35 | 36 | # Optional configuration with default values 37 | self.categories = config.get('categories', []) 38 | self.torrent_type = config.get('torrent_type', '') 39 | self.virtual_path = config.get('virtual_path', '') 40 | self.real_path = config.get('real_path', '') 41 | self.enable_recheck = config.get('enable_recheck', True) 42 | self.enable_orphan_check = config.get('enable_orphan_check', True) 43 | self.orphan_states = [state.lower() for state in config.get('orphan_states', [])] 44 | self.min_peers = config.get('min_peers', 1) 45 | 46 | self.enable_auto_update_trackers = config.get('enable_auto-update_trackers', False) 47 | self.auto_update_trackers_script = config.get('auto-update_trackers_script', '') 48 | 49 | self.base_url = f"{self.host}:{self.port}" 50 | self.session = requests.Session() 51 | 52 | def _setup_session(self) -> None: 53 | """Initialize the HTTP session and log in""" 54 | self.login() 55 | 56 | def login(self) -> None: 57 | """Log in to qBittorrent""" 58 | try: 59 | response = self.session.post( 60 | urljoin(self.base_url, 'api/v2/auth/login'), 61 | data={'username': self.username, 'password': self.password} 62 | ) 63 | if response.text != 'Ok.': 64 | raise Exception("Login failed") 65 | except Exception as e: 66 | print(f"Failed to login: {str(e)}") 67 | sys.exit(1) 68 | 69 | def get_torrent_list(self) -> List[Dict[str, Any]]: 70 | """Gets the list of torrents""" 71 | try: 72 | torrent_list = [] 73 | # If "All" is specified in the categories, it takes all torrents 74 | if "All" in self.categories: 75 | response = self.session.get(urljoin(self.base_url, 'api/v2/torrents/info')) 76 | else: 77 | # Gets the torrents for each specified category 78 | for category in self.categories: 79 | if category == "Uncategorized": 80 | # For torrents without category 81 | response = self.session.get(urljoin(self.base_url, 'api/v2/torrents/info'), 82 | params={'category': ''}) 83 | else: 84 | response = self.session.get(urljoin(self.base_url, 'api/v2/torrents/info'), 85 | params={'category': category}) 86 | torrent_list.extend(response.json()) 87 | return torrent_list 88 | 89 | return response.json() 90 | except Exception as e: 91 | print(f"Failed to get torrent list: {str(e)}") 92 | return [] 93 | 94 | def get_torrent_properties(self, torrent_hash: str) -> Dict[str, Any]: 95 | """Gets the properties of a specific torrent""" 96 | try: 97 | response = self.session.get( 98 | urljoin(self.base_url, 'api/v2/torrents/properties'), 99 | params={'hash': torrent_hash} 100 | ) 101 | return response.json() 102 | except Exception as e: 103 | print(f"Failed to get torrent properties: {str(e)}") 104 | return {} 105 | 106 | def recheck_torrent(self, torrent_hash: str) -> None: 107 | """Double-check a torrent""" 108 | try: 109 | if self.dry_run: 110 | print(f"[DRY-RUN] Would recheck torrent with hash {torrent_hash}") 111 | return 112 | self.session.post( 113 | urljoin(self.base_url, 'api/v2/torrents/recheck'), 114 | data={'hashes': torrent_hash} 115 | ) 116 | print(f"Rechecking torrent with hash {torrent_hash}") 117 | except Exception as e: 118 | print(f"Failed to recheck torrent: {str(e)}") 119 | 120 | def reannounce_torrent(self, torrent_hash: str) -> None: 121 | """Performs reannounce of a torrent""" 122 | try: 123 | if self.dry_run: 124 | print(f"[DRY-RUN] Reannouncing torrent with hash {torrent_hash}") 125 | return 126 | self.session.post( 127 | urljoin(self.base_url, 'api/v2/torrents/reannounce'), 128 | data={'hashes': torrent_hash} 129 | ) 130 | print(f"Reannouncing torrent with hash {torrent_hash}") 131 | except Exception as e: 132 | print(f"Failed to reannounce torrent: {str(e)}") 133 | 134 | def delete_torrent(self, torrent_hash: str) -> None: 135 | """Remove a torrent""" 136 | try: 137 | if self.dry_run: 138 | print(f"[DRY-RUN] Torrent with hash {torrent_hash} deleted") 139 | return 140 | self.session.post( 141 | urljoin(self.base_url, 'api/v2/torrents/delete'), 142 | data={'hashes': torrent_hash, 'deleteFiles': True} 143 | ) 144 | print(f"Torrent with hash {torrent_hash} deleted") 145 | except Exception as e: 146 | print(f"Failed to delete torrent: {str(e)}") 147 | 148 | def check_hardlinks(self, path: str) -> bool: 149 | """Check if a file has hardlinks""" 150 | try: 151 | if os.path.isfile(path): 152 | return os.stat(path).st_nlink > 1 153 | elif os.path.isdir(path): 154 | for root, _, files in os.walk(path): 155 | for file in files: 156 | if os.stat(os.path.join(root, file)).st_nlink > 1: 157 | return True 158 | return False 159 | except Exception as e: 160 | print(f"Failed to check hardlinks: {str(e)}") 161 | return False 162 | 163 | def check_bad_trackers(self, torrent: Dict[str, Any]) -> Dict[str, str]: 164 | """Check problematic trackers""" 165 | bad_trackers = {} 166 | try: 167 | response = self.session.get( 168 | urljoin(self.base_url, 'api/v2/torrents/trackers'), 169 | params={'hash': torrent['hash']} 170 | ) 171 | trackers = response.json() 172 | 173 | for tracker in trackers: 174 | if tracker.get('status') == 4: 175 | bad_trackers[tracker['url']] = tracker.get('msg', 'Unknown error') 176 | 177 | return bad_trackers 178 | except Exception as e: 179 | print(f"Failed to check trackers: {str(e)}") 180 | return {} 181 | 182 | def remove_trackers(self, torrent_hash: str, trackers: Dict[str, str]) -> None: 183 | """Removes specified trackers""" 184 | try: 185 | if self.dry_run: 186 | print(f"- [DRY-RUN] Bad tracker{'s' if len(trackers) > 1 else ''} removed") 187 | return 188 | for tracker in trackers: 189 | self.session.post( 190 | urljoin(self.base_url, 'api/v2/torrents/removeTrackers'), 191 | data={'hash': torrent_hash, 'urls': tracker} 192 | ) 193 | print(f"- Bad tracker{'s' if len(trackers) > 1 else ''} removed") 194 | except Exception as e: 195 | print(f"Failed to remove trackers: {str(e)}") 196 | 197 | def run_tracker_update_script(self, torrent_hash: str, torrent_name: str) -> None: 198 | """Execute tracker update script for the specified torrent using the torrent name""" 199 | try: 200 | if self.dry_run: 201 | print(f"- [DRY-RUN] Would run tracker update script for torrent: {torrent_name}") 202 | return 203 | 204 | import subprocess 205 | 206 | # We use the name of the torrent with the argument -n 207 | command = [self.auto_update_trackers_script, "-n", torrent_name] 208 | 209 | result = subprocess.run( 210 | command, 211 | capture_output=True, 212 | text=True 213 | ) 214 | 215 | if result.returncode == 0: 216 | print(f"- Tracker update script executed successfully") 217 | else: 218 | print(f"- Tracker update script failed: {result.stderr.strip() or result.stdout.strip()}") 219 | except Exception as e: 220 | print(f"Failed to run tracker update script: {str(e)}") 221 | 222 | def _print_configuration(self) -> None: 223 | """Print the current configuration""" 224 | print("\nCurrent configuration:") 225 | print(f"- Host: {self.host}:{self.port}") 226 | print(f"- Username: {self.username}") 227 | print(f"- Password: {'*' * len(self.password)}") 228 | print(f"- Processing: {'Only ' + self.torrent_type if self.torrent_type else 'Private & Public'} torrents") 229 | print(f"- Categories: {', '.join(self.categories) if self.categories else 'All'}") 230 | print(f"- Minimum seeding time: {self.min_seeding_time} seconds") 231 | print(f"- Minimum peers: {self.min_peers}") 232 | print(f"- Virtual path: {self.virtual_path if self.virtual_path else 'not set'}") 233 | print(f"- Real path: {self.real_path if self.real_path else 'not set'}") 234 | print(f"- Enable recheck: {self.enable_recheck}") 235 | print(f"- Enable orphan check: {self.enable_orphan_check}") 236 | print(f"- Orphan states: {self.orphan_states if self.orphan_states else 'not set'}") 237 | 238 | print(f"- Auto-update trackers: {self.enable_auto_update_trackers}") 239 | if self.enable_auto_update_trackers: 240 | print(f"- Update script: {self.auto_update_trackers_script}") 241 | 242 | if self.dry_run: 243 | print(f"{Fore.GREEN}- DRY-RUN mode enabled{Style.RESET_ALL}") 244 | print("\nProcessing only selected torrents...") 245 | 246 | def process_torrents(self) -> None: 247 | """Process all torrents""" 248 | torrents = self.get_torrent_list() 249 | self._print_configuration() 250 | 251 | for torrent in torrents: 252 | 253 | properties = self.get_torrent_properties(torrent['hash']) 254 | is_private = properties.get('is_private', False) 255 | 256 | print(f"\nTorrent -> {Fore.CYAN if is_private else Fore.GREEN}{torrent['name']}{Style.RESET_ALL} ({('private' if is_private else 'public')})") 257 | 258 | if self.torrent_type == 'private' and not is_private: 259 | print("Skipping further checks: torrent is public but only private torrents are configured") 260 | continue 261 | elif self.torrent_type == 'public' and is_private: 262 | print("Skipping further checks: torrent is private but only public torrents are configured") 263 | continue 264 | 265 | # Control recheck 266 | if self.enable_recheck: 267 | print("- Checking for errors ->", end=" ") 268 | if torrent.get('state') in ["error", "missingFiles"]: 269 | print(f"{Fore.RED}errors found, forcing recheck{Style.RESET_ALL}") 270 | self.recheck_torrent(torrent['hash']) 271 | else: 272 | print(f"{Fore.GREEN}no errors found{Style.RESET_ALL}") 273 | 274 | # Tracker check 275 | if not is_private: 276 | print("- Checking for bad trackers ->", end=" ") 277 | bad_trackers = self.check_bad_trackers(torrent) 278 | if bad_trackers: 279 | print(f"{Fore.YELLOW}{len(bad_trackers)} bad tracker{'s' if len(bad_trackers) > 1 else ''} found:{Style.RESET_ALL}") 280 | for tracker, error in bad_trackers.items(): 281 | print(f" {tracker} -> {Fore.RED}{error}{Style.RESET_ALL}") 282 | self.remove_trackers(torrent['hash'], bad_trackers) 283 | else: 284 | print(f"{Fore.GREEN}no bad trackers found{Style.RESET_ALL}") 285 | 286 | # Orphan check 287 | if self.enable_orphan_check and is_private: 288 | print("- Checking for orphan status ->", end=" ") 289 | trackers = self.session.get( 290 | urljoin(self.base_url, 'api/v2/torrents/trackers'), 291 | params={'hash': torrent['hash']} 292 | ).json() 293 | 294 | # Filtra i trackers speciali (DHT, PeX, LSD) 295 | special_trackers = ['** [DHT] **', '** [PeX] **', '** [LSD] **'] 296 | real_trackers = [t for t in trackers if t.get('url', '') not in special_trackers] 297 | 298 | is_orphan = False 299 | for tracker in real_trackers: 300 | if any(state in tracker.get('msg', '').lower() for state in self.orphan_states): 301 | if torrent.get('num_leechs', 0) < self.min_peers: 302 | is_orphan = True 303 | break 304 | 305 | if is_orphan: 306 | print(f"{Fore.RED}orphan detected{Style.RESET_ALL}") 307 | self.reannounce_torrent(torrent['hash']) 308 | time.sleep(2) 309 | self.delete_torrent(torrent['hash']) 310 | else: 311 | print("no orphan detected") 312 | 313 | # Tracker update script 314 | if self.enable_auto_update_trackers and not is_private and torrent['progress'] != 1: 315 | print("- Running tracker update script ->", end=" ") 316 | if self.auto_update_trackers_script: 317 | self.run_tracker_update_script(torrent['hash'], torrent['name']) 318 | else: 319 | print("script path not configured") 320 | 321 | # Controllo hardlink 322 | content_path = torrent.get('content_path', '') 323 | if content_path: 324 | if torrent['progress'] != 1: 325 | print("- Skipping hardlink check: torrent not downloaded") 326 | continue 327 | 328 | print("- Checking for hardlinks ->", end=" ") 329 | if self.virtual_path and self.real_path: 330 | content_path = content_path.replace(self.virtual_path, self.real_path) 331 | 332 | has_hardlinks = self.check_hardlinks(content_path) 333 | seeding_time = properties['seeding_time'] 334 | 335 | if has_hardlinks: 336 | print("hardlinks found, nothing to do") 337 | continue 338 | else: 339 | if self.min_seeding_time > 0 and seeding_time < self.min_seeding_time: 340 | print(f"no hardlinks found but I can't delete this torrent, seeding time not met -> {seeding_time}/{self.min_seeding_time}") 341 | continue 342 | 343 | print(f"no hardlinks found deleting torrent...") 344 | self.reannounce_torrent(torrent['hash']) 345 | time.sleep(2) 346 | self.delete_torrent(torrent['hash']) 347 | 348 | DEFAULT_CONFIG = """# qBittorrent server configuration 349 | qbt_host: "http://localhost" # Server address (with http/https). 350 | qbt_port: "8081" # Web UI Port 351 | qbt_username: "admin" # Web UI Username 352 | qbt_password: "adminadmin" # Web UI Password 353 | 354 | # Configuration torrent management 355 | # Minimum seeding time in seconds (ex: 259200 = 3 days). 356 | # Set to 0 if you want to disable the min_seeding_time check 357 | min_seeding_time: 864000 358 | 359 | # List of categories to be processed. 360 | # Use ["All"] for all categories. 361 | # Use ["Uncategorized"] for torrents without category. 362 | # Or specify categories: ["movies", "tv", "books"] 363 | categories: 364 | - "All" 365 | 366 | # Type of torrent to be processed 367 | # Options: "private", "public" or blank "" to process all. 368 | torrent_type: "" 369 | 370 | # Configuring paths (useful with Docker) 371 | virtual_path: "" # Examample: "/downloads" in Docker 372 | real_path: "" # Example: "/home/user/downloads" real path on the system 373 | 374 | # Automatic controls 375 | enable_recheck: true # Enable automatic recheck torrent in error. 376 | enable_orphan_check: true # Enable orphan torrent checking, works only on private torrents 377 | 378 | # States that identify a torrent as orphaned. 379 | orphan_states: 380 | - "unregistered" 381 | - "not registered" 382 | - "not found" 383 | - "not working" 384 | - "torrent has been deleted" 385 | 386 | # Minimum number of peers before considering a torrent orphaned. 387 | # Default: 1 388 | min_peers: 1""" 389 | 390 | def create_default_config(config_path: str) -> None: 391 | """Creates a default configuration file""" 392 | if os.path.exists(config_path): 393 | raise FileExistsError(f"Configuration file already exists: {config_path}") 394 | 395 | with open(config_path, 'w') as f: 396 | f.write(DEFAULT_CONFIG) 397 | 398 | print(f"Default configuration file created: {config_path}") 399 | 400 | def get_default_config_name() -> str: 401 | """Get the default configuration file name based on the script name""" 402 | script_name = os.path.basename(sys.argv[0]) 403 | base_name = os.path.splitext(script_name)[0] 404 | return f"{base_name}_config.yaml" 405 | 406 | def validate_config_file(config_path: str) -> None: 407 | """Validates the existence and format of the configuration file""" 408 | path = Path(config_path) 409 | if not path.exists(): 410 | raise FileNotFoundError(f"Configuration file not found: {config_path}") 411 | if not path.suffix.lower() == '.yaml': 412 | raise ValueError("The configuration file must be in YAML format") 413 | 414 | def parse_arguments() -> argparse.Namespace: 415 | """Parsing of command line arguments""" 416 | parser = argparse.ArgumentParser( 417 | description='QBittorrent Manager - Automated torrent management' 418 | ) 419 | 420 | parser.add_argument( 421 | '-c', '--config', 422 | default=get_default_config_name(), 423 | help='YAML configuration file path (default: _config.yaml)' 424 | ) 425 | 426 | parser.add_argument( 427 | '--dry-run', 428 | action='store_true', 429 | help='Run in simulation mode (no actual changes)' 430 | ) 431 | 432 | parser.add_argument( 433 | '--create-config', 434 | action='store_true', 435 | help='Create a default configuration file' 436 | ) 437 | 438 | return parser.parse_args() 439 | 440 | def main() -> None: 441 | try: 442 | args = parse_arguments() 443 | 444 | if args.create_config: 445 | create_default_config(args.config) 446 | return 447 | 448 | validate_config_file(args.config) 449 | manager = QBittorrentManager(args.config, args.dry_run) 450 | manager.process_torrents() 451 | except FileExistsError as e: 452 | print(f"Error: {e}") 453 | sys.exit(1) 454 | except FileNotFoundError as e: 455 | print(f"Error: {e}") 456 | print("Use --create-config to create a default configuration file") 457 | sys.exit(1) 458 | except ValueError as e: 459 | print(f"Configuration error: {e}") 460 | sys.exit(1) 461 | except KeyboardInterrupt: 462 | print("\nOperation aborted by user") 463 | sys.exit(1) 464 | except Exception as e: 465 | print(f"Unexpected error: {e}") 466 | sys.exit(1) 467 | 468 | if __name__ == "__main__": 469 | main() 470 | -------------------------------------------------------------------------------- /AddqBittorrentTrackers/AddqBittorrentTrackers.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | ########## CONFIGURATIONS ########## 4 | # Host on which qBittorrent runs 5 | qbt_host="http://10.0.0.100" 6 | # Port -> the same port that is inside qBittorrent option -> Web UI -> Web User Interface 7 | qbt_port="8081" 8 | # Username to access to Web UI 9 | qbt_username="admin" 10 | # Password to access to Web UI 11 | qbt_password="adminadmin" 12 | 13 | # If true (lowercase) the script will inject trackers inside private torrent too (not a good idea) 14 | ignore_private=false 15 | 16 | # If true (lowercase) the script will remove all existing trackers before inject the new one, this functionality will works only for public trackers 17 | clean_existing_trackers=false 18 | 19 | # Configure here your trackers list 20 | declare -a live_trackers_list_urls=( 21 | "https://newtrackon.com/api/stable" 22 | "https://trackerslist.com/best.txt" 23 | "https://trackerslist.com/http.txt" 24 | "https://raw.githubusercontent.com/ngosang/trackerslist/master/trackers_best.txt" 25 | ) 26 | ########## CONFIGURATIONS ########## 27 | 28 | jq_executable="$(command -v jq)" 29 | curl_executable="$(command -v curl)" 30 | auto_tor_grab=0 31 | test_in_progress=0 32 | applytheforce=0 33 | all_torrent=0 34 | emptycategory=0 35 | 36 | if [[ -z $jq_executable ]]; then 37 | echo -e "\n\e[0;91;1mFail on jq. Aborting.\n\e[0m" 38 | echo "You can find it here: https://stedolan.github.io/jq/" 39 | echo "Or you can install it with -> sudo apt install jq" 40 | exit 1 41 | fi 42 | 43 | if [[ -z $curl_executable ]]; then 44 | echo -e "\n\e[0;91;1mFail on curl. Aborting.\n\e[0m" 45 | echo "You can install it with -> sudo apt install curl" 46 | exit 2 47 | fi 48 | 49 | if [[ $qbt_host == "https://"* ]]; then 50 | curl_executable="${curl_executable} --insecure" 51 | fi 52 | 53 | version="v3.16" 54 | 55 | STATIC_TRACKERS_LIST=$( 56 | cat <<'EOL' 57 | udp://tracker.coppersurfer.tk:6969/announce 58 | http://tracker.internetwarriors.net:1337/announce 59 | udp://tracker.internetwarriors.net:1337/announce 60 | udp://tracker.opentrackr.org:1337/announce 61 | udp://9.rarbg.to:2710/announce 62 | udp://exodus.desync.com:6969/announce 63 | udp://explodie.org:6969/announce 64 | http://explodie.org:6969/announce 65 | udp://public.popcorn-tracker.org:6969/announce 66 | udp://tracker.vanitycore.co:6969/announce 67 | http://tracker.vanitycore.co:6969/announce 68 | udp://tracker1.itzmx.com:8080/announce 69 | http://tracker1.itzmx.com:8080/announce 70 | udp://ipv4.tracker.harry.lu:80/announce 71 | udp://tracker.torrent.eu.org:451/announce 72 | udp://tracker.tiny-vps.com:6969/announce 73 | udp://tracker.port443.xyz:6969/announce 74 | udp://open.stealth.si:80/announce 75 | udp://open.demonii.si:1337/announce 76 | udp://denis.stalker.upeer.me:6969/announce 77 | udp://bt.xxx-tracker.com:2710/announce 78 | http://tracker.port443.xyz:6969/announce 79 | udp://tracker2.itzmx.com:6961/announce 80 | udp://retracker.lanta-net.ru:2710/announce 81 | http://tracker2.itzmx.com:6961/announce 82 | http://tracker4.itzmx.com:2710/announce 83 | http://tracker3.itzmx.com:6961/announce 84 | http://tracker.city9x.com:2710/announce 85 | http://torrent.nwps.ws:80/announce 86 | http://retracker.telecom.by:80/announce 87 | http://open.acgnxtracker.com:80/announce 88 | wss://ltrackr.iamhansen.xyz:443/announce 89 | udp://zephir.monocul.us:6969/announce 90 | udp://tracker.toss.li:6969/announce 91 | http://opentracker.xyz:80/announce 92 | http://open.trackerlist.xyz:80/announce 93 | udp://tracker.swateam.org.uk:2710/announce 94 | udp://tracker.kamigami.org:2710/announce 95 | udp://tracker.iamhansen.xyz:2000/announce 96 | udp://tracker.ds.is:6969/announce 97 | udp://pubt.in:2710/announce 98 | https://tracker.fastdownload.xyz:443/announce 99 | https://opentracker.xyz:443/announce 100 | http://tracker.torrentyorg.pl:80/announce 101 | http://t.nyaatracker.com:80/announce 102 | http://open.acgtracker.com:1096/announce 103 | wss://tracker.openwebtorrent.com:443/announce 104 | wss://tracker.fastcast.nz:443/announce 105 | wss://tracker.btorrent.xyz:443/announce 106 | udp://tracker.justseed.it:1337/announce 107 | udp://thetracker.org:80/announce 108 | udp://packages.crunchbangplusplus.org:6969/announce 109 | https://1337.abcvg.info:443/announce 110 | http://tracker.tfile.me:80/announce.php 111 | http://tracker.tfile.me:80/announce 112 | http://tracker.tfile.co:80/announce 113 | http://retracker.mgts.by:80/announce 114 | http://peersteers.org:80/announce 115 | http://fxtt.ru:80/announce 116 | EOL 117 | ) 118 | 119 | ########## FUNCTIONS ########## 120 | generate_trackers_list () { 121 | # If trackers_list is already populated, do nothing and return 122 | if [[ -n "$trackers_list" ]]; then 123 | echo "Trackers list already populated. Skipping generation." 124 | return 125 | fi 126 | 127 | trackers_list="" # Local variable for dynamic trackers 128 | all_failed=true # Assume that all URLs fail 129 | 130 | # 1. Check if the list of URLs is empty 131 | if [[ ${#live_trackers_list_urls[@]} -eq 0 ]]; then 132 | echo "No live tracker URLs provided. Using the static list." 133 | trackers_list="$STATIC_TRACKERS_LIST" 134 | return 135 | fi 136 | 137 | # 2. Attempts to download trackers from each URL 138 | for url in "${live_trackers_list_urls[@]}"; do 139 | echo "Fetching trackers from: $url" 140 | # Download data, silently 141 | new_trackers=$($curl_executable -sS "$url") 142 | if [[ $? -eq 0 && -n "$new_trackers" ]]; then 143 | # If the download was successful, add the new trackers to trackers_list 144 | trackers_list+="$new_trackers"$'\n' 145 | all_failed=false # At least one URL worked 146 | else 147 | # If the download fails, report the error but continue 148 | echo "Warning: Failed to fetch trackers from $url" 149 | fi 150 | done 151 | 152 | # 3. Check if all downloads have failed 153 | if [[ "$all_failed" == true ]]; then 154 | echo "All live tracker URLs failed. Using the static list." 155 | trackers_list="$STATIC_TRACKERS_LIST" 156 | fi 157 | } 158 | 159 | inject_trackers () { 160 | echo -ne "\e[0;36;1mInjecting... \e[0;36m" 161 | 162 | torrent_trackers=$(echo "$qbt_cookie" | $curl_executable --silent --fail --show-error \ 163 | --cookie - \ 164 | --request GET "${qbt_host}:${qbt_port}/api/v2/torrents/trackers?hash=${1}" | $jq_executable --raw-output '.[] | .url' | tail -n +4) 165 | 166 | remove_trackers $1 "${torrent_trackers//$'\n'/|}" 167 | 168 | if [[ $clean_existing_trackers == true ]]; then 169 | echo -e " \e[32mBut before a quick cleaning the existing trackers... " 170 | trackers_list=$(echo "$trackers_list" | sort | uniq) 171 | else 172 | trackers_list=$(echo "$trackers_list"$'\n'"$torrent_trackers" | sort | uniq) 173 | fi 174 | 175 | trackers_list=$(sed '/^$/d' <<< "$trackers_list") 176 | 177 | number_of_trackers_in_list=$(echo "$trackers_list" | wc -l) 178 | 179 | urls=${trackers_list//$'\n'/%0A%0A} 180 | 181 | echo "$qbt_cookie" | $curl_executable --silent --fail --show-error \ 182 | -d "hash=${1}&urls=$urls" \ 183 | --cookie - \ 184 | --request POST "${qbt_host}:${qbt_port}/api/v2/torrents/addTrackers" 185 | 186 | echo -e "\e[32mdone, injected $number_of_trackers_in_list trackers!" 187 | } 188 | 189 | get_torrent_list () { 190 | get_cookie 191 | torrent_list=$(echo "$qbt_cookie" | $curl_executable --silent --fail --show-error \ 192 | --cookie - \ 193 | --request GET "${qbt_host}:${qbt_port}/api/v2/torrents/info") 194 | } 195 | 196 | url_encode() { 197 | local string="${1}" 198 | 199 | # Check if xxd is available 200 | if command -v xxd >/dev/null 2>&1; then 201 | # If xxd is available, use xxd for encoding 202 | printf '%s' "$string" | xxd -p | sed 's/\(..\)/%\1/g' | tr -d '\n' 203 | else 204 | # If jq is available, use jq for encoding 205 | jq -nr --arg s "$string" '$s|@uri' 206 | fi 207 | } 208 | 209 | get_cookie () { 210 | encoded_username=$(url_encode "$qbt_username") 211 | encoded_password=$(url_encode "$qbt_password") 212 | 213 | # If encoding fails, exit the function 214 | if [ $? -ne 0 ]; then 215 | echo "Error during URL encoding" >&2 216 | return 1 217 | fi 218 | 219 | qbt_cookie=$($curl_executable --silent --fail --show-error \ 220 | --header "Referer: ${qbt_host}:${qbt_port}" \ 221 | --cookie-jar - \ 222 | --data "username=${encoded_username}&password=${encoded_password}" ${qbt_host}:${qbt_port}/api/v2/auth/login) 223 | } 224 | 225 | hash_check() { 226 | case $1 in 227 | ( *[!0-9A-Fa-f]* | "" ) return 1 ;; 228 | ( * ) 229 | case ${#1} in 230 | ( 32 | 40 ) return 0 ;; 231 | ( * ) return 1 ;; 232 | esac 233 | esac 234 | } 235 | 236 | remove_trackers () { 237 | hash="$1" 238 | single_url="$2" 239 | echo "$qbt_cookie" | $curl_executable --silent --fail --show-error \ 240 | -d "hash=${hash}&urls=${single_url}" \ 241 | --cookie - \ 242 | --request POST "${qbt_host}:${qbt_port}/api/v2/torrents/removeTrackers" 243 | } 244 | 245 | wait() { 246 | w=$1 247 | echo "I'll wait ${w}s to be sure ..." 248 | while [ $w -gt 0 ]; do 249 | echo -ne "$w\033[0K\r" 250 | sleep 1 251 | w=$((w-1)) 252 | done 253 | } 254 | ########## FUNCTIONS ########## 255 | 256 | if [ -t 1 ] || [[ "$PWD" == *qbittorrent* ]] ; then 257 | if [[ ! $@ =~ ^\-.+ ]]; then 258 | echo "Arguments must be passed with - in front, like -n foo. Check instructions" 259 | echo "" 260 | $0 -h 261 | exit 262 | fi 263 | 264 | [ $# -eq 0 ] && $0 -h 265 | 266 | if [ $# -eq 1 ] && [ $1 == "-f" ]; then 267 | echo "Don't use only -f, you need to specify also the torrent!" 268 | exit 269 | fi 270 | 271 | while getopts ":acflhn:s:" opt; do 272 | case ${opt} in 273 | a ) # If used inject trackers to all torrent. 274 | all_torrent=1 275 | ;; 276 | c ) # If used remove all the existing trackers before injecting the new ones. 277 | clean_existing_trackers=true 278 | ;; 279 | f ) # If used force the injection also in private trackers. 280 | applytheforce=1 281 | ;; 282 | l ) # Print the list of the torrent where you can inject trackers. 283 | get_torrent_list 284 | echo -e "\n\e[0;32;1mCurrent torrents:\e[0;32m" 285 | echo "$torrent_list" | $jq_executable --raw-output '.[] .name' 286 | exit 287 | ;; 288 | n ) # Specify the name of the torrent example -n foo or -n "foo bar", multiple -n can be used. 289 | tor_arg_names+=("$OPTARG") 290 | ;; 291 | s ) # Specify the category of the torrent example -s foo or -s "foo bar", multiple -s can be used. If -s is passed without arguments, the "default" categories will be used 292 | tor_categories+=("$OPTARG") 293 | ;; 294 | : ) 295 | echo "Invalid option: -${OPTARG} requires an argument" 1>&2 296 | exit 0 297 | ;; 298 | \? ) 299 | echo "Unknow option: -${OPTARG}" 1>&2 300 | exit 1 301 | ;; 302 | h | * ) # Display help. 303 | echo "Usage:" 304 | echo "$0 -a Inject trackers to all torrent in qBittorrent, this not require any extra information" 305 | echo "$0 -c Clean all the existing trackers before the injection, this not require any extra information" 306 | echo "$0 -f Force the injection of the trackers inside the private torrent too, this not require any extra information" 307 | echo "$0 -l Print the list of the torrent where you can inject trackers, this not require any extra information" 308 | echo "$0 -n Specify the torrent name or part of it, for example -n foo or -n 'foo bar'" 309 | echo "$0 -s Specify the exact category name, for example -s foo or -s 'foo bar'. If -s is passed empty, \"\", the \"Uncategorized\" category will be used" 310 | echo "$0 -h Display this help" 311 | echo "" 312 | echo "NOTE:" 313 | echo "It's possible to specify more than -n in one single command" 314 | echo "It's possible to specify more than -s in one single command" 315 | echo "Is also possible use -n foo -s bar to select specific name in specific category" 316 | echo "Just remember that if you set -a, is useless to add any extra arguments, like -n, but -f can always be used" 317 | exit 2 318 | ;; 319 | esac 320 | done 321 | shift $((OPTIND -1)) 322 | else 323 | if [[ -n "${sonarr_download_id}" ]] || [[ -n "${radarr_download_id}" ]] || [[ -n "${lidarr_download_id}" ]] || [[ -n "${readarr_download_id}" ]]; then 324 | #wait 5 325 | if [[ -n "${sonarr_download_id}" ]]; then 326 | echo "Sonarr variable found -> $sonarr_download_id" 327 | hash=$(echo "$sonarr_download_id" | awk '{print tolower($0)}') 328 | fi 329 | 330 | if [[ -n "${radarr_download_id}" ]]; then 331 | echo "Radarr variable found -> $radarr_download_id" 332 | hash=$(echo "$radarr_download_id" | awk '{print tolower($0)}') 333 | fi 334 | 335 | if [[ -n "${lidarr_download_id}" ]]; then 336 | echo "Lidarr variable found -> $lidarr_download_id" 337 | hash=$(echo "$lidarr_download_id" | awk '{print tolower($0)}') 338 | fi 339 | 340 | if [[ -n "${readarr_download_id}" ]]; then 341 | echo "Readarr variable found -> $readarr_download_id" 342 | hash=$(echo "$readarr_download_id" | awk '{print tolower($0)}') 343 | fi 344 | 345 | hash_check "${hash}" 346 | if [[ $? -ne 0 ]]; then 347 | echo "No valid hash found for the torrent, I'll exit" 348 | exit 3 349 | fi 350 | auto_tor_grab=1 351 | fi 352 | 353 | if [[ $sonarr_eventtype == "Test" ]] || [[ $radarr_eventtype == "Test" ]] || [[ $lidarr_eventtype == "Test" ]] || [[ $readarr_eventtype == "Test" ]]; then 354 | echo "Test in progress..." 355 | test_in_progress=1 356 | fi 357 | fi 358 | 359 | for i in "${tor_arg_names[@]}"; do 360 | if [[ -z "${i// }" ]]; then 361 | echo "one or more argument for -n not valid, try again" 362 | exit 363 | fi 364 | done 365 | 366 | if [ $test_in_progress -eq 1 ]; then 367 | echo "Good-bye!" 368 | elif [ $auto_tor_grab -eq 0 ]; then # manual run 369 | get_torrent_list 370 | 371 | if [ $all_torrent -eq 1 ]; then 372 | while IFS= read -r line; do 373 | torrent_name_array+=("$line") 374 | done < <(echo $torrent_list | $jq_executable --raw-output '.[] | .name') 375 | 376 | while IFS= read -r line; do 377 | torrent_hash_array+=("$line") 378 | done < <(echo $torrent_list | $jq_executable --raw-output '.[] | .hash') 379 | else 380 | if [[ ${#tor_arg_names[@]} -gt 0 && ${#tor_categories[@]} -gt 0 ]]; then 381 | for name in "${tor_arg_names[@]}"; do 382 | for category in "${tor_categories[@]}"; do 383 | torrent_name_list=$(echo "$torrent_list" | $jq_executable --arg category "$category" --arg name "$name" --raw-output '.[] | select(.category | ascii_downcase == ($category | ascii_downcase)) | select(.name | ascii_downcase | contains($name | ascii_downcase)) | .name') 384 | 385 | if [ -n "$torrent_name_list" ]; then # not empty 386 | torrent_name_check=1 387 | 388 | if [[ $category == "" ]]; then 389 | echo -e "\n\e[0;32;1mFor the name ### $name ### in category ### Uncategorized ###\e[0;32m" 390 | else 391 | echo -e "\n\e[0;32;1mFor the name ### $name ### in category ### $category ###\e[0;32m" 392 | fi 393 | 394 | echo -e "\e[0;32;1mI found the following torrent(s):\e[0;32m" 395 | echo "$torrent_name_list" 396 | else 397 | torrent_name_check=0 398 | fi 399 | 400 | if [ $torrent_name_check -eq 0 ]; then 401 | if [[ $category == "" ]]; then 402 | echo -e "\n\e[0;31;1mI didn't find a torrent with name ### $name ### in category ### Uncategorized ###\e[0m" 403 | else 404 | echo -e "\n\e[0;31;1mI didn't find a torrent with name ### $name ### in category ### $category ###\e[0m" 405 | fi 406 | 407 | shift 408 | continue 409 | else 410 | while read -r single_found; do 411 | torrent_name_array+=("$single_found") 412 | hash=$(echo "$torrent_list" | $jq_executable --arg single "$single_found" --raw-output '.[] | select(.name == "\($single)") | .hash') 413 | torrent_hash_array+=("$hash") 414 | done <<< "$torrent_name_list" 415 | fi 416 | done 417 | done 418 | elif [[ ${#tor_arg_names[@]} -gt 0 ]]; then 419 | for name in "${tor_arg_names[@]}"; do 420 | torrent_name_list=$(echo "$torrent_list" | $jq_executable --arg name "$name" --raw-output '.[] | select(.name | ascii_downcase | contains($name | ascii_downcase)) | .name') #possible fix for ONIGURUMA regex libary 421 | 422 | if [ -n "$torrent_name_list" ]; then # not empty 423 | torrent_name_check=1 424 | echo -e "\n\e[0;32;1mFor the name ### $name ###\e[0;32m" 425 | echo -e "\e[0;32;1mI found the following torrent(s):\e[0;32m" 426 | echo "$torrent_name_list" 427 | else 428 | torrent_name_check=0 429 | fi 430 | 431 | if [ $torrent_name_check -eq 0 ]; then 432 | echo -e "\n\e[0;31;1mI didn't find a torrent with this part of the text: \e[21m$name\e[0m" 433 | shift 434 | continue 435 | else 436 | while read -r single_found; do 437 | torrent_name_array+=("$single_found") 438 | hash=$(echo "$torrent_list" | $jq_executable --arg single "$single_found" --raw-output '.[] | select(.name == "\($single)") | .hash') 439 | torrent_hash_array+=("$hash") 440 | done <<< "$torrent_name_list" 441 | fi 442 | done 443 | else 444 | for category in "${tor_categories[@]}"; do 445 | torrent_name_list=$(echo "$torrent_list" | $jq_executable --arg category "$category" --raw-output '.[] | select(.category | ascii_downcase == ($category | ascii_downcase)) | .name') 446 | 447 | if [ -n "$torrent_name_list" ]; then # not empty 448 | torrent_name_check=1 449 | 450 | if [[ $category == "" ]]; then 451 | echo -e "\n\e[0;32;1mFor category ### Uncategorized ###\e[0;32m" 452 | else 453 | echo -e "\n\e[0;32;1mFor category ### $category ###\e[0;32m" 454 | fi 455 | 456 | echo -e "\e[0;32;1mI found the following torrent(s):\e[0;32m" 457 | echo "$torrent_name_list" 458 | else 459 | torrent_name_check=0 460 | fi 461 | 462 | if [ $torrent_name_check -eq 0 ]; then 463 | echo -e "\n\e[0;31;1mI didn't find a torrent in the category: \e[21m$category\e[0m" 464 | shift 465 | continue 466 | else 467 | while read -r single_found; do 468 | torrent_name_array+=("$single_found") 469 | hash=$(echo "$torrent_list" | $jq_executable --arg single "$single_found" --raw-output '.[] | select(.name == "\($single)") | .hash') 470 | torrent_hash_array+=("$hash") 471 | done <<< "$torrent_name_list" 472 | fi 473 | done 474 | fi 475 | fi 476 | 477 | if [ ${#torrent_name_array[@]} -gt 0 ]; then 478 | echo "" 479 | for i in "${!torrent_name_array[@]}"; do 480 | echo -ne "\n\e[0;1;4;32mFor the Torrent: \e[0;4;32m" 481 | echo "${torrent_name_array[$i]}" 482 | 483 | if [[ $ignore_private == true ]] || [ $applytheforce -eq 1 ]; then # Inject anyway the trackers inside any torrent 484 | if [ $applytheforce -eq 1 ]; then 485 | echo -e "\e[0m\e[33mForce mode is active, I'll inject trackers anyway\e[0m" 486 | else 487 | echo -e "\e[0m\e[33mignore_private set to true, I'll inject trackers anyway\e[0m" 488 | fi 489 | generate_trackers_list 490 | inject_trackers ${torrent_hash_array[$i]} 491 | else 492 | private_check=$(echo "$qbt_cookie" | $curl_executable --silent --fail --show-error --cookie - --request GET "${qbt_host}:${qbt_port}/api/v2/torrents/properties?hash=$(echo "$torrent_list" | $jq_executable --raw-output --arg tosearch "${torrent_name_array[$i]}" '.[] | select(.name == "\($tosearch)") | .hash')" | $jq_executable --raw-output '.is_private') 493 | 494 | if [[ $private_check == true ]]; then 495 | private_tracker_name=$(echo "$qbt_cookie" | $curl_executable --silent --fail --show-error --cookie - --request GET "${qbt_host}:${qbt_port}/api/v2/torrents/trackers?hash=$(echo "$torrent_list" | $jq_executable --raw-output --arg tosearch "${torrent_name_array[$i]}" '.[] | select(.name == "\($tosearch)") | .hash')" | $jq_executable --raw-output '.[3] | .url' | sed -e 's/[^/]*\/\/\([^@]*@\)\?\([^:/]*\).*/\2/') 496 | echo -e "\e[31m< Private tracker found \e[0m\e[33m-> $private_tracker_name <- \e[0m\e[31mI'll not add any extra tracker >\e[0m" 497 | else 498 | echo -e "\e[0m\e[33mThe torrent is not private, I'll inject trackers on it\e[0m" 499 | generate_trackers_list 500 | inject_trackers ${torrent_hash_array[$i]} 501 | fi 502 | fi 503 | done 504 | else 505 | echo "No torrents found, exiting" 506 | fi 507 | else # auto_tor_grab active, so some *Arr 508 | wait 5 509 | get_torrent_list 510 | 511 | private_check=$(echo "$qbt_cookie" | $curl_executable --silent --fail --show-error --cookie - --request GET "${qbt_host}:${qbt_port}/api/v2/torrents/properties?hash=$hash" | $jq_executable --raw-output '.is_private') 512 | 513 | if [[ $private_check == true ]]; then 514 | private_tracker_name=$(echo "$qbt_cookie" | $curl_executable --silent --fail --show-error --cookie - --request GET "${qbt_host}:${qbt_port}/api/v2/torrents/trackers?hash=$hash" | $jq_executable --raw-output '.[3] | .url' | sed -e 's/[^/]*\/\/\([^@]*@\)\?\([^:/]*\).*/\2/') 515 | echo -e "\e[31m< Private tracker found \e[0m\e[33m-> $private_tracker_name <- \e[0m\e[31mI'll not add any extra tracker >\e[0m" 516 | else 517 | echo -e "\e[0m\e[33mThe torrent is not private, I'll inject trackers on it\e[0m" 518 | generate_trackers_list 519 | inject_trackers $hash 520 | fi 521 | fi 522 | -------------------------------------------------------------------------------- /AudioMediaChecker/AudioMediaChecker.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import os 3 | import signal 4 | import sys 5 | import subprocess 6 | import json 7 | import random 8 | import logging 9 | from faster_whisper import WhisperModel 10 | from pydub import AudioSegment 11 | import pycountry 12 | import io 13 | from pathlib import Path 14 | import psutil 15 | from tqdm import tqdm 16 | import datetime 17 | 18 | def _setup_logger(verbose=False, json=False): 19 | """ 20 | Configures and returns a logger with stream on stdout. 21 | 22 | Arguments: 23 | verbose (bool): if True, sets the DEBUG level, otherwise INFO. 24 | json (bool): if True, disables all logging output. 25 | 26 | Returns: 27 | logging.Logger: the configured logger. 28 | """ 29 | logger = logging.getLogger(__name__) 30 | 31 | # Evita duplicazioni di log se il logger è già configurato 32 | if not logger.handlers: 33 | handler = logging.StreamHandler(sys.stdout) 34 | formatter = logging.Formatter('%(asctime)s - %(levelname)s - %(message)s') 35 | handler.setFormatter(formatter) 36 | logger.addHandler(handler) 37 | logger.propagate = False 38 | 39 | if json: 40 | # In json mode, we set a very high level to disable all logs. 41 | logger.setLevel(logging.CRITICAL + 1) # Level higher than CRITICAL, so nothing is logged 42 | else: 43 | logger.setLevel(logging.DEBUG if verbose else logging.INFO) 44 | 45 | return logger 46 | 47 | def find_files(folder_path: Path, max_depth: int, current_level: int = 0, dry_run: bool = False): 48 | """ 49 | Search for video files in 'folder_path' by exploring to the specified depth. 50 | 51 | Arguments: 52 | folder_path (Path): starting directory. 53 | max_depth (int): depth levels to explore; 54 | if 0, includes all subdirectories (unlimited recursion). 55 | If > 0, only sub-levels such that current_level < max_depth are explored, 56 | where the starting directory equals level 0. 57 | current_level (int): current level in recursion (not to be set from outside). 58 | dry_run (bool): if True, searches for all video formats; if False, only .mkv files. 59 | 60 | Returns: 61 | list[Path]: files found. 62 | """ 63 | # I define extensions by mode 64 | if dry_run: 65 | # In dry-run I search for all video formats supported by ffmpeg 66 | extensions = ['*.mkv', '*.mp4', '*.avi', '*.mov', '*.m4v', '*.flv', '*.wmv', '*.webm'] 67 | else: 68 | # In normal mode I only search for mkv 69 | extensions = ['*.mkv'] 70 | 71 | # If max_depth is 0, we perform an unlimited recursive search. 72 | if max_depth == 0: 73 | files = [] 74 | for ext in extensions: 75 | files.extend(folder_path.rglob(ext)) 76 | return files 77 | 78 | # Searching for files in the current directory for all extensions 79 | files = [] 80 | for ext in extensions: 81 | files.extend(folder_path.glob(ext)) 82 | 83 | # If we have not reached the maximum level, we explore the subfolders. 84 | if current_level < max_depth: 85 | for subdir in folder_path.iterdir(): 86 | if subdir.is_dir(): 87 | files.extend(find_files(subdir, max_depth, current_level + 1, dry_run)) 88 | 89 | return files 90 | 91 | class AudioMediaChecker: 92 | def __init__(self, file_path, check_all_tracks=False, verbose=False, dry_run=False, 93 | force_language=None, confidence_threshold=65, model='base', gpu=False, logger=None, json_output=False): 94 | """ 95 | Initialize the media file controller. 96 | 97 | Arguments: 98 | file_path (str): file path. 99 | check_all_tracks (bool): if True, parses all audio tracks. 100 | verbose (bool): enable detailed logging. 101 | dry_run (bool): if True, does not perform edit operations. 102 | force_language (str): force language code in ISO 639-2 format. 103 | confidence_threshold (int): confidence threshold. 104 | model (str): whisper model to be used. 105 | gpu (bool): if True, use GPU. 106 | logger (logging.Logger): logger to use. 107 | """ 108 | self.verbose = verbose 109 | self.file_path = Path(file_path) 110 | self.check_all_tracks = check_all_tracks 111 | self.dry_run = dry_run 112 | self.force_language = force_language 113 | self.confidence_threshold = confidence_threshold 114 | self.interrupted = False 115 | self.whisper_model_size = model 116 | self.gpu = gpu 117 | self.logger = logger if logger else _setup_logger(verbose) 118 | self.json_output = json_output 119 | 120 | # Whisper model cache (lazy-loaded on first use) 121 | self._whisper = None 122 | 123 | # In dry-run mode I accept all video formats, otherwise only mkv 124 | if not self.dry_run and self.file_path.suffix.lower() != '.mkv': 125 | raise ValueError(f"Formato file non supportato: {self.file_path}") 126 | 127 | # Get the multimedia information once and save it. 128 | self.media_info = self.get_media_info() 129 | self.total_duration = float(self.media_info['format']['duration']) 130 | 131 | self._validate_model_ram() 132 | 133 | def _lazy_load_whisper(self): 134 | """ 135 | Instantiate and cache the Whisper model on first use. 136 | Reuses the same instance for subsequent transcriptions. 137 | """ 138 | if self._whisper is None: 139 | device = 'cuda' if self.gpu else 'cpu' 140 | compute_type = self._best_compute_type() 141 | cpu_threads = self._optimal_cpu_threads() if device == 'cpu' else 0 142 | 143 | self.logger.info(f"Loading Whisper model '{self.whisper_model_size}' on {device}...") 144 | self.logger.debug(f"Whisper configuration: compute_type={compute_type}, threads={cpu_threads or 'auto'}") 145 | 146 | self._whisper = WhisperModel( 147 | self.whisper_model_size, 148 | device=device, 149 | compute_type=compute_type, 150 | cpu_threads=cpu_threads, 151 | download_root="/models" # persist weights if volume-mapped 152 | ) 153 | 154 | self.logger.info("Whisper model loaded.") 155 | return self._whisper 156 | 157 | def _validate_model_ram(self): 158 | """ 159 | Verify that the available RAM is sufficient for the selected model. 160 | """ 161 | model_requirements = { 162 | 'tiny': 2, 'base': 3, 'small': 5, 'medium': 10, 'large': 16, 'large-v3': 16 163 | } 164 | required_ram = model_requirements.get(self.whisper_model_size, 4) 165 | 166 | if self._system_ram_gb() < required_ram: 167 | raise MemoryError(f"Il modello {self.whisper_model_size} richiede almeno {required_ram}GB di RAM") 168 | 169 | def _best_compute_type(self): 170 | """ 171 | Determines the best type of computation based on the model and available RAM. 172 | """ 173 | model_size_map = { 174 | 'tiny': 'int8', 175 | 'base': 'int8', 176 | 'small': 'int8', 177 | 'medium': 'int8' if self._system_ram_gb() >= 16 else 'float32', 178 | 'large': 'float32', 179 | 'large-v3': 'float32' 180 | } 181 | return model_size_map.get(self.whisper_model_size, 'int8') 182 | 183 | def _optimal_cpu_threads(self): 184 | """ 185 | It calculates the optimal number of CPU threads (maximum 8). 186 | """ 187 | available_cores = os.cpu_count() or 4 188 | return min(available_cores, 8) 189 | 190 | @staticmethod 191 | def _system_ram_gb(): 192 | """ 193 | Returns the amount of system RAM (in GB). 194 | """ 195 | try: 196 | return round(psutil.virtual_memory().total / (1024 ** 3)) 197 | except Exception: 198 | return 4 # Conservative value if RAM cannot be determined. 199 | 200 | def process_file(self): 201 | """ 202 | Main process for analyzing and possibly updating audio track tags. 203 | """ 204 | if self.interrupted: 205 | return False 206 | 207 | # Initialize the list to collect the results (only in json mode) 208 | json_results = [] if self.json_output else None 209 | 210 | if not self.json_output: 211 | tqdm.write(f" - File analysis: {self.file_path}") 212 | 213 | if not self.file_path.exists(): 214 | self.logger.error("File not found") 215 | return False 216 | 217 | try: 218 | # Using the media_info obtained in __init__. 219 | audio_streams = [s for s in self.media_info['streams'] if s['codec_type'] == 'audio'] 220 | 221 | if not audio_streams: 222 | self.logger.warning("No audio track found in the file") 223 | return False 224 | 225 | # Select the tracks to be analyzed 226 | tracks_to_analyze = self.get_tracks_to_analyze(audio_streams) 227 | 228 | if not tracks_to_analyze: 229 | self.logger.info("There are no unknown audio tracks to analyze") 230 | self.logger.info("--" * 30) 231 | return True 232 | 233 | self.logger.info("--" * 30) 234 | num_tracks = len(tracks_to_analyze) 235 | self.logger.info(f"Analysis of {num_tracks} audio {'tracks' if num_tracks > 1 else 'track'}") 236 | self.logger.info("--" * 30) 237 | 238 | first_attempt_positions = [10, 35, 60, 85] 239 | first_attempt_duration = 30 240 | 241 | for track in tracks_to_analyze: 242 | if self.interrupted: 243 | return False 244 | 245 | stream = track['stream'] 246 | # Relative index for ffmpeg 247 | audio_position = track['relative_index'] 248 | # Absolute index as reported by ffprobe (used for mkvpropedit and log). 249 | ffprobe_index = track['ffprobe_index'] 250 | 251 | self.log_stream_info(stream) 252 | self.logger.info("--" * 30) 253 | 254 | attempt_successful = False 255 | 256 | # First attempt 257 | self.logger.info(f"Attempt 1 - Track with ffprobe index {ffprobe_index}") 258 | self.logger.info("--" * 30) 259 | 260 | first_attempt_confidences = {} 261 | for start_percent in first_attempt_positions: 262 | audio_segment = self.extract_audio_sample(audio_position, start_percent, first_attempt_duration) 263 | if audio_segment is None: 264 | continue 265 | detected_lang, confidence = self.detect_language(audio_segment) 266 | 267 | if detected_lang not in first_attempt_confidences: 268 | first_attempt_confidences[detected_lang] = {'total_confidence': 0, 'count': 0} 269 | first_attempt_confidences[detected_lang]['total_confidence'] += confidence 270 | first_attempt_confidences[detected_lang]['count'] += 1 271 | 272 | self.logger.info(f"Position {start_percent}%: Language detected '{detected_lang}', Confidence {confidence * 100:.2f}%") 273 | 274 | # Save only if there are detections. 275 | if first_attempt_confidences: 276 | total_detections = sum(lang_data['count'] for lang_data in first_attempt_confidences.values()) 277 | first_attempt_weighted_averages = {} 278 | for lang, stats in first_attempt_confidences.items(): 279 | average_confidence = stats['total_confidence'] / stats['count'] 280 | weighted_average = (average_confidence * stats['count']) / total_detections 281 | first_attempt_weighted_averages[lang] = weighted_average * 100 282 | 283 | self.logger.info("--" * 30) 284 | self.logger.info("Weighted averages of the confidences of each language surveyed:") 285 | for lang, weighted_avg in first_attempt_weighted_averages.items(): 286 | self.logger.info(f"-> {lang}: {weighted_avg:.2f}%") 287 | 288 | detected_lang = max(first_attempt_weighted_averages, key=first_attempt_weighted_averages.get) 289 | confidence_percent = first_attempt_weighted_averages[detected_lang] 290 | else: 291 | detected_lang = None 292 | confidence_percent = 0 293 | 294 | self.logger.info("--" * 30) 295 | self.logger.info(f"Language with higher weighted average: '{detected_lang}', Weighted average: {confidence_percent:.2f}%") 296 | 297 | if confidence_percent >= self.confidence_threshold: 298 | self.logger.info( 299 | f"Attempt 1 successful for trace with ffprobe index {ffprobe_index}. " 300 | f"Language detected: {detected_lang}, Confidence: {confidence_percent:.2f}% >= {self.confidence_threshold}%" 301 | ) 302 | self.handle_detection_result(ffprobe_index, detected_lang, confidence_percent / 100) 303 | 304 | attempt_successful = True 305 | 306 | # Collect results in json mode 307 | if self.json_output: 308 | # Converti il codice lingua da ISO 639-1 (2 char) a ISO 639-2 (3 char) 309 | try: 310 | detected_lang_3 = pycountry.languages.get(alpha_2=detected_lang).alpha_3 311 | except (AttributeError, KeyError): 312 | self.logger.warning(f"Language code not found for {detected_lang}. Using the original code.") 313 | detected_lang_3 = detected_lang 314 | 315 | json_results.append({ 316 | "track": ffprobe_index, 317 | "language": detected_lang_3 318 | }) 319 | 320 | self.logger.info("--" * 30) 321 | continue 322 | 323 | self.logger.info(f"Attempt 1 failed. Weighted average: {confidence_percent:.2f}% < {self.confidence_threshold}%") 324 | self.logger.info("--" * 30) 325 | 326 | # Subsequent attempts 327 | used_positions = set(first_attempt_positions) 328 | for attempt in range(2, 11): 329 | attempt_duration = random.randint(30, 90) 330 | attempt_positions = [] 331 | 332 | while len(attempt_positions) < 4: 333 | new_position = random.randint(5, 95) 334 | if new_position not in used_positions: 335 | attempt_positions.append(new_position) 336 | used_positions.add(new_position) 337 | 338 | self.logger.info(f"Attempt {attempt} - Track with ffprobe index {ffprobe_index}") 339 | self.logger.info("--" * 30) 340 | 341 | attempt_confidences = {} 342 | for start_percent in attempt_positions: 343 | audio_segment = self.extract_audio_sample(audio_position, start_percent, attempt_duration) 344 | if audio_segment is None: 345 | continue 346 | detected_lang, confidence = self.detect_language(audio_segment) 347 | 348 | if detected_lang not in attempt_confidences: 349 | attempt_confidences[detected_lang] = {'total_confidence': 0, 'count': 0} 350 | attempt_confidences[detected_lang]['total_confidence'] += confidence 351 | attempt_confidences[detected_lang]['count'] += 1 352 | 353 | self.logger.info(f"Position {start_percent}%: Language detected '{detected_lang}', Confidence {confidence * 100:.2f}%") 354 | 355 | if attempt_confidences: 356 | total_detections = sum(lang_data['count'] for lang_data in attempt_confidences.values()) 357 | attempt_weighted_averages = {} 358 | for lang, stats in attempt_confidences.items(): 359 | average_confidence = stats['total_confidence'] / stats['count'] 360 | weighted_average = (average_confidence * stats['count']) / total_detections 361 | attempt_weighted_averages[lang] = weighted_average * 100 362 | 363 | self.logger.info("--" * 30) 364 | self.logger.info("Weighted averages of the confidences of each language surveyed:") 365 | for lang, weighted_avg in attempt_weighted_averages.items(): 366 | self.logger.info(f"-> {lang}: {weighted_avg:.2f}%") 367 | 368 | detected_lang = max(attempt_weighted_averages, key=attempt_weighted_averages.get) 369 | confidence_percent = attempt_weighted_averages[detected_lang] 370 | else: 371 | detected_lang = None 372 | confidence_percent = 0 373 | 374 | self.logger.info(f"Language with higher weighted average: '{detected_lang}', Weighted average: {confidence_percent:.2f}%") 375 | 376 | if confidence_percent >= self.confidence_threshold: 377 | self.logger.info( 378 | f"Attempt {attempt} successful for trace with ffprobe index {ffprobe_index}. " 379 | f"Language detected: {detected_lang}, Confidence: {confidence_percent:.2f}% >= {self.confidence_threshold}%" 380 | ) 381 | self.handle_detection_result(ffprobe_index, detected_lang, confidence_percent / 100) 382 | 383 | attempt_successful = True 384 | 385 | # Collect results in json mode 386 | if self.json_output: 387 | # Converti il codice lingua da ISO 639-1 (2 char) a ISO 639-2 (3 char) 388 | try: 389 | detected_lang_3 = pycountry.languages.get(alpha_2=detected_lang).alpha_3 390 | except (AttributeError, KeyError): 391 | self.logger.warning(f"Language code not found for {detected_lang}. Using the original code.") 392 | detected_lang_3 = detected_lang 393 | 394 | json_results.append({ 395 | "track": ffprobe_index, 396 | "language": detected_lang_3 397 | }) 398 | 399 | self.logger.info("--" * 30) 400 | break 401 | 402 | self.logger.info(f"Attempt {attempt} failed. Weighted average: {confidence_percent:.2f}% < {self.confidence_threshold}%") 403 | self.logger.info("--" * 30) 404 | 405 | # If no attempt was successful, add "und" 406 | if not attempt_successful and self.json_output: 407 | json_results.append({ 408 | "track": ffprobe_index, 409 | "language": "und" 410 | }) 411 | 412 | # Print final JSON only in json mode 413 | if self.json_output and json_results: 414 | print(json.dumps(json_results, indent=2)) 415 | 416 | return True 417 | 418 | except Exception as e: 419 | self.logger.error(f"Error during file processing: {str(e)}", exc_info=self.verbose) 420 | return False 421 | 422 | def get_tracks_to_analyze(self, audio_streams): 423 | """Select the audio tracks to be analyzed according to the parameters. 424 | 425 | For each track, store: 426 | - 'relative_index': the relative index among the audio tracks only (for ffmpeg) 427 | - 'ffprobe_index': the absolute index of the stream, as reported by ffprobe 428 | """ 429 | tracks = [] 430 | relative_index = 0 431 | for stream in audio_streams: 432 | tags = stream.get('tags', {}) 433 | current_lang = tags.get('LANGUAGE', None) or tags.get('language', None) 434 | if self.check_all_tracks or not current_lang: 435 | tracks.append({ 436 | 'stream': stream, 437 | 'relative_index': relative_index, # To use in ffmpeg: 0:a:{relative_index} 438 | 'ffprobe_index': stream.get('index') # Use with mkvpropedit to identify the exact track 439 | }) 440 | relative_index += 1 441 | return tracks 442 | 443 | def log_stream_info(self, stream): 444 | """ 445 | Logs the audio track information. 446 | """ 447 | bitrate = stream.get('bit_rate') 448 | info = { 449 | 'Index': stream.get('index', 'n.d.'), 450 | 'Codec': stream.get('codec_name', 'unknown'), 451 | 'Current language': stream.get('tags', {}).get('language', 'not set'), 452 | 'Bitrate': f"{int(bitrate) // 1000} Kb/s" if bitrate else 'unknown' 453 | } 454 | self.logger.info("Audio track details:") 455 | for k, v in info.items(): 456 | self.logger.info(f"• {k}: {v}") 457 | 458 | def handle_detection_result(self, stream_index, detected_lang, confidence): 459 | """ 460 | Handles the result of language detection: 461 | - converts the code to ISO 639-2 (using pycountry) 462 | - checks the confidence obtained 463 | - updates the trace tag if necessary 464 | 465 | Arguments: 466 | stream_index (int): stream index (ffprobe) 467 | detected_lang (str): detected language code 468 | confidence (float): confidence (0-1) 469 | """ 470 | self.logger.debug(f"Start handle_detection_result for trace {stream_index}") 471 | 472 | try: 473 | detected_lang_3 = pycountry.languages.get(alpha_2=detected_lang).alpha_3 474 | except AttributeError: 475 | self.logger.warning(f"Language code not found for {detected_lang}. Using the original code.") 476 | detected_lang_3 = detected_lang 477 | 478 | current_lang = self.media_info['streams'][stream_index].get('tags', {}).get('language') 479 | self.logger.debug(f"Current language by track {stream_index}: {current_lang}") 480 | 481 | confidence_percent = confidence * 100 482 | 483 | if detected_lang_3: 484 | self.logger.debug(f"Language detected for track {stream_index}: {detected_lang_3} (original: {detected_lang})") 485 | 486 | if confidence_percent >= self.confidence_threshold: 487 | if detected_lang_3 != current_lang: 488 | self.logger.info(f"Recognized language for track {stream_index}: {detected_lang_3} with confidence {confidence_percent:.2f}%") 489 | self.update_language_tag(stream_index, detected_lang_3) 490 | else: 491 | self.logger.info(f"Language by track {stream_index} remains unchanged: {detected_lang_3} with confidence {confidence_percent:.2f}%") 492 | else: 493 | if self.force_language is not None: 494 | if self.force_language == '': 495 | self.logger.info(f"Recognized language for track {stream_index}: {detected_lang_3} forced under threshold") 496 | self.update_language_tag(stream_index, detected_lang_3) 497 | else: 498 | self.logger.info(f"Forced language for track {stream_index}: {self.force_language}") 499 | self.update_language_tag(stream_index, self.force_language) 500 | else: 501 | self.logger.info(f"Recognized language for track {stream_index}: {detected_lang_3} with confidence {confidence_percent:.2f}%, but below the required threshold") 502 | else: 503 | self.logger.warning(f"No language detected by trace {stream_index} (confidence {confidence_percent:.2f}%)") 504 | if self.force_language and self.force_language != '': 505 | self.logger.info(f"Forced language for track {stream_index}: {self.force_language}") 506 | self.update_language_tag(stream_index, self.force_language) 507 | else: 508 | self.logger.debug(f"No update by track {stream_index}") 509 | 510 | self.logger.debug(f"Done handle_detection_result for track {stream_index}") 511 | 512 | def get_media_info(self): 513 | """ 514 | Extracts media file information using ffprobe. 515 | Returns: 516 | dict: information in JSON format. 517 | """ 518 | cmd = [ 519 | 'ffprobe', 520 | '-v', 'quiet', 521 | '-print_format', 'json', 522 | '-show_format', 523 | '-show_streams', 524 | str(self.file_path) 525 | ] 526 | result = subprocess.run(cmd, capture_output=True, text=True) 527 | if result.returncode != 0 or not result.stdout: 528 | raise RuntimeError("Error executing ffprobe") 529 | return json.loads(result.stdout) 530 | 531 | def update_language_tag(self, stream_index, language): 532 | """ 533 | Updates the language tag for the track identified by stream_index. 534 | 535 | Arguments: 536 | stream_index (int): stream index (ffprobe) to update the tag to. 537 | language (str): new language code (ISO 639-2). 538 | """ 539 | if not language: 540 | self.logger.info(f"No language to set by track {stream_index}") 541 | return 542 | 543 | # I check if the file is an mkv 544 | is_mkv = self.file_path.suffix.lower() == '.mkv' 545 | 546 | cmd = [ 547 | 'mkvpropedit', 548 | str(self.file_path), 549 | '--edit', f'track:{stream_index + 1}', # mkvpropedit use index base-1 550 | '--set', f'language={language}' 551 | ] 552 | 553 | if not self.dry_run: 554 | if is_mkv: 555 | try: 556 | result = subprocess.run(cmd, check=True, capture_output=True, text=True) 557 | self.logger.info(f"Updated language tag for track {stream_index}: {language}") 558 | except subprocess.CalledProcessError as e: 559 | self.logger.error(f"Error running mkvpropedit: {e.stderr}") 560 | else: 561 | self.logger.info(f"File {self.file_path.name} is not MKV format - language tag update skipped") 562 | else: 563 | if is_mkv: 564 | self.logger.info(f"[DRY RUN] Update language tag per track {stream_index}: {language}") 565 | else: 566 | self.logger.info(f"[DRY RUN] File {self.file_path.name} is not MKV format - would not be modified in normal mode") 567 | 568 | def detect_language(self, audio_file): 569 | """ 570 | Performs language detection using the (cached) Whisper model. 571 | 572 | Arguments: 573 | audio_file (file-like): audio sample in BytesIO format. 574 | 575 | Returns: 576 | tuple: (language detected (str), confidence (float)) 577 | """ 578 | self.logger.info("Beginning language detection") 579 | 580 | model = self._lazy_load_whisper() 581 | segments, info = model.transcribe(audio_file, language=None, beam_size=5) 582 | detected_language = info.language 583 | 584 | if self.verbose: 585 | self.logger.debug("Recognized text:") 586 | for segment in segments: 587 | self.logger.debug(f"[{segment.start:.2f}s -> {segment.end:.2f}s] {segment.text}") 588 | self.logger.info(f"Detected language: {detected_language} with confidence: {info.language_probability:.2f}") 589 | 590 | return detected_language, info.language_probability 591 | 592 | def extract_audio_sample(self, audio_position, start_percent, duration_seconds): 593 | """ 594 | Extracts an audio sample in WAV format of the specified duration from a percentage of the file. 595 | 596 | Arguments: 597 | audio_position (int): index of the audio track (for ffmpeg). 598 | start_percent (float): start percentage of the sample. 599 | duration_seconds (float): duration of the sample in seconds. 600 | 601 | Return: 602 | BytesIO: campione audio; None in caso di errore. 603 | """ 604 | try: 605 | audio_sample = io.BytesIO() 606 | 607 | # Reuse self.total_duration if available 608 | total_duration = self.total_duration 609 | start_time_seconds = (total_duration * start_percent) / 100 610 | 611 | extract_cmd = [ 612 | 'ffmpeg', '-y', 613 | '-ss', f'{start_time_seconds:.2f}', 614 | '-i', str(self.file_path), 615 | '-t', f'{duration_seconds:.2f}', 616 | '-map', f'0:a:{audio_position}', 617 | '-ac', '1', 618 | '-ar', '16000', 619 | '-acodec', 'pcm_s16le', 620 | '-f', 'wav', 621 | '-' 622 | ] 623 | 624 | with subprocess.Popen(extract_cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) as process: 625 | stdout, stderr = process.communicate() 626 | 627 | if process.returncode != 0: 628 | self.logger.error(f"Sample extraction error by position {start_percent}:") 629 | self.logger.error(f"Command: {' '.join(extract_cmd)}") 630 | self.logger.error(f"Error: {stderr.decode('utf-8', errors='ignore')}") 631 | raise subprocess.CalledProcessError(process.returncode, extract_cmd, stdout, stderr) 632 | 633 | audio_sample.write(stdout) 634 | audio_sample.seek(0) 635 | return audio_sample 636 | 637 | except Exception as e: 638 | self.logger.error(f"Sample extraction error: {str(e)}") 639 | return None 640 | 641 | def main(): 642 | checker = None 643 | try: 644 | VALID_MODELS = ['tiny', 'base', 'small', 'medium', 'large', 'large-v3'] 645 | 646 | parser = argparse.ArgumentParser(description='Analyzes and corrects language tags of audio tracks') 647 | parser.add_argument('--file', help='Path of the file to be analyzed') 648 | parser.add_argument('--folder', help='Directory path to be parsed') 649 | parser.add_argument('--recursive', nargs='?', const=0, type=int, 650 | help="""Depth levels to explore. 651 | If 0 or omitted, the search is unlimited (the starting folder and all subdirectories). 652 | If > 0, the search is limited to that number of levels (starting folder is level 0).""") 653 | parser.add_argument('--check-all-tracks', action='store_true', 654 | help='Analyzes all audio tracks, not just those without tags') 655 | parser.add_argument('--verbose', action='store_true', 656 | help='Enable detailed logging') 657 | parser.add_argument('--json', action='store_true', 658 | help='Output results ONLY in JSON format with minimal information (track number and language only), usefull to use with others script. Better to use in combination with --dry-run and to analyze all with also --check-all-tracks') 659 | parser.add_argument('--dry-run', action='store_true', 660 | help='Simulates operations without modifying the file') 661 | parser.add_argument('--force-language', nargs='?', const='', 662 | help='Language to be set when detection fails. Use ISO 639-2 format (3 letters)') 663 | parser.add_argument('--confidence', type=int, default=65, 664 | help='Confidence threshold for language detection (default: 65)') 665 | parser.add_argument('--model', 666 | choices=VALID_MODELS, 667 | default='base', 668 | help=f"Whisper model (size): {' '.join(VALID_MODELS)}, default: %(default)s") 669 | parser.add_argument('--gpu', action='store_true', help='Use GPU for language detection (optional)') 670 | parser.add_argument('--help-languages', action='store_true', help='Show a list of available language codes') 671 | 672 | args = parser.parse_args() 673 | 674 | logger = _setup_logger(args.verbose, args.json) 675 | 676 | if args.help_languages: 677 | print("Available language codes (ISO 639-2 format):") 678 | for language in pycountry.languages: 679 | if hasattr(language, 'alpha_3'): 680 | print(f"{language.alpha_3} - {language.name}") 681 | sys.exit(0) 682 | 683 | if not args.file and not args.folder: 684 | parser.error("the following arguments are required: --file or --folder") 685 | 686 | # Check incompatibility between --verbose and --json 687 | if args.verbose and args.json: 688 | parser.error("--verbose and --json cannot be used together") 689 | 690 | # Forced language code validation 691 | if args.force_language: 692 | if args.force_language != '': 693 | language_obj = pycountry.languages.get(alpha_3=args.force_language) 694 | if language_obj is not None: 695 | logger.debug(f"Forced language set to: {args.force_language} -> {language_obj.name}") 696 | else: 697 | logger.info(f"Error: '{args.force_language}' is not a valid language code according to ISO 639-2.") 698 | logger.info("For a list of available codes, use the option --help-languages.") 699 | sys.exit(1) 700 | else: 701 | logger.debug("Forces language detection even if below threshold.") 702 | 703 | # Validation of confidence threshold 704 | if args.confidence < 1 or args.confidence > 100: 705 | print("Error: the confidence threshold should be between 1 and 100") 706 | sys.exit(1) 707 | 708 | files_to_process = [] 709 | 710 | if args.file: 711 | file_path = Path(args.file) 712 | # In normal mode I accept only mkv, in dry-run all video formats 713 | if not args.dry_run and file_path.suffix.lower() != '.mkv': 714 | print(f"Error: the file must be in MKV format. File provided: {file_path}") 715 | sys.exit(1) 716 | files_to_process.append(file_path) 717 | 718 | if args.folder: 719 | folder_path = Path(args.folder) 720 | if not folder_path.is_dir(): 721 | print(f"Error: '{folder_path}' is not a valid directory.") 722 | sys.exit(1) 723 | 724 | # If --recursive is not passed, args.recursive will be None, 725 | # and then a NON-recursive search will be done (only in the source directory) 726 | if args.recursive is None: 727 | # In this case we consider only the indicated directory (no recursion) 728 | if args.dry_run: 729 | # In dry-run I look for all video formats 730 | extensions = ['*.mkv', '*.mp4', '*.avi', '*.mov', '*.m4v', '*.flv', '*.wmv', '*.webm'] 731 | for ext in extensions: 732 | files_to_process.extend(list(folder_path.glob(ext))) 733 | else: 734 | # In normal mode I only search for mkv 735 | files_to_process.extend(list(folder_path.glob('*.mkv'))) 736 | else: 737 | depth = args.recursive 738 | files_to_process.extend(find_files(folder_path, depth, dry_run=args.dry_run)) 739 | 740 | if not files_to_process: 741 | print("No MKV files found.") 742 | sys.exit(1) 743 | 744 | if args.verbose: 745 | params = { 746 | 'check_all_tracks': args.check_all_tracks, 747 | 'dry_run': args.dry_run, 748 | 'force_language': args.force_language if args.force_language is not None else 'False', 749 | 'confidence_threshold': args.confidence, 750 | 'model': args.model, 751 | 'gpu': args.gpu, 752 | 'json_output': args.json 753 | } 754 | logger.info("Execution parameters:") 755 | for param, value in params.items(): 756 | logger.info(f" {param}: {value}") 757 | logger.info("--" * 30) 758 | 759 | # Progress bar visible only if NOT in json mode 760 | if not args.json: 761 | with tqdm(total=len(files_to_process), desc=" - INFO - Processing files", unit="file", initial=1, leave=False) as pbar: 762 | for file_path in files_to_process: 763 | now = datetime.datetime.now() 764 | timestamp = now.strftime("%Y-%m-%d %H:%M:%S,%f")[:-3] 765 | pbar.set_description(f"{timestamp} - INFO - Processing files") 766 | 767 | checker = AudioMediaChecker( 768 | str(file_path), 769 | check_all_tracks=args.check_all_tracks, 770 | verbose=args.verbose, 771 | dry_run=args.dry_run, 772 | force_language=args.force_language, 773 | confidence_threshold=args.confidence, 774 | model=args.model, 775 | gpu=args.gpu, 776 | logger=logger, 777 | json_output=args.json 778 | ) 779 | checker.process_file() 780 | pbar.update(1) 781 | else: 782 | # Json mode: no progress bar 783 | for file_path in files_to_process: 784 | checker = AudioMediaChecker( 785 | str(file_path), 786 | check_all_tracks=args.check_all_tracks, 787 | verbose=args.verbose, 788 | dry_run=args.dry_run, 789 | force_language=args.force_language, 790 | confidence_threshold=args.confidence, 791 | model=args.model, 792 | gpu=args.gpu, 793 | logger=logger, 794 | json_output=args.json 795 | ) 796 | checker.process_file() 797 | 798 | logger.info("Script successfully completed.") 799 | sys.exit(0) 800 | 801 | except KeyboardInterrupt: 802 | print("\nOperation aborted by user.") 803 | sys.exit(1) 804 | except Exception as e: 805 | print(f"Unexpected error: {str(e)}") 806 | sys.exit(1) 807 | 808 | if __name__ == "__main__": 809 | main() 810 | --------------------------------------------------------------------------------