Compare commits

..

No commits in common. "a68bea95d24c1cdcd9635284499177cad74f3dac" and "b23ca49a8391933bcf959250f36b078288cf4a25" have entirely different histories.

3 changed files with 208 additions and 282 deletions

@ -1,2 +1 @@
qbittorrent-api==2025.2.0 python-qbittorrent==0.4.3
SQLAlchemy==2.0.38

@ -13,227 +13,260 @@ import sys
import re import re
import uuid import uuid
import argparse import argparse
import sqlite3
from datetime import datetime, timezone from datetime import datetime, timezone
import qbittorrentapi import qbittorrent
from sqlalchemy import create_engine, inspect
from sqlalchemy.orm import Session
from .models import Base, SchemaVersion, Client
# SCHEMA format is YYYYMMDDX # SCHEMA format is YYYYMMDDX
SCHEMA = 202503100 SCHEMA = 202410060
def init_db(engine): def init_db(conn):
""" """
Initialize database Initialize database
""" """
Base.metadata.create_all(engine)
with Session(engine) as session: c = conn.cursor()
if not session.query(SchemaVersion).first(): c.executescript(
now = datetime.now(timezone.utc) f"""
version = SchemaVersion(version=SCHEMA, applied_at=now) PRAGMA user_version = {SCHEMA};
session.add(version)
session.commit() CREATE TABLE IF NOT EXISTS clients (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
uuid TEXT NOT NULL UNIQUE,
endpoint TEXT NOT NULL,
last_seen DATETIME NOT NULL
);
CREATE TABLE IF NOT EXISTS torrents (
id INTEGER PRIMARY KEY AUTOINCREMENT,
info_hash_v1 TEXT NOT NULL UNIQUE,
info_hash_v2 TEXT UNIQUE,
file_count INTEGER NOT NULL,
completed_on DATETIME NOT NULL
);
CREATE TABLE IF NOT EXISTS torrent_clients (
id INTEGER PRIMARY KEY AUTOINCREMENT,
torrent_id INTEGER NOT NULL,
client_id INTEGER NOT NULL,
name TEXT NOT NULL,
content_path TEXT NOT NULL,
last_seen DATETIME NOT NULL,
FOREIGN KEY (torrent_id) REFERENCES torrents(id),
FOREIGN KEY (client_id) REFERENCES clients(id),
UNIQUE (torrent_id, client_id)
);
CREATE TABLE IF NOT EXISTS trackers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
url TEXT NOT NULL UNIQUE,
last_seen DATETIME NOT NULL
);
CREATE TABLE IF NOT EXISTS torrent_trackers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
client_id INTEGER NOT NULL,
torrent_id INTEGER NOT NULL,
tracker_id INTEGER NOT NULL,
last_seen DATETIME NOT NULL,
FOREIGN KEY (client_id) REFERENCES clients(id),
FOREIGN KEY (torrent_id) REFERENCES torrents(id),
FOREIGN KEY (tracker_id) REFERENCES trackers(id),
UNIQUE (client_id, torrent_id, tracker_id)
);
CREATE TABLE IF NOT EXISTS files (
id INTEGER PRIMARY KEY AUTOINCREMENT,
size INTEGER NOT NULL,
oshash TEXT NOT NULL UNIQUE,
hash TEXT UNIQUE
);
CREATE TABLE IF NOT EXISTS torrent_files (
id INTEGER PRIMARY KEY AUTOINCREMENT,
file_id INTEGER NOT NULL,
torrent_id INTEGER NOT NULL,
client_id INTEGER NOT NULL,
file_index INTEGER NOT NULL,
file_path TEXT NOT NULL,
is_downloaded BOOLEAN NOT NULL,
last_checked DATETIME NOT NULL,
FOREIGN KEY (file_id) REFERENCES files(id),
FOREIGN KEY (torrent_id) REFERENCES torrents(id),
FOREIGN KEY (client_id) REFERENCES clients(id),
UNIQUE (file_id, torrent_id, client_id, file_index)
);
"""
)
conn.commit()
c.close()
def get_schema_version(engine): def list_tables(conn):
"""
Get current schema version from database
"""
with Session(engine) as session:
version = session.query(SchemaVersion).order_by(SchemaVersion.id.desc()).first()
return version.version if version else None
def list_tables(engine):
""" """
List all tables in database List all tables in database
""" """
inspector = inspect(engine) c = conn.cursor()
return inspector.get_table_names() c.execute("SELECT name FROM sqlite_master WHERE type='table';")
table_list = c.fetchall()
c.close()
return [table[0] for table in table_list]
def add_client(engine, name, endpoint, last_seen): def add_client(conn, name, endpoint, last_seen):
""" """
Add a new client endpoint to database Add a new client endpoint to database
""" """
with Session(engine) as session: c = conn.cursor()
client = Client( c.execute(
uuid=str(uuid.uuid4()), name=name, endpoint=endpoint, last_seen=last_seen f"""
) INSERT INTO clients (uuid, name, endpoint, last_seen)
session.add(client) VALUES ("{uuid.uuid4()}", "{name}", "{endpoint}", "{last_seen}");
session.commit() """
)
conn.commit()
c.close()
def find_client(engine, endpoint): def find_client(conn, endpoint):
""" """
Find existing client Find existing client
""" """
with Session(engine) as session: c = conn.cursor()
clients = ( c.execute(f'SELECT id, name, uuid FROM clients WHERE endpoint="{endpoint}";')
session.query(Client.id, Client.name, Client.uuid) response = c.fetchall()
.filter_by(endpoint=endpoint) c.close()
.all() return response
)
return clients
def list_clients(engine): def list_clients(conn):
""" """
List all stored clients List all stored clients
""" """
with Session(engine) as session: c = conn.cursor()
return session.query(Client).all() c.execute("SELECT * FROM clients;")
rows = c.fetchall()
c.close()
def authenticate_qbittorrent(endpoint, username, password): return rows
"""
Authenticate with the qBittorrent client
"""
qb = qbittorrentapi.Client(host=endpoint, username=username, password=password)
try:
qb.auth_log_in()
except qbittorrentapi.LoginFailed as e:
raise ValueError(f'Login failed for endpoint "{endpoint}": {e}') from e
if not re.match(r"^v?\d+(\.\d+)*$", qb.app.version):
raise ValueError(f'Invalid version "{qb.app.version}" found at "{endpoint}"')
return qb
def scan_torrents(qb_client, debug=False):
"""
Scan torrents using the provided qBittorrent client.
"""
torrents = qb_client.torrents_info()
print(f"[INFO]: There are {len(torrents)} torrents\n")
for torrent in torrents[:2]:
files = qb_client.torrents_files(torrent.hash)
trackers = qb_client.torrents_trackers(torrent.hash)
print(f"[name]: {torrent.name}")
print(f"[infohash_v1]: {torrent.hash}")
print(f"[content_path]: {torrent.content_path}")
print(f"[magnet_uri]: {torrent.magnet_uri[:80]}")
print(f"[completed_on]: {torrent.completed}\n")
print(f"[trackers]: {len(trackers)}")
print(f"[file_count]: {len(files)}\n")
if debug:
print(f"[DEBUG]: {repr(torrent)}")
for elem in trackers:
print(f"[DEBUG]: Tracker {repr(elem)}")
print("\n", end="")
def handle_scan(args, engine):
"""
Handle the scan command to authenticate with the qBittorrent client and scan torrents.
"""
if args.endpoint:
qb_client = authenticate_qbittorrent(args.endpoint, args.username, args.password)
clients = find_client(engine, args.endpoint)
if args.confirm_add:
if len(clients) == 0:
if args.name:
now = datetime.now(timezone.utc)
add_client(engine, args.name, args.endpoint, now)
print(f"[INFO]: Added client {args.name} ({args.endpoint})")
else:
raise ValueError("Must specify --name for a new client")
elif len(clients) == 1:
raise ValueError(f"{clients[0][1]} ({clients[0][2]}) already exists")
else:
raise ValueError(f"Multiple clients with the same endpoint: {args.endpoint}")
elif len(clients) == 0:
raise ValueError(
f'Client using endpoint "{args.endpoint}" not found. '
'Use --confirm-add to add a new endpoint'
)
elif len(clients) == 1:
scan_torrents(qb_client, debug=args.debug)
else:
raise ValueError(f'Multiple clients ({len(clients)}) using "{args.endpoint}"')
elif args.directory:
print("[INFO]: --directory is not implemented")
else:
raise ValueError("Must specify directory OR client endpoint")
def handle_client_add(args, engine):
"""
Handle the client addition command to add a new client to the database.
"""
if args.name and args.endpoint:
now = datetime.now(timezone.utc)
add_client(engine, args.name, args.endpoint, now)
print(f"[INFO]: Added client {args.name} ({args.endpoint})")
else:
raise ValueError("Must specify --name and --endpoint to add a client")
def handle_client_list(args, engine):
"""
Handle the client listing command to display all stored clients.
"""
clients = list_clients(engine)
for client in clients:
print(f"{client.name} ({client.endpoint}) - Last seen: {client.last_seen}")
def main(): def main():
""" """
Parses command-line arguments and executes the corresponding command. Entrypoint of the program.
""" """
parser = argparse.ArgumentParser(description="Manage BT archives", prog="tarc")
subparsers = parser.add_subparsers(dest="command", required=True, help="Available commands")
# scan command parser = argparse.ArgumentParser(description="Manage BT archives", prog="tarc")
scan_parser = subparsers.add_parser("scan", help="Scan torrents") subparsers = parser.add_subparsers(
dest="command", required=True, help="Available commands"
)
scan_parser = subparsers.add_parser("scan", help="Scan command")
scan_parser.add_argument("--debug", action="store_true", help="Enable debug mode") scan_parser.add_argument("--debug", action="store_true", help="Enable debug mode")
scan_parser.add_argument( scan_parser.add_argument(
"--confirm-add", action="store_true", help="Confirm adding a new client" "--confirm-add", action="store_true", help="Confirm adding a new client"
) )
scan_parser.add_argument("-n", "--name", help="Name of client") scan_parser.add_argument("-n", "--name", help="Name of client")
scan_parser.add_argument("-d", "--directory", help="Directory to scan") scan_parser.add_argument("-d", "--directory", help="Directory to scan")
scan_parser.add_argument("-t", "--type", help="Scan type")
scan_parser.add_argument("-e", "--endpoint", help="Endpoint URL") scan_parser.add_argument("-e", "--endpoint", help="Endpoint URL")
scan_parser.add_argument("-u", "--username", help="Username") scan_parser.add_argument("-u", "--username", help="Username")
scan_parser.add_argument("-p", "--password", help="Password") scan_parser.add_argument("-p", "--password", help="Password")
scan_parser.add_argument("-s", "--storage", help="Path of sqlite3 database") scan_parser.add_argument("-s", "--storage", help="Path of sqlite3 database")
# client add command
client_add_parser = subparsers.add_parser("client-add", help="Add a new client")
client_add_parser.add_argument("-n", "--name", required=True, help="Name of client")
client_add_parser.add_argument("-e", "--endpoint", required=True, help="Endpoint URL")
client_add_parser.add_argument("-s", "--storage", help="Path of sqlite3 database")
# client list command
client_list_parser = subparsers.add_parser("client-list", help="List all clients")
client_list_parser.add_argument("-s", "--storage", help="Path of sqlite3 database")
args = parser.parse_args() args = parser.parse_args()
storage_path = args.storage or os.path.expanduser("~/.tarc.db") if args.command == "scan":
engine = create_engine(f"sqlite:///{storage_path}") if args.storage is None:
STORAGE = os.path.expanduser("~/.tarch.db")
if not list_tables(engine): else:
print(f"[INFO]: Initializing database at {storage_path}") STORAGE = args.storage
init_db(engine) try:
sqlitedb = sqlite3.connect(STORAGE)
schema_found = get_schema_version(engine) tables = list_tables(sqlitedb)
if schema_found != SCHEMA: except sqlite3.DatabaseError as e:
raise ValueError(f"SCHEMA {schema_found}, expected {SCHEMA}") print(f'[ERROR]: Database Error "{STORAGE}" ({str(e)})')
sys.exit(1)
try: if len(tables) == 0:
if args.command == "scan": print(f"[INFO]: Initializing database at {STORAGE}")
handle_scan(args, engine) init_db(sqlitedb)
elif args.command == "client-add": cursor = sqlitedb.cursor()
handle_client_add(args, engine) cursor.execute("PRAGMA user_version;")
elif args.command == "client-list": SCHEMA_FOUND = cursor.fetchone()[0]
handle_client_list(args, engine) cursor.close()
except ValueError as e: if not SCHEMA == SCHEMA_FOUND:
print(f"[ERROR]: {e}") print(f"[ERROR]: SCHEMA {SCHEMA_FOUND}, expected {SCHEMA}")
sys.exit(1) sys.exit(1)
if not args.directory is None:
print("[INFO]: --directory is not implemented")
sys.exit(0)
elif not args.endpoint is None:
qb = qbittorrent.Client(args.endpoint)
if qb.qbittorrent_version is None:
print(f'[ERROR]: Couldn\'t find client version at "{args.endpoint}"')
sys.exit(1)
elif not re.match(r"^v?\d+(\.\d+)*$", qb.qbittorrent_version):
print(f'[ERROR]: Invalid version found at "{args.endpoint}"')
if args.debug:
print(f"[DEBUG]: {qb.qbittorrent_version}")
sys.exit(1)
else:
print(
f'[INFO]: Found qbittorrent {qb.qbittorrent_version} at "{args.endpoint}"'
)
clients = find_client(sqlitedb, args.endpoint)
if args.confirm_add:
if len(clients) == 0:
if not args.name is None:
now = datetime.now(timezone.utc).isoformat(
sep=" ", timespec="seconds"
)
add_client(sqlitedb, args.name, args.endpoint, now)
print(f"[INFO]: Added client {args.name} ({args.endpoint})")
else:
print("[ERROR]: Must specify --name for a new client")
sys.exit(1)
elif len(clients) == 1:
print(f"[ERROR]: {clients[0][1]} ({clients[0][2]}) already exists")
sys.exit(1)
else:
print(
f"[ERROR]: Multiple clients with the same endpoint: {args.endpoint}"
)
sys.exit(1)
elif len(clients) == 0:
print(f'[ERROR]: Client using endpoint "{args.endpoint}" not found')
print("[ERROR]: Use --confirm-add to add a new endpoint")
sys.exit(1)
elif len(clients) == 1:
torrents = qb.torrents()
print(f"[INFO]: There are {len(torrents)} torrents\n")
for torrent in torrents[:2]:
files = qb.get_torrent_files(torrent["hash"])
trackers = qb.get_torrent_trackers(torrent["hash"])
print(f"[name]: {torrent['name']}")
print(f"[infohash_v1]: {torrent['infohash_v1']}")
print(f"[content_path]: {torrent['content_path']}")
print(f"[magent_uri]: {torrent['magnet_uri'][0:80]}")
print(f"[completed_on]: {torrent['completed']}")
print(f"[trackers]: {len(trackers)}")
print(f"[file_count]: {len(files)}\n")
if args.debug:
print(f"[DEBUG]: {repr(torrent)}")
for elem in trackers:
print(f"[DEBUG]: Tracker {repr(elem)}")
print("\n", end="")
else:
print(
f'[ERROR]: Multiple clients ({len(clients)}) using "{args.endpoint}"'
)
sys.exit(1)
else:
print("[ERROR]: Must specify directory OR client endpoint")
sys.exit(1)
if __name__ == "__main__": if __name__ == "__main__":

@ -1,106 +0,0 @@
"""SQLAlchemy models for the tarc database."""
from sqlalchemy import (
Column,
Integer,
String,
DateTime,
Boolean,
ForeignKey,
UniqueConstraint,
)
from sqlalchemy.orm import declarative_base
Base = declarative_base()
class SchemaVersion(Base): # pylint: disable=too-few-public-methods
"""Database schema version tracking."""
__tablename__ = "schema_version"
id = Column(Integer, primary_key=True)
version = Column(Integer, nullable=False)
applied_at = Column(DateTime, nullable=False)
class Client(Base): # pylint: disable=too-few-public-methods
"""BitTorrent client instance."""
__tablename__ = "clients"
id = Column(Integer, primary_key=True)
name = Column(String, nullable=False, unique=True)
uuid = Column(String, nullable=False, unique=True)
endpoint = Column(String, nullable=False)
last_seen = Column(DateTime, nullable=False)
class Torrent(Base): # pylint: disable=too-few-public-methods
"""BitTorrent metadata."""
__tablename__ = "torrents"
id = Column(Integer, primary_key=True)
info_hash_v1 = Column(String, nullable=False, unique=True)
info_hash_v2 = Column(String, unique=True)
file_count = Column(Integer, nullable=False)
completed_on = Column(DateTime, nullable=False)
class TorrentClient(Base): # pylint: disable=too-few-public-methods
"""Association between torrents and clients."""
__tablename__ = "torrent_clients"
id = Column(Integer, primary_key=True)
torrent_id = Column(Integer, ForeignKey("torrents.id"), nullable=False)
client_id = Column(Integer, ForeignKey("clients.id"), nullable=False)
name = Column(String, nullable=False)
content_path = Column(String, nullable=False)
last_seen = Column(DateTime, nullable=False)
__table_args__ = (UniqueConstraint("torrent_id", "client_id"),)
class Tracker(Base): # pylint: disable=too-few-public-methods
"""BitTorrent tracker information."""
__tablename__ = "trackers"
id = Column(Integer, primary_key=True)
url = Column(String, nullable=False, unique=True)
last_seen = Column(DateTime, nullable=False)
class TorrentTracker(Base): # pylint: disable=too-few-public-methods
"""Association between torrents and trackers."""
__tablename__ = "torrent_trackers"
id = Column(Integer, primary_key=True)
client_id = Column(Integer, ForeignKey("clients.id"), nullable=False)
torrent_id = Column(Integer, ForeignKey("torrents.id"), nullable=False)
tracker_id = Column(Integer, ForeignKey("trackers.id"), nullable=False)
last_seen = Column(DateTime, nullable=False)
__table_args__ = (UniqueConstraint("client_id", "torrent_id", "tracker_id"),)
class File(Base): # pylint: disable=too-few-public-methods
"""File metadata and hashes."""
__tablename__ = "files"
id = Column(Integer, primary_key=True)
size = Column(Integer, nullable=False)
oshash = Column(String, nullable=False, unique=True)
hash = Column(String, unique=True)
class TorrentFile(Base): # pylint: disable=too-few-public-methods
"""Association between torrents and files."""
__tablename__ = "torrent_files"
id = Column(Integer, primary_key=True)
file_id = Column(Integer, ForeignKey("files.id"), nullable=False)
torrent_id = Column(Integer, ForeignKey("torrents.id"), nullable=False)
client_id = Column(Integer, ForeignKey("clients.id"), nullable=False)
file_index = Column(Integer, nullable=False)
file_path = Column(String, nullable=False)
is_downloaded = Column(Boolean, nullable=False)
last_checked = Column(DateTime, nullable=False)
__table_args__ = (
UniqueConstraint("file_id", "torrent_id", "client_id", "file_index"),
)