Compare commits

..

3 Commits

Author SHA1 Message Date
a68bea95d2 testing 2025-03-10 21:33:25 -04:00
7b42d55cc1 Replace python-qbittorrent with qbittorrent-api 2025-03-10 16:02:37 -04:00
f325768c1b Migrate from raw SQL to SQLAlchemy ORM 2025-03-10 14:54:11 -04:00
3 changed files with 241 additions and 213 deletions

View File

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

View File

@@ -15,194 +15,225 @@ import uuid
import argparse import argparse
from datetime import datetime, timezone from datetime import datetime, timezone
import qbittorrent import qbittorrentapi
from sqlalchemy import create_engine, event from sqlalchemy import create_engine, inspect
from sqlalchemy.orm import sessionmaker from sqlalchemy.orm import Session
from .models import Base, Client from .models import Base, SchemaVersion, Client
# SCHEMA format is YYYYMMDDX # SCHEMA format is YYYYMMDDX
SCHEMA = 202410060 SCHEMA = 202503100
def init_db(engine): def init_db(engine):
""" """
Initialize database Initialize database
""" """
# Create all tables first
Base.metadata.create_all(engine) Base.metadata.create_all(engine)
# Set the schema version using SQLAlchemy primitives with Session(engine) as session:
@event.listens_for(engine, 'connect') if not session.query(SchemaVersion).first():
def set_sqlite_pragma(dbapi_connection, connection_record): now = datetime.now(timezone.utc)
cursor = dbapi_connection.cursor() version = SchemaVersion(version=SCHEMA, applied_at=now)
cursor.execute(f"PRAGMA user_version = {SCHEMA}") session.add(version)
cursor.close() session.commit()
def get_schema_version(engine):
"""
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): def list_tables(engine):
""" """
List all tables in database List all tables in database
""" """
return Base.metadata.tables.keys() inspector = inspect(engine)
return inspector.get_table_names()
def add_client(session, name, endpoint, last_seen): def add_client(engine, name, endpoint, last_seen):
""" """
Add a new client endpoint to database Add a new client endpoint to database
""" """
new_client = Client( with Session(engine) as session:
uuid=str(uuid.uuid4()), client = Client(
name=name, uuid=str(uuid.uuid4()), name=name, endpoint=endpoint, last_seen=last_seen
endpoint=endpoint, )
last_seen=last_seen session.add(client)
) session.commit()
session.add(new_client)
session.commit()
def find_client(session, endpoint): def find_client(engine, endpoint):
""" """
Find existing client Find existing client
""" """
clients = session.query(Client.id, Client.name, Client.uuid).filter(Client.endpoint == endpoint).all() with Session(engine) as session:
return clients clients = (
session.query(Client.id, Client.name, Client.uuid)
.filter_by(endpoint=endpoint)
.all()
)
return clients
def list_clients(session): def list_clients(engine):
""" """
List all stored clients List all stored clients
""" """
return session.query(Client).all() with Session(engine) as session:
return session.query(Client).all()
def authenticate_qbittorrent(endpoint, username, password):
"""
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():
""" """
Entrypoint of the program. Parses command-line arguments and executes the corresponding command.
""" """
parser = argparse.ArgumentParser(description="Manage BT archives", prog="tarc") parser = argparse.ArgumentParser(description="Manage BT archives", prog="tarc")
subparsers = parser.add_subparsers( subparsers = parser.add_subparsers(dest="command", required=True, help="Available commands")
dest="command", required=True, help="Available commands"
)
scan_parser = subparsers.add_parser("scan", help="Scan command") # scan command
scan_parser = subparsers.add_parser("scan", help="Scan torrents")
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()
if args.command == "scan": storage_path = args.storage or os.path.expanduser("~/.tarc.db")
if args.storage is None: engine = create_engine(f"sqlite:///{storage_path}")
STORAGE = os.path.expanduser("~/.tarch.db")
else:
STORAGE = args.storage
try:
engine = create_engine(f"sqlite:///{STORAGE}")
tables = list_tables(engine)
Session = sessionmaker(bind=engine)
session = Session()
except Exception as e:
print(f'[ERROR]: Database Error "{STORAGE}" ({str(e)})')
sys.exit(1)
if len(tables) == 0:
print(f"[INFO]: Initializing database at {STORAGE}")
init_db(engine)
# Check schema version using SQLAlchemy primitives if not list_tables(engine):
schema_found = None print(f"[INFO]: Initializing database at {storage_path}")
init_db(engine)
@event.listens_for(engine, 'connect')
def get_sqlite_pragma(dbapi_connection, connection_record): schema_found = get_schema_version(engine)
nonlocal schema_found if schema_found != SCHEMA:
cursor = dbapi_connection.cursor() raise ValueError(f"SCHEMA {schema_found}, expected {SCHEMA}")
cursor.execute("PRAGMA user_version")
schema_found = cursor.fetchone()[0] try:
cursor.close() if args.command == "scan":
handle_scan(args, engine)
# Force a connection to trigger the event elif args.command == "client-add":
with engine.connect() as conn: handle_client_add(args, engine)
pass elif args.command == "client-list":
handle_client_list(args, engine)
if not SCHEMA == schema_found: except ValueError as e:
print(f"[ERROR]: SCHEMA {schema_found}, expected {SCHEMA}") print(f"[ERROR]: {e}")
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 args.username and args.password:
qb.login(args.username, args.password)
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(session, args.endpoint)
if args.confirm_add:
if len(clients) == 0:
if not args.name is None:
now = datetime.now(timezone.utc)
add_client(session, 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__":

View File

@@ -1,109 +1,106 @@
#!/usr/bin/env python3 """SQLAlchemy models for the tarc database."""
"""
Database models for Torrent Archiver
"""
from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, UniqueConstraint from sqlalchemy import (
from sqlalchemy.ext.declarative import declarative_base Column,
from sqlalchemy.orm import relationship Integer,
String,
DateTime,
Boolean,
ForeignKey,
UniqueConstraint,
)
from sqlalchemy.orm import declarative_base
Base = declarative_base() Base = declarative_base()
class Client(Base):
__tablename__ = 'clients' class SchemaVersion(Base): # pylint: disable=too-few-public-methods
"""Database schema version tracking."""
id = Column(Integer, primary_key=True, autoincrement=True)
__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) name = Column(String, nullable=False, unique=True)
uuid = Column(String, nullable=False, unique=True) uuid = Column(String, nullable=False, unique=True)
endpoint = Column(String, nullable=False) endpoint = Column(String, nullable=False)
last_seen = Column(DateTime, nullable=False) last_seen = Column(DateTime, nullable=False)
torrent_clients = relationship("TorrentClient", back_populates="client")
torrent_trackers = relationship("TorrentTracker", back_populates="client")
torrent_files = relationship("TorrentFile", back_populates="client")
class Torrent(Base): class Torrent(Base): # pylint: disable=too-few-public-methods
__tablename__ = 'torrents' """BitTorrent metadata."""
id = Column(Integer, primary_key=True, autoincrement=True) __tablename__ = "torrents"
id = Column(Integer, primary_key=True)
info_hash_v1 = Column(String, nullable=False, unique=True) info_hash_v1 = Column(String, nullable=False, unique=True)
info_hash_v2 = Column(String, unique=True) info_hash_v2 = Column(String, unique=True)
file_count = Column(Integer, nullable=False) file_count = Column(Integer, nullable=False)
completed_on = Column(DateTime, nullable=False) completed_on = Column(DateTime, nullable=False)
torrent_clients = relationship("TorrentClient", back_populates="torrent")
torrent_trackers = relationship("TorrentTracker", back_populates="torrent")
torrent_files = relationship("TorrentFile", back_populates="torrent")
class TorrentClient(Base): class TorrentClient(Base): # pylint: disable=too-few-public-methods
__tablename__ = 'torrent_clients' """Association between torrents and clients."""
id = Column(Integer, primary_key=True, autoincrement=True) __tablename__ = "torrent_clients"
torrent_id = Column(Integer, ForeignKey('torrents.id'), nullable=False) id = Column(Integer, primary_key=True)
client_id = Column(Integer, ForeignKey('clients.id'), nullable=False) torrent_id = Column(Integer, ForeignKey("torrents.id"), nullable=False)
client_id = Column(Integer, ForeignKey("clients.id"), nullable=False)
name = Column(String, nullable=False) name = Column(String, nullable=False)
content_path = Column(String, nullable=False) content_path = Column(String, nullable=False)
last_seen = Column(DateTime, nullable=False) last_seen = Column(DateTime, nullable=False)
__table_args__ = (UniqueConstraint("torrent_id", "client_id"),)
torrent = relationship("Torrent", back_populates="torrent_clients")
client = relationship("Client", back_populates="torrent_clients")
__table_args__ = (UniqueConstraint('torrent_id', 'client_id'),)
class Tracker(Base): class Tracker(Base): # pylint: disable=too-few-public-methods
__tablename__ = 'trackers' """BitTorrent tracker information."""
id = Column(Integer, primary_key=True, autoincrement=True) __tablename__ = "trackers"
id = Column(Integer, primary_key=True)
url = Column(String, nullable=False, unique=True) url = Column(String, nullable=False, unique=True)
last_seen = Column(DateTime, nullable=False) last_seen = Column(DateTime, nullable=False)
torrent_trackers = relationship("TorrentTracker", back_populates="tracker")
class TorrentTracker(Base): class TorrentTracker(Base): # pylint: disable=too-few-public-methods
__tablename__ = 'torrent_trackers' """Association between torrents and trackers."""
id = Column(Integer, primary_key=True, autoincrement=True) __tablename__ = "torrent_trackers"
client_id = Column(Integer, ForeignKey('clients.id'), nullable=False) id = Column(Integer, primary_key=True)
torrent_id = Column(Integer, ForeignKey('torrents.id'), nullable=False) client_id = Column(Integer, ForeignKey("clients.id"), nullable=False)
tracker_id = Column(Integer, ForeignKey('trackers.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) last_seen = Column(DateTime, nullable=False)
__table_args__ = (UniqueConstraint("client_id", "torrent_id", "tracker_id"),)
client = relationship("Client", back_populates="torrent_trackers")
torrent = relationship("Torrent", back_populates="torrent_trackers")
tracker = relationship("Tracker", back_populates="torrent_trackers")
__table_args__ = (UniqueConstraint('client_id', 'torrent_id', 'tracker_id'),)
class File(Base): class File(Base): # pylint: disable=too-few-public-methods
__tablename__ = 'files' """File metadata and hashes."""
id = Column(Integer, primary_key=True, autoincrement=True) __tablename__ = "files"
id = Column(Integer, primary_key=True)
size = Column(Integer, nullable=False) size = Column(Integer, nullable=False)
oshash = Column(String, nullable=False, unique=True) oshash = Column(String, nullable=False, unique=True)
hash = Column(String, unique=True) hash = Column(String, unique=True)
torrent_files = relationship("TorrentFile", back_populates="file")
class TorrentFile(Base): class TorrentFile(Base): # pylint: disable=too-few-public-methods
__tablename__ = 'torrent_files' """Association between torrents and files."""
id = Column(Integer, primary_key=True, autoincrement=True) __tablename__ = "torrent_files"
file_id = Column(Integer, ForeignKey('files.id'), nullable=False) id = Column(Integer, primary_key=True)
torrent_id = Column(Integer, ForeignKey('torrents.id'), nullable=False) file_id = Column(Integer, ForeignKey("files.id"), nullable=False)
client_id = Column(Integer, ForeignKey('clients.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_index = Column(Integer, nullable=False)
file_path = Column(String, nullable=False) file_path = Column(String, nullable=False)
is_downloaded = Column(Boolean, nullable=False) is_downloaded = Column(Boolean, nullable=False)
last_checked = Column(DateTime, nullable=False) last_checked = Column(DateTime, nullable=False)
__table_args__ = (
file = relationship("File", back_populates="torrent_files") UniqueConstraint("file_id", "torrent_id", "client_id", "file_index"),
torrent = relationship("Torrent", back_populates="torrent_files") )
client = relationship("Client", back_populates="torrent_files")
__table_args__ = (UniqueConstraint('file_id', 'torrent_id', 'client_id', 'file_index'),)