infra/stacks/servarr/mam-farming/files/freeleech-grabber.py

274 lines
8.9 KiB
Python
Raw Normal View History

[servarr] Rewrite MAM ratio farming — break Mouse death spiral, adopt in TF ## Context A MAM (MyAnonamouse) freeleech farming workflow was deployed on 2026-04-14 via kubectl apply (outside Terraform). Five days later the account was still stuck in Mouse class: 715 MiB downloaded, 0 uploaded, ratio 0. Tracker responses on 7 of 9 active torrents returned `status=4 | msg="User currently mouse rank, you need to get your ratio up!"` — MAM was actively refusing to serve peer lists because the account was in Mouse class, and refusing to serve peer lists made the ratio impossible to recover. Meanwhile the grabber kept digging: 501 torrents sat in qBittorrent, 0 completed, 0 bytes uploaded. Root causes (ranked): 1. Death spiral — Mouse class blocks announces, nothing uploads. 2. BP-spender 30 000 BP threshold blocked the only exit even though the account already had 24 500 BP. 3. Grabber selection (`score = 1.0 / (seeders+1)`) preferred low-demand torrents filtered to <100 MiB — ratio-hostile by design. 4. Grabber/cleanup deadlock: cleanup only fired on seed_time > 3d, so torrents that never started never qualified. Combined with the 500- torrent cap this stalled the grabber indefinitely. 5. qBittorrent queueing amplified (4) — 495/501 stuck in queuedDL. 6. Ratio-monitor labelled queued torrents `unknown` (empty tracker field), hiding the problem on the MAM Grafana panel. 7. qBittorrent memory limit (256 Mi LimitRange default) too low. 8. All of the above was Terraform drift with no reviewability. ## This change Introduces `stacks/servarr/mam-farming/` — a new TF module that adopts the three kubectl-applied resources and replaces their scripts with demand-first, H&R-aware logic. Also bumps qBittorrent resources, fixes ratio-monitor labelling, and adds five Prometheus alerts plus a Grafana panel row. ### Architecture MAM API ───┬─── jsonLoad.php (profile: ratio, class, BP) ├─── loadSearchJSONbasic.php (freeleech search) ├─── bonusBuy.php (50 GiB min tier for API) └─── download.php (torrent file) │ Pushgateway <──┬────────────┤ │ mam_ratio ┌────────────────────┐ │ mam_class_code │ freeleech-grabber │ */30 │ mam_bp_balance ◄───│ (ratio-guarded) │ │ mam_farming_* └──────────┬─────────┘ │ mam_janitor_* │ adds to │ ▼ │ Grafana panels qBittorrent (mam-farming) │ + 5 alerts ▲ │ │ deletes by rule │ ┌──────────┴─────────┐ │ ◄───│ farming-janitor │ */15 │ │ (H&R-aware) │ │ └──────────┬─────────┘ │ │ buys credit │ ┌──────────┴─────────┐ └───────────────────────│ bp-spender │ 0 */6 │ (tier-aware) │ └────────────────────┘ ### Key decisions - **Ratio guard on grabber** — refuse to grab if ratio < 1.2 OR class == Mouse. Prevents the death spiral from deepening. Emits `mam_grabber_skipped_reason{reason=...}` and exits clean. - **Demand-first selection** — new score formula `leechers*3 - seeders*0.5 + 200 if freeleech_wedge else 0`; size band 50 MiB – 1 GiB; leecher floor 1; seeder ceiling 50. Picks titles that will actually upload. - **Janitor decoupled from grabber** — runs every 15 min regardless of the ratio-guard state. Without this, stuck torrents accumulate fastest exactly when the grabber is skipping (Mouse class). H&R-aware: never deletes `progress==1.0 AND seeding_time < 72h`. Six delete reasons observable via `mam_janitor_deleted_per_run{reason=...}`. - **BP-spender tier-aware** — MAM imposes a hard 50 GiB minimum on API buyers ("Automated spenders are limited to buying at least 50 GB... due to log spam"). Valid API tiers: 50/100/200/500 GiB at 500 BP/GiB. The spender picks the smallest tier that satisfies the ratio deficit AND fits the budget, preserving a 500 BP reserve. If even the 50 GiB tier is too expensive, it skips and retries on the next 6-hour cron. - **Authoritative metrics use MAM profile fields** — `downloaded_bytes` / `uploaded_bytes` (integers) rather than the pretty-printed `downloaded` / `uploaded` strings like "715.55 MiB" that MAM also returns. - **Ratio-monitor category-first labelling** — `tracker` is empty for queued torrents that never announced. Now maps `category==mam-farming` to label `mam` first, only falls back to tracker-URL parsing when category is absent. Stops hundreds of MAM torrents collecting under `unknown`. - **qBittorrent resources bumped** to `requests=512Mi / limits=1Gi` so hundreds of active torrents don't OOM. ### Emergency recovery performed this session 1. Adopted 5 in-cluster resources via root-module `import {}` blocks (Terraform 1.5+ rejects imports inside child modules). 2. Ran the janitor in DRY_RUN=1 to verify rules against live state — 466 `never_started` candidates, 0 false positives in any other reason bucket. Flipped to enforce mode. 3. Janitor deleted 466 stuck torrents (matches plan's ~495 target; 35 preserved as active/in-progress). 4. Truncated `/data/grabbed_ids.txt` so newly-popular titles become eligible again. The ratio is still 0 because the API cannot buy below 50 GiB and the account sits at 24 551 BP (needs 25 000). Manual 1 GiB purchase via the MAM web UI — 500 BP — would immediately lift the account to ratio ≈ 1.4 and unblock announces. Future automation cannot do this for us due to MAMs anti-spam rule. ### What is NOT in this change - qBittorrent prefs reconciliation (max_active_downloads=20, max_active_uploads=150, max_active_torrents=150). The plan wanted this; deferred to a follow-up because the janitor + ratio recovery handles the 500-torrent backlog first. A small reconciler CronJob posting to /api/v2/app/setPreferences is the intended follow-up. - VIP purchase (~100 k BP) — deferred until BP accumulates. - Cross-seed / autobrr — separate initiative. ## Alerts added - P1 MAMMouseClass — `mam_class_code == 0` for 1h - P1 MAMCookieExpired — `mam_farming_cookie_expired > 0` - P2 MAMRatioBelowOne — `mam_ratio < 1.0` for 24h (replaces old QBittorrentMAMRatioLow, now driven by authoritative profile metric) - P2 MAMFarmingStuck — no grabs in 4h while ratio is healthy - P2 MAMJanitorStuckBacklog — `skipped_active > 400` for 6h ## Test plan ### Automated $ cd infra/stacks/servarr && ../../scripts/tg plan 2>&1 | grep Plan Plan: 5 to import, 2 to add, 6 to change, 0 to destroy. $ ../../scripts/tg apply --non-interactive Apply complete! Resources: 5 imported, 2 added, 6 changed, 0 destroyed. # Re-plan after import block removal (idempotent) $ ../../scripts/tg plan 2>&1 | grep Plan Plan: 0 to add, 1 to change, 0 to destroy. # The 1 change is a pre-existing MetalLB annotation drift on the # qbittorrent-torrenting Service — unrelated to this change. $ cd ../monitoring && ../../scripts/tg apply --non-interactive Apply complete! Resources: 0 added, 2 changed, 0 destroyed. # Python + JSON syntax $ python3 -c 'import ast; [ast.parse(open(p).read()) for p in [ "infra/stacks/servarr/mam-farming/files/freeleech-grabber.py", "infra/stacks/servarr/mam-farming/files/bp-spender.py", "infra/stacks/servarr/mam-farming/files/mam-farming-janitor.py"]]' $ python3 -c 'import json; json.load(open( "infra/stacks/monitoring/modules/monitoring/dashboards/qbittorrent.json"))' ### Manual Verification 1. Grabber ratio-guard path: $ kubectl -n servarr create job --from=cronjob/mam-freeleech-grabber g1 $ kubectl -n servarr logs job/g1 Skip grab: ratio=0.0 class=Mouse (floor=1.2) reason=mouse_class 2. BP-spender tier path: $ kubectl -n servarr create job --from=cronjob/mam-bp-spender s1 $ kubectl -n servarr logs job/s1 Profile: ratio=0.0 class=Mouse DL=0.70 GiB UL=0.00 GiB BP=24551 | deficit=1.40 GiB needed=3 affordable=48 buy=0 Done: BP=24551, spent=0 GiB (needed=3, affordable=48) Correctly skips because affordable (48) < smallest API tier (50). 3. Janitor in enforce mode: $ kubectl -n servarr create job --from=cronjob/mam-farming-janitor j1 $ kubectl -n servarr logs job/j1 | tail -3 Done: deleted=466 preserved_hnr=0 skipped_active=35 dry_run=False per reason: {'never_started': 466, ...} Second run immediately after: `deleted=0 skipped_active=35` — steady state with only active/seeding torrents left. 4. Alerts loaded: $ kubectl -n monitoring get cm prometheus-server \ -o jsonpath='{.data.alerting_rules\.yml}' \ | grep -E "alert: MAM|alert: QBittorrent" - alert: MAMMouseClass - alert: MAMCookieExpired - alert: MAMRatioBelowOne - alert: MAMFarmingStuck - alert: MAMJanitorStuckBacklog - alert: QBittorrentDisconnected - alert: QBittorrentMAMUnsatisfied 5. Dashboard: browse to Grafana "qBittorrent - Seeding & Ratio" → new "MAM Profile (from jsonLoad.php)" row at the bottom shows class, BP balance, profile ratio, transfer, BP-vs-reserve timeseries, janitor deletion stacked chart, janitor state stat, grabber state stat. ## Reproduce locally 1. `cd infra/stacks/servarr && ../../scripts/tg plan` — expect 0 add / 1 change (unrelated MetalLB annotation drift). 2. `kubectl -n servarr get cronjobs` — expect three: mam-freeleech-grabber, mam-bp-spender, mam-farming-janitor. 3. Trigger each via `kubectl create job --from=cronjob/<name> <job>` and read logs; outputs match the manual-verification snippets above. Closes: code-qfs Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 11:45:38 +00:00
"""
MAM freeleech grabber demand-first, ratio-guarded.
Selects small-but-popular freeleech titles to grow the account's upload
credit. Refuses to grab while the account is in Mouse class or ratio is
below 1.2, because MAM rejects peer-list announces under those conditions
and new grabs only deepen the ratio hole.
Cleanup is handled by `mam-farming-janitor.py`, which runs unconditionally.
"""
import json
import math
import os
import random
import sys
import tempfile
import time
import requests
QB_URL = "http://qbittorrent.servarr.svc.cluster.local"
PUSHGW = "http://prometheus-prometheus-pushgateway.monitoring:9091"
COOKIE_FILE = "/data/mam_id"
GRABBED_IDS_FILE = "/data/grabbed_ids.txt"
MIN_MB = int(os.environ.get("MIN_MB", "50"))
MAX_MB = int(os.environ.get("MAX_MB", "1024"))
LEECHER_FLOOR = int(os.environ.get("LEECHER_FLOOR", "1"))
[servarr/mam-farming] Tune grabber for MAM's real catalogue ## Context After the Mouse-class unblock on 2026-04-19, end-to-end testing of the grabber revealed three issues with the plan's original filter values: 1. **`SEEDER_CEILING=50` rejects ~99% of MAM's catalogue.** MAM is a well-seeded private tracker — 100-700 seeders per torrent is normal. A ceiling of 50 makes the filter too tight: across 140 FL torrents sampled in one loop, only 0-1 matched. The intent ("avoid oversupplied swarms") is still valid; the threshold was wrong for MAM's shape. 2. **`RATIO_FLOOR=1.2` was sized for Mouse-class defence and is now over-tight.** Its job is preventing the death spiral where Mouse-class accounts can't announce, so any grab deepens the ratio hole. Once class > Mouse, MAM serves peer lists normally and demand-first filtering (`leechers>=1`) keeps new grabs upload-positive on average. With ratio sitting at 0.7 post-recovery (we over-downloaded while unblocking), 1.2 was preventing the very grabs that would earn us back to healthy ratio. 3. **`parse_size` crashed on `"1,002.9 MiB"`.** MAM's pretty-printed sizes use thousands separators; `float("1,002.9")` raises `ValueError`. Every grabber run that hit a ≥1000-MiB candidate on the page crashed with a traceback instead of skipping the size. ## This change - `SEEDER_CEILING`: 50 → 200 — live catalogue evidence showed 50 was rejecting viable demand-first candidates like `Zen and the Art of Motorcycle Maintenance` (S=156, L=1, score=125). - `RATIO_FLOOR`: 1.2 → 0.5 — still a tripwire for catastrophic dips, but no longer a steady-state block. Class == Mouse remains an absolute skip (separate branch). - `parse_size`: `s.replace(",", "").split()` before int-parse. ## Verified post-change Manual grabber loop (5 runs at random offsets) after applying: run=1 parse_size crash on "1,002.9" (this crash motivated fix #3) run=2 GRABBED 3 torrents: Dean and Me: A Love Story (240.7 MiB, S:18, L:1) score=194 Digital Nature Photography (83.7 MiB, S:42, L:1) score=182 Zen and the Art of Motorcycle (830.3 MiB, S:156, L:1) score=125 run=3-5 grabbed=0 at offsets that landed on pages with no matches (expected — MAM returns 20/page, many offsets yield nothing) MAM profile: class=User, ratio=0.7 (recovering from the Mouse unblock), BP=24,053. 28 mam-farming torrents in forcedUP state, actively uploading ~8 MiB to MAM this session across 2 of the Maxximized comic issues. ## What is NOT in this change - No alert threshold changes — `MAMRatioBelowOne` (24h) and `MAMMouseClass` (1h) already handle the "going back to Mouse" case; lowering the floor on the grabber doesn't change alerting. - No janitor changes — the janitor rules are H&R-based and independent of ratio/class state. ## Test plan ### Automated $ cd infra/stacks/servarr && ../../scripts/tg apply --non-interactive Apply complete! Resources: 0 added, 2 changed, 0 destroyed. $ python3 -c 'import ast; ast.parse(open( "infra/stacks/servarr/mam-farming/files/freeleech-grabber.py").read())' ### Manual Verification 1. Trigger the grabber and confirm it doesn't skip-for-ratio at ratio 0.7: $ kubectl -n servarr create job --from=cronjob/mam-freeleech-grabber g1 $ kubectl -n servarr logs job/g1 | head -5 Profile: ratio=0.7 class=User | Farming: 33, 2.0 GiB, tracked IDs: 4 Search offset=<random>, found=1323, page_results=20 Added (score=...) ... 2. Repeat 3-5× at different random offsets. Over the course of a 30-min cron cadence, expect 2-5 grabs across the day given MAM's catalogue churn and our filter intersection. ## Reproduce locally cd infra/stacks/servarr ../../scripts/tg plan # expect: 0 to add, 2 to change (configmap + cronjob) ../../scripts/tg apply --non-interactive kubectl -n servarr create job --from=cronjob/mam-freeleech-grabber g1 kubectl -n servarr logs job/g1 Follow-up: `bd close code-qfs` already completed in the parent commit; this is a post-shipping tune, no beads action needed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 15:46:46 +00:00
# MAM's catalogue is well-seeded by design — a ceiling of 50 rejected ~99%
# of candidates in live testing. 200 still filters out truly oversupplied
# swarms while keeping enough working-set to grab 3-5 titles per run.
SEEDER_CEILING = int(os.environ.get("SEEDER_CEILING", "200"))
[servarr] Rewrite MAM ratio farming — break Mouse death spiral, adopt in TF ## Context A MAM (MyAnonamouse) freeleech farming workflow was deployed on 2026-04-14 via kubectl apply (outside Terraform). Five days later the account was still stuck in Mouse class: 715 MiB downloaded, 0 uploaded, ratio 0. Tracker responses on 7 of 9 active torrents returned `status=4 | msg="User currently mouse rank, you need to get your ratio up!"` — MAM was actively refusing to serve peer lists because the account was in Mouse class, and refusing to serve peer lists made the ratio impossible to recover. Meanwhile the grabber kept digging: 501 torrents sat in qBittorrent, 0 completed, 0 bytes uploaded. Root causes (ranked): 1. Death spiral — Mouse class blocks announces, nothing uploads. 2. BP-spender 30 000 BP threshold blocked the only exit even though the account already had 24 500 BP. 3. Grabber selection (`score = 1.0 / (seeders+1)`) preferred low-demand torrents filtered to <100 MiB — ratio-hostile by design. 4. Grabber/cleanup deadlock: cleanup only fired on seed_time > 3d, so torrents that never started never qualified. Combined with the 500- torrent cap this stalled the grabber indefinitely. 5. qBittorrent queueing amplified (4) — 495/501 stuck in queuedDL. 6. Ratio-monitor labelled queued torrents `unknown` (empty tracker field), hiding the problem on the MAM Grafana panel. 7. qBittorrent memory limit (256 Mi LimitRange default) too low. 8. All of the above was Terraform drift with no reviewability. ## This change Introduces `stacks/servarr/mam-farming/` — a new TF module that adopts the three kubectl-applied resources and replaces their scripts with demand-first, H&R-aware logic. Also bumps qBittorrent resources, fixes ratio-monitor labelling, and adds five Prometheus alerts plus a Grafana panel row. ### Architecture MAM API ───┬─── jsonLoad.php (profile: ratio, class, BP) ├─── loadSearchJSONbasic.php (freeleech search) ├─── bonusBuy.php (50 GiB min tier for API) └─── download.php (torrent file) │ Pushgateway <──┬────────────┤ │ mam_ratio ┌────────────────────┐ │ mam_class_code │ freeleech-grabber │ */30 │ mam_bp_balance ◄───│ (ratio-guarded) │ │ mam_farming_* └──────────┬─────────┘ │ mam_janitor_* │ adds to │ ▼ │ Grafana panels qBittorrent (mam-farming) │ + 5 alerts ▲ │ │ deletes by rule │ ┌──────────┴─────────┐ │ ◄───│ farming-janitor │ */15 │ │ (H&R-aware) │ │ └──────────┬─────────┘ │ │ buys credit │ ┌──────────┴─────────┐ └───────────────────────│ bp-spender │ 0 */6 │ (tier-aware) │ └────────────────────┘ ### Key decisions - **Ratio guard on grabber** — refuse to grab if ratio < 1.2 OR class == Mouse. Prevents the death spiral from deepening. Emits `mam_grabber_skipped_reason{reason=...}` and exits clean. - **Demand-first selection** — new score formula `leechers*3 - seeders*0.5 + 200 if freeleech_wedge else 0`; size band 50 MiB – 1 GiB; leecher floor 1; seeder ceiling 50. Picks titles that will actually upload. - **Janitor decoupled from grabber** — runs every 15 min regardless of the ratio-guard state. Without this, stuck torrents accumulate fastest exactly when the grabber is skipping (Mouse class). H&R-aware: never deletes `progress==1.0 AND seeding_time < 72h`. Six delete reasons observable via `mam_janitor_deleted_per_run{reason=...}`. - **BP-spender tier-aware** — MAM imposes a hard 50 GiB minimum on API buyers ("Automated spenders are limited to buying at least 50 GB... due to log spam"). Valid API tiers: 50/100/200/500 GiB at 500 BP/GiB. The spender picks the smallest tier that satisfies the ratio deficit AND fits the budget, preserving a 500 BP reserve. If even the 50 GiB tier is too expensive, it skips and retries on the next 6-hour cron. - **Authoritative metrics use MAM profile fields** — `downloaded_bytes` / `uploaded_bytes` (integers) rather than the pretty-printed `downloaded` / `uploaded` strings like "715.55 MiB" that MAM also returns. - **Ratio-monitor category-first labelling** — `tracker` is empty for queued torrents that never announced. Now maps `category==mam-farming` to label `mam` first, only falls back to tracker-URL parsing when category is absent. Stops hundreds of MAM torrents collecting under `unknown`. - **qBittorrent resources bumped** to `requests=512Mi / limits=1Gi` so hundreds of active torrents don't OOM. ### Emergency recovery performed this session 1. Adopted 5 in-cluster resources via root-module `import {}` blocks (Terraform 1.5+ rejects imports inside child modules). 2. Ran the janitor in DRY_RUN=1 to verify rules against live state — 466 `never_started` candidates, 0 false positives in any other reason bucket. Flipped to enforce mode. 3. Janitor deleted 466 stuck torrents (matches plan's ~495 target; 35 preserved as active/in-progress). 4. Truncated `/data/grabbed_ids.txt` so newly-popular titles become eligible again. The ratio is still 0 because the API cannot buy below 50 GiB and the account sits at 24 551 BP (needs 25 000). Manual 1 GiB purchase via the MAM web UI — 500 BP — would immediately lift the account to ratio ≈ 1.4 and unblock announces. Future automation cannot do this for us due to MAMs anti-spam rule. ### What is NOT in this change - qBittorrent prefs reconciliation (max_active_downloads=20, max_active_uploads=150, max_active_torrents=150). The plan wanted this; deferred to a follow-up because the janitor + ratio recovery handles the 500-torrent backlog first. A small reconciler CronJob posting to /api/v2/app/setPreferences is the intended follow-up. - VIP purchase (~100 k BP) — deferred until BP accumulates. - Cross-seed / autobrr — separate initiative. ## Alerts added - P1 MAMMouseClass — `mam_class_code == 0` for 1h - P1 MAMCookieExpired — `mam_farming_cookie_expired > 0` - P2 MAMRatioBelowOne — `mam_ratio < 1.0` for 24h (replaces old QBittorrentMAMRatioLow, now driven by authoritative profile metric) - P2 MAMFarmingStuck — no grabs in 4h while ratio is healthy - P2 MAMJanitorStuckBacklog — `skipped_active > 400` for 6h ## Test plan ### Automated $ cd infra/stacks/servarr && ../../scripts/tg plan 2>&1 | grep Plan Plan: 5 to import, 2 to add, 6 to change, 0 to destroy. $ ../../scripts/tg apply --non-interactive Apply complete! Resources: 5 imported, 2 added, 6 changed, 0 destroyed. # Re-plan after import block removal (idempotent) $ ../../scripts/tg plan 2>&1 | grep Plan Plan: 0 to add, 1 to change, 0 to destroy. # The 1 change is a pre-existing MetalLB annotation drift on the # qbittorrent-torrenting Service — unrelated to this change. $ cd ../monitoring && ../../scripts/tg apply --non-interactive Apply complete! Resources: 0 added, 2 changed, 0 destroyed. # Python + JSON syntax $ python3 -c 'import ast; [ast.parse(open(p).read()) for p in [ "infra/stacks/servarr/mam-farming/files/freeleech-grabber.py", "infra/stacks/servarr/mam-farming/files/bp-spender.py", "infra/stacks/servarr/mam-farming/files/mam-farming-janitor.py"]]' $ python3 -c 'import json; json.load(open( "infra/stacks/monitoring/modules/monitoring/dashboards/qbittorrent.json"))' ### Manual Verification 1. Grabber ratio-guard path: $ kubectl -n servarr create job --from=cronjob/mam-freeleech-grabber g1 $ kubectl -n servarr logs job/g1 Skip grab: ratio=0.0 class=Mouse (floor=1.2) reason=mouse_class 2. BP-spender tier path: $ kubectl -n servarr create job --from=cronjob/mam-bp-spender s1 $ kubectl -n servarr logs job/s1 Profile: ratio=0.0 class=Mouse DL=0.70 GiB UL=0.00 GiB BP=24551 | deficit=1.40 GiB needed=3 affordable=48 buy=0 Done: BP=24551, spent=0 GiB (needed=3, affordable=48) Correctly skips because affordable (48) < smallest API tier (50). 3. Janitor in enforce mode: $ kubectl -n servarr create job --from=cronjob/mam-farming-janitor j1 $ kubectl -n servarr logs job/j1 | tail -3 Done: deleted=466 preserved_hnr=0 skipped_active=35 dry_run=False per reason: {'never_started': 466, ...} Second run immediately after: `deleted=0 skipped_active=35` — steady state with only active/seeding torrents left. 4. Alerts loaded: $ kubectl -n monitoring get cm prometheus-server \ -o jsonpath='{.data.alerting_rules\.yml}' \ | grep -E "alert: MAM|alert: QBittorrent" - alert: MAMMouseClass - alert: MAMCookieExpired - alert: MAMRatioBelowOne - alert: MAMFarmingStuck - alert: MAMJanitorStuckBacklog - alert: QBittorrentDisconnected - alert: QBittorrentMAMUnsatisfied 5. Dashboard: browse to Grafana "qBittorrent - Seeding & Ratio" → new "MAM Profile (from jsonLoad.php)" row at the bottom shows class, BP balance, profile ratio, transfer, BP-vs-reserve timeseries, janitor deletion stacked chart, janitor state stat, grabber state stat. ## Reproduce locally 1. `cd infra/stacks/servarr && ../../scripts/tg plan` — expect 0 add / 1 change (unrelated MetalLB annotation drift). 2. `kubectl -n servarr get cronjobs` — expect three: mam-freeleech-grabber, mam-bp-spender, mam-farming-janitor. 3. Trigger each via `kubectl create job --from=cronjob/<name> <job>` and read logs; outputs match the manual-verification snippets above. Closes: code-qfs Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 11:45:38 +00:00
GRAB_PER_RUN = int(os.environ.get("GRAB_PER_RUN", "5"))
MAX_TORRENTS = int(os.environ.get("MAX_TORRENTS", "500"))
[servarr/mam-farming] Tune grabber for MAM's real catalogue ## Context After the Mouse-class unblock on 2026-04-19, end-to-end testing of the grabber revealed three issues with the plan's original filter values: 1. **`SEEDER_CEILING=50` rejects ~99% of MAM's catalogue.** MAM is a well-seeded private tracker — 100-700 seeders per torrent is normal. A ceiling of 50 makes the filter too tight: across 140 FL torrents sampled in one loop, only 0-1 matched. The intent ("avoid oversupplied swarms") is still valid; the threshold was wrong for MAM's shape. 2. **`RATIO_FLOOR=1.2` was sized for Mouse-class defence and is now over-tight.** Its job is preventing the death spiral where Mouse-class accounts can't announce, so any grab deepens the ratio hole. Once class > Mouse, MAM serves peer lists normally and demand-first filtering (`leechers>=1`) keeps new grabs upload-positive on average. With ratio sitting at 0.7 post-recovery (we over-downloaded while unblocking), 1.2 was preventing the very grabs that would earn us back to healthy ratio. 3. **`parse_size` crashed on `"1,002.9 MiB"`.** MAM's pretty-printed sizes use thousands separators; `float("1,002.9")` raises `ValueError`. Every grabber run that hit a ≥1000-MiB candidate on the page crashed with a traceback instead of skipping the size. ## This change - `SEEDER_CEILING`: 50 → 200 — live catalogue evidence showed 50 was rejecting viable demand-first candidates like `Zen and the Art of Motorcycle Maintenance` (S=156, L=1, score=125). - `RATIO_FLOOR`: 1.2 → 0.5 — still a tripwire for catastrophic dips, but no longer a steady-state block. Class == Mouse remains an absolute skip (separate branch). - `parse_size`: `s.replace(",", "").split()` before int-parse. ## Verified post-change Manual grabber loop (5 runs at random offsets) after applying: run=1 parse_size crash on "1,002.9" (this crash motivated fix #3) run=2 GRABBED 3 torrents: Dean and Me: A Love Story (240.7 MiB, S:18, L:1) score=194 Digital Nature Photography (83.7 MiB, S:42, L:1) score=182 Zen and the Art of Motorcycle (830.3 MiB, S:156, L:1) score=125 run=3-5 grabbed=0 at offsets that landed on pages with no matches (expected — MAM returns 20/page, many offsets yield nothing) MAM profile: class=User, ratio=0.7 (recovering from the Mouse unblock), BP=24,053. 28 mam-farming torrents in forcedUP state, actively uploading ~8 MiB to MAM this session across 2 of the Maxximized comic issues. ## What is NOT in this change - No alert threshold changes — `MAMRatioBelowOne` (24h) and `MAMMouseClass` (1h) already handle the "going back to Mouse" case; lowering the floor on the grabber doesn't change alerting. - No janitor changes — the janitor rules are H&R-based and independent of ratio/class state. ## Test plan ### Automated $ cd infra/stacks/servarr && ../../scripts/tg apply --non-interactive Apply complete! Resources: 0 added, 2 changed, 0 destroyed. $ python3 -c 'import ast; ast.parse(open( "infra/stacks/servarr/mam-farming/files/freeleech-grabber.py").read())' ### Manual Verification 1. Trigger the grabber and confirm it doesn't skip-for-ratio at ratio 0.7: $ kubectl -n servarr create job --from=cronjob/mam-freeleech-grabber g1 $ kubectl -n servarr logs job/g1 | head -5 Profile: ratio=0.7 class=User | Farming: 33, 2.0 GiB, tracked IDs: 4 Search offset=<random>, found=1323, page_results=20 Added (score=...) ... 2. Repeat 3-5× at different random offsets. Over the course of a 30-min cron cadence, expect 2-5 grabs across the day given MAM's catalogue churn and our filter intersection. ## Reproduce locally cd infra/stacks/servarr ../../scripts/tg plan # expect: 0 to add, 2 to change (configmap + cronjob) ../../scripts/tg apply --non-interactive kubectl -n servarr create job --from=cronjob/mam-freeleech-grabber g1 kubectl -n servarr logs job/g1 Follow-up: `bd close code-qfs` already completed in the parent commit; this is a post-shipping tune, no beads action needed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 15:46:46 +00:00
# The guard's real job is to prevent the Mouse-class death spiral (see RC1
# in the original recovery plan). Once class > Mouse, MAM serves peer
# lists normally and demand-first filtering (leechers>=1) keeps new grabs
# upload-positive. Keep a low floor as a tripwire for catastrophic dips
# rather than a steady-state block.
RATIO_FLOOR = float(os.environ.get("RATIO_FLOOR", "0.5"))
[servarr] Rewrite MAM ratio farming — break Mouse death spiral, adopt in TF ## Context A MAM (MyAnonamouse) freeleech farming workflow was deployed on 2026-04-14 via kubectl apply (outside Terraform). Five days later the account was still stuck in Mouse class: 715 MiB downloaded, 0 uploaded, ratio 0. Tracker responses on 7 of 9 active torrents returned `status=4 | msg="User currently mouse rank, you need to get your ratio up!"` — MAM was actively refusing to serve peer lists because the account was in Mouse class, and refusing to serve peer lists made the ratio impossible to recover. Meanwhile the grabber kept digging: 501 torrents sat in qBittorrent, 0 completed, 0 bytes uploaded. Root causes (ranked): 1. Death spiral — Mouse class blocks announces, nothing uploads. 2. BP-spender 30 000 BP threshold blocked the only exit even though the account already had 24 500 BP. 3. Grabber selection (`score = 1.0 / (seeders+1)`) preferred low-demand torrents filtered to <100 MiB — ratio-hostile by design. 4. Grabber/cleanup deadlock: cleanup only fired on seed_time > 3d, so torrents that never started never qualified. Combined with the 500- torrent cap this stalled the grabber indefinitely. 5. qBittorrent queueing amplified (4) — 495/501 stuck in queuedDL. 6. Ratio-monitor labelled queued torrents `unknown` (empty tracker field), hiding the problem on the MAM Grafana panel. 7. qBittorrent memory limit (256 Mi LimitRange default) too low. 8. All of the above was Terraform drift with no reviewability. ## This change Introduces `stacks/servarr/mam-farming/` — a new TF module that adopts the three kubectl-applied resources and replaces their scripts with demand-first, H&R-aware logic. Also bumps qBittorrent resources, fixes ratio-monitor labelling, and adds five Prometheus alerts plus a Grafana panel row. ### Architecture MAM API ───┬─── jsonLoad.php (profile: ratio, class, BP) ├─── loadSearchJSONbasic.php (freeleech search) ├─── bonusBuy.php (50 GiB min tier for API) └─── download.php (torrent file) │ Pushgateway <──┬────────────┤ │ mam_ratio ┌────────────────────┐ │ mam_class_code │ freeleech-grabber │ */30 │ mam_bp_balance ◄───│ (ratio-guarded) │ │ mam_farming_* └──────────┬─────────┘ │ mam_janitor_* │ adds to │ ▼ │ Grafana panels qBittorrent (mam-farming) │ + 5 alerts ▲ │ │ deletes by rule │ ┌──────────┴─────────┐ │ ◄───│ farming-janitor │ */15 │ │ (H&R-aware) │ │ └──────────┬─────────┘ │ │ buys credit │ ┌──────────┴─────────┐ └───────────────────────│ bp-spender │ 0 */6 │ (tier-aware) │ └────────────────────┘ ### Key decisions - **Ratio guard on grabber** — refuse to grab if ratio < 1.2 OR class == Mouse. Prevents the death spiral from deepening. Emits `mam_grabber_skipped_reason{reason=...}` and exits clean. - **Demand-first selection** — new score formula `leechers*3 - seeders*0.5 + 200 if freeleech_wedge else 0`; size band 50 MiB – 1 GiB; leecher floor 1; seeder ceiling 50. Picks titles that will actually upload. - **Janitor decoupled from grabber** — runs every 15 min regardless of the ratio-guard state. Without this, stuck torrents accumulate fastest exactly when the grabber is skipping (Mouse class). H&R-aware: never deletes `progress==1.0 AND seeding_time < 72h`. Six delete reasons observable via `mam_janitor_deleted_per_run{reason=...}`. - **BP-spender tier-aware** — MAM imposes a hard 50 GiB minimum on API buyers ("Automated spenders are limited to buying at least 50 GB... due to log spam"). Valid API tiers: 50/100/200/500 GiB at 500 BP/GiB. The spender picks the smallest tier that satisfies the ratio deficit AND fits the budget, preserving a 500 BP reserve. If even the 50 GiB tier is too expensive, it skips and retries on the next 6-hour cron. - **Authoritative metrics use MAM profile fields** — `downloaded_bytes` / `uploaded_bytes` (integers) rather than the pretty-printed `downloaded` / `uploaded` strings like "715.55 MiB" that MAM also returns. - **Ratio-monitor category-first labelling** — `tracker` is empty for queued torrents that never announced. Now maps `category==mam-farming` to label `mam` first, only falls back to tracker-URL parsing when category is absent. Stops hundreds of MAM torrents collecting under `unknown`. - **qBittorrent resources bumped** to `requests=512Mi / limits=1Gi` so hundreds of active torrents don't OOM. ### Emergency recovery performed this session 1. Adopted 5 in-cluster resources via root-module `import {}` blocks (Terraform 1.5+ rejects imports inside child modules). 2. Ran the janitor in DRY_RUN=1 to verify rules against live state — 466 `never_started` candidates, 0 false positives in any other reason bucket. Flipped to enforce mode. 3. Janitor deleted 466 stuck torrents (matches plan's ~495 target; 35 preserved as active/in-progress). 4. Truncated `/data/grabbed_ids.txt` so newly-popular titles become eligible again. The ratio is still 0 because the API cannot buy below 50 GiB and the account sits at 24 551 BP (needs 25 000). Manual 1 GiB purchase via the MAM web UI — 500 BP — would immediately lift the account to ratio ≈ 1.4 and unblock announces. Future automation cannot do this for us due to MAMs anti-spam rule. ### What is NOT in this change - qBittorrent prefs reconciliation (max_active_downloads=20, max_active_uploads=150, max_active_torrents=150). The plan wanted this; deferred to a follow-up because the janitor + ratio recovery handles the 500-torrent backlog first. A small reconciler CronJob posting to /api/v2/app/setPreferences is the intended follow-up. - VIP purchase (~100 k BP) — deferred until BP accumulates. - Cross-seed / autobrr — separate initiative. ## Alerts added - P1 MAMMouseClass — `mam_class_code == 0` for 1h - P1 MAMCookieExpired — `mam_farming_cookie_expired > 0` - P2 MAMRatioBelowOne — `mam_ratio < 1.0` for 24h (replaces old QBittorrentMAMRatioLow, now driven by authoritative profile metric) - P2 MAMFarmingStuck — no grabs in 4h while ratio is healthy - P2 MAMJanitorStuckBacklog — `skipped_active > 400` for 6h ## Test plan ### Automated $ cd infra/stacks/servarr && ../../scripts/tg plan 2>&1 | grep Plan Plan: 5 to import, 2 to add, 6 to change, 0 to destroy. $ ../../scripts/tg apply --non-interactive Apply complete! Resources: 5 imported, 2 added, 6 changed, 0 destroyed. # Re-plan after import block removal (idempotent) $ ../../scripts/tg plan 2>&1 | grep Plan Plan: 0 to add, 1 to change, 0 to destroy. # The 1 change is a pre-existing MetalLB annotation drift on the # qbittorrent-torrenting Service — unrelated to this change. $ cd ../monitoring && ../../scripts/tg apply --non-interactive Apply complete! Resources: 0 added, 2 changed, 0 destroyed. # Python + JSON syntax $ python3 -c 'import ast; [ast.parse(open(p).read()) for p in [ "infra/stacks/servarr/mam-farming/files/freeleech-grabber.py", "infra/stacks/servarr/mam-farming/files/bp-spender.py", "infra/stacks/servarr/mam-farming/files/mam-farming-janitor.py"]]' $ python3 -c 'import json; json.load(open( "infra/stacks/monitoring/modules/monitoring/dashboards/qbittorrent.json"))' ### Manual Verification 1. Grabber ratio-guard path: $ kubectl -n servarr create job --from=cronjob/mam-freeleech-grabber g1 $ kubectl -n servarr logs job/g1 Skip grab: ratio=0.0 class=Mouse (floor=1.2) reason=mouse_class 2. BP-spender tier path: $ kubectl -n servarr create job --from=cronjob/mam-bp-spender s1 $ kubectl -n servarr logs job/s1 Profile: ratio=0.0 class=Mouse DL=0.70 GiB UL=0.00 GiB BP=24551 | deficit=1.40 GiB needed=3 affordable=48 buy=0 Done: BP=24551, spent=0 GiB (needed=3, affordable=48) Correctly skips because affordable (48) < smallest API tier (50). 3. Janitor in enforce mode: $ kubectl -n servarr create job --from=cronjob/mam-farming-janitor j1 $ kubectl -n servarr logs job/j1 | tail -3 Done: deleted=466 preserved_hnr=0 skipped_active=35 dry_run=False per reason: {'never_started': 466, ...} Second run immediately after: `deleted=0 skipped_active=35` — steady state with only active/seeding torrents left. 4. Alerts loaded: $ kubectl -n monitoring get cm prometheus-server \ -o jsonpath='{.data.alerting_rules\.yml}' \ | grep -E "alert: MAM|alert: QBittorrent" - alert: MAMMouseClass - alert: MAMCookieExpired - alert: MAMRatioBelowOne - alert: MAMFarmingStuck - alert: MAMJanitorStuckBacklog - alert: QBittorrentDisconnected - alert: QBittorrentMAMUnsatisfied 5. Dashboard: browse to Grafana "qBittorrent - Seeding & Ratio" → new "MAM Profile (from jsonLoad.php)" row at the bottom shows class, BP balance, profile ratio, transfer, BP-vs-reserve timeseries, janitor deletion stacked chart, janitor state stat, grabber state stat. ## Reproduce locally 1. `cd infra/stacks/servarr && ../../scripts/tg plan` — expect 0 add / 1 change (unrelated MetalLB annotation drift). 2. `kubectl -n servarr get cronjobs` — expect three: mam-freeleech-grabber, mam-bp-spender, mam-farming-janitor. 3. Trigger each via `kubectl create job --from=cronjob/<name> <job>` and read logs; outputs match the manual-verification snippets above. Closes: code-qfs Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 11:45:38 +00:00
REQUEST_SLEEP = float(os.environ.get("REQUEST_SLEEP", "3"))
CLASS_CODES = {
"Mouse": 0,
"Vole": 1,
"User": 2,
"Power User": 3,
"Elite": 4,
"Torrent Master": 5,
"Power TM": 6,
"Elite TM": 7,
"VIP": 8,
}
def parse_size(s):
[servarr/mam-farming] Tune grabber for MAM's real catalogue ## Context After the Mouse-class unblock on 2026-04-19, end-to-end testing of the grabber revealed three issues with the plan's original filter values: 1. **`SEEDER_CEILING=50` rejects ~99% of MAM's catalogue.** MAM is a well-seeded private tracker — 100-700 seeders per torrent is normal. A ceiling of 50 makes the filter too tight: across 140 FL torrents sampled in one loop, only 0-1 matched. The intent ("avoid oversupplied swarms") is still valid; the threshold was wrong for MAM's shape. 2. **`RATIO_FLOOR=1.2` was sized for Mouse-class defence and is now over-tight.** Its job is preventing the death spiral where Mouse-class accounts can't announce, so any grab deepens the ratio hole. Once class > Mouse, MAM serves peer lists normally and demand-first filtering (`leechers>=1`) keeps new grabs upload-positive on average. With ratio sitting at 0.7 post-recovery (we over-downloaded while unblocking), 1.2 was preventing the very grabs that would earn us back to healthy ratio. 3. **`parse_size` crashed on `"1,002.9 MiB"`.** MAM's pretty-printed sizes use thousands separators; `float("1,002.9")` raises `ValueError`. Every grabber run that hit a ≥1000-MiB candidate on the page crashed with a traceback instead of skipping the size. ## This change - `SEEDER_CEILING`: 50 → 200 — live catalogue evidence showed 50 was rejecting viable demand-first candidates like `Zen and the Art of Motorcycle Maintenance` (S=156, L=1, score=125). - `RATIO_FLOOR`: 1.2 → 0.5 — still a tripwire for catastrophic dips, but no longer a steady-state block. Class == Mouse remains an absolute skip (separate branch). - `parse_size`: `s.replace(",", "").split()` before int-parse. ## Verified post-change Manual grabber loop (5 runs at random offsets) after applying: run=1 parse_size crash on "1,002.9" (this crash motivated fix #3) run=2 GRABBED 3 torrents: Dean and Me: A Love Story (240.7 MiB, S:18, L:1) score=194 Digital Nature Photography (83.7 MiB, S:42, L:1) score=182 Zen and the Art of Motorcycle (830.3 MiB, S:156, L:1) score=125 run=3-5 grabbed=0 at offsets that landed on pages with no matches (expected — MAM returns 20/page, many offsets yield nothing) MAM profile: class=User, ratio=0.7 (recovering from the Mouse unblock), BP=24,053. 28 mam-farming torrents in forcedUP state, actively uploading ~8 MiB to MAM this session across 2 of the Maxximized comic issues. ## What is NOT in this change - No alert threshold changes — `MAMRatioBelowOne` (24h) and `MAMMouseClass` (1h) already handle the "going back to Mouse" case; lowering the floor on the grabber doesn't change alerting. - No janitor changes — the janitor rules are H&R-based and independent of ratio/class state. ## Test plan ### Automated $ cd infra/stacks/servarr && ../../scripts/tg apply --non-interactive Apply complete! Resources: 0 added, 2 changed, 0 destroyed. $ python3 -c 'import ast; ast.parse(open( "infra/stacks/servarr/mam-farming/files/freeleech-grabber.py").read())' ### Manual Verification 1. Trigger the grabber and confirm it doesn't skip-for-ratio at ratio 0.7: $ kubectl -n servarr create job --from=cronjob/mam-freeleech-grabber g1 $ kubectl -n servarr logs job/g1 | head -5 Profile: ratio=0.7 class=User | Farming: 33, 2.0 GiB, tracked IDs: 4 Search offset=<random>, found=1323, page_results=20 Added (score=...) ... 2. Repeat 3-5× at different random offsets. Over the course of a 30-min cron cadence, expect 2-5 grabs across the day given MAM's catalogue churn and our filter intersection. ## Reproduce locally cd infra/stacks/servarr ../../scripts/tg plan # expect: 0 to add, 2 to change (configmap + cronjob) ../../scripts/tg apply --non-interactive kubectl -n servarr create job --from=cronjob/mam-freeleech-grabber g1 kubectl -n servarr logs job/g1 Follow-up: `bd close code-qfs` already completed in the parent commit; this is a post-shipping tune, no beads action needed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 15:46:46 +00:00
# MAM pretty-prints sizes with thousands separators (e.g. "1,002.9 MiB").
[servarr] Rewrite MAM ratio farming — break Mouse death spiral, adopt in TF ## Context A MAM (MyAnonamouse) freeleech farming workflow was deployed on 2026-04-14 via kubectl apply (outside Terraform). Five days later the account was still stuck in Mouse class: 715 MiB downloaded, 0 uploaded, ratio 0. Tracker responses on 7 of 9 active torrents returned `status=4 | msg="User currently mouse rank, you need to get your ratio up!"` — MAM was actively refusing to serve peer lists because the account was in Mouse class, and refusing to serve peer lists made the ratio impossible to recover. Meanwhile the grabber kept digging: 501 torrents sat in qBittorrent, 0 completed, 0 bytes uploaded. Root causes (ranked): 1. Death spiral — Mouse class blocks announces, nothing uploads. 2. BP-spender 30 000 BP threshold blocked the only exit even though the account already had 24 500 BP. 3. Grabber selection (`score = 1.0 / (seeders+1)`) preferred low-demand torrents filtered to <100 MiB — ratio-hostile by design. 4. Grabber/cleanup deadlock: cleanup only fired on seed_time > 3d, so torrents that never started never qualified. Combined with the 500- torrent cap this stalled the grabber indefinitely. 5. qBittorrent queueing amplified (4) — 495/501 stuck in queuedDL. 6. Ratio-monitor labelled queued torrents `unknown` (empty tracker field), hiding the problem on the MAM Grafana panel. 7. qBittorrent memory limit (256 Mi LimitRange default) too low. 8. All of the above was Terraform drift with no reviewability. ## This change Introduces `stacks/servarr/mam-farming/` — a new TF module that adopts the three kubectl-applied resources and replaces their scripts with demand-first, H&R-aware logic. Also bumps qBittorrent resources, fixes ratio-monitor labelling, and adds five Prometheus alerts plus a Grafana panel row. ### Architecture MAM API ───┬─── jsonLoad.php (profile: ratio, class, BP) ├─── loadSearchJSONbasic.php (freeleech search) ├─── bonusBuy.php (50 GiB min tier for API) └─── download.php (torrent file) │ Pushgateway <──┬────────────┤ │ mam_ratio ┌────────────────────┐ │ mam_class_code │ freeleech-grabber │ */30 │ mam_bp_balance ◄───│ (ratio-guarded) │ │ mam_farming_* └──────────┬─────────┘ │ mam_janitor_* │ adds to │ ▼ │ Grafana panels qBittorrent (mam-farming) │ + 5 alerts ▲ │ │ deletes by rule │ ┌──────────┴─────────┐ │ ◄───│ farming-janitor │ */15 │ │ (H&R-aware) │ │ └──────────┬─────────┘ │ │ buys credit │ ┌──────────┴─────────┐ └───────────────────────│ bp-spender │ 0 */6 │ (tier-aware) │ └────────────────────┘ ### Key decisions - **Ratio guard on grabber** — refuse to grab if ratio < 1.2 OR class == Mouse. Prevents the death spiral from deepening. Emits `mam_grabber_skipped_reason{reason=...}` and exits clean. - **Demand-first selection** — new score formula `leechers*3 - seeders*0.5 + 200 if freeleech_wedge else 0`; size band 50 MiB – 1 GiB; leecher floor 1; seeder ceiling 50. Picks titles that will actually upload. - **Janitor decoupled from grabber** — runs every 15 min regardless of the ratio-guard state. Without this, stuck torrents accumulate fastest exactly when the grabber is skipping (Mouse class). H&R-aware: never deletes `progress==1.0 AND seeding_time < 72h`. Six delete reasons observable via `mam_janitor_deleted_per_run{reason=...}`. - **BP-spender tier-aware** — MAM imposes a hard 50 GiB minimum on API buyers ("Automated spenders are limited to buying at least 50 GB... due to log spam"). Valid API tiers: 50/100/200/500 GiB at 500 BP/GiB. The spender picks the smallest tier that satisfies the ratio deficit AND fits the budget, preserving a 500 BP reserve. If even the 50 GiB tier is too expensive, it skips and retries on the next 6-hour cron. - **Authoritative metrics use MAM profile fields** — `downloaded_bytes` / `uploaded_bytes` (integers) rather than the pretty-printed `downloaded` / `uploaded` strings like "715.55 MiB" that MAM also returns. - **Ratio-monitor category-first labelling** — `tracker` is empty for queued torrents that never announced. Now maps `category==mam-farming` to label `mam` first, only falls back to tracker-URL parsing when category is absent. Stops hundreds of MAM torrents collecting under `unknown`. - **qBittorrent resources bumped** to `requests=512Mi / limits=1Gi` so hundreds of active torrents don't OOM. ### Emergency recovery performed this session 1. Adopted 5 in-cluster resources via root-module `import {}` blocks (Terraform 1.5+ rejects imports inside child modules). 2. Ran the janitor in DRY_RUN=1 to verify rules against live state — 466 `never_started` candidates, 0 false positives in any other reason bucket. Flipped to enforce mode. 3. Janitor deleted 466 stuck torrents (matches plan's ~495 target; 35 preserved as active/in-progress). 4. Truncated `/data/grabbed_ids.txt` so newly-popular titles become eligible again. The ratio is still 0 because the API cannot buy below 50 GiB and the account sits at 24 551 BP (needs 25 000). Manual 1 GiB purchase via the MAM web UI — 500 BP — would immediately lift the account to ratio ≈ 1.4 and unblock announces. Future automation cannot do this for us due to MAMs anti-spam rule. ### What is NOT in this change - qBittorrent prefs reconciliation (max_active_downloads=20, max_active_uploads=150, max_active_torrents=150). The plan wanted this; deferred to a follow-up because the janitor + ratio recovery handles the 500-torrent backlog first. A small reconciler CronJob posting to /api/v2/app/setPreferences is the intended follow-up. - VIP purchase (~100 k BP) — deferred until BP accumulates. - Cross-seed / autobrr — separate initiative. ## Alerts added - P1 MAMMouseClass — `mam_class_code == 0` for 1h - P1 MAMCookieExpired — `mam_farming_cookie_expired > 0` - P2 MAMRatioBelowOne — `mam_ratio < 1.0` for 24h (replaces old QBittorrentMAMRatioLow, now driven by authoritative profile metric) - P2 MAMFarmingStuck — no grabs in 4h while ratio is healthy - P2 MAMJanitorStuckBacklog — `skipped_active > 400` for 6h ## Test plan ### Automated $ cd infra/stacks/servarr && ../../scripts/tg plan 2>&1 | grep Plan Plan: 5 to import, 2 to add, 6 to change, 0 to destroy. $ ../../scripts/tg apply --non-interactive Apply complete! Resources: 5 imported, 2 added, 6 changed, 0 destroyed. # Re-plan after import block removal (idempotent) $ ../../scripts/tg plan 2>&1 | grep Plan Plan: 0 to add, 1 to change, 0 to destroy. # The 1 change is a pre-existing MetalLB annotation drift on the # qbittorrent-torrenting Service — unrelated to this change. $ cd ../monitoring && ../../scripts/tg apply --non-interactive Apply complete! Resources: 0 added, 2 changed, 0 destroyed. # Python + JSON syntax $ python3 -c 'import ast; [ast.parse(open(p).read()) for p in [ "infra/stacks/servarr/mam-farming/files/freeleech-grabber.py", "infra/stacks/servarr/mam-farming/files/bp-spender.py", "infra/stacks/servarr/mam-farming/files/mam-farming-janitor.py"]]' $ python3 -c 'import json; json.load(open( "infra/stacks/monitoring/modules/monitoring/dashboards/qbittorrent.json"))' ### Manual Verification 1. Grabber ratio-guard path: $ kubectl -n servarr create job --from=cronjob/mam-freeleech-grabber g1 $ kubectl -n servarr logs job/g1 Skip grab: ratio=0.0 class=Mouse (floor=1.2) reason=mouse_class 2. BP-spender tier path: $ kubectl -n servarr create job --from=cronjob/mam-bp-spender s1 $ kubectl -n servarr logs job/s1 Profile: ratio=0.0 class=Mouse DL=0.70 GiB UL=0.00 GiB BP=24551 | deficit=1.40 GiB needed=3 affordable=48 buy=0 Done: BP=24551, spent=0 GiB (needed=3, affordable=48) Correctly skips because affordable (48) < smallest API tier (50). 3. Janitor in enforce mode: $ kubectl -n servarr create job --from=cronjob/mam-farming-janitor j1 $ kubectl -n servarr logs job/j1 | tail -3 Done: deleted=466 preserved_hnr=0 skipped_active=35 dry_run=False per reason: {'never_started': 466, ...} Second run immediately after: `deleted=0 skipped_active=35` — steady state with only active/seeding torrents left. 4. Alerts loaded: $ kubectl -n monitoring get cm prometheus-server \ -o jsonpath='{.data.alerting_rules\.yml}' \ | grep -E "alert: MAM|alert: QBittorrent" - alert: MAMMouseClass - alert: MAMCookieExpired - alert: MAMRatioBelowOne - alert: MAMFarmingStuck - alert: MAMJanitorStuckBacklog - alert: QBittorrentDisconnected - alert: QBittorrentMAMUnsatisfied 5. Dashboard: browse to Grafana "qBittorrent - Seeding & Ratio" → new "MAM Profile (from jsonLoad.php)" row at the bottom shows class, BP balance, profile ratio, transfer, BP-vs-reserve timeseries, janitor deletion stacked chart, janitor state stat, grabber state stat. ## Reproduce locally 1. `cd infra/stacks/servarr && ../../scripts/tg plan` — expect 0 add / 1 change (unrelated MetalLB annotation drift). 2. `kubectl -n servarr get cronjobs` — expect three: mam-freeleech-grabber, mam-bp-spender, mam-farming-janitor. 3. Trigger each via `kubectl create job --from=cronjob/<name> <job>` and read logs; outputs match the manual-verification snippets above. Closes: code-qfs Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 11:45:38 +00:00
units = {"B": 1, "KiB": 1024, "MiB": 1024**2, "GiB": 1024**3, "TiB": 1024**4}
[servarr/mam-farming] Tune grabber for MAM's real catalogue ## Context After the Mouse-class unblock on 2026-04-19, end-to-end testing of the grabber revealed three issues with the plan's original filter values: 1. **`SEEDER_CEILING=50` rejects ~99% of MAM's catalogue.** MAM is a well-seeded private tracker — 100-700 seeders per torrent is normal. A ceiling of 50 makes the filter too tight: across 140 FL torrents sampled in one loop, only 0-1 matched. The intent ("avoid oversupplied swarms") is still valid; the threshold was wrong for MAM's shape. 2. **`RATIO_FLOOR=1.2` was sized for Mouse-class defence and is now over-tight.** Its job is preventing the death spiral where Mouse-class accounts can't announce, so any grab deepens the ratio hole. Once class > Mouse, MAM serves peer lists normally and demand-first filtering (`leechers>=1`) keeps new grabs upload-positive on average. With ratio sitting at 0.7 post-recovery (we over-downloaded while unblocking), 1.2 was preventing the very grabs that would earn us back to healthy ratio. 3. **`parse_size` crashed on `"1,002.9 MiB"`.** MAM's pretty-printed sizes use thousands separators; `float("1,002.9")` raises `ValueError`. Every grabber run that hit a ≥1000-MiB candidate on the page crashed with a traceback instead of skipping the size. ## This change - `SEEDER_CEILING`: 50 → 200 — live catalogue evidence showed 50 was rejecting viable demand-first candidates like `Zen and the Art of Motorcycle Maintenance` (S=156, L=1, score=125). - `RATIO_FLOOR`: 1.2 → 0.5 — still a tripwire for catastrophic dips, but no longer a steady-state block. Class == Mouse remains an absolute skip (separate branch). - `parse_size`: `s.replace(",", "").split()` before int-parse. ## Verified post-change Manual grabber loop (5 runs at random offsets) after applying: run=1 parse_size crash on "1,002.9" (this crash motivated fix #3) run=2 GRABBED 3 torrents: Dean and Me: A Love Story (240.7 MiB, S:18, L:1) score=194 Digital Nature Photography (83.7 MiB, S:42, L:1) score=182 Zen and the Art of Motorcycle (830.3 MiB, S:156, L:1) score=125 run=3-5 grabbed=0 at offsets that landed on pages with no matches (expected — MAM returns 20/page, many offsets yield nothing) MAM profile: class=User, ratio=0.7 (recovering from the Mouse unblock), BP=24,053. 28 mam-farming torrents in forcedUP state, actively uploading ~8 MiB to MAM this session across 2 of the Maxximized comic issues. ## What is NOT in this change - No alert threshold changes — `MAMRatioBelowOne` (24h) and `MAMMouseClass` (1h) already handle the "going back to Mouse" case; lowering the floor on the grabber doesn't change alerting. - No janitor changes — the janitor rules are H&R-based and independent of ratio/class state. ## Test plan ### Automated $ cd infra/stacks/servarr && ../../scripts/tg apply --non-interactive Apply complete! Resources: 0 added, 2 changed, 0 destroyed. $ python3 -c 'import ast; ast.parse(open( "infra/stacks/servarr/mam-farming/files/freeleech-grabber.py").read())' ### Manual Verification 1. Trigger the grabber and confirm it doesn't skip-for-ratio at ratio 0.7: $ kubectl -n servarr create job --from=cronjob/mam-freeleech-grabber g1 $ kubectl -n servarr logs job/g1 | head -5 Profile: ratio=0.7 class=User | Farming: 33, 2.0 GiB, tracked IDs: 4 Search offset=<random>, found=1323, page_results=20 Added (score=...) ... 2. Repeat 3-5× at different random offsets. Over the course of a 30-min cron cadence, expect 2-5 grabs across the day given MAM's catalogue churn and our filter intersection. ## Reproduce locally cd infra/stacks/servarr ../../scripts/tg plan # expect: 0 to add, 2 to change (configmap + cronjob) ../../scripts/tg apply --non-interactive kubectl -n servarr create job --from=cronjob/mam-freeleech-grabber g1 kubectl -n servarr logs job/g1 Follow-up: `bd close code-qfs` already completed in the parent commit; this is a post-shipping tune, no beads action needed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 15:46:46 +00:00
parts = s.replace(",", "").split()
[servarr] Rewrite MAM ratio farming — break Mouse death spiral, adopt in TF ## Context A MAM (MyAnonamouse) freeleech farming workflow was deployed on 2026-04-14 via kubectl apply (outside Terraform). Five days later the account was still stuck in Mouse class: 715 MiB downloaded, 0 uploaded, ratio 0. Tracker responses on 7 of 9 active torrents returned `status=4 | msg="User currently mouse rank, you need to get your ratio up!"` — MAM was actively refusing to serve peer lists because the account was in Mouse class, and refusing to serve peer lists made the ratio impossible to recover. Meanwhile the grabber kept digging: 501 torrents sat in qBittorrent, 0 completed, 0 bytes uploaded. Root causes (ranked): 1. Death spiral — Mouse class blocks announces, nothing uploads. 2. BP-spender 30 000 BP threshold blocked the only exit even though the account already had 24 500 BP. 3. Grabber selection (`score = 1.0 / (seeders+1)`) preferred low-demand torrents filtered to <100 MiB — ratio-hostile by design. 4. Grabber/cleanup deadlock: cleanup only fired on seed_time > 3d, so torrents that never started never qualified. Combined with the 500- torrent cap this stalled the grabber indefinitely. 5. qBittorrent queueing amplified (4) — 495/501 stuck in queuedDL. 6. Ratio-monitor labelled queued torrents `unknown` (empty tracker field), hiding the problem on the MAM Grafana panel. 7. qBittorrent memory limit (256 Mi LimitRange default) too low. 8. All of the above was Terraform drift with no reviewability. ## This change Introduces `stacks/servarr/mam-farming/` — a new TF module that adopts the three kubectl-applied resources and replaces their scripts with demand-first, H&R-aware logic. Also bumps qBittorrent resources, fixes ratio-monitor labelling, and adds five Prometheus alerts plus a Grafana panel row. ### Architecture MAM API ───┬─── jsonLoad.php (profile: ratio, class, BP) ├─── loadSearchJSONbasic.php (freeleech search) ├─── bonusBuy.php (50 GiB min tier for API) └─── download.php (torrent file) │ Pushgateway <──┬────────────┤ │ mam_ratio ┌────────────────────┐ │ mam_class_code │ freeleech-grabber │ */30 │ mam_bp_balance ◄───│ (ratio-guarded) │ │ mam_farming_* └──────────┬─────────┘ │ mam_janitor_* │ adds to │ ▼ │ Grafana panels qBittorrent (mam-farming) │ + 5 alerts ▲ │ │ deletes by rule │ ┌──────────┴─────────┐ │ ◄───│ farming-janitor │ */15 │ │ (H&R-aware) │ │ └──────────┬─────────┘ │ │ buys credit │ ┌──────────┴─────────┐ └───────────────────────│ bp-spender │ 0 */6 │ (tier-aware) │ └────────────────────┘ ### Key decisions - **Ratio guard on grabber** — refuse to grab if ratio < 1.2 OR class == Mouse. Prevents the death spiral from deepening. Emits `mam_grabber_skipped_reason{reason=...}` and exits clean. - **Demand-first selection** — new score formula `leechers*3 - seeders*0.5 + 200 if freeleech_wedge else 0`; size band 50 MiB – 1 GiB; leecher floor 1; seeder ceiling 50. Picks titles that will actually upload. - **Janitor decoupled from grabber** — runs every 15 min regardless of the ratio-guard state. Without this, stuck torrents accumulate fastest exactly when the grabber is skipping (Mouse class). H&R-aware: never deletes `progress==1.0 AND seeding_time < 72h`. Six delete reasons observable via `mam_janitor_deleted_per_run{reason=...}`. - **BP-spender tier-aware** — MAM imposes a hard 50 GiB minimum on API buyers ("Automated spenders are limited to buying at least 50 GB... due to log spam"). Valid API tiers: 50/100/200/500 GiB at 500 BP/GiB. The spender picks the smallest tier that satisfies the ratio deficit AND fits the budget, preserving a 500 BP reserve. If even the 50 GiB tier is too expensive, it skips and retries on the next 6-hour cron. - **Authoritative metrics use MAM profile fields** — `downloaded_bytes` / `uploaded_bytes` (integers) rather than the pretty-printed `downloaded` / `uploaded` strings like "715.55 MiB" that MAM also returns. - **Ratio-monitor category-first labelling** — `tracker` is empty for queued torrents that never announced. Now maps `category==mam-farming` to label `mam` first, only falls back to tracker-URL parsing when category is absent. Stops hundreds of MAM torrents collecting under `unknown`. - **qBittorrent resources bumped** to `requests=512Mi / limits=1Gi` so hundreds of active torrents don't OOM. ### Emergency recovery performed this session 1. Adopted 5 in-cluster resources via root-module `import {}` blocks (Terraform 1.5+ rejects imports inside child modules). 2. Ran the janitor in DRY_RUN=1 to verify rules against live state — 466 `never_started` candidates, 0 false positives in any other reason bucket. Flipped to enforce mode. 3. Janitor deleted 466 stuck torrents (matches plan's ~495 target; 35 preserved as active/in-progress). 4. Truncated `/data/grabbed_ids.txt` so newly-popular titles become eligible again. The ratio is still 0 because the API cannot buy below 50 GiB and the account sits at 24 551 BP (needs 25 000). Manual 1 GiB purchase via the MAM web UI — 500 BP — would immediately lift the account to ratio ≈ 1.4 and unblock announces. Future automation cannot do this for us due to MAMs anti-spam rule. ### What is NOT in this change - qBittorrent prefs reconciliation (max_active_downloads=20, max_active_uploads=150, max_active_torrents=150). The plan wanted this; deferred to a follow-up because the janitor + ratio recovery handles the 500-torrent backlog first. A small reconciler CronJob posting to /api/v2/app/setPreferences is the intended follow-up. - VIP purchase (~100 k BP) — deferred until BP accumulates. - Cross-seed / autobrr — separate initiative. ## Alerts added - P1 MAMMouseClass — `mam_class_code == 0` for 1h - P1 MAMCookieExpired — `mam_farming_cookie_expired > 0` - P2 MAMRatioBelowOne — `mam_ratio < 1.0` for 24h (replaces old QBittorrentMAMRatioLow, now driven by authoritative profile metric) - P2 MAMFarmingStuck — no grabs in 4h while ratio is healthy - P2 MAMJanitorStuckBacklog — `skipped_active > 400` for 6h ## Test plan ### Automated $ cd infra/stacks/servarr && ../../scripts/tg plan 2>&1 | grep Plan Plan: 5 to import, 2 to add, 6 to change, 0 to destroy. $ ../../scripts/tg apply --non-interactive Apply complete! Resources: 5 imported, 2 added, 6 changed, 0 destroyed. # Re-plan after import block removal (idempotent) $ ../../scripts/tg plan 2>&1 | grep Plan Plan: 0 to add, 1 to change, 0 to destroy. # The 1 change is a pre-existing MetalLB annotation drift on the # qbittorrent-torrenting Service — unrelated to this change. $ cd ../monitoring && ../../scripts/tg apply --non-interactive Apply complete! Resources: 0 added, 2 changed, 0 destroyed. # Python + JSON syntax $ python3 -c 'import ast; [ast.parse(open(p).read()) for p in [ "infra/stacks/servarr/mam-farming/files/freeleech-grabber.py", "infra/stacks/servarr/mam-farming/files/bp-spender.py", "infra/stacks/servarr/mam-farming/files/mam-farming-janitor.py"]]' $ python3 -c 'import json; json.load(open( "infra/stacks/monitoring/modules/monitoring/dashboards/qbittorrent.json"))' ### Manual Verification 1. Grabber ratio-guard path: $ kubectl -n servarr create job --from=cronjob/mam-freeleech-grabber g1 $ kubectl -n servarr logs job/g1 Skip grab: ratio=0.0 class=Mouse (floor=1.2) reason=mouse_class 2. BP-spender tier path: $ kubectl -n servarr create job --from=cronjob/mam-bp-spender s1 $ kubectl -n servarr logs job/s1 Profile: ratio=0.0 class=Mouse DL=0.70 GiB UL=0.00 GiB BP=24551 | deficit=1.40 GiB needed=3 affordable=48 buy=0 Done: BP=24551, spent=0 GiB (needed=3, affordable=48) Correctly skips because affordable (48) < smallest API tier (50). 3. Janitor in enforce mode: $ kubectl -n servarr create job --from=cronjob/mam-farming-janitor j1 $ kubectl -n servarr logs job/j1 | tail -3 Done: deleted=466 preserved_hnr=0 skipped_active=35 dry_run=False per reason: {'never_started': 466, ...} Second run immediately after: `deleted=0 skipped_active=35` — steady state with only active/seeding torrents left. 4. Alerts loaded: $ kubectl -n monitoring get cm prometheus-server \ -o jsonpath='{.data.alerting_rules\.yml}' \ | grep -E "alert: MAM|alert: QBittorrent" - alert: MAMMouseClass - alert: MAMCookieExpired - alert: MAMRatioBelowOne - alert: MAMFarmingStuck - alert: MAMJanitorStuckBacklog - alert: QBittorrentDisconnected - alert: QBittorrentMAMUnsatisfied 5. Dashboard: browse to Grafana "qBittorrent - Seeding & Ratio" → new "MAM Profile (from jsonLoad.php)" row at the bottom shows class, BP balance, profile ratio, transfer, BP-vs-reserve timeseries, janitor deletion stacked chart, janitor state stat, grabber state stat. ## Reproduce locally 1. `cd infra/stacks/servarr && ../../scripts/tg plan` — expect 0 add / 1 change (unrelated MetalLB annotation drift). 2. `kubectl -n servarr get cronjobs` — expect three: mam-freeleech-grabber, mam-bp-spender, mam-farming-janitor. 3. Trigger each via `kubectl create job --from=cronjob/<name> <job>` and read logs; outputs match the manual-verification snippets above. Closes: code-qfs Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 11:45:38 +00:00
if len(parts) != 2:
return 0
return int(float(parts[0]) * units.get(parts[1], 1))
def save_cookie(resp):
for c in resp.cookies:
if c.name == "mam_id":
fd, tmp = tempfile.mkstemp(dir="/data")
os.write(fd, c.value.encode())
os.close(fd)
os.rename(tmp, COOKIE_FILE)
return
def push(metrics):
try:
requests.post(
f"{PUSHGW}/metrics/job/mam-freeleech-grabber", data=metrics, timeout=10
)
except Exception as e:
print(f"pushgateway error: {e}", file=sys.stderr)
def load_cookie():
if os.path.exists(COOKIE_FILE):
return open(COOKIE_FILE).read().strip()
return os.environ.get("MAM_ID", "")
def exit_cookie_expired(status):
push("mam_farming_cookie_expired 1\n")
print(f"Cookie expired: {status}", file=sys.stderr)
sys.exit(1)
def main():
mam_id = load_cookie()
if not mam_id:
print("No mam_id available", file=sys.stderr)
sys.exit(1)
s = requests.Session()
s.cookies.set("mam_id", mam_id, domain=".myanonamouse.net")
r = s.get("https://www.myanonamouse.net/jsonLoad.php", timeout=15)
if r.status_code != 200:
exit_cookie_expired(r.status_code)
save_cookie(r)
profile = r.json()
ratio = float(profile.get("ratio", 0) or 0)
classname = profile.get("classname", "Mouse")
# `*_bytes` are authoritative integers; `downloaded`/`uploaded` are
# pretty strings like "715.55 MiB".
downloaded = int(profile.get("downloaded_bytes", 0) or 0)
uploaded = int(profile.get("uploaded_bytes", 0) or 0)
class_code = CLASS_CODES.get(classname, 0)
profile_metrics = (
f"mam_farming_cookie_expired 0\n"
f"mam_ratio {ratio}\n"
f'mam_class_code{{classname="{classname}"}} {class_code}\n'
f"mam_downloaded_bytes {downloaded}\n"
f"mam_uploaded_bytes {uploaded}\n"
)
if ratio < RATIO_FLOOR or classname == "Mouse":
reason = "mouse_class" if classname == "Mouse" else "low_ratio"
print(
f"Skip grab: ratio={ratio} class={classname} (floor={RATIO_FLOOR}) "
f"reason={reason}"
)
push(
profile_metrics
+ f'mam_grabber_skipped_reason{{reason="{reason}"}} 1\n'
+ f"mam_farming_grabbed 0\n"
)
return
time.sleep(REQUEST_SLEEP)
r = s.get("https://t.myanonamouse.net/json/dynamicSeedbox.php", timeout=15)
save_cookie(r)
print(f"Seedbox: {r.text[:80]}")
grabbed_ids = set()
if os.path.exists(GRABBED_IDS_FILE):
raw = open(GRABBED_IDS_FILE).read().strip()
grabbed_ids = set(raw.split("\n")) if raw else set()
try:
all_torrents = requests.get(
f"{QB_URL}/api/v2/torrents/info", timeout=10
).json()
except Exception as e:
print(f"qBittorrent unreachable: {e}", file=sys.stderr)
push(profile_metrics + "mam_farming_grabbed 0\n")
sys.exit(1)
farming = [t for t in all_torrents if t.get("category") == "mam-farming"]
all_names_lower = {t["name"].lower() for t in all_torrents}
total_size = sum(t.get("size", 0) for t in farming)
print(
f"Profile: ratio={ratio} class={classname} | "
f"Farming: {len(farming)}, {total_size / (1024**3):.1f} GiB, "
f"tracked IDs: {len(grabbed_ids)}"
)
grabbed = 0
if len(farming) >= MAX_TORRENTS:
print(f"At max torrents ({MAX_TORRENTS}), skipping grab")
else:
time.sleep(REQUEST_SLEEP)
offset = random.randint(0, 1400)
params = {
"tor[searchType]": "fl",
"tor[searchIn]": "torrents",
"tor[perpage]": "50",
"tor[startNumber]": str(offset),
}
r = s.get(
"https://www.myanonamouse.net/tor/js/loadSearchJSONbasic.php",
params=params,
timeout=15,
)
save_cookie(r)
data = r.json()
results = data.get("data", []) or []
print(
f"Search offset={offset}, found={data.get('found', 0)}, "
f"page_results={len(results)}"
)
candidates = []
for t in results:
tid = str(t.get("id", ""))
if tid in grabbed_ids:
continue
title = t.get("title", "")
if any(title.lower() in n for n in all_names_lower):
grabbed_ids.add(tid)
continue
size = parse_size(t.get("size", "0 B"))
if size < MIN_MB * 1024**2 or size > MAX_MB * 1024**2:
continue
seeders = int(t.get("seeders", 999) or 999)
leechers = int(t.get("leechers", 0) or 0)
if leechers < LEECHER_FLOOR:
continue
if seeders > SEEDER_CEILING:
continue
wedge_bonus = (
200 if (t.get("free") == 1 or t.get("personal_freeleech") == 1) else 0
)
score = leechers * 3 - seeders * 0.5 + wedge_bonus
candidates.append((score, t))
candidates.sort(key=lambda x: -x[0])
for score, t in candidates[:GRAB_PER_RUN]:
time.sleep(REQUEST_SLEEP)
tid = t["id"]
r = s.get(
f"https://www.myanonamouse.net/tor/download.php?tid={tid}", timeout=15
)
save_cookie(r)
if not r.content.startswith(b"d"):
print(f"Bad torrent body for tid={tid}")
grabbed_ids.add(str(tid))
continue
add_resp = requests.post(
f"{QB_URL}/api/v2/torrents/add",
files={
"torrents": (
f"{tid}.torrent",
r.content,
"application/x-bittorrent",
)
},
data={
"savepath": "/downloads/mam-farming",
"category": "mam-farming",
"tags": "mam,freeleech",
},
timeout=20,
)
ok = add_resp.status_code == 200 and add_resp.text.strip() != "Fails."
print(
f"{'Added' if ok else 'FAILED'} (score={score:.1f}): "
f"{t['title'][:60]} ({t['size']}, S:{t.get('seeders')} "
f"L:{t.get('leechers')}) -> {add_resp.status_code}"
)
grabbed_ids.add(str(tid))
if ok:
grabbed += 1
fd, tmp = tempfile.mkstemp(dir="/data")
os.write(fd, "\n".join(grabbed_ids).encode())
os.close(fd)
os.rename(tmp, GRABBED_IDS_FILE)
metrics = (
profile_metrics
+ f"mam_farming_grabbed {grabbed}\n"
+ f"mam_farming_total_seeding {len(farming) + grabbed}\n"
+ f"mam_farming_size_bytes {total_size}\n"
)
push(metrics)
print(f"Done: grabbed={grabbed}")
if __name__ == "__main__":
main()