Compare commits

...

2 Commits

Author SHA1 Message Date
constantprojects
a12814d271 Update README description to mention --upload-csv 2026-02-17 15:32:12 -07:00
constantprojects
cb63e66d50 Merge csv_uploader into xb_seed_status as --upload-csv mode 2026-02-17 15:30:51 -07:00
3 changed files with 121 additions and 229 deletions

View File

@@ -1,6 +1,6 @@
# Seed Tracker # Seed Tracker
Matches `{id}.torrent` files against your qBittorrent session and updates NocoDB with your username. Matches `{id}.torrent` files against your qBittorrent session and updates NocoDB with your username. Can also upload CSVs generated by others via `--upload-csv`.
## Requirements ## Requirements
@@ -12,7 +12,7 @@ Matches `{id}.torrent` files against your qBittorrent session and updates NocoDB
### API Mode (updates NocoDB directly) ### API Mode (updates NocoDB directly)
```bash ```bash
python seed_tracker.py \ python xb_seed_status.py \
--id-folder ./torrents \ --id-folder ./torrents \
--bt-backup ~/.local/share/qBittorrent/BT_backup \ --bt-backup ~/.local/share/qBittorrent/BT_backup \
--nocodb-url https://noco.example.com \ --nocodb-url https://noco.example.com \
@@ -23,40 +23,17 @@ python seed_tracker.py \
### CSV Mode (outputs file for manual import) ### CSV Mode (outputs file for manual import)
```bash ```bash
python seed_tracker.py \ python xb_seed_status.py \
--id-folder ./torrents \ --id-folder ./torrents \
--bt-backup ~/.local/share/qBittorrent/BT_backup \ --bt-backup ~/.local/share/qBittorrent/BT_backup \
--csv-only --csv-only
``` ```
## Flags ### Upload CSV (import someone else's CSV to NocoDB)
| 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
```bash ```bash
python csv_uploader.py \ python xb_seed_status.py \
--csv seeds.csv \ --upload-csv seeds.csv \
--nocodb-url https://noco.example.com \ --nocodb-url https://noco.example.com \
--table-id xxxxxxxxxxxxx \ --table-id xxxxxxxxxxxxx \
--api-token xc-xxxx --api-token xc-xxxx
@@ -66,7 +43,16 @@ python csv_uploader.py \
| Flag | Required | Description | | Flag | Required | Description |
|------|----------|-------------| |------|----------|-------------|
| `--csv` | Yes | CSV file to upload | | `--id-folder` | Scan modes | Folder containing `{id}.torrent` files |
| `--nocodb-url` | Yes | NocoDB base URL | | `--bt-backup` | Scan modes | qBittorrent's `BT_backup` folder |
| `--table-id` | Yes | Table ID | | `--nocodb-url` | API modes | NocoDB base URL |
| `--api-token` | Yes | API token | | `--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

View File

@@ -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()

View File

@@ -306,12 +306,16 @@ Examples:
# CSV mode - just output a file: # CSV mode - just output a file:
%(prog)s --id-folder ./torrents --bt-backup /path/to/BT_backup --csv-only %(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') 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") help="Path to qBittorrent's BT_backup folder")
# NocoDB API options # NocoDB API options
@@ -321,27 +325,109 @@ Examples:
help='NocoDB table ID (starts with "tbl")') help='NocoDB table ID (starts with "tbl")')
parser.add_argument('--api-token', type=str, default=None, parser.add_argument('--api-token', type=str, default=None,
help='NocoDB API token (xc-token)') help='NocoDB API token (xc-token)')
# CSV fallback # CSV fallback
parser.add_argument('--csv-only', action='store_true', parser.add_argument('--csv-only', action='store_true',
help='Skip API, just output CSV') help='Skip API, just output CSV')
parser.add_argument('--output', type=Path, default=Path('seeding_update.csv'), parser.add_argument('--output', type=Path, default=Path('seeding_update.csv'),
help='Output CSV path (default: 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', parser.add_argument('--debug', action='store_true',
help='Print debug info for API calls') help='Print debug info for API calls')
args = parser.parse_args() args = parser.parse_args()
# Validate paths # Validate args based on mode
if not args.id_folder.exists(): if args.upload_csv:
print(f"Error: ID folder does not exist: {args.id_folder}", file=sys.stderr) if not args.upload_csv.exists():
sys.exit(1) print(f"Error: CSV file not found: {args.upload_csv}", file=sys.stderr)
if not args.bt_backup.exists(): sys.exit(1)
print(f"Error: BT_backup folder does not exist: {args.bt_backup}", file=sys.stderr) if not all([args.nocodb_url, args.table_id, args.api_token]):
sys.exit(1) 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 # Determine mode
use_api = not args.csv_only 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 # Show banner
print("=" * 50) print("=" * 50)
print("Seed Tracker") print("Seed Tracker")