Merge csv_uploader into xb_seed_status as --upload-csv mode
This commit is contained in:
50
README.md
50
README.md
@@ -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
|
||||||
|
|||||||
180
csv_uploader.py
180
csv_uploader.py
@@ -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()
|
|
||||||
@@ -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
|
||||||
@@ -326,12 +330,25 @@ Examples:
|
|||||||
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 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():
|
if not args.id_folder.exists():
|
||||||
print(f"Error: ID folder does not exist: {args.id_folder}", file=sys.stderr)
|
print(f"Error: ID folder does not exist: {args.id_folder}", file=sys.stderr)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
@@ -342,6 +359,75 @@ Examples:
|
|||||||
# 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")
|
||||||
|
|||||||
Reference in New Issue
Block a user