qbit_manage/scripts/mover.py
bobokun 8a28864e79
feat(mover): add Unraid version detection and intelligent mover selection
Add automatic Unraid version detection to handle mover binary changes in 7.2.1+.
Refactor mover execution logic into run_mover_logic() function that intelligently
selects between stock mover, mover.old, and age_mover based on OS version and
available binaries. Update --mover-old help text to clarify behavior on newer
Unraid versions where it forces native mover usage.
2025-12-18 16:51:11 -05:00

234 lines
8.1 KiB
Python
Executable file

#!/usr/bin/env python3
# This standalone script is used to pause torrents older than last x days,
# run mover (in Unraid) and start torrents again once completed
import argparse
import logging
import os
import re
import sys
import time
from datetime import datetime
from datetime import timedelta
# Configure logging
logging.basicConfig(
level=logging.DEBUG,
format="%(asctime)s - %(levelname)s - %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
handlers=[logging.StreamHandler(sys.stdout)],
)
parser = argparse.ArgumentParser(prog="Qbit Mover", description="Stop torrents and kick off Unraid mover process")
parser.add_argument("--host", help="qbittorrent host including port", required=True)
parser.add_argument("-u", "--user", help="qbittorrent user", default="admin")
parser.add_argument("-p", "--password", help="qbittorrent password", default="adminadmin")
parser.add_argument(
"--cache-mount",
"--cache_mount",
help="Cache mount point in Unraid. This is used to additionally filter for only torrents that exists on the cache mount."
"Use this option ONLY if you follow TRaSH Guides folder structure. (For default cache drive set this to /mnt/cache)",
default=None,
)
parser.add_argument(
"--days-from", "--days_from", help="Set Number of Days to stop torrents between two offsets", type=int, default=0
)
parser.add_argument("--days-to", "--days_to", help="Set Number of Days to stop torrents between two offsets", type=int, default=2)
parser.add_argument(
"--mover-old",
help="Use the native mover. Useful if you're using the Mover Tuning Plugin (On Unraid 7.2.1+, this forces the native mover)",
action="store_true",
default=False,
)
parser.add_argument(
"--status-filter",
help="Define a status to limit which torrents to pause. Useful if you want to leave certain torrents unpaused.",
choices=[
"all",
"downloading",
"seeding",
"completed",
"paused",
"stopped",
"active",
"inactive",
"resumed",
"running",
"stalled",
"stalled_uploading",
"stalled_downloading",
"checking",
"moving",
"errored",
],
default="completed",
)
parser.add_argument(
"--pause",
help="Pause torrents matching the specified criteria",
action="store_true",
default=False,
)
parser.add_argument(
"--resume",
help="Resume torrents matching the specified criteria",
action="store_true",
default=False,
)
parser.add_argument(
"--move",
help="Start the Unraid mover process",
action="store_true",
default=False,
)
# --DEFINE VARIABLES--#
# --START SCRIPT--#
try:
from qbittorrentapi import APIConnectionError
from qbittorrentapi import Client
from qbittorrentapi import LoginFailed
except ModuleNotFoundError:
logging.error(
'Requirements Error: qbittorrent-api not installed. Please install using the command "pip install qbittorrent-api"'
)
sys.exit(1)
def filter_torrents(torrent_list, timeoffset_from, timeoffset_to, cache_mount):
result = []
for torrent in torrent_list:
if torrent.added_on >= timeoffset_to and torrent.added_on <= timeoffset_from:
if not cache_mount or exists_in_cache(cache_mount, torrent.content_path):
result.append(torrent)
elif torrent.added_on < timeoffset_to:
break
return result
def exists_in_cache(cache_mount, content_path):
cache_path = os.path.join(cache_mount, content_path.lstrip("/"))
return os.path.exists(cache_path)
def stop_start_torrents(torrent_list, pause=True):
for torrent in torrent_list:
if pause:
logging.info(f"Pausing: {torrent.name} [{torrent.added_on}]")
torrent.pause()
else:
logging.info(f"Resuming: {torrent.name} [{torrent.added_on}]")
torrent.resume()
def get_unraid_version_tuple():
"""Reads /etc/unraid-version and returns a tuple (major, minor, patch)."""
try:
if os.path.exists("/etc/unraid-version"):
with open("/etc/unraid-version") as f:
content = f.read().strip()
# format: version="6.12.4"
match = re.search(r'version="(\d+)\.(\d+)\.(\d+)', content)
if match:
return tuple(map(int, match.groups()))
except Exception as e:
logging.warning(f"Could not determine Unraid version: {e}")
return (0, 0, 0)
def run_mover_logic(use_mover_old):
"""Determines and runs the correct mover command based on OS version and binary existence."""
# Check Environment
version = get_unraid_version_tuple()
age_mover_path = "/usr/local/sbin/age_mover"
age_mover_exists = os.path.exists(age_mover_path)
cmd = ""
# Logic for Unraid 7.2.1+ (where Mover Tuning is separated)
if version >= (7, 2, 1):
logging.info(f"Detected Unraid Version {version[0]}.{version[1]}.{version[2]} (>= 7.2.1)")
if use_mover_old:
# User explicitly requested "old" mover behavior (Stock Mover)
# In 7.2.1+, 'mover' IS the stock mover.
logging.info("Argument '--mover-old' passed: Forcing stock Unraid mover.")
cmd = "/usr/local/sbin/mover start"
elif age_mover_exists:
# User wants standard move behavior, and has Mover Tuning (age_mover) installed.
logging.info("Mover Tuning detected: Running 'age_mover'.")
cmd = f"{age_mover_path} start"
else:
# User wants standard move, but no Mover Tuning binary found. Fallback to stock.
logging.info("No Mover Tuning binary found: Running stock Unraid mover.")
cmd = "/usr/local/sbin/mover start"
# Logic for Older Unraid Versions
else:
if version != (0, 0, 0):
logging.info(f"Detected Unraid Version {version[0]}.{version[1]}.{version[2]} (< 7.2.1)")
if use_mover_old:
logging.info("Argument '--mover-old' passed: Running 'mover.old'.")
cmd = "/usr/local/sbin/mover.old start"
else:
logging.info("Running standard 'mover'.")
cmd = "/usr/local/sbin/mover start"
# Execute
if cmd:
logging.info(f"Executing: {cmd}")
os.system(cmd)
else:
logging.error("Could not determine mover command.")
if __name__ == "__main__":
current = datetime.now()
args = parser.parse_args()
# If no specific operation is requested, default to all operations (original behavior)
if not any([args.pause, args.resume, args.move]):
args.pause = True
args.resume = True
args.move = True
if args.days_from > args.days_to:
raise ("Config Error: days_from must be set lower than days_to")
# Initialize client and torrents only if pause or resume operations are requested
client = None
torrents = []
if args.pause or args.resume:
try:
client = Client(host=args.host, username=args.user, password=args.password)
except LoginFailed:
raise ("Qbittorrent Error: Failed to login. Invalid username/password.")
except APIConnectionError:
raise ("Qbittorrent Error: Unable to connect to the client.")
except Exception:
raise ("Qbittorrent Error: Unable to connect to the client.")
timeoffset_from = current - timedelta(days=args.days_from)
timeoffset_to = current - timedelta(days=args.days_to)
torrent_list = client.torrents.info(status_filter=args.status_filter, sort="added_on", reverse=True)
torrents = filter_torrents(torrent_list, timeoffset_from.timestamp(), timeoffset_to.timestamp(), args.cache_mount)
# Pause Torrents
if args.pause:
logging.info(f"Pausing [{len(torrents)}] torrents from {args.days_from} - {args.days_to} days ago")
stop_start_torrents(torrents, True)
if args.move: # Only sleep if mover will run next
time.sleep(10)
# Run mover
if args.move:
run_mover_logic(args.mover_old)
# Resume Torrents
if args.resume:
logging.info(f"Resuming [{len(torrents)}] torrents from {args.days_from} - {args.days_to} days ago")
stop_start_torrents(torrents, False)