From cb63e66d50817e0c30802ec2012c92ea96e5b571 Mon Sep 17 00:00:00 2001 From: constantprojects Date: Tue, 17 Feb 2026 15:30:51 -0700 Subject: [PATCH] Merge csv_uploader into xb_seed_status as --upload-csv mode --- README.md | 50 +++++-------- csv_uploader.py | 180 ---------------------------------------------- xb_seed_status.py | 118 +++++++++++++++++++++++++----- 3 files changed, 120 insertions(+), 228 deletions(-) delete mode 100644 csv_uploader.py diff --git a/README.md b/README.md index a34de38..126ebda 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ Matches `{id}.torrent` files against your qBittorrent session and updates NocoDB ### API Mode (updates NocoDB directly) ```bash -python seed_tracker.py \ +python xb_seed_status.py \ --id-folder ./torrents \ --bt-backup ~/.local/share/qBittorrent/BT_backup \ --nocodb-url https://noco.example.com \ @@ -23,40 +23,17 @@ python seed_tracker.py \ ### CSV Mode (outputs file for manual import) ```bash -python seed_tracker.py \ +python xb_seed_status.py \ --id-folder ./torrents \ --bt-backup ~/.local/share/qBittorrent/BT_backup \ --csv-only ``` -## Flags - -| Flag | Required | Description | -|------|----------|-------------| -| `--id-folder` | Yes | Folder containing `{id}.torrent` files | -| `--bt-backup` | Yes | qBittorrent's `BT_backup` folder | -| `--nocodb-url` | API mode | NocoDB base URL | -| `--table-id` | API mode | Table ID | -| `--api-token` | API mode | API token (`xc-token`) | -| `--csv-only` | No | Skip API, output CSV instead | -| `--output` | No | CSV output path (default: `seeding_update.csv`) | -| `--debug` | No | Print API request/response details | - -## Finding NocoDB IDs - -- **Table ID**: Click `...` next to table name → Copy Table ID - ---- - -# CSV Uploader - -Uploads CSV files generated by others (via `--csv-only`) to NocoDB. - -## Usage +### Upload CSV (import someone else's CSV to NocoDB) ```bash -python csv_uploader.py \ - --csv seeds.csv \ +python xb_seed_status.py \ + --upload-csv seeds.csv \ --nocodb-url https://noco.example.com \ --table-id xxxxxxxxxxxxx \ --api-token xc-xxxx @@ -66,7 +43,16 @@ python csv_uploader.py \ | Flag | Required | Description | |------|----------|-------------| -| `--csv` | Yes | CSV file to upload | -| `--nocodb-url` | Yes | NocoDB base URL | -| `--table-id` | Yes | Table ID | -| `--api-token` | Yes | API token | +| `--id-folder` | Scan modes | Folder containing `{id}.torrent` files | +| `--bt-backup` | Scan modes | qBittorrent's `BT_backup` folder | +| `--nocodb-url` | API modes | NocoDB base URL | +| `--table-id` | API modes | Table ID | +| `--api-token` | API modes | API token (`xc-token`) | +| `--csv-only` | No | Skip API, output CSV instead | +| `--output` | No | CSV output path (default: `seeding_update.csv`) | +| `--upload-csv` | No | Upload a CSV file to NocoDB (skip torrent scanning) | +| `--debug` | No | Print API request/response details | + +## Finding NocoDB IDs + +- **Table ID**: Click `...` next to table name → Copy Table ID diff --git a/csv_uploader.py b/csv_uploader.py deleted file mode 100644 index 200ed11..0000000 --- a/csv_uploader.py +++ /dev/null @@ -1,180 +0,0 @@ -#!/usr/bin/env python3 -""" -CSV Uploader - Upload seed_tracker CSV output to NocoDB. - -For use when others generate CSVs with --csv-only and send them to you for upload. -""" - -import argparse -import csv -import json -import sys -import time -import urllib.request -import urllib.error -from pathlib import Path - - -class NocoDBClient: - """Simple NocoDB API client.""" - - ID_FIELD = "Id" - SEEDING_FIELD = "Seeding Users" - - def __init__(self, base_url: str, table_id: str, api_token: str): - self.base_url = base_url.rstrip('/') - self.table_id = table_id - self.api_token = api_token - self.endpoint = f"{self.base_url}/api/v2/tables/{table_id}/records" - - def _request(self, method: str, data: dict | None = None, params: dict | None = None) -> dict: - url = self.endpoint - if params: - query = "&".join(f"{k}={urllib.request.quote(str(v))}" for k, v in params.items()) - url = f"{url}?{query}" - - headers = { - "xc-token": self.api_token, - "Content-Type": "application/json", - "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", - "Accept": "application/json", - } - - body = json.dumps(data).encode('utf-8') if data else None - req = urllib.request.Request(url, data=body, headers=headers, method=method) - - try: - with urllib.request.urlopen(req, timeout=30) as response: - return json.loads(response.read().decode('utf-8')) - except urllib.error.HTTPError as e: - error_body = e.read().decode('utf-8') if e.fp else "" - raise Exception(f"HTTP {e.code}: {error_body}") - - def get_record(self, record_id: int | str) -> dict | None: - try: - params = {"where": f"({self.ID_FIELD},eq,{record_id})", "limit": "1"} - result = self._request("GET", params=params) - records = result.get("list", []) - return records[0] if records else None - except Exception: - return None - - def update_record(self, row_id: int, value: str) -> bool: - try: - data = {"Id": row_id, self.SEEDING_FIELD: value} - self._request("PATCH", data=data) - return True - except Exception as e: - print(f" API error: {e}", file=sys.stderr) - return False - - -def parse_multiselect(value) -> set[str]: - if value is None: - return set() - if isinstance(value, list): - return set(value) - if isinstance(value, str): - if not value.strip(): - return set() - return set(v.strip() for v in value.split(',')) - return set() - - -def format_multiselect(values: set[str]) -> str: - if not values: - return "" - return ",".join(sorted(values)) - - -def main(): - parser = argparse.ArgumentParser( - description='Upload seed_tracker CSV to NocoDB.', - formatter_class=argparse.RawDescriptionHelpFormatter, - epilog=""" -Example: - %(prog)s --csv seeds.csv --nocodb-url https://noco.example.com \\ - --table-id xxxxxxxxxxxxx --api-token xc-xxx - """ - ) - - parser.add_argument('--csv', required=True, type=Path, - help='CSV file to upload (Id,seeding_users columns)') - parser.add_argument('--nocodb-url', required=True, type=str, - help='NocoDB base URL') - parser.add_argument('--table-id', required=True, type=str, - help='Table ID') - parser.add_argument('--api-token', required=True, type=str, - help='API token') - - args = parser.parse_args() - - if not args.csv.exists(): - print(f"Error: CSV file not found: {args.csv}", file=sys.stderr) - sys.exit(1) - - # Read CSV - with open(args.csv, 'r', newline='', encoding='utf-8') as f: - reader = csv.DictReader(f) - rows = list(reader) - - print(f"Loaded {len(rows)} rows from {args.csv}") - - # Connect - noco = NocoDBClient(args.nocodb_url, args.table_id, args.api_token) - - print("Testing connection...") - try: - noco._request("GET", params={"limit": "1"}) - print(" ✓ Connected!") - except Exception as e: - print(f" ✗ Connection failed: {e}", file=sys.stderr) - sys.exit(1) - - # Process - print(f"\nUpdating records...") - stats = {'updated': 0, 'already': 0, 'not_found': 0, 'failed': 0} - - for i, row in enumerate(rows, 1): - record_id = row.get('Id') or row.get('id') - username = row.get('seeding_users') or row.get('Seeding_Users') - - if not record_id or not username: - continue - - record = noco.get_record(record_id) - - if record is None: - print(f" ? {record_id}: not found") - stats['not_found'] += 1 - continue - - current = parse_multiselect(record.get(noco.SEEDING_FIELD)) - - if username in current: - print(f" = {record_id}: already listed") - stats['already'] += 1 - continue - - current.add(username) - - if noco.update_record(record["Id"], format_multiselect(current)): - print(f" + {record_id}: added {username}") - stats['updated'] += 1 - else: - print(f" ! {record_id}: failed") - stats['failed'] += 1 - - if i % 3 == 0: - time.sleep(1) - - # Summary - print(f"\nDone!") - print(f" Updated: {stats['updated']}") - print(f" Already: {stats['already']}") - print(f" Not found: {stats['not_found']}") - print(f" Failed: {stats['failed']}") - - -if __name__ == '__main__': - main() diff --git a/xb_seed_status.py b/xb_seed_status.py index 0864c76..0ee9ace 100644 --- a/xb_seed_status.py +++ b/xb_seed_status.py @@ -306,14 +306,18 @@ Examples: # CSV mode - just output a file: %(prog)s --id-folder ./torrents --bt-backup /path/to/BT_backup --csv-only + + # Upload a CSV generated by someone else: + %(prog)s --upload-csv seeds.csv --nocodb-url https://noco.example.com \\ + --table-id tblXXXXX --api-token xc-xxxx """ ) - - parser.add_argument('--id-folder', required=True, type=Path, + + parser.add_argument('--id-folder', type=Path, help='Path to folder containing {id}.torrent files') - parser.add_argument('--bt-backup', required=True, type=Path, + parser.add_argument('--bt-backup', type=Path, help="Path to qBittorrent's BT_backup folder") - + # NocoDB API options parser.add_argument('--nocodb-url', type=str, default=None, help='NocoDB base URL (e.g., http://localhost:8080)') @@ -321,33 +325,115 @@ Examples: help='NocoDB table ID (starts with "tbl")') parser.add_argument('--api-token', type=str, default=None, help='NocoDB API token (xc-token)') -# CSV fallback + # CSV fallback parser.add_argument('--csv-only', action='store_true', help='Skip API, just output CSV') parser.add_argument('--output', type=Path, default=Path('seeding_update.csv'), help='Output CSV path (default: seeding_update.csv)') + parser.add_argument('--upload-csv', type=Path, default=None, + help='Upload a CSV file to NocoDB (skip torrent scanning)') parser.add_argument('--debug', action='store_true', help='Print debug info for API calls') - + args = parser.parse_args() - - # Validate paths - if not args.id_folder.exists(): - print(f"Error: ID folder does not exist: {args.id_folder}", file=sys.stderr) - sys.exit(1) - if not args.bt_backup.exists(): - print(f"Error: BT_backup folder does not exist: {args.bt_backup}", file=sys.stderr) - sys.exit(1) - + + # Validate args based on mode + if args.upload_csv: + if not args.upload_csv.exists(): + print(f"Error: CSV file not found: {args.upload_csv}", file=sys.stderr) + sys.exit(1) + if not all([args.nocodb_url, args.table_id, args.api_token]): + print("Error: --upload-csv requires --nocodb-url, --table-id, and --api-token", file=sys.stderr) + sys.exit(1) + else: + if not args.id_folder or not args.bt_backup: + print("Error: --id-folder and --bt-backup are required (unless using --upload-csv)", file=sys.stderr) + sys.exit(1) + if not args.id_folder.exists(): + print(f"Error: ID folder does not exist: {args.id_folder}", file=sys.stderr) + sys.exit(1) + if not args.bt_backup.exists(): + print(f"Error: BT_backup folder does not exist: {args.bt_backup}", file=sys.stderr) + sys.exit(1) + # Determine mode use_api = not args.csv_only + # --- Upload CSV mode --- + if args.upload_csv: + print("=" * 50) + print("Seed Tracker — CSV Upload") + print("=" * 50) + + # Read CSV + with open(args.upload_csv, 'r', newline='', encoding='utf-8') as f: + reader = csv.DictReader(f) + rows = list(reader) + + print(f"Loaded {len(rows)} rows from {args.upload_csv}") + + noco = NocoDBClient( + args.nocodb_url, args.table_id, args.api_token, + args.debug + ) + + print("Testing connection...") + try: + noco._request("GET", params={"limit": "1"}) + print(" ✓ Connected!") + except Exception as e: + print(f" ✗ Connection failed: {e}", file=sys.stderr) + sys.exit(1) + + print(f"\nUpdating records...") + stats = {'updated': 0, 'already': 0, 'not_found': 0, 'failed': 0} + + for i, row in enumerate(rows, 1): + record_id = row.get('Id') or row.get('id') + username = row.get('seeding_users') or row.get('Seeding_Users') + + if not record_id or not username: + continue + + record = noco.get_record(record_id) + + if record is None: + print(f" ? {record_id}: not found") + stats['not_found'] += 1 + continue + + current = parse_multiselect(record.get(noco.SEEDING_FIELD)) + + if username in current: + print(f" = {record_id}: already listed") + stats['already'] += 1 + continue + + current.add(username) + + if noco.update_record(record["Id"], format_multiselect(current)): + print(f" + {record_id}: added {username}") + stats['updated'] += 1 + else: + print(f" ! {record_id}: failed") + stats['failed'] += 1 + + if i % 3 == 0: + time.sleep(1) + + print(f"\nDone!") + print(f" Updated: {stats['updated']}") + print(f" Already: {stats['already']}") + print(f" Not found: {stats['not_found']}") + print(f" Failed: {stats['failed']}") + return + # Show banner print("=" * 50) print("Seed Tracker") print("=" * 50) print(f"Mode: {'NocoDB API' if use_api else 'CSV output'}") - + if use_api: if not all([args.nocodb_url, args.table_id, args.api_token]): print("Error: API mode requires --nocodb-url, --table-id, and --api-token", file=sys.stderr)