Compare commits

..

3 Commits

Author SHA1 Message Date
998872cb4f testing 2025-02-28 21:15:59 -05:00
b23ca49a83 Add Makefile and refactor package structure
- Replace shell script with Makefile for build/install tasks
- Move main logic into main() function for proper packaging
- Configure package entrypoint in pyproject.toml
2024-10-12 22:40:18 -04:00
4ab46ee2fc Restructure project layout and update references 2024-10-06 21:50:00 -04:00
7 changed files with 294 additions and 224 deletions

25
Makefile Normal file
View File

@@ -0,0 +1,25 @@
.PHONY: default venv build install clean
default: install
venv:
@[ ! -d ./venv ] && python3 -m venv venv && bash -c \
"source venv/bin/activate && \
pip install --upgrade pip && \
pip install -r requirements.txt" || true
build: venv
@if [ -n "$$(git status --porcelain)" ]; then \
echo "[ERROR]: There are uncommitted changes or untracked files."; \
exit 1; \
fi
@bash -c \
"source venv/bin/activate && \
pip install build twine && \
python -m build"
install: venv
@bash -c "source venv/bin/activate && pip install -e ."
clean:
rm -rf venv dist tarc.egg-info

View File

@@ -1,4 +1,7 @@
[project] [project]
name = "tarc" name = "tarc"
version = "0.0.1dev2" version = "0.0.1dev3"
description = "Manage BT archives" description = "Manage BT archives"
[project.scripts]
tarc = "tarc:main"

View File

@@ -1 +1,2 @@
python-qbittorrent==0.4.3 python-qbittorrent==0.4.3
sqlalchemy==2.0.27

20
tarc.sh
View File

@@ -1,20 +0,0 @@
#!/bin/bash
# Create a virtual environment if it does not exist
if [ ! -d "venv" ]; then
# shellcheck disable=SC1091
python3 -m venv venv && \
source venv/bin/activate && \
pip install -r requirements.txt && \
deactivate
fi
# Activate the virtual environment
# shellcheck disable=SC1091
source venv/bin/activate
# Run the Python script
python tarc/main.py "$@"
# Deactivate the virtual environment
deactivate

View File

@@ -0,0 +1,5 @@
"""
tarc - Manage BT archives
"""
from .main import main

View File

@@ -13,250 +13,197 @@ 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 qbittorrent import qbittorrent
from sqlalchemy import create_engine, event
from sqlalchemy.orm import sessionmaker
from .models import Base, Client
# SCHEMA format is YYYYMMDDX # SCHEMA format is YYYYMMDDX
SCHEMA = 202410060 SCHEMA = 202410060
def init_db(engine):
def init_db(conn):
""" """
Initialize database Initialize database
""" """
# Create all tables first
Base.metadata.create_all(engine)
c = conn.cursor() # Set the schema version using SQLAlchemy primitives
c.executescript( @event.listens_for(engine, 'connect')
f""" def set_sqlite_pragma(dbapi_connection, connection_record):
PRAGMA user_version = {SCHEMA}; cursor = dbapi_connection.cursor()
cursor.execute(f"PRAGMA user_version = {SCHEMA}")
CREATE TABLE IF NOT EXISTS clients ( cursor.close()
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 list_tables(conn): def list_tables(engine):
""" """
List all tables in database List all tables in database
""" """
c = conn.cursor() return Base.metadata.tables.keys()
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(conn, name, endpoint, last_seen): def add_client(session, name, endpoint, last_seen):
""" """
Add a new client endpoint to database Add a new client endpoint to database
""" """
c = conn.cursor() new_client = Client(
c.execute( uuid=str(uuid.uuid4()),
f""" name=name,
INSERT INTO clients (uuid, name, endpoint, last_seen) endpoint=endpoint,
VALUES ("{uuid.uuid4()}", "{name}", "{endpoint}", "{last_seen}"); last_seen=last_seen
"""
) )
conn.commit() session.add(new_client)
c.close() session.commit()
def find_client(conn, endpoint): def find_client(session, endpoint):
""" """
Find existing client Find existing client
""" """
c = conn.cursor() clients = session.query(Client.id, Client.name, Client.uuid).filter(Client.endpoint == endpoint).all()
c.execute(f'SELECT id, name, uuid FROM clients WHERE endpoint="{endpoint}";') return clients
response = c.fetchall()
c.close()
return response
def list_clients(conn): def list_clients(session):
""" """
List all stored clients List all stored clients
""" """
c = conn.cursor() return session.query(Client).all()
c.execute("SELECT * FROM clients;")
rows = c.fetchall()
c.close()
return rows
parser = argparse.ArgumentParser(description="Manage BT archives", prog="tarc") def main():
subparsers = parser.add_subparsers( """
dest="command", required=True, help="Available commands" Entrypoint of the program.
) """
scan_parser = subparsers.add_parser("scan", help="Scan command") parser = argparse.ArgumentParser(description="Manage BT archives", prog="tarc")
scan_parser.add_argument("--debug", action="store_true", help="Enable debug mode") subparsers = parser.add_subparsers(
scan_parser.add_argument( dest="command", required=True, help="Available commands"
"--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("-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("-u", "--username", help="Username")
scan_parser.add_argument("-p", "--password", help="Password")
scan_parser.add_argument("-s", "--storage", help="Path of sqlite3 database")
args = parser.parse_args() 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(
"--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("-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("-u", "--username", help="Username")
scan_parser.add_argument("-p", "--password", help="Password")
scan_parser.add_argument("-s", "--storage", help="Path of sqlite3 database")
if args.command == "scan": args = parser.parse_args()
if args.storage is None:
STORAGE = os.path.expanduser("~/.tarch.db") if args.command == "scan":
else: if args.storage is None:
STORAGE = args.storage STORAGE = os.path.expanduser("~/.tarch.db")
try:
sqlitedb = sqlite3.connect(STORAGE)
tables = list_tables(sqlitedb)
except sqlite3.DatabaseError 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(sqlitedb)
cursor = sqlitedb.cursor()
cursor.execute("PRAGMA user_version;")
SCHEMA_FOUND = cursor.fetchone()[0]
cursor.close()
if not SCHEMA == SCHEMA_FOUND:
print(f"[ERROR]: SCHEMA {SCHEMA_FOUND}, expected {SCHEMA}")
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: else:
print( STORAGE = args.storage
f'[INFO]: Found qbittorrent {qb.qbittorrent_version} at "{args.endpoint}"' try:
) engine = create_engine(f"sqlite:///{STORAGE}")
clients = find_client(sqlitedb, args.endpoint) tables = list_tables(engine)
if args.confirm_add: Session = sessionmaker(bind=engine)
if len(clients) == 0: session = Session()
if not args.name is None: except Exception as e:
now = datetime.now(timezone.utc).isoformat( print(f'[ERROR]: Database Error "{STORAGE}" ({str(e)})')
sep=" ", timespec="seconds" sys.exit(1)
) if len(tables) == 0:
add_client(sqlitedb, args.name, args.endpoint, now) print(f"[INFO]: Initializing database at {STORAGE}")
print(f"[INFO]: Added client {args.name} ({args.endpoint})") init_db(engine)
else:
print("[ERROR]: Must specify --name for a new client") # Check schema version using SQLAlchemy primitives
sys.exit(1) schema_found = None
elif len(clients) == 1:
print(f"[ERROR]: {clients[0][1]} ({clients[0][2]}) already exists") @event.listens_for(engine, 'connect')
def get_sqlite_pragma(dbapi_connection, connection_record):
nonlocal schema_found
cursor = dbapi_connection.cursor()
cursor.execute("PRAGMA user_version")
schema_found = cursor.fetchone()[0]
cursor.close()
# Force a connection to trigger the event
with engine.connect() as conn:
pass
if not SCHEMA == schema_found:
print(f"[ERROR]: SCHEMA {schema_found}, expected {SCHEMA}")
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) sys.exit(1)
else: else:
print( print(
f"[ERROR]: Multiple clients with the same endpoint: {args.endpoint}" 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) 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: else:
print(f'[ERROR]: Multiple clients ({len(clients)}) using "{args.endpoint}"') print("[ERROR]: Must specify directory OR client endpoint")
sys.exit(1) sys.exit(1)
else:
print("[ERROR]: Must specify directory OR client endpoint")
sys.exit(1) if __name__ == "__main__":
main()

109
tarc/models.py Normal file
View File

@@ -0,0 +1,109 @@
#!/usr/bin/env python3
"""
Database models for Torrent Archiver
"""
from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, UniqueConstraint
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import relationship
Base = declarative_base()
class Client(Base):
__tablename__ = 'clients'
id = Column(Integer, primary_key=True, autoincrement=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)
torrent_clients = relationship("TorrentClient", back_populates="client")
torrent_trackers = relationship("TorrentTracker", back_populates="client")
torrent_files = relationship("TorrentFile", back_populates="client")
class Torrent(Base):
__tablename__ = 'torrents'
id = Column(Integer, primary_key=True, autoincrement=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)
torrent_clients = relationship("TorrentClient", back_populates="torrent")
torrent_trackers = relationship("TorrentTracker", back_populates="torrent")
torrent_files = relationship("TorrentFile", back_populates="torrent")
class TorrentClient(Base):
__tablename__ = 'torrent_clients'
id = Column(Integer, primary_key=True, autoincrement=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)
torrent = relationship("Torrent", back_populates="torrent_clients")
client = relationship("Client", back_populates="torrent_clients")
__table_args__ = (UniqueConstraint('torrent_id', 'client_id'),)
class Tracker(Base):
__tablename__ = 'trackers'
id = Column(Integer, primary_key=True, autoincrement=True)
url = Column(String, nullable=False, unique=True)
last_seen = Column(DateTime, nullable=False)
torrent_trackers = relationship("TorrentTracker", back_populates="tracker")
class TorrentTracker(Base):
__tablename__ = 'torrent_trackers'
id = Column(Integer, primary_key=True, autoincrement=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)
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):
__tablename__ = 'files'
id = Column(Integer, primary_key=True, autoincrement=True)
size = Column(Integer, nullable=False)
oshash = Column(String, nullable=False, unique=True)
hash = Column(String, unique=True)
torrent_files = relationship("TorrentFile", back_populates="file")
class TorrentFile(Base):
__tablename__ = 'torrent_files'
id = Column(Integer, primary_key=True, autoincrement=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)
file = relationship("File", back_populates="torrent_files")
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'),)