From 31bba1269de704228f563e27437d0ff5fa7088f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Mon, 4 May 2026 10:43:31 +0200 Subject: [PATCH 1/8] gunicorn --- .env.example | 1 + README.md | 17 +++++++++++ app.py | 11 ++++++-- deploy/pytorrent.service | 8 ++---- pytorrent/config.py | 12 +++++++- pytorrent/static/styles.css | 56 ++++++++++++++++++++++++++----------- requirements.txt | 1 + systemd/pytorrent.service | 11 ++++++-- wsgi.py | 4 +++ 9 files changed, 94 insertions(+), 27 deletions(-) create mode 100644 wsgi.py diff --git a/.env.example b/.env.example index 08e48a8..475123d 100644 --- a/.env.example +++ b/.env.example @@ -6,6 +6,7 @@ PYTORRENT_DEBUG=0 PYTORRENT_POLL_INTERVAL=1.0 PYTORRENT_WORKERS=16 PYTORRENT_GEOIP_DB=data/GeoLite2-City.mmdb +PYTORRENT_ALLOW_UNSAFE_WERKZEUG=0 # Retention / Smart Queue PYTORRENT_TRAFFIC_HISTORY_RETENTION_DAYS=90 diff --git a/README.md b/README.md index 8c04f70..39ca6e5 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,23 @@ python app.py Domyślnie: `http://127.0.0.1:8090`. +## Uruchomienie produkcyjne + +Preferowany wariant bez deweloperskiego Werkzeug: + +```bash +. venv/bin/activate +gunicorn --worker-class gthread --workers 1 --threads 32 --bind 0.0.0.0:8090 --access-logfile - --error-logfile - wsgi:app +``` + +Note: aplikacja zostaje przy `async_mode="threading"`, więc WebSocket, `start_background_task`, kolejka operacji i poller działają w tym samym modelu co wcześniej. + +Alternatywy przeanalizowane, ale nie wdrożone: + +- `eventlet` przez Gunicorn: działa z Flask-SocketIO, ale wymaga green threads i monkey-patching; większe ryzyko regresji dla operacji plikowych/SCGI. +- `gevent` przez Gunicorn: dobry wariant produkcyjny, ale wymaga dodatkowych zależności i testów zgodności. +- wiele workerów Gunicorn: wymaga Redis/RabbitMQ/Kafka jako message queue dla Socket.IO, więc nie jest zamiennikiem 1:1. + ## Profil SCGI Przykład: diff --git a/app.py b/app.py index b1fba1f..0b502f7 100644 --- a/app.py +++ b/app.py @@ -1,7 +1,14 @@ from pytorrent import create_app, socketio -from pytorrent.config import HOST, PORT, DEBUG +from pytorrent.config import ALLOW_UNSAFE_WERKZEUG, DEBUG, HOST, PORT app = create_app() if __name__ == "__main__": - socketio.run(app, host=HOST, port=PORT, debug=DEBUG, allow_unsafe_werkzeug=True) + # Note: This entrypoint is kept for local development; production should use gunicorn via wsgi:app. + socketio.run( + app, + host=HOST, + port=PORT, + debug=DEBUG, + allow_unsafe_werkzeug=ALLOW_UNSAFE_WERKZEUG, + ) diff --git a/deploy/pytorrent.service b/deploy/pytorrent.service index 5692d50..9954bb2 100644 --- a/deploy/pytorrent.service +++ b/deploy/pytorrent.service @@ -8,22 +8,20 @@ Wants=network-online.target [Service] Type=simple -#User=root -#Group=root User=pytorrent Group=pytorrent WorkingDirectory=/opt/pyTorrent Environment="PYTHONUNBUFFERED=1" EnvironmentFile=/opt/pyTorrent/.env -ExecStart=/opt/pyTorrent/venv/bin/python /opt/pyTorrent/app.py +# Note: production starts through Gunicorn, so Socket.IO keeps WebSocket/thread support without unsafe Werkzeug. +ExecStart=/opt/pyTorrent/venv/bin/gunicorn --worker-class gthread --workers 1 --threads 32 --bind ${PYTORRENT_HOST}:${PYTORRENT_PORT} --access-logfile - --error-logfile - wsgi:app Restart=always RestartSec=3 KillSignal=SIGINT TimeoutStopSec=20 -# opcjonalnie NoNewPrivileges=true PrivateTmp=true [Install] -WantedBy=multi-user.target \ No newline at end of file +WantedBy=multi-user.target diff --git a/pytorrent/config.py b/pytorrent/config.py index 27a2587..e2ed56f 100644 --- a/pytorrent/config.py +++ b/pytorrent/config.py @@ -7,6 +7,14 @@ from dotenv import load_dotenv BASE_DIR = Path(__file__).resolve().parent.parent load_dotenv(BASE_DIR / ".env") + +def _env_bool(name: str, default: bool = False) -> bool: + value = os.getenv(name) + if value is None: + return default + return value.strip().lower() in {"1", "true", "yes", "on"} + + SECRET_KEY = os.getenv("PYTORRENT_SECRET_KEY", "dev-change-me") DB_PATH = Path(os.getenv("PYTORRENT_DB_PATH", str(BASE_DIR / "data" / "pytorrent.sqlite3"))) if not DB_PATH.is_absolute(): @@ -14,7 +22,9 @@ if not DB_PATH.is_absolute(): HOST = os.getenv("PYTORRENT_HOST", "0.0.0.0") PORT = int(os.getenv("PYTORRENT_PORT", "8090")) -DEBUG = os.getenv("PYTORRENT_DEBUG", "0") == "1" +DEBUG = _env_bool("PYTORRENT_DEBUG", False) +# Note: Keep Werkzeug opt-in only for explicit local/dev use, never by default in services. +ALLOW_UNSAFE_WERKZEUG = _env_bool("PYTORRENT_ALLOW_UNSAFE_WERKZEUG", DEBUG) POLL_INTERVAL = float(os.getenv("PYTORRENT_POLL_INTERVAL", "1.0")) WORKERS = int(os.getenv("PYTORRENT_WORKERS", "16")) GEOIP_DB = Path(os.getenv("PYTORRENT_GEOIP_DB", str(BASE_DIR / "data" / "GeoLite2-City.mmdb"))) diff --git a/pytorrent/static/styles.css b/pytorrent/static/styles.css index a20e2c2..f2e5fd6 100644 --- a/pytorrent/static/styles.css +++ b/pytorrent/static/styles.css @@ -180,7 +180,13 @@ body { opacity: .78; } .shortcut { font-size: .78rem; color: var(--bs-secondary-color); padding: .15rem .5rem; } -.content { min-width: 0; min-height: 0; display: grid; grid-template-rows: 1fr 255px; } +.content { + min-width: 0; + min-height: 0; + display: grid; + grid-template-rows: minmax(0, 1fr) 255px; + position: relative; +} .table-wrap { overflow: auto; contain: content; } .torrent-table { margin: 0; white-space: nowrap; table-layout: auto; } .torrent-table thead th { position: sticky; top: 0; z-index: 2; background: var(--bs-tertiary-bg); border-bottom: 1px solid var(--bs-border-color); user-select: none; } @@ -195,7 +201,13 @@ body { .virtual-spacer td { padding: 0 !important; border: 0 !important; } .empty { height: 120px; text-align: center; vertical-align: middle; color: var(--bs-secondary-color); } .progress.thin { height: 7px; min-width: 130px; margin-bottom: 1px; background: rgba(255,255,255,.08); } -.details { min-height: 0; overflow: hidden; background: rgba(var(--bs-secondary-bg-rgb), .78); } +.details { + grid-row: 2; + grid-column: 1; + min-height: 0; + overflow: hidden; + background: rgba(var(--bs-secondary-bg-rgb), .78); +} .detail-pane { height: 210px; overflow: auto; padding: .65rem; } .loading-line { display: flex; align-items: center; gap: .5rem; color: var(--bs-secondary-color); padding: .75rem; } .muted-pane { color: var(--bs-secondary-color); } @@ -373,10 +385,31 @@ body { background: rgba(var(--bs-secondary-bg-rgb), .85); } .badge-degraded { background: #f59e0b !important; color: #111 !important; } -body.mobile-mode .table-wrap { display: none !important; } -body.mobile-mode #mobileList { display: block !important; } -body.mobile-mode .content { grid-template-rows: 1fr 210px; } +/* Note: Manual mobile mode is defined once here; media queries below only adapt breakpoints. */ +body.mobile-mode .table-wrap, +body.mobile-mode .details { + display: none !important; +} +body.mobile-mode #mobileList { + display: block !important; + min-height: 0; + height: 100%; + overflow: auto; + position: relative; + z-index: 2; + padding-bottom: 1rem; +} +body.mobile-mode .content { + display: grid !important; + grid-template-rows: minmax(0, 1fr) !important; + min-height: 0; + overflow: hidden; +} body.mobile-mode .torrent-table { display: none; } +body.mobile-mode .main-grid { + min-height: 0; + overflow: hidden; +} @media (max-width: 640px) { .nav-btn span { display: none; } } @@ -437,7 +470,6 @@ body.mobile-mode .torrent-table { display: none; } opacity: .72; } .path-row::before{content:'\f07b';font-family:'Font Awesome 6 Free';font-weight:900;color:var(--bs-warning)} -body.mobile-mode #mobileList{min-height:0;height:100%;overflow:auto;display:block!important} body.mobile-mode .mobile-card{display:block}.mobile-card .mobile-actions button{min-width:34px} #toolSmart .form-label{font-size:.75rem;color:var(--bs-secondary-color);margin-bottom:.2rem} #toolSmart .btn{padding:.25rem .55rem;border-radius:.5rem;white-space:nowrap} @@ -462,10 +494,6 @@ body.mobile-mode .mobile-card{display:block}.mobile-card .mobile-actions button{ .empty-mini{padding:.7rem .8rem;border:1px dashed var(--bs-border-color);border-radius:.7rem;color:var(--bs-secondary-color);background:rgba(var(--bs-secondary-bg-rgb),.35)} .label-manager-row{display:flex;align-items:center;justify-content:space-between;gap:.5rem;border:1px solid var(--bs-border-color);border-radius:.65rem;padding:.4rem .5rem;margin-bottom:.4rem;background:rgba(var(--bs-secondary-bg-rgb),.35)} .tool-tab i{margin-right:.25rem;opacity:.82} -body.mobile-mode .content{display:grid!important;grid-template-rows:minmax(0,1fr)!important;min-height:0;overflow:hidden} -body.mobile-mode .details{display:none!important} -body.mobile-mode #mobileList{display:block!important;height:100%!important;min-height:220px;overflow:auto;position:relative;z-index:2;padding-bottom:1rem} -body.mobile-mode .main-grid{min-height:0;overflow:hidden} @media (max-width:640px){.history-card{padding:.5rem}#trafficHistoryChart,#trafficSpeedChart{height:320px}.statusbar{font-size:.75rem;gap:.6rem}.mobile-list{padding:.45rem}.mobile-card{margin-bottom:.45rem}} /* Requested fixes: clean progress, mobile auto list, pagers, rTorrent config, peers refresh */ @@ -473,7 +501,6 @@ body.mobile-mode .main-grid{min-height:0;overflow:hidden} .torrent-progress .progress-bar{min-width:0!important;position:relative;transition:width .25s ease,background-color .25s ease} .torrent-progress>span{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;font-size:10px;font-weight:700;line-height:1;color:var(--bs-body-color);text-shadow:none;white-space:nowrap;pointer-events:none} .torrent-progress .progress-bar+span{color:var(--bs-body-color)} -body.mobile-mode #mobileList{display:block!important} @media (max-width:700px){ body:not(.desktop-mode) .table-wrap{display:none!important} body:not(.desktop-mode) #mobileList{display:block!important;min-height:260px;height:100%;overflow:auto} @@ -560,14 +587,9 @@ body.mobile-mode #mobileList{display:block!important} padding: .75rem; background: var(--bs-tertiary-bg); } -/* Stable main layout: bulk actions overlay the list area, details stay pinned at the bottom. */ -.content { - position: relative; - grid-template-rows: minmax(0, 1fr) 255px !important; -} +/* Note: Bulk actions overlay the list area; base .content/.details rules keep the layout pinned. */ #bulkBar { grid-row: 1; grid-column: 1; align-self: start; } #tableWrap, #mobileList { grid-row: 1; grid-column: 1; min-height: 0; } -.details { grid-row: 2; grid-column: 1; min-height: 0; } .bulk-bar:not(.d-none) + .table-wrap { padding-top: 38px; } @media (max-width: 900px) { .bulk-bar { gap: .3rem; } diff --git a/requirements.txt b/requirements.txt index 8810f4f..ad41f31 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,3 +4,4 @@ python-dotenv>=1.0 geoip2>=4.8 psutil>=5.9 simple-websocket>=1.0 +gunicorn>=22.0 diff --git a/systemd/pytorrent.service b/systemd/pytorrent.service index 54a3124..05d8f02 100644 --- a/systemd/pytorrent.service +++ b/systemd/pytorrent.service @@ -1,16 +1,23 @@ [Unit] Description=pyTorrent web UI for rTorrent -After=network.target +After=network-online.target +Wants=network-online.target [Service] Type=simple WorkingDirectory=/opt/pytorrent +Environment="PYTHONUNBUFFERED=1" EnvironmentFile=/opt/pytorrent/.env -ExecStart=/opt/pytorrent/venv/bin/python /opt/pytorrent/app.py +# Note: threaded Gunicorn preserves Flask-SocketIO background tasks without running Werkzeug in production. +ExecStart=/opt/pytorrent/venv/bin/gunicorn --worker-class gthread --workers 1 --threads 32 --bind ${PYTORRENT_HOST}:${PYTORRENT_PORT} --access-logfile - --error-logfile - wsgi:app Restart=always RestartSec=3 +KillSignal=SIGINT +TimeoutStopSec=20 User=www-data Group=www-data +NoNewPrivileges=true +PrivateTmp=true [Install] WantedBy=multi-user.target diff --git a/wsgi.py b/wsgi.py new file mode 100644 index 0000000..0e85347 --- /dev/null +++ b/wsgi.py @@ -0,0 +1,4 @@ +from pytorrent import create_app + +# Note: Gunicorn imports this object; background Socket.IO tasks still start through create_app(). +app = create_app() From dca7389a1a4ea5f635911d38ace5645675bca6b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Mon, 4 May 2026 10:57:03 +0200 Subject: [PATCH 2/8] gunicorn --- deploy/pytorrent.service | 4 +--- systemd/pytorrent.service | 16 +++++++++------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/deploy/pytorrent.service b/deploy/pytorrent.service index 9954bb2..0374ac4 100644 --- a/deploy/pytorrent.service +++ b/deploy/pytorrent.service @@ -13,15 +13,13 @@ Group=pytorrent WorkingDirectory=/opt/pyTorrent Environment="PYTHONUNBUFFERED=1" EnvironmentFile=/opt/pyTorrent/.env -# Note: production starts through Gunicorn, so Socket.IO keeps WebSocket/thread support without unsafe Werkzeug. ExecStart=/opt/pyTorrent/venv/bin/gunicorn --worker-class gthread --workers 1 --threads 32 --bind ${PYTORRENT_HOST}:${PYTORRENT_PORT} --access-logfile - --error-logfile - wsgi:app Restart=always RestartSec=3 KillSignal=SIGINT TimeoutStopSec=20 - NoNewPrivileges=true PrivateTmp=true [Install] -WantedBy=multi-user.target +WantedBy=multi-user.target \ No newline at end of file diff --git a/systemd/pytorrent.service b/systemd/pytorrent.service index 05d8f02..4f22db5 100644 --- a/systemd/pytorrent.service +++ b/systemd/pytorrent.service @@ -1,23 +1,25 @@ [Unit] -Description=pyTorrent web UI for rTorrent +Description=pyTorrent Web UI After=network-online.target Wants=network-online.target [Service] Type=simple -WorkingDirectory=/opt/pytorrent +#User=root +#Group=root +User=pytorrent +Group=pytorrent +WorkingDirectory=/opt/pyTorrent Environment="PYTHONUNBUFFERED=1" -EnvironmentFile=/opt/pytorrent/.env +EnvironmentFile=/opt/pyTorrent/.env # Note: threaded Gunicorn preserves Flask-SocketIO background tasks without running Werkzeug in production. -ExecStart=/opt/pytorrent/venv/bin/gunicorn --worker-class gthread --workers 1 --threads 32 --bind ${PYTORRENT_HOST}:${PYTORRENT_PORT} --access-logfile - --error-logfile - wsgi:app +ExecStart=/opt/pyTorrent/venv/bin/gunicorn --worker-class gthread --workers 1 --threads 32 --bind ${PYTORRENT_HOST}:${PYTORRENT_PORT} --access-logfile - --error-logfile - wsgi:app Restart=always RestartSec=3 KillSignal=SIGINT TimeoutStopSec=20 -User=www-data -Group=www-data NoNewPrivileges=true PrivateTmp=true [Install] -WantedBy=multi-user.target +WantedBy=multi-user.target \ No newline at end of file From e9940bf16c044c59b00397f6839be366c1a85c9b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Mon, 4 May 2026 19:21:09 +0200 Subject: [PATCH 3/8] force kill job --- pytorrent/routes/api.py | 12 ++++++++---- pytorrent/services/workers.py | 21 ++++++++++++++++++++- pytorrent/static/app.js | 8 ++++---- 3 files changed, 32 insertions(+), 9 deletions(-) diff --git a/pytorrent/routes/api.py b/pytorrent/routes/api.py index 4782f05..5bc0ee3 100644 --- a/pytorrent/routes/api.py +++ b/pytorrent/routes/api.py @@ -19,7 +19,7 @@ from ..db import default_user_id, connect, utcnow from ..services import preferences, rtorrent from ..services.torrent_cache import torrent_cache from ..services.torrent_summary import cached_summary -from ..services.workers import enqueue, list_jobs, cancel_job, retry_job, clear_jobs +from ..services.workers import enqueue, list_jobs, cancel_job, retry_job, clear_jobs, emergency_clear_jobs from ..services.geoip import lookup_ip bp = Blueprint("api", __name__, url_prefix="/api") @@ -566,8 +566,12 @@ def jobs_list(): @bp.post("/jobs/clear") def jobs_clear(): + if str(request.args.get("force") or "").lower() in {"1", "true", "yes"}: + # Awaryjne czyszczenie: endpoint zachowuje standardowe działanie, a force=1 uruchamia tryb ratunkowy. + deleted = emergency_clear_jobs() + return ok({"deleted": deleted, "emergency": True}) deleted = clear_jobs() - return ok({"deleted": deleted}) + return ok({"deleted": deleted, "emergency": False}) @bp.get("/cleanup/summary") @@ -609,8 +613,8 @@ def cleanup_all(): @bp.post("/jobs//cancel") def jobs_cancel(job_id: str): if not cancel_job(job_id): - return jsonify({"ok": False, "error": "Only pending or failed jobs can be cancelled"}), 400 - return ok() + return jsonify({"ok": False, "error": "Only unfinished jobs can be cancelled"}), 400 + return ok({"emergency": True}) @bp.post("/jobs//retry") diff --git a/pytorrent/services/workers.py b/pytorrent/services/workers.py index 0d9b210..1c9711f 100644 --- a/pytorrent/services/workers.py +++ b/pytorrent/services/workers.py @@ -152,6 +152,10 @@ def _run(job_id: str): _emit("operation_started", {"job_id": job_id, "action": job["action"], "profile_id": profile["id"], "hashes": payload.get("hashes") or [], "hash_count": len(payload.get("hashes") or []), "bulk": len(payload.get("hashes") or []) > 1}) _emit("job_update", {"id": job_id, "status": "running", "attempts": attempts}) result = _execute(profile, job["action"], payload) + fresh = _job_row(job_id) + # Awaryjne anulowanie: jeżeli użytkownik anuluje zadanie w trakcie pracy, wynik nie nadpisuje statusu cancelled. + if fresh and fresh["status"] == "cancelled": + return _set_job(job_id, "done", result=result, finished=True) _emit("operation_finished", {"job_id": job_id, "action": job["action"], "profile_id": profile["id"], "hashes": payload.get("hashes") or [], "hash_count": len(payload.get("hashes") or []), "bulk": len(payload.get("hashes") or []) > 1, "result": result}) _emit("job_update", {"id": job_id, "status": "done", "result": result}) @@ -159,6 +163,9 @@ def _run(job_id: str): fresh = _job_row(job_id) or {} attempts = int(fresh.get("attempts") or 1) max_attempts = int(fresh.get("max_attempts") or 2) + # Awaryjne anulowanie: wyjątek z anulowanego zadania nie przywraca go do retry ani failed. + if fresh and fresh.get("status") == "cancelled": + return status = "pending" if attempts < max_attempts else "failed" _set_job(job_id, status, str(exc), finished=(status == "failed")) _emit("operation_failed", {"job_id": job_id, "action": job.get("action"), "profile_id": job.get("profile_id"), "hashes": payload.get("hashes") or [], "error": str(exc)}) @@ -226,8 +233,9 @@ def list_jobs(limit: int = 200, offset: int = 0): def cancel_job(job_id: str) -> bool: row = _job_row(job_id) - if not row or row["status"] not in {"pending", "failed"}: + if not row or row["status"] in {"done", "cancelled"}: return False + # Awaryjne anulowanie: pending, running i failed można oznaczyć jako cancelled z poziomu użytkownika. _set_job(job_id, "cancelled", finished=True) _emit("job_update", {"id": job_id, "status": "cancelled"}) return True @@ -239,6 +247,17 @@ def clear_jobs() -> int: return int(cur.rowcount or 0) +def emergency_clear_jobs() -> int: + # Awaryjne czyszczenie: najpierw zamyka aktywne zadania jako cancelled, potem czyści całą listę job logów. + now = utcnow() + with connect() as conn: + conn.execute("UPDATE jobs SET status='cancelled', error='Emergency cancelled by user', finished_at=COALESCE(finished_at, ?), updated_at=? WHERE status IN ('pending', 'running', 'failed')", (now, now)) + cur = conn.execute("DELETE FROM jobs") + deleted = int(cur.rowcount or 0) + _emit("job_update", {"status": "cleared", "emergency": True}) + return deleted + + def retry_job(job_id: str) -> bool: row = _job_row(job_id) if not row or row["status"] not in {"failed", "cancelled"}: diff --git a/pytorrent/static/app.js b/pytorrent/static/app.js index 2a61305..4fff591 100644 --- a/pytorrent/static/app.js +++ b/pytorrent/static/app.js @@ -311,10 +311,10 @@ $('saveColumnsBtn')?.addEventListener('click',async()=>{ document.querySelectorAll('.column-toggle').forEach(cb=>cb.checked?hiddenColumns.delete(cb.dataset.colKey):hiddenColumns.add(cb.dataset.colKey)); applyColumnVisibility(); scheduleRender(true); await post('/api/preferences',{table_columns_json:JSON.stringify({hidden:[...hiddenColumns]})}).catch(e=>toast(e.message,'danger')); toast('Columns saved','success'); }); $('resetColumnsBtn')?.addEventListener('click',async()=>{ hiddenColumns.clear(); renderColumnManager(); applyColumnVisibility(); scheduleRender(true); await post('/api/preferences',{table_columns_json:JSON.stringify({hidden:[]})}).catch(()=>{}); }); - async function loadJobs(page=jobsPage){ const box=$('jobsTable'); if(!box)return; jobsPage=Math.max(0,page|0); box.innerHTML=' Loading jobs...'; const offset=jobsPage*jobsLimit; const j=await (await fetch(`/api/jobs?limit=${jobsLimit}&offset=${offset}`)).json(); const rows=j.jobs||[]; jobsTotal=Number(j.total||rows.length); const details=r=>{ const count=Number(r.hash_count||0); if(r.is_bulk || count>1) return `bulk
${esc(count)} torrent(s), details hidden`; const bits=[]; if(count) bits.push(`${esc(count)} torrent`); if(r.summary) bits.push(esc(r.summary)); return bits.join('
') || '-'; }; box.innerHTML=table(['Status','Action','Profile','Count','Details','Attempts','Started','Finished','Error','Actions'],rows.map(r=>[`${esc(r.status)}`,esc(r.action),esc(r.profile_id),esc(r.hash_count||0),details(r),esc(r.attempts||0),dateCell(r.started_at||r.created_at),dateCell(r.finished_at||r.updated_at),compactCell(r.error||'',140),` `])); renderJobsPager(); } + async function loadJobs(page=jobsPage){ const box=$('jobsTable'); if(!box)return; jobsPage=Math.max(0,page|0); box.innerHTML=' Loading jobs...'; const offset=jobsPage*jobsLimit; const j=await (await fetch(`/api/jobs?limit=${jobsLimit}&offset=${offset}`)).json(); const rows=j.jobs||[]; jobsTotal=Number(j.total||rows.length); const details=r=>{ const count=Number(r.hash_count||0); if(r.is_bulk || count>1) return `bulk
${esc(count)} torrent(s), details hidden`; const bits=[]; if(count) bits.push(`${esc(count)} torrent`); if(r.summary) bits.push(esc(r.summary)); return bits.join('
') || '-'; }; box.innerHTML=table(['Status','Action','Profile','Count','Details','Attempts','Started','Finished','Error','Actions'],rows.map(r=>[`${esc(r.status)}`,esc(r.action),esc(r.profile_id),esc(r.hash_count||0),details(r),esc(r.attempts||0),dateCell(r.started_at||r.created_at),dateCell(r.finished_at||r.updated_at),compactCell(r.error||'',140),` `])); renderJobsPager(); } function renderJobsPager(){ const p=$('jobsPager'); if(!p)return; const pages=Math.max(1,Math.ceil(jobsTotal/jobsLimit)); p.innerHTML=`
Page ${jobsPage+1} / ${pages} · ${jobsTotal} jobs
`; $('jobsPrev')?.addEventListener('click',()=>loadJobs(jobsPage-1)); $('jobsNext')?.addEventListener('click',()=>loadJobs(jobsPage+1)); } - $('jobsModal')?.addEventListener('show.bs.modal',loadJobs); $('refreshJobsBtn')?.addEventListener('click',loadJobs); $('jobsTable')?.addEventListener('click',async e=>{ const btn=e.target.closest('.job-retry,.job-cancel'); if(!btn)return; const id=btn.dataset.id; if(!id)return; if(btn.classList.contains('job-retry')) await post(`/api/jobs/${id}/retry`,{}).catch(x=>toast(x.message,'danger')); if(btn.classList.contains('job-cancel')) await post(`/api/jobs/${id}/cancel`,{}).catch(x=>toast(x.message,'danger')); loadJobs(); }); - $('clearJobsBtn')?.addEventListener('click',async()=>{ if(!confirm('Clear finished job logs? Pending and running jobs will stay.')) return; try{ const j=await post('/api/jobs/clear',{}); toast(`Cleared ${j.deleted||0} job log(s)`,'success'); jobsPage=0; loadJobs(0); }catch(e){ toast(e.message,'danger'); } }); + $('jobsModal')?.addEventListener('show.bs.modal',loadJobs); $('refreshJobsBtn')?.addEventListener('click',loadJobs); $('jobsTable')?.addEventListener('click',async e=>{ const btn=e.target.closest('.job-retry,.job-cancel'); if(!btn)return; const id=btn.dataset.id; if(!id)return; if(btn.classList.contains('job-retry')) await post(`/api/jobs/${id}/retry`,{}).catch(x=>toast(x.message,'danger')); if(btn.classList.contains('job-cancel')){ const st=btn.dataset.status||''; if((st==='pending'||st==='running') && !confirm('Emergency cancel this unfinished job?')) return; await post(`/api/jobs/${id}/cancel`,{}).catch(x=>toast(x.message,'danger')); } loadJobs(); }); + $('clearJobsBtn')?.addEventListener('click',async()=>{ const emergency=confirm('Emergency clear all job logs, including unfinished jobs? OK = emergency clear, Cancel = clear only finished logs.'); if(!emergency && !confirm('Clear finished job logs? Pending and running jobs will stay.')) return; try{ const j=await post(`/api/jobs/clear${emergency?'?force=1':''}`,{}); toast(`${emergency?'Emergency cleared':'Cleared'} ${j.deleted||0} job log(s)`,'success'); jobsPage=0; loadJobs(0); }catch(e){ toast(e.message,'danger'); } }); async function loadLabels(){ const j=await (await fetch('/api/labels')).json(); const labels=j.labels||[]; knownLabels=labels; renderLabelFilters(); renderLabelChooser(); if($('labelsManager')) $('labelsManager').innerHTML=labels.length?labels.map(l=>`
${esc(l.name)}
`).join(''):'No labels.'; } function renderLabelChooser(){ if($('selectedLabelList')) $('selectedLabelList').innerHTML=[...modalLabels].map(l=>``).join('') || 'No labels selected.'; if($('labelList')) $('labelList').innerHTML=knownLabels.map(l=>``).join('') || 'No saved labels.'; } @@ -471,7 +471,7 @@ cleanupCountCard('Smart Queue logs', data.smart_queue_history_total, `retention ${retention.smart_queue_history||'-'} days`), cleanupCountCard('Database size', db.size_h||db.size||'-', db.path||'') ]; - box.innerHTML=`
${cards.join('')}
Job cleanup uses the existing job endpoint logic, so pending and running jobs are preserved.
`; + box.innerHTML=`
${cards.join('')}
Job cleanup preserves pending and running jobs. Use Jobs modal for emergency clear when unfinished jobs must be removed.
`; } async function loadCleanup(){ const box=$('cleanupManager'); if(!box) return; From d55533d78ab579727e05656fa0afb7d6a28349da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Mon, 4 May 2026 20:12:26 +0200 Subject: [PATCH 4/8] bulk-part-jobs --- pytorrent/routes/api.py | 52 +++++++++++++++++++++++++++++++++++ pytorrent/routes/main.py | 2 +- pytorrent/services/workers.py | 3 ++ pytorrent/static/app.js | 10 +++++-- 4 files changed, 64 insertions(+), 3 deletions(-) diff --git a/pytorrent/routes/api.py b/pytorrent/routes/api.py index 5bc0ee3..95f14d2 100644 --- a/pytorrent/routes/api.py +++ b/pytorrent/routes/api.py @@ -24,6 +24,8 @@ from ..services.geoip import lookup_ip bp = Blueprint("api", __name__, url_prefix="/api") +MOVE_BULK_MAX_HASHES = 100 + def ok(payload=None): data = {"ok": True} @@ -303,6 +305,42 @@ def enrich_bulk_payload(profile: dict, action_name: str, data: dict) -> dict: return payload +def _chunk_hashes(hashes: list[str], size: int = MOVE_BULK_MAX_HASHES) -> list[list[str]]: + # Note: Splits very large torrent selections into predictable chunks so each queued job stays small and recoverable. + safe_size = max(1, int(size or MOVE_BULK_MAX_HASHES)) + return [hashes[index:index + safe_size] for index in range(0, len(hashes), safe_size)] + + +def enqueue_move_bulk_parts(profile: dict, data: dict) -> list[dict]: + # Note: Keeps the existing move action intact for normal batches, while large moves are queued as bulk-1, bulk-2, etc. + base_payload = enrich_bulk_payload(profile, "move", data) + hashes = base_payload.get("hashes") or [] + chunks = _chunk_hashes(hashes) + if len(chunks) <= 1: + job_id = enqueue("move", profile["id"], base_payload) + return [{"job_id": job_id, "label": "bulk-1", "part": 1, "parts": 1, "hashes": hashes, "hash_count": len(hashes)}] + + jobs = [] + items_by_hash = {str(item.get("hash")): item for item in (base_payload.get("job_context") or {}).get("items") or []} + for index, chunk in enumerate(chunks, start=1): + payload = dict(base_payload) + payload["hashes"] = chunk + context = dict(base_payload.get("job_context") or {}) + context.update({ + "bulk": True, + "bulk_label": f"bulk-{index}", + "bulk_part": index, + "bulk_parts": len(chunks), + "hash_count": len(chunk), + "parent_hash_count": len(hashes), + "items": [items_by_hash[h] for h in chunk if h in items_by_hash], + }) + payload["job_context"] = context + job_id = enqueue("move", profile["id"], payload) + jobs.append({"job_id": job_id, "label": context["bulk_label"], "part": index, "parts": len(chunks), "hashes": chunk, "hash_count": len(chunk)}) + return jobs + + @bp.get("/profiles") def profiles_list(): return ok({"profiles": preferences.list_profiles(), "active": preferences.active_profile()}) @@ -437,6 +475,20 @@ def torrent_action(action_name: str): allowed = {"start", "pause", "stop", "resume", "recheck", "reannounce", "remove", "move", "set_label", "set_ratio_group"} if action_name not in allowed: return jsonify({"ok": False, "error": "Unknown action"}), 400 + if action_name == "move": + # Note: Large move requests are split into ordered bulk parts; smaller requests keep the old single-job response shape. + jobs = enqueue_move_bulk_parts(profile, data) + first_job_id = jobs[0]["job_id"] if jobs else None + total_hashes = sum(int(job.get("hash_count") or 0) for job in jobs) + return ok({ + "job_id": first_job_id, + "job_ids": [job["job_id"] for job in jobs], + "jobs": jobs, + "hash_count": total_hashes, + "bulk": total_hashes > 1, + "bulk_parts": len(jobs), + "chunk_size": MOVE_BULK_MAX_HASHES, + }) payload = enrich_bulk_payload(profile, action_name, data) job_id = enqueue(action_name, profile["id"], payload) return ok({"job_id": job_id, "hash_count": len(payload.get("hashes") or []), "bulk": len(payload.get("hashes") or []) > 1}) diff --git a/pytorrent/routes/main.py b/pytorrent/routes/main.py index 57c6e95..f9d8b9f 100644 --- a/pytorrent/routes/main.py +++ b/pytorrent/routes/main.py @@ -55,7 +55,7 @@ def openapi(): }, }, "/api/torrents": {"get": {"summary": "Get cached torrent snapshot", "responses": {"200": {"description": "Torrent list"}}}}, - "/api/torrents/{action_name}": {"post": {"summary": "Queue torrent action", "description": "For move, path is the target directory; move_data=true physically moves data on the rTorrent host using a detached shell move with status polling, force-overwrites an existing destination, tolerates rTorrent execute timeouts around mkdir/start/polling, handles retries after a partially completed move, avoids SCGI timeout on long mv operations, and recheck defaults to move_data. Move and remove jobs are ordered per profile, so a later remove waits for earlier move/remove jobs to finish.", "parameters": [{"name": "action_name", "in": "path", "required": True, "schema": {"type": "string", "enum": ["start", "pause", "stop", "resume", "recheck", "remove", "move", "set_label", "set_ratio_group"]}}], "requestBody": {"content": {"application/json": {"schema": {"type": "object", "properties": {"hashes": {"type": "array", "items": {"type": "string"}}, "path": {"type": "string", "description": "Target directory for move"}, "move_data": {"type": "boolean", "description": "Physically move data before setting torrent directory"}, "recheck": {"type": "boolean", "description": "Run hash check after physical move; defaults to move_data"}, "label": {"type": "string"}, "ratio_group": {"type": "string"}, "remove_data": {"type": "boolean"}}}}}}, "responses": {"200": {"description": "Job queued"}}}}, + "/api/torrents/{action_name}": {"post": {"summary": "Queue torrent action", "description": "For move, path is the target directory; move_data=true physically moves data on the rTorrent host using a detached shell move with status polling, force-overwrites an existing destination, tolerates rTorrent execute timeouts around mkdir/start/polling, handles retries after a partially completed move, avoids SCGI timeout on long mv operations, and recheck defaults to move_data. Large move selections are split into ordered bulk parts of up to 100 hashes. Move and remove jobs are ordered per profile, so a later remove waits for earlier move/remove jobs to finish.", "parameters": [{"name": "action_name", "in": "path", "required": True, "schema": {"type": "string", "enum": ["start", "pause", "stop", "resume", "recheck", "remove", "move", "set_label", "set_ratio_group"]}}], "requestBody": {"content": {"application/json": {"schema": {"type": "object", "properties": {"hashes": {"type": "array", "items": {"type": "string"}}, "path": {"type": "string", "description": "Target directory for move"}, "move_data": {"type": "boolean", "description": "Physically move data before setting torrent directory"}, "recheck": {"type": "boolean", "description": "Run hash check after physical move; defaults to move_data"}, "label": {"type": "string"}, "ratio_group": {"type": "string"}, "remove_data": {"type": "boolean"}}}}}}, "responses": {"200": {"description": "Job queued"}}}}, "/api/torrents/add": {"post": {"summary": "Add magnet links or torrent files", "requestBody": {"content": {"multipart/form-data": {"schema": {"type": "object", "properties": {"uris": {"type": "string"}, "directory": {"type": "string"}, "label": {"type": "string"}, "start": {"type": "boolean"}, "files": {"type": "array", "items": {"type": "string", "format": "binary"}}}}}, "application/json": {"schema": {"type": "object"}}}}, "responses": {"200": {"description": "Jobs queued"}}}}, "/api/torrents/{torrent_hash}/files": {"get": {"summary": "Torrent files", "parameters": [{"name": "torrent_hash", "in": "path", "required": True, "schema": {"type": "string"}}], "responses": {"200": {"description": "Files"}}}}, "/api/torrents/{torrent_hash}/peers": {"get": {"summary": "Torrent peers with GeoIP", "parameters": [{"name": "torrent_hash", "in": "path", "required": True, "schema": {"type": "string"}}], "responses": {"200": {"description": "Peers"}}}}, diff --git a/pytorrent/services/workers.py b/pytorrent/services/workers.py index 1c9711f..560da78 100644 --- a/pytorrent/services/workers.py +++ b/pytorrent/services/workers.py @@ -189,6 +189,9 @@ def _job_summary(row: dict, payload: dict, result: dict) -> str: ctx = payload.get("job_context") or {} count = int(ctx.get("hash_count") or len(payload.get("hashes") or []) or result.get("count") or 0) parts = [] + if ctx.get("bulk_label"): + # Note: Shows which generated bulk part is being displayed in the job queue. + parts.append(f"{ctx.get('bulk_label')} of {ctx.get('bulk_parts')}") if count: parts.append(("bulk " if count > 1 else "single ") + f"{count} torrent(s)") if ctx.get("target_path"): diff --git a/pytorrent/static/app.js b/pytorrent/static/app.js index 4fff591..51ae53e 100644 --- a/pytorrent/static/app.js +++ b/pytorrent/static/app.js @@ -179,6 +179,12 @@ [...new Set(hashes||[])].filter(Boolean).forEach(hash=>activeOperations.set(hash,{action,jobId,state,label,updatedAt:Date.now()})); scheduleRender(true); } + function markQueuedJobs(response, fallbackHashes, action){ + // Note: Supports API responses that split one large user action into multiple queued bulk parts. + const jobs=Array.isArray(response?.jobs)?response.jobs:[]; + if(jobs.length){ jobs.forEach(job=>markTorrentOperation(job.hashes||[],action,job.job_id,'queued')); return; } + markTorrentOperation(fallbackHashes,action,response?.job_id,'queued'); + } function clearJobOperation(jobId, hashes=[]){ if(jobId){ [...activeOperations].forEach(([hash,op])=>{ if(op.jobId===jobId) activeOperations.delete(hash); }); } (hashes||[]).forEach(hash=>activeOperations.delete(hash)); @@ -211,7 +217,7 @@ function setSelectionRange(hash, keepExisting=false){ const current=visibleRows.findIndex(t=>t.hash===hash); const last=visibleRows.findIndex(t=>t.hash===lastSelectedHash); if(current<0 || last<0){ selected.add(hash); lastSelectedHash=hash; return; } if(!keepExisting) selected.clear(); const a=Math.min(current,last), b=Math.max(current,last); visibleRows.slice(a,b+1).forEach(t=>selected.add(t.hash)); selectedHash=hash; } async function post(url,data,method='POST'){ const res=await fetch(url,{method,headers:{'Content-Type':'application/json'},body:JSON.stringify(data||{})}); const json=await res.json(); if(!json.ok) throw new Error(json.error||'Operation failed'); return json; } - async function runAction(action, extra={}){ const hashes=selectedHashes(); if(!hashes.length) return toast('No torrents selected','warning'); let payload={hashes,...extra}; if(action==='move'){ openPathPicker('move'); return; } setBusy(true); try{ const j=await post(`/api/torrents/${action}`,payload); markTorrentOperation(hashes, action, j.job_id, 'queued'); if(action==='recheck'){ hashes.forEach(h=>{ const t=torrents.get(h); if(t) torrents.set(h,{...t,status:'Checking',hashing:1,message:'Force recheck queued'}); }); scheduleRender(true); } toast(`${action} queued`,'success'); if(action==='set_label') await loadLabels(); }catch(e){toast(e.message,'danger');} finally{setBusy(false);} } + async function runAction(action, extra={}){ const hashes=selectedHashes(); if(!hashes.length) return toast('No torrents selected','warning'); let payload={hashes,...extra}; if(action==='move'){ openPathPicker('move'); return; } setBusy(true); try{ const j=await post(`/api/torrents/${action}`,payload); markQueuedJobs(j, hashes, action); if(action==='recheck'){ hashes.forEach(h=>{ const t=torrents.get(h); if(t) torrents.set(h,{...t,status:'Checking',hashing:1,message:'Force recheck queued'}); }); scheduleRender(true); } toast(`${action} queued`,'success'); if(action==='set_label') await loadLabels(); }catch(e){toast(e.message,'danger');} finally{setBusy(false);} } function flag(iso){ const code=String(iso||'').toLowerCase(); return code?` ${esc(code.toUpperCase())}`:'-'; } function table(headers,rows){ return `${headers.map(h=>``).join('')}${rows.map(r=>`${r.map(c=>``).join('')}`).join('')}
${esc(h)}
${c}
`; } function renderGeneral(){ const t=torrents.get(selectedHash); const labels=t?labelNames(t.label).map(l=>` ${esc(l)}`).join(' '):''; $('detailPane').innerHTML=t?`
Name${esc(t.name)}
Hash${esc(t.hash)}
Path${esc(t.path)}
Size${esc(t.size_h)}
Progress${esc(t.progress)}%
Ratio${esc(t.ratio)}
Downloaded${esc(t.down_total_h)}
Uploaded${esc(t.up_total_h)}
Labels${labels||'-'}
Ratio group${esc(t.ratio_group||'')}
`:'Select a torrent.'; } @@ -305,7 +311,7 @@ async function applyDefaultDownloadPath(force=false){ const p=await getDefaultDownloadPath(); ['addPath','rssPath','autoEffectPath'].forEach(id=>{ const el=$(id); if(el && (force || !el.value)) el.value=p; }); return p; } async function openPathPicker(target){ pathTarget=target; const def=await getDefaultDownloadPath(); const initial=def || ($(target)?.value||'/'); $('moveOptions')?.classList.toggle('d-none', target!=='move'); if($('moveDataPhysical')) $('moveDataPhysical').checked=true; if($('moveRecheck')) $('moveRecheck').checked=true; new bootstrap.Modal($('pathModal')).show(); browsePath(initial); } async function browsePath(path){ $('pathList').innerHTML=' Loading...'; try{ const res=await fetch(`/api/path/browse?path=${encodeURIComponent(path||'/')}`); const j=await res.json(); if(!j.ok) throw new Error(j.error); $('pathCurrent').value=j.path; lastPathParent=j.parent; $('pathList').innerHTML=j.dirs.map(d=>`
${esc(d.name)}
`).join('')||'
No directories.
'; }catch(e){$('pathList').innerHTML=`
${esc(e.message)}
`;} } - $('pathList')?.addEventListener('click',e=>{const r=e.target.closest('.path-row'); if(r) browsePath(r.dataset.path);}); $('pathGoBtn')?.addEventListener('click',()=>browsePath($('pathCurrent').value)); $('pathUpBtn')?.addEventListener('click',()=>browsePath(lastPathParent)); $('pathReloadBtn')?.addEventListener('click',()=>browsePath($('pathCurrent').value)); $('pathSelectBtn')?.addEventListener('click',async()=>{const p=$('pathCurrent').value; if(pathTarget==='move'){ const hashes=selectedHashes(); const j=await post('/api/torrents/move',{hashes,path:p,move_data:!!($('moveDataPhysical')?.checked),recheck:!!($('moveRecheck')?.checked)}); markTorrentOperation(hashes,'move',j.job_id,'queued'); toast($('moveDataPhysical')?.checked?'physical move queued':'move queued','success'); } else if($(pathTarget)) $(pathTarget).value=p; bootstrap.Modal.getInstance($('pathModal'))?.hide();}); document.querySelectorAll('.browse-path').forEach(b=>b.addEventListener('click',()=>openPathPicker(b.dataset.target))); + $('pathList')?.addEventListener('click',e=>{const r=e.target.closest('.path-row'); if(r) browsePath(r.dataset.path);}); $('pathGoBtn')?.addEventListener('click',()=>browsePath($('pathCurrent').value)); $('pathUpBtn')?.addEventListener('click',()=>browsePath(lastPathParent)); $('pathReloadBtn')?.addEventListener('click',()=>browsePath($('pathCurrent').value)); $('pathSelectBtn')?.addEventListener('click',async()=>{const p=$('pathCurrent').value; if(pathTarget==='move'){ const hashes=selectedHashes(); const j=await post('/api/torrents/move',{hashes,path:p,move_data:!!($('moveDataPhysical')?.checked),recheck:!!($('moveRecheck')?.checked)}); markQueuedJobs(j,hashes,'move'); const parts=Number(j.bulk_parts||1); toast(parts>1?`move queued in ${parts} bulk parts`:$('moveDataPhysical')?.checked?'physical move queued':'move queued','success'); } else if($(pathTarget)) $(pathTarget).value=p; bootstrap.Modal.getInstance($('pathModal'))?.hide();}); document.querySelectorAll('.browse-path').forEach(b=>b.addEventListener('click',()=>openPathPicker(b.dataset.target))); function renderColumnManager(){ const box=$('columnManager'); if(!box) return; box.innerHTML=COLUMN_DEFS.map(([key,label])=>``).join(''); } $('saveColumnsBtn')?.addEventListener('click',async()=>{ document.querySelectorAll('.column-toggle').forEach(cb=>cb.checked?hiddenColumns.delete(cb.dataset.colKey):hiddenColumns.add(cb.dataset.colKey)); applyColumnVisibility(); scheduleRender(true); await post('/api/preferences',{table_columns_json:JSON.stringify({hidden:[...hiddenColumns]})}).catch(e=>toast(e.message,'danger')); toast('Columns saved','success'); }); From 1ff1525f0bb2fc505d0efe9180ed0d207c6a992f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Mon, 4 May 2026 21:08:30 +0200 Subject: [PATCH 5/8] bulk-part-jobs, and scgi retries --- .env.example | 1 + pytorrent/routes/api.py | 26 +++++--- pytorrent/services/rtorrent.py | 114 +++++++++++++++++++++++++++------ pytorrent/services/workers.py | 6 +- pytorrent/static/app.js | 6 +- 5 files changed, 120 insertions(+), 33 deletions(-) diff --git a/.env.example b/.env.example index 475123d..372ebc4 100644 --- a/.env.example +++ b/.env.example @@ -7,6 +7,7 @@ PYTORRENT_POLL_INTERVAL=1.0 PYTORRENT_WORKERS=16 PYTORRENT_GEOIP_DB=data/GeoLite2-City.mmdb PYTORRENT_ALLOW_UNSAFE_WERKZEUG=0 +PYTORRENT_SCGI_RETRIES=8 # Retention / Smart Queue PYTORRENT_TRAFFIC_HISTORY_RETENTION_DAYS=90 diff --git a/pytorrent/routes/api.py b/pytorrent/routes/api.py index 95f14d2..1e131dc 100644 --- a/pytorrent/routes/api.py +++ b/pytorrent/routes/api.py @@ -311,13 +311,13 @@ def _chunk_hashes(hashes: list[str], size: int = MOVE_BULK_MAX_HASHES) -> list[l return [hashes[index:index + safe_size] for index in range(0, len(hashes), safe_size)] -def enqueue_move_bulk_parts(profile: dict, data: dict) -> list[dict]: - # Note: Keeps the existing move action intact for normal batches, while large moves are queued as bulk-1, bulk-2, etc. - base_payload = enrich_bulk_payload(profile, "move", data) +def enqueue_bulk_parts(profile: dict, action_name: str, data: dict) -> list[dict]: + # Note: Jedna wspolna funkcja dzieli duze operacje move/remove na male, uporzadkowane party bez ruszania pozostalych akcji. + base_payload = enrich_bulk_payload(profile, action_name, data) hashes = base_payload.get("hashes") or [] chunks = _chunk_hashes(hashes) if len(chunks) <= 1: - job_id = enqueue("move", profile["id"], base_payload) + job_id = enqueue(action_name, profile["id"], base_payload) return [{"job_id": job_id, "label": "bulk-1", "part": 1, "parts": 1, "hashes": hashes, "hash_count": len(hashes)}] jobs = [] @@ -336,11 +336,21 @@ def enqueue_move_bulk_parts(profile: dict, data: dict) -> list[dict]: "items": [items_by_hash[h] for h in chunk if h in items_by_hash], }) payload["job_context"] = context - job_id = enqueue("move", profile["id"], payload) + job_id = enqueue(action_name, profile["id"], payload) jobs.append({"job_id": job_id, "label": context["bulk_label"], "part": index, "parts": len(chunks), "hashes": chunk, "hash_count": len(chunk)}) return jobs +def enqueue_move_bulk_parts(profile: dict, data: dict) -> list[dict]: + # Note: Zachowuje stary publiczny helper dla move, ale korzysta z tej samej logiki partycji. + return enqueue_bulk_parts(profile, "move", data) + + +def enqueue_remove_bulk_parts(profile: dict, data: dict) -> list[dict]: + # Note: Remove/rm dostaje identyczne dzielenie na party jak move, co zmniejsza load na rTorrent. + return enqueue_bulk_parts(profile, "remove", data) + + @bp.get("/profiles") def profiles_list(): return ok({"profiles": preferences.list_profiles(), "active": preferences.active_profile()}) @@ -475,9 +485,9 @@ def torrent_action(action_name: str): allowed = {"start", "pause", "stop", "resume", "recheck", "reannounce", "remove", "move", "set_label", "set_ratio_group"} if action_name not in allowed: return jsonify({"ok": False, "error": "Unknown action"}), 400 - if action_name == "move": - # Note: Large move requests are split into ordered bulk parts; smaller requests keep the old single-job response shape. - jobs = enqueue_move_bulk_parts(profile, data) + if action_name in {"move", "remove"}: + # Note: Large move/remove requests are split into ordered bulk parts; smaller requests keep the old single-job response shape. + jobs = enqueue_bulk_parts(profile, action_name, data) first_job_id = jobs[0]["job_id"] if jobs else None total_hashes = sum(int(job.get("hash_count") or 0) for job in jobs) return ok({ diff --git a/pytorrent/services/rtorrent.py b/pytorrent/services/rtorrent.py index 8464fed..b2af777 100644 --- a/pytorrent/services/rtorrent.py +++ b/pytorrent/services/rtorrent.py @@ -1,5 +1,6 @@ from __future__ import annotations +import errno import os import posixpath import socket @@ -53,24 +54,57 @@ class ScgiRtorrentClient: } header_blob = b"".join(k.encode() + b"\0" + v.encode() + b"\0" for k, v in headers.items()) payload = str(len(header_blob)).encode("ascii") + b":" + header_blob + b"," + body - with socket.create_connection((self.host, self.port), timeout=self.timeout) as sock: - sock.settimeout(self.timeout) - sock.sendall(payload) - chunks: list[bytes] = [] - while True: - chunk = sock.recv(65536) - if not chunk: - break - chunks.append(chunk) - response = b"".join(chunks) - if not response: - raise ConnectionError("Empty response from rTorrent SCGI") - if b"\r\n\r\n" in response: - response = response.split(b"\r\n\r\n", 1)[1] - elif b"\n\n" in response: - response = response.split(b"\n\n", 1)[1] - result, _ = loads(response) - return result[0] if len(result) == 1 else result + attempts = _scgi_retry_attempts() + last_exc = None + for attempt in range(1, attempts + 1): + try: + with socket.create_connection((self.host, self.port), timeout=self.timeout) as sock: + sock.settimeout(self.timeout) + sock.sendall(payload) + chunks: list[bytes] = [] + while True: + chunk = sock.recv(65536) + if not chunk: + break + chunks.append(chunk) + response = b"".join(chunks) + if not response: + raise ConnectionError("Empty response from rTorrent SCGI") + if b"\r\n\r\n" in response: + response = response.split(b"\r\n\r\n", 1)[1] + elif b"\n\n" in response: + response = response.split(b"\n\n", 1)[1] + result, _ = loads(response) + return result[0] if len(result) == 1 else result + except Exception as exc: + last_exc = exc + if attempt >= attempts or not _is_transient_scgi_error(exc): + raise + time.sleep(_scgi_retry_delay(attempt)) + raise last_exc or ConnectionError("rTorrent SCGI call failed") + + +def _scgi_retry_attempts() -> int: + # Note: Krotki retry/backoff chroni masowe operacje przed chwilowym Errno 111 przy wysokim loadzie rTorrent. + try: + return max(1, min(10, int(os.environ.get("PYTORRENT_SCGI_RETRIES", "5")))) + except Exception: + return 5 + + +def _scgi_retry_delay(attempt: int) -> float: + return min(5.0, 0.35 * (2 ** max(0, attempt - 1))) + + +def _is_transient_scgi_error(exc: Exception) -> bool: + # Note: Retry obejmuje typowe chwilowe bledy SCGI/socket, ale nie ukrywa bledow merytorycznych XML-RPC. + if isinstance(exc, (ConnectionRefusedError, ConnectionResetError, TimeoutError, socket.timeout)): + return True + err_no = getattr(exc, "errno", None) + if err_no in {errno.ECONNREFUSED, errno.ECONNRESET, errno.ETIMEDOUT, errno.EHOSTUNREACH, errno.ENETUNREACH}: + return True + msg = str(exc).lower() + return any(text in msg for text in ("connection refused", "connection reset", "timed out", "empty response")) def client_for(profile: dict) -> ScgiRtorrentClient: @@ -159,7 +193,7 @@ def _run_remote_move(c: ScgiRtorrentClient, src: str, dst: str, poll_interval: f try: output = str(_rt_execute(c, "execute.capture", "sh", "-c", poll_script, "pytorrent-move-poll", status_path) or "").strip() except Exception as exc: - if _is_rt_timeout_error(exc): + if _is_rt_timeout_error(exc) or _is_transient_scgi_error(exc): continue raise if not output: @@ -207,6 +241,46 @@ def _safe_rm_rf_path(path: str) -> str: return path +def _run_remote_rm(c: ScgiRtorrentClient, path: str, poll_interval: float = 2.0) -> None: + # Note: rm -rf dziala w tle po stronie rTorrent, wiec dlugie kasowanie nie trzyma jednego polaczenia SCGI. + token = uuid.uuid4().hex + status_path = f"/tmp/pytorrent-rm-{token}.status" + script = ( + 'target=$1; status=$2; tmp=${status}.tmp; ' + 'rm -f "$status" "$tmp"; ' + '( rc=0; ' + 'if [ -z "$target" ] || [ "$target" = "/" ] || [ "$target" = "." ]; then echo "unsafe remove target: $target" >&2; rc=5; ' + 'else rm -rf -- "$target" || rc=$?; fi; ' + 'if [ $rc -eq 0 ]; then printf "OK\n" > "$status"; else printf "ERR %s\n" "$rc" > "$status"; fi; ' + 'if [ -s "$tmp" ]; then cat "$tmp" >> "$status"; fi; ' + 'rm -f "$tmp" ) > "$tmp" 2>&1 &' + ) + poll_script = 'status=$1; [ -f "$status" ] && cat "$status" || true' + cleanup_script = 'rm -f "$1"' + _rt_execute_allow_timeout(c, "execute.throw", "sh", "-c", script, "pytorrent-rm-start", path, status_path) + while True: + time.sleep(max(0.25, poll_interval)) + try: + output = str(_rt_execute(c, "execute.capture", "sh", "-c", poll_script, "pytorrent-rm-poll", status_path) or "").strip() + except Exception as exc: + if _is_rt_timeout_error(exc) or _is_transient_scgi_error(exc): + continue + raise + if not output: + continue + try: + _rt_execute(c, "execute.throw", "sh", "-c", cleanup_script, "pytorrent-rm-clean", status_path) + except Exception: + pass + first_line = output.splitlines()[0].strip() + if first_line == "OK": + return + if first_line.startswith("ERR"): + details = "\n".join(output.splitlines()[1:]).strip() + raise RuntimeError(details or first_line) + raise RuntimeError(output) + + def _remove_torrent_data(c: ScgiRtorrentClient, torrent_hash: str) -> dict: data_path = _safe_rm_rf_path(_torrent_data_path(c, torrent_hash)) try: @@ -217,7 +291,7 @@ def _remove_torrent_data(c: ScgiRtorrentClient, torrent_hash: str) -> dict: c.call("d.close", torrent_hash) except Exception: pass - _rt_execute(c, "execute.throw", "rm", "-rf", data_path) + _run_remote_rm(c, data_path) return {"hash": torrent_hash, "removed_path": data_path} diff --git a/pytorrent/services/workers.py b/pytorrent/services/workers.py index 560da78..5a0a3bc 100644 --- a/pytorrent/services/workers.py +++ b/pytorrent/services/workers.py @@ -236,9 +236,9 @@ def list_jobs(limit: int = 200, offset: int = 0): def cancel_job(job_id: str) -> bool: row = _job_row(job_id) - if not row or row["status"] in {"done", "cancelled"}: + if not row or row["status"] not in {"pending", "running"}: return False - # Awaryjne anulowanie: pending, running i failed można oznaczyć jako cancelled z poziomu użytkownika. + # Note: Emergency cancel ma sens tylko dla niedokonczonych zadan; failed/done zostaja tylko do retry albo czyszczenia logow. _set_job(job_id, "cancelled", finished=True) _emit("job_update", {"id": job_id, "status": "cancelled"}) return True @@ -254,7 +254,7 @@ def emergency_clear_jobs() -> int: # Awaryjne czyszczenie: najpierw zamyka aktywne zadania jako cancelled, potem czyści całą listę job logów. now = utcnow() with connect() as conn: - conn.execute("UPDATE jobs SET status='cancelled', error='Emergency cancelled by user', finished_at=COALESCE(finished_at, ?), updated_at=? WHERE status IN ('pending', 'running', 'failed')", (now, now)) + conn.execute("UPDATE jobs SET status='cancelled', error='Emergency cancelled by user', finished_at=COALESCE(finished_at, ?), updated_at=? WHERE status IN ('pending', 'running')", (now, now)) cur = conn.execute("DELETE FROM jobs") deleted = int(cur.rowcount or 0) _emit("job_update", {"status": "cleared", "emergency": True}) diff --git a/pytorrent/static/app.js b/pytorrent/static/app.js index 51ae53e..1fcb77c 100644 --- a/pytorrent/static/app.js +++ b/pytorrent/static/app.js @@ -217,7 +217,7 @@ function setSelectionRange(hash, keepExisting=false){ const current=visibleRows.findIndex(t=>t.hash===hash); const last=visibleRows.findIndex(t=>t.hash===lastSelectedHash); if(current<0 || last<0){ selected.add(hash); lastSelectedHash=hash; return; } if(!keepExisting) selected.clear(); const a=Math.min(current,last), b=Math.max(current,last); visibleRows.slice(a,b+1).forEach(t=>selected.add(t.hash)); selectedHash=hash; } async function post(url,data,method='POST'){ const res=await fetch(url,{method,headers:{'Content-Type':'application/json'},body:JSON.stringify(data||{})}); const json=await res.json(); if(!json.ok) throw new Error(json.error||'Operation failed'); return json; } - async function runAction(action, extra={}){ const hashes=selectedHashes(); if(!hashes.length) return toast('No torrents selected','warning'); let payload={hashes,...extra}; if(action==='move'){ openPathPicker('move'); return; } setBusy(true); try{ const j=await post(`/api/torrents/${action}`,payload); markQueuedJobs(j, hashes, action); if(action==='recheck'){ hashes.forEach(h=>{ const t=torrents.get(h); if(t) torrents.set(h,{...t,status:'Checking',hashing:1,message:'Force recheck queued'}); }); scheduleRender(true); } toast(`${action} queued`,'success'); if(action==='set_label') await loadLabels(); }catch(e){toast(e.message,'danger');} finally{setBusy(false);} } + async function runAction(action, extra={}){ const hashes=selectedHashes(); if(!hashes.length) return toast('No torrents selected','warning'); let payload={hashes,...extra}; if(action==='move'){ openPathPicker('move'); return; } setBusy(true); try{ const j=await post(`/api/torrents/${action}`,payload); markQueuedJobs(j, hashes, action); if(action==='recheck'){ hashes.forEach(h=>{ const t=torrents.get(h); if(t) torrents.set(h,{...t,status:'Checking',hashing:1,message:'Force recheck queued'}); }); scheduleRender(true); } const parts=Number(j.bulk_parts||1); toast(parts>1?`${action} queued in ${parts} bulk parts`:`${action} queued`,'success'); if(action==='set_label') await loadLabels(); }catch(e){toast(e.message,'danger');} finally{setBusy(false);} } function flag(iso){ const code=String(iso||'').toLowerCase(); return code?` ${esc(code.toUpperCase())}`:'-'; } function table(headers,rows){ return `${headers.map(h=>``).join('')}${rows.map(r=>`${r.map(c=>``).join('')}`).join('')}
${esc(h)}
${c}
`; } function renderGeneral(){ const t=torrents.get(selectedHash); const labels=t?labelNames(t.label).map(l=>` ${esc(l)}`).join(' '):''; $('detailPane').innerHTML=t?`
Name${esc(t.name)}
Hash${esc(t.hash)}
Path${esc(t.path)}
Size${esc(t.size_h)}
Progress${esc(t.progress)}%
Ratio${esc(t.ratio)}
Downloaded${esc(t.down_total_h)}
Uploaded${esc(t.up_total_h)}
Labels${labels||'-'}
Ratio group${esc(t.ratio_group||'')}
`:'Select a torrent.'; } @@ -317,8 +317,10 @@ $('saveColumnsBtn')?.addEventListener('click',async()=>{ document.querySelectorAll('.column-toggle').forEach(cb=>cb.checked?hiddenColumns.delete(cb.dataset.colKey):hiddenColumns.add(cb.dataset.colKey)); applyColumnVisibility(); scheduleRender(true); await post('/api/preferences',{table_columns_json:JSON.stringify({hidden:[...hiddenColumns]})}).catch(e=>toast(e.message,'danger')); toast('Columns saved','success'); }); $('resetColumnsBtn')?.addEventListener('click',async()=>{ hiddenColumns.clear(); renderColumnManager(); applyColumnVisibility(); scheduleRender(true); await post('/api/preferences',{table_columns_json:JSON.stringify({hidden:[]})}).catch(()=>{}); }); - async function loadJobs(page=jobsPage){ const box=$('jobsTable'); if(!box)return; jobsPage=Math.max(0,page|0); box.innerHTML=' Loading jobs...'; const offset=jobsPage*jobsLimit; const j=await (await fetch(`/api/jobs?limit=${jobsLimit}&offset=${offset}`)).json(); const rows=j.jobs||[]; jobsTotal=Number(j.total||rows.length); const details=r=>{ const count=Number(r.hash_count||0); if(r.is_bulk || count>1) return `bulk
${esc(count)} torrent(s), details hidden`; const bits=[]; if(count) bits.push(`${esc(count)} torrent`); if(r.summary) bits.push(esc(r.summary)); return bits.join('
') || '-'; }; box.innerHTML=table(['Status','Action','Profile','Count','Details','Attempts','Started','Finished','Error','Actions'],rows.map(r=>[`${esc(r.status)}`,esc(r.action),esc(r.profile_id),esc(r.hash_count||0),details(r),esc(r.attempts||0),dateCell(r.started_at||r.created_at),dateCell(r.finished_at||r.updated_at),compactCell(r.error||'',140),` `])); renderJobsPager(); } + function jobActions(r){ const id=esc(r.id); const status=String(r.status||''); const actions=[]; if(status==='failed'||status==='cancelled') actions.push(``); if(status==='pending'||status==='running') actions.push(``); return actions.join(' ') || '-'; } + async function loadJobs(page=jobsPage){ const box=$('jobsTable'); if(!box)return; jobsPage=Math.max(0,page|0); box.innerHTML=' Loading jobs...'; const offset=jobsPage*jobsLimit; const j=await (await fetch(`/api/jobs?limit=${jobsLimit}&offset=${offset}`)).json(); const rows=j.jobs||[]; jobsTotal=Number(j.total||rows.length); const details=r=>{ const count=Number(r.hash_count||0); if(r.is_bulk || count>1) return `bulk
${esc(count)} torrent(s), details hidden`; const bits=[]; if(count) bits.push(`${esc(count)} torrent`); if(r.summary) bits.push(esc(r.summary)); return bits.join('
') || '-'; }; box.innerHTML=table(['Status','Action','Profile','Count','Details','Attempts','Started','Finished','Error','Actions'],rows.map(r=>[`${esc(r.status)}`,esc(r.action),esc(r.profile_id),esc(r.hash_count||0),details(r),esc(r.attempts||0),dateCell(r.started_at||r.created_at),dateCell(r.finished_at||r.updated_at),compactCell(r.error||'',140),jobActions(r)])); renderJobsPager(); } function renderJobsPager(){ const p=$('jobsPager'); if(!p)return; const pages=Math.max(1,Math.ceil(jobsTotal/jobsLimit)); p.innerHTML=`
Page ${jobsPage+1} / ${pages} · ${jobsTotal} jobs
`; $('jobsPrev')?.addEventListener('click',()=>loadJobs(jobsPage-1)); $('jobsNext')?.addEventListener('click',()=>loadJobs(jobsPage+1)); } + // Note: Przyciski w job logu sa zalezne od statusu: failed ma retry, a emergency cancel tylko pending/running. $('jobsModal')?.addEventListener('show.bs.modal',loadJobs); $('refreshJobsBtn')?.addEventListener('click',loadJobs); $('jobsTable')?.addEventListener('click',async e=>{ const btn=e.target.closest('.job-retry,.job-cancel'); if(!btn)return; const id=btn.dataset.id; if(!id)return; if(btn.classList.contains('job-retry')) await post(`/api/jobs/${id}/retry`,{}).catch(x=>toast(x.message,'danger')); if(btn.classList.contains('job-cancel')){ const st=btn.dataset.status||''; if((st==='pending'||st==='running') && !confirm('Emergency cancel this unfinished job?')) return; await post(`/api/jobs/${id}/cancel`,{}).catch(x=>toast(x.message,'danger')); } loadJobs(); }); $('clearJobsBtn')?.addEventListener('click',async()=>{ const emergency=confirm('Emergency clear all job logs, including unfinished jobs? OK = emergency clear, Cancel = clear only finished logs.'); if(!emergency && !confirm('Clear finished job logs? Pending and running jobs will stay.')) return; try{ const j=await post(`/api/jobs/clear${emergency?'?force=1':''}`,{}); toast(`${emergency?'Emergency cleared':'Cleared'} ${j.deleted||0} job log(s)`,'success'); jobsPage=0; loadJobs(0); }catch(e){ toast(e.message,'danger'); } }); From d5b7d97528b43f3ce9a1b0d6517602f99e19713f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Mon, 4 May 2026 21:14:17 +0200 Subject: [PATCH 6/8] badge colors --- pytorrent/static/app.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/pytorrent/static/app.js b/pytorrent/static/app.js index 1fcb77c..06a6523 100644 --- a/pytorrent/static/app.js +++ b/pytorrent/static/app.js @@ -318,7 +318,12 @@ $('resetColumnsBtn')?.addEventListener('click',async()=>{ hiddenColumns.clear(); renderColumnManager(); applyColumnVisibility(); scheduleRender(true); await post('/api/preferences',{table_columns_json:JSON.stringify({hidden:[]})}).catch(()=>{}); }); function jobActions(r){ const id=esc(r.id); const status=String(r.status||''); const actions=[]; if(status==='failed'||status==='cancelled') actions.push(``); if(status==='pending'||status==='running') actions.push(``); return actions.join(' ') || '-'; } - async function loadJobs(page=jobsPage){ const box=$('jobsTable'); if(!box)return; jobsPage=Math.max(0,page|0); box.innerHTML=' Loading jobs...'; const offset=jobsPage*jobsLimit; const j=await (await fetch(`/api/jobs?limit=${jobsLimit}&offset=${offset}`)).json(); const rows=j.jobs||[]; jobsTotal=Number(j.total||rows.length); const details=r=>{ const count=Number(r.hash_count||0); if(r.is_bulk || count>1) return `bulk
${esc(count)} torrent(s), details hidden`; const bits=[]; if(count) bits.push(`${esc(count)} torrent`); if(r.summary) bits.push(esc(r.summary)); return bits.join('
') || '-'; }; box.innerHTML=table(['Status','Action','Profile','Count','Details','Attempts','Started','Finished','Error','Actions'],rows.map(r=>[`${esc(r.status)}`,esc(r.action),esc(r.profile_id),esc(r.hash_count||0),details(r),esc(r.attempts||0),dateCell(r.started_at||r.created_at),dateCell(r.finished_at||r.updated_at),compactCell(r.error||'',140),jobActions(r)])); renderJobsPager(); } + function jobStatusBadgeClass(status){ + // Note: Status running oznacza aktywna prace, dlatego uzywa primary zamiast danger; danger zostaje tylko dla failed. + const classes={done:'success',failed:'danger',running:'primary',cancelled:'secondary',pending:'warning'}; + return classes[String(status||'')] || 'warning'; + } + async function loadJobs(page=jobsPage){ const box=$('jobsTable'); if(!box)return; jobsPage=Math.max(0,page|0); box.innerHTML=' Loading jobs...'; const offset=jobsPage*jobsLimit; const j=await (await fetch(`/api/jobs?limit=${jobsLimit}&offset=${offset}`)).json(); const rows=j.jobs||[]; jobsTotal=Number(j.total||rows.length); const details=r=>{ const count=Number(r.hash_count||0); if(r.is_bulk || count>1) return `bulk
${esc(count)} torrent(s), details hidden`; const bits=[]; if(count) bits.push(`${esc(count)} torrent`); if(r.summary) bits.push(esc(r.summary)); return bits.join('
') || '-'; }; box.innerHTML=table(['Status','Action','Profile','Count','Details','Attempts','Started','Finished','Error','Actions'],rows.map(r=>[`${esc(r.status)}`,esc(r.action),esc(r.profile_id),esc(r.hash_count||0),details(r),esc(r.attempts||0),dateCell(r.started_at||r.created_at),dateCell(r.finished_at||r.updated_at),compactCell(r.error||'',140),jobActions(r)])); renderJobsPager(); } function renderJobsPager(){ const p=$('jobsPager'); if(!p)return; const pages=Math.max(1,Math.ceil(jobsTotal/jobsLimit)); p.innerHTML=`
Page ${jobsPage+1} / ${pages} · ${jobsTotal} jobs
`; $('jobsPrev')?.addEventListener('click',()=>loadJobs(jobsPage-1)); $('jobsNext')?.addEventListener('click',()=>loadJobs(jobsPage+1)); } // Note: Przyciski w job logu sa zalezne od statusu: failed ma retry, a emergency cancel tylko pending/running. $('jobsModal')?.addEventListener('show.bs.modal',loadJobs); $('refreshJobsBtn')?.addEventListener('click',loadJobs); $('jobsTable')?.addEventListener('click',async e=>{ const btn=e.target.closest('.job-retry,.job-cancel'); if(!btn)return; const id=btn.dataset.id; if(!id)return; if(btn.classList.contains('job-retry')) await post(`/api/jobs/${id}/retry`,{}).catch(x=>toast(x.message,'danger')); if(btn.classList.contains('job-cancel')){ const st=btn.dataset.status||''; if((st==='pending'||st==='running') && !confirm('Emergency cancel this unfinished job?')) return; await post(`/api/jobs/${id}/cancel`,{}).catch(x=>toast(x.message,'danger')); } loadJobs(); }); From 879c60d563afdd935339ca62392eee95acae2e37 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Mon, 4 May 2026 21:37:02 +0200 Subject: [PATCH 7/8] execute.capture fix --- pytorrent/services/rtorrent.py | 88 +- pytorrent/static/styles.css | 1397 ++++++++++++++++++++++++-------- 2 files changed, 1145 insertions(+), 340 deletions(-) diff --git a/pytorrent/services/rtorrent.py b/pytorrent/services/rtorrent.py index b2af777..226f866 100644 --- a/pytorrent/services/rtorrent.py +++ b/pytorrent/services/rtorrent.py @@ -104,7 +104,7 @@ def _is_transient_scgi_error(exc: Exception) -> bool: if err_no in {errno.ECONNREFUSED, errno.ECONNRESET, errno.ETIMEDOUT, errno.EHOSTUNREACH, errno.ENETUNREACH}: return True msg = str(exc).lower() - return any(text in msg for text in ("connection refused", "connection reset", "timed out", "empty response")) + return any(text in msg for text in ("connection refused", "connection reset", "timed out", "timeout", "empty response", "pipe creation failed", "resource temporarily unavailable", "try again", "temporarily unavailable")) def client_for(profile: dict) -> ScgiRtorrentClient: @@ -112,32 +112,78 @@ def client_for(profile: dict) -> ScgiRtorrentClient: _UNSUPPORTED_EXEC_METHODS: set[str] = set() +_EXEC_TARGET_STYLE: dict[str, int] = {} + +def _rt_execute_preview(method_name: str, call_args: tuple) -> str: + # Note: Skrocony opis RPC usuwa dlugie skrypty z komunikatu bledu, ale zostawia metode i pierwsze argumenty do diagnostyki. + preview = ", ".join(repr(x) for x in call_args[:3]) + if len(call_args) > 3: + preview += ", ..." + return f"{method_name}({preview})" + + +def _rt_execute_target_variants(method: str, args: tuple) -> list[tuple]: + # Note: rTorrent XML-RPC w zaleznosci od wersji wymaga pustego targetu albo go odrzuca; zapamietujemy dzialajacy wariant per metoda. + variants = [("", *args), args] + preferred = _EXEC_TARGET_STYLE.get(method) + if preferred is not None and 0 <= preferred < len(variants): + return [variants[preferred]] + [v for i, v in enumerate(variants) if i != preferred] + return variants + + +def _is_rt_method_missing(exc: Exception) -> bool: + msg = str(exc).lower() + return "not defined" in msg or "no such method" in msg or "unknown method" in msg + + +def _rt_execute_methods(method: str) -> list[str]: + # Note: execute2.* jest probowane dopiero gdy podstawowe execute.* nie istnieje, zeby nie generowac falszywych bledow retry. + methods = [method] + if method.startswith("execute."): + fallback = method.replace("execute.", "execute2.", 1) + if fallback not in _UNSUPPORTED_EXEC_METHODS: + methods.append(fallback) + return methods + def _rt_execute(c: ScgiRtorrentClient, method: str, *args): """Run rTorrent execute.* as the rTorrent user across XML-RPC variants.""" - method_names = [method] - if method.startswith("execute."): - execute2 = method.replace("execute.", "execute2.", 1) - if execute2 not in _UNSUPPORTED_EXEC_METHODS: - method_names.append(execute2) - errors = [] - for method_name in method_names: - for call_args in (("", *args), args): - try: - return c.call(method_name, *call_args) - except Exception as exc: - message = str(exc) - if "not defined" in message.lower(): - _UNSUPPORTED_EXEC_METHODS.add(method_name) - preview = ", ".join(repr(x) for x in call_args[:3]) - if len(call_args) > 3: - preview += ", ..." - errors.append(f"{method_name}({preview}): {exc}") + errors: list[str] = [] + attempts = _scgi_retry_attempts() + for attempt in range(1, attempts + 1): + errors.clear() + transient_seen = False + primary_missing = False + for method_index, method_name in enumerate(_rt_execute_methods(method)): + if method_name in _UNSUPPORTED_EXEC_METHODS: + continue + if method_index > 0 and not primary_missing: + continue + for call_args in _rt_execute_target_variants(method_name, args): + try: + result = c.call(method_name, *call_args) + if method_name == method: + _EXEC_TARGET_STYLE[method_name] = 0 if call_args and call_args[0] == "" else 1 + return result + except Exception as exc: + if _is_rt_method_missing(exc): + _UNSUPPORTED_EXEC_METHODS.add(method_name) + if method_name == method: + primary_missing = True + errors.append(f"{method_name}: method not defined") + break + transient_seen = transient_seen or _is_transient_scgi_error(exc) + errors.append(f"{_rt_execute_preview(method_name, call_args)}: {exc}") + if transient_seen and attempt < attempts: + time.sleep(_scgi_retry_delay(attempt)) + continue + break raise RuntimeError("rTorrent execute failed: " + "; ".join(errors)) def _is_rt_timeout_error(exc: Exception) -> bool: - return isinstance(exc, (TimeoutError, socket.timeout)) or "timed out" in str(exc).lower() + msg = str(exc).lower() + return isinstance(exc, (TimeoutError, socket.timeout)) or "timed out" in msg or "timeout" in msg def _rt_execute_allow_timeout(c: ScgiRtorrentClient, method: str, *args): @@ -193,6 +239,7 @@ def _run_remote_move(c: ScgiRtorrentClient, src: str, dst: str, poll_interval: f try: output = str(_rt_execute(c, "execute.capture", "sh", "-c", poll_script, "pytorrent-move-poll", status_path) or "").strip() except Exception as exc: + # Note: Podczas masowego move rTorrent potrafi chwilowo nie utworzyc pipe dla execute.capture; polling czeka i probuje dalej. if _is_rt_timeout_error(exc) or _is_transient_scgi_error(exc): continue raise @@ -263,6 +310,7 @@ def _run_remote_rm(c: ScgiRtorrentClient, path: str, poll_interval: float = 2.0) try: output = str(_rt_execute(c, "execute.capture", "sh", "-c", poll_script, "pytorrent-rm-poll", status_path) or "").strip() except Exception as exc: + # Note: Remove uzywa tego samego bezpiecznego pollingu co move, wiec chwilowy brak pipe nie wywala calej kolejki. if _is_rt_timeout_error(exc) or _is_transient_scgi_error(exc): continue raise diff --git a/pytorrent/static/styles.css b/pytorrent/static/styles.css index f2e5fd6..9f74a96 100644 --- a/pytorrent/static/styles.css +++ b/pytorrent/static/styles.css @@ -1,5 +1,7 @@ +/* Note: CSS po zmianach jest formatowany jednolicie; nie dodano nowych zduplikowanych klas ani nadpisan selektorow. */ :root { - --app-font-family: Inter, system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif; + --app-font-family: + Inter, system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif; --topbar: 50px; --statusbar: 34px; --sidebar: 270px; @@ -7,10 +9,10 @@ } [data-bs-theme="dark"] { --bs-body-bg: #05070a; - --bs-body-bg-rgb: 5,7,10; + --bs-body-bg-rgb: 5, 7, 10; --bs-body-color: #d6dde8; --bs-secondary-bg: #0a0f16; - --bs-secondary-bg-rgb: 10,15,22; + --bs-secondary-bg-rgb: 10, 15, 22; --bs-tertiary-bg: #0e141d; --bs-border-color: #1d2734; --bs-secondary-color: #8d98aa; @@ -19,12 +21,33 @@ --torrent-progress-complete: #2f9e75; } -html[data-app-font="adwaita-mono"] { --app-font-family: "Adwaita Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace; } -html[data-app-font="inter"] { --app-font-family: Inter, system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif; } -html[data-app-font="system-ui"] { --app-font-family: system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif; } -html[data-app-font="source-sans-3"] { --app-font-family: "Source Sans 3", "Source Sans Pro", system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif; } -html[data-app-font="jetbrains-mono"] { --app-font-family: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace; } -html, body { height: 100%; } +html[data-app-font="adwaita-mono"] { + --app-font-family: + "Adwaita Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, + "Liberation Mono", monospace; +} +html[data-app-font="inter"] { + --app-font-family: + Inter, system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif; +} +html[data-app-font="system-ui"] { + --app-font-family: + system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif; +} +html[data-app-font="source-sans-3"] { + --app-font-family: + "Source Sans 3", "Source Sans Pro", system-ui, -apple-system, Segoe UI, + Roboto, Arial, sans-serif; +} +html[data-app-font="jetbrains-mono"] { + --app-font-family: + "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, + "Liberation Mono", monospace; +} +html, +body { + height: 100%; +} body { overflow: hidden; font-size: 13px; @@ -40,31 +63,93 @@ body { border: 1px solid var(--bs-border-color); border-radius: 12px; overflow: hidden; - box-shadow: 0 12px 45px rgba(0,0,0,.38); + box-shadow: 0 12px 45px rgba(0, 0, 0, 0.38); } .topbar { display: flex; align-items: center; justify-content: space-between; - gap: .75rem; - padding: .42rem .7rem; + gap: 0.75rem; + padding: 0.42rem 0.7rem; min-height: var(--topbar); background: var(--bs-secondary-bg); } -.toolbar-left, .toolbar-right { display: flex; align-items: center; gap: .45rem; min-width: 0; } -.toolbar-left { flex: 0 1 auto; overflow: hidden; } -.toolbar-right { flex: 1 1 0; justify-content: flex-end; margin-left: auto; } -.brand { font-weight: 800; font-size: 1.05rem; letter-spacing: .2px; white-space: nowrap; line-height: 32px; } -.profile-picker-btn { max-width: 180px; } -.profile-picker-btn span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.profile-select { width: 100%; } -.search { width: min(38vw, 420px); min-width: clamp(160px, 20vw, 220px); max-width: 420px; flex: 0 1 420px; } -.mobile-speed-stats { display: none; align-items: center; gap: .45rem; flex: 0 0 auto; color: var(--bs-secondary-color); font-size: .72rem; white-space: nowrap; } -.mobile-speed-stats b { color: var(--bs-body-color); font-weight: 700; } -.topbar .form-control, .topbar .form-select { height: 32px; line-height: 1.15; } -.topbar .btn { min-height: 28px; line-height: 1; } -#themeToggle, #mobileToggle { width: 32px; min-width: 32px; display: inline-flex; align-items: center; justify-content: center; } -.spinner-border-xs { width: .75rem; height: .75rem; border-width: .12em; vertical-align: -1px; } +.toolbar-left, +.toolbar-right { + display: flex; + align-items: center; + gap: 0.45rem; + min-width: 0; +} +.toolbar-left { + flex: 0 1 auto; + overflow: hidden; +} +.toolbar-right { + flex: 1 1 0; + justify-content: flex-end; + margin-left: auto; +} +.brand { + font-weight: 800; + font-size: 1.05rem; + letter-spacing: 0.2px; + white-space: nowrap; + line-height: 32px; +} +.profile-picker-btn { + max-width: 180px; +} +.profile-picker-btn span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.profile-select { + width: 100%; +} +.search { + width: min(38vw, 420px); + min-width: clamp(160px, 20vw, 220px); + max-width: 420px; + flex: 0 1 420px; +} +.mobile-speed-stats { + display: none; + align-items: center; + gap: 0.45rem; + flex: 0 0 auto; + color: var(--bs-secondary-color); + font-size: 0.72rem; + white-space: nowrap; +} +.mobile-speed-stats b { + color: var(--bs-body-color); + font-weight: 700; +} +.topbar .form-control, +.topbar .form-select { + height: 32px; + line-height: 1.15; +} +.topbar .btn { + min-height: 28px; + line-height: 1; +} +#themeToggle, +#mobileToggle { + width: 32px; + min-width: 32px; + display: inline-flex; + align-items: center; + justify-content: center; +} +.spinner-border-xs { + width: 0.75rem; + height: 0.75rem; + border-width: 0.12em; + vertical-align: -1px; +} .global-loader { position: fixed; right: 14px; @@ -72,13 +157,13 @@ body { z-index: 7000; display: inline-flex; align-items: center; - gap: .4rem; - padding: .4rem .65rem; + gap: 0.4rem; + padding: 0.4rem 0.65rem; border-radius: 999px; background: var(--bs-tertiary-bg); color: var(--bs-body-color); border: 1px solid var(--bs-border-color); - box-shadow: 0 8px 28px rgba(0,0,0,.35); + box-shadow: 0 8px 28px rgba(0, 0, 0, 0.35); } .initial-loader { @@ -88,9 +173,15 @@ body { display: grid; place-items: center; padding: 1rem; - background: radial-gradient(circle at 50% 35%, rgba(var(--bs-secondary-bg-rgb), .98), var(--bs-body-bg) 68%); + background: radial-gradient( + circle at 50% 35%, + rgba(var(--bs-secondary-bg-rgb), 0.98), + var(--bs-body-bg) 68% + ); color: var(--bs-body-color); - transition: opacity .22s ease, visibility .22s ease; + transition: + opacity 0.22s ease, + visibility 0.22s ease; } .initial-loader.is-hidden { opacity: 0; @@ -102,14 +193,14 @@ body { padding: 2rem; border: 1px solid var(--bs-border-color); border-radius: 18px; - background: rgba(var(--bs-secondary-bg-rgb), .88); - box-shadow: 0 24px 70px rgba(0,0,0,.48); + background: rgba(var(--bs-secondary-bg-rgb), 0.88); + box-shadow: 0 24px 70px rgba(0, 0, 0, 0.48); text-align: center; } .initial-loader-brand { font-size: 1.35rem; font-weight: 800; - letter-spacing: .2px; + letter-spacing: 0.2px; } .initial-loader-spinner { margin: 1.4rem 0 1rem; @@ -119,23 +210,31 @@ body { font-weight: 700; } .initial-loader-text { - margin-top: .35rem; + margin-top: 0.35rem; color: var(--bs-secondary-color); } -.main-grid { min-height: 0; display: grid; grid-template-columns: var(--sidebar) 1fr; } -.sidebar { padding: .65rem; overflow: auto; background: rgba(var(--bs-secondary-bg-rgb), .9); } +.main-grid { + min-height: 0; + display: grid; + grid-template-columns: var(--sidebar) 1fr; +} +.sidebar { + padding: 0.65rem; + overflow: auto; + background: rgba(var(--bs-secondary-bg-rgb), 0.9); +} /* Note: Sidebar filters are wider and use one structured block per class to avoid duplicate overrides. */ .filter { width: 100%; display: grid; grid-template-columns: minmax(0, 1fr) auto; - gap: .15rem .55rem; + gap: 0.15rem 0.55rem; align-items: center; - margin-bottom: .2rem; - padding: .45rem .6rem; + margin-bottom: 0.2rem; + padding: 0.45rem 0.6rem; border: 0; - border-radius: .55rem; + border-radius: 0.55rem; background: transparent; color: var(--bs-body-color); text-align: left; @@ -164,12 +263,12 @@ body { } .filter-meta { display: block; - margin-top: .05rem; + margin-top: 0.05rem; color: var(--bs-secondary-color); - font-size: .68rem; + font-size: 0.68rem; font-weight: 400; line-height: 1.15; - opacity: .72; + opacity: 0.72; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; @@ -177,9 +276,13 @@ body { .filter.active .filter-meta, .filter:hover .filter-meta { color: var(--bs-primary-text-emphasis); - opacity: .78; + opacity: 0.78; +} +.shortcut { + font-size: 0.78rem; + color: var(--bs-secondary-color); + padding: 0.15rem 0.5rem; } -.shortcut { font-size: .78rem; color: var(--bs-secondary-color); padding: .15rem .5rem; } .content { min-width: 0; min-height: 0; @@ -187,54 +290,183 @@ body { grid-template-rows: minmax(0, 1fr) 255px; position: relative; } -.table-wrap { overflow: auto; contain: content; } -.torrent-table { margin: 0; white-space: nowrap; table-layout: auto; } -.torrent-table thead th { position: sticky; top: 0; z-index: 2; background: var(--bs-tertiary-bg); border-bottom: 1px solid var(--bs-border-color); user-select: none; } -.torrent-table thead th[data-sort] { cursor: pointer; } -.torrent-table thead th[data-sort]:hover, .torrent-table thead th.sorted { color: var(--bs-primary-text-emphasis); } -.sort-icon { opacity: .85; } -.torrent-table tbody tr { cursor: default; height: 36px; } -.torrent-table tbody tr.selected td { background: var(--bs-primary-bg-subtle); } -.torrent-table .sel { width: 34px; text-align: center; } -.torrent-table .name { min-width: 280px; max-width: 520px; overflow: hidden; text-overflow: ellipsis; } -.torrent-table .path { max-width: 360px; overflow: hidden; text-overflow: ellipsis; color: var(--bs-secondary-color); } -.virtual-spacer td { padding: 0 !important; border: 0 !important; } -.empty { height: 120px; text-align: center; vertical-align: middle; color: var(--bs-secondary-color); } -.progress.thin { height: 7px; min-width: 130px; margin-bottom: 1px; background: rgba(255,255,255,.08); } +.table-wrap { + overflow: auto; + contain: content; +} +.torrent-table { + margin: 0; + white-space: nowrap; + table-layout: auto; +} +.torrent-table thead th { + position: sticky; + top: 0; + z-index: 2; + background: var(--bs-tertiary-bg); + border-bottom: 1px solid var(--bs-border-color); + user-select: none; +} +.torrent-table thead th[data-sort] { + cursor: pointer; +} +.torrent-table thead th[data-sort]:hover, +.torrent-table thead th.sorted { + color: var(--bs-primary-text-emphasis); +} +.sort-icon { + opacity: 0.85; +} +.torrent-table tbody tr { + cursor: default; + height: 36px; +} +.torrent-table tbody tr.selected td { + background: var(--bs-primary-bg-subtle); +} +.torrent-table .sel { + width: 34px; + text-align: center; +} +.torrent-table .name { + min-width: 280px; + max-width: 520px; + overflow: hidden; + text-overflow: ellipsis; +} +.torrent-table .path { + max-width: 360px; + overflow: hidden; + text-overflow: ellipsis; + color: var(--bs-secondary-color); +} +.virtual-spacer td { + padding: 0 !important; + border: 0 !important; +} +.empty { + height: 120px; + text-align: center; + vertical-align: middle; + color: var(--bs-secondary-color); +} +.progress.thin { + height: 7px; + min-width: 130px; + margin-bottom: 1px; + background: rgba(255, 255, 255, 0.08); +} .details { grid-row: 2; grid-column: 1; min-height: 0; overflow: hidden; - background: rgba(var(--bs-secondary-bg-rgb), .78); + background: rgba(var(--bs-secondary-bg-rgb), 0.78); +} +.detail-pane { + height: 210px; + overflow: auto; + padding: 0.65rem; +} +.loading-line { + display: flex; + align-items: center; + gap: 0.5rem; + color: var(--bs-secondary-color); + padding: 0.75rem; +} +.muted-pane { + color: var(--bs-secondary-color); +} +.detail-table { + white-space: nowrap; +} +.general-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0.6rem; +} +.general-grid div { + border: 1px solid var(--bs-border-color); + border-radius: 0.6rem; + padding: 0.5rem; + background: var(--bs-body-bg); + min-width: 0; +} +.general-grid b { + display: block; + color: var(--bs-secondary-color); + font-size: 0.72rem; + text-transform: uppercase; +} +.general-grid span { + overflow-wrap: anywhere; +} +.statusbar { + display: flex; + align-items: center; + gap: 1rem; + padding: 0 0.75rem; + overflow-x: auto; + background: var(--bs-tertiary-bg); + color: var(--bs-secondary-color); + white-space: nowrap; +} +.statusbar b { + color: var(--bs-body-color); +} +.status-limit { + border: 1px solid var(--bs-border-color); + background: rgba(var(--bs-secondary-bg-rgb), 0.9); + color: var(--bs-secondary-color); + border-radius: 0.45rem; + padding: 0.12rem 0.5rem; + white-space: nowrap; +} +.status-limit:hover { + color: var(--bs-body-color); + background: var(--bs-secondary-bg); +} +.ctx-menu { + display: none; + position: absolute; + z-index: 5000; + min-width: 200px; + padding: 0.35rem; + border: 1px solid var(--bs-border-color); + border-radius: 0.6rem; + background: var(--bs-body-bg); +} +.ctx-menu button { + display: block; + width: 100%; + text-align: left; + border: 0; + background: transparent; + color: var(--bs-body-color); + padding: 0.42rem 0.55rem; + border-radius: 0.4rem; +} +.ctx-menu button:hover { + background: var(--bs-secondary-bg); +} +.ctx-menu .danger { + color: var(--bs-danger); +} +.ctx-menu hr { + margin: 0.25rem 0; + border-color: var(--bs-border-color); } -.detail-pane { height: 210px; overflow: auto; padding: .65rem; } -.loading-line { display: flex; align-items: center; gap: .5rem; color: var(--bs-secondary-color); padding: .75rem; } -.muted-pane { color: var(--bs-secondary-color); } -.detail-table { white-space: nowrap; } -.general-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: .6rem; } -.general-grid div { border: 1px solid var(--bs-border-color); border-radius: .6rem; padding: .5rem; background: var(--bs-body-bg); min-width: 0; } -.general-grid b { display: block; color: var(--bs-secondary-color); font-size: .72rem; text-transform: uppercase; } -.general-grid span { overflow-wrap: anywhere; } -.statusbar { display: flex; align-items: center; gap: 1rem; padding: 0 .75rem; overflow-x: auto; background: var(--bs-tertiary-bg); color: var(--bs-secondary-color); white-space: nowrap; } -.statusbar b { color: var(--bs-body-color); } -.status-limit { border: 1px solid var(--bs-border-color); background: rgba(var(--bs-secondary-bg-rgb), .9); color: var(--bs-secondary-color); border-radius: .45rem; padding: .12rem .5rem; white-space: nowrap; } -.status-limit:hover { color: var(--bs-body-color); background: var(--bs-secondary-bg); } -.ctx-menu { display: none; position: absolute; z-index: 5000; min-width: 200px; padding: .35rem; border: 1px solid var(--bs-border-color); border-radius: .6rem; background: var(--bs-body-bg); } -.ctx-menu button { display: block; width: 100%; text-align: left; border: 0; background: transparent; color: var(--bs-body-color); padding: .42rem .55rem; border-radius: .4rem; } -.ctx-menu button:hover { background: var(--bs-secondary-bg); } -.ctx-menu .danger { color: var(--bs-danger); } -.ctx-menu hr { margin: .25rem 0; border-color: var(--bs-border-color); } .profile-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; - gap: .25rem .5rem; + gap: 0.25rem 0.5rem; align-items: center; - margin-bottom: .45rem; - padding: .45rem; + margin-bottom: 0.45rem; + padding: 0.45rem; border: 1px solid var(--bs-border-color); - border-radius: .6rem; - background: rgba(var(--bs-secondary-bg-rgb), .58); + border-radius: 0.6rem; + background: rgba(var(--bs-secondary-bg-rgb), 0.58); } .profile-row.active { border-color: var(--bs-primary); @@ -248,23 +480,26 @@ body { .profile-actions, .profile-form-actions { display: inline-flex; - gap: .35rem; + gap: 0.35rem; flex-wrap: wrap; } .profile-form-grid { display: grid; - grid-template-columns: minmax(150px, 1.1fr) minmax(260px, 2.1fr) minmax(90px, .55fr) minmax(120px, .75fr) minmax(145px, auto) auto; - gap: .65rem; + grid-template-columns: minmax(150px, 1.1fr) minmax(260px, 2.1fr) minmax( + 90px, + 0.55fr + ) minmax(120px, 0.75fr) minmax(145px, auto) auto; + gap: 0.65rem; align-items: start; } .profile-form-field { display: grid; - gap: .25rem; + gap: 0.25rem; min-width: 0; } .profile-form-field > span:first-child { color: var(--bs-secondary-color); - font-size: .72rem; + font-size: 0.72rem; font-weight: 700; line-height: 1.1; text-transform: uppercase; @@ -277,114 +512,243 @@ body { min-height: 31px; display: flex; align-items: center; - gap: .45rem; + gap: 0.45rem; +} +.flag-icon { + border-radius: 2px; + box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.12); +} +.flag-code { + color: var(--bs-secondary-color); + margin-left: 0.25rem; +} +.peer-actions { + display: flex; + align-items: center; + gap: 0.25rem; + flex-wrap: nowrap; +} +.peer-actions .btn { + display: inline-flex; + align-items: center; + gap: 0.25rem; + border-radius: 0.35rem !important; +} +.modal-content { + background: var(--bs-body-bg); + border: 1px solid var(--bs-border-color); + border-radius: 14px; +} +.modal-header, +.modal-footer { + background: rgba(var(--bs-secondary-bg-rgb), 0.82); + border-color: var(--bs-border-color); +} +.add-grid { + display: grid; + gap: 0.85rem; +} +.magnet-box { + min-height: 64px; + resize: vertical; +} +.upload-box, +.surface-section { + border: 1px solid var(--bs-border-color); + background: rgba(var(--bs-secondary-bg-rgb), 0.5); + border-radius: 0.75rem; + padding: 0.75rem; +} +.section-title { + font-weight: 700; + margin-bottom: 0.55rem; + color: var(--bs-body-color); +} +.preset-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0.4rem; +} +.toast-host { + position: fixed; + right: 14px; + top: 70px; + z-index: 8000; + display: grid; + gap: 0.4rem; +} +.toast-item { + padding: 0.45rem 0.65rem; + border-radius: 0.55rem; + box-shadow: 0 8px 25px rgba(0, 0, 0, 0.28); + max-width: 360px; } -.flag-icon { border-radius: 2px; box-shadow: 0 0 0 1px rgba(255,255,255,.12); } -.flag-code { color: var(--bs-secondary-color); margin-left: .25rem; } -.peer-actions { display: flex; align-items: center; gap: .25rem; flex-wrap: nowrap; } -.peer-actions .btn { display: inline-flex; align-items: center; gap: .25rem; border-radius: .35rem !important; } -.modal-content { background: var(--bs-body-bg); border: 1px solid var(--bs-border-color); border-radius: 14px; } -.modal-header, .modal-footer { background: rgba(var(--bs-secondary-bg-rgb), .82); border-color: var(--bs-border-color); } -.add-grid { display: grid; gap: .85rem; } -.magnet-box { min-height: 64px; resize: vertical; } -.upload-box, .surface-section { border: 1px solid var(--bs-border-color); background: rgba(var(--bs-secondary-bg-rgb), .5); border-radius: .75rem; padding: .75rem; } -.section-title { font-weight: 700; margin-bottom: .55rem; color: var(--bs-body-color); } -.preset-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: .4rem; } -.toast-host { position: fixed; right: 14px; top: 70px; z-index: 8000; display: grid; gap: .4rem; } -.toast-item { padding: .45rem .65rem; border-radius: .55rem; box-shadow: 0 8px 25px rgba(0,0,0,.28); max-width: 360px; } @media (max-width: 1100px) { - :root { --topbar: 88px; } - .topbar { align-items: flex-start; flex-wrap: wrap; } - .toolbar-left { flex: 1 1 100%; overflow: visible; flex-wrap: wrap; } - .toolbar-right { flex: 1 1 100%; justify-content: flex-end; } - .search { flex: 1 1 220px; width: auto; min-width: 160px; max-width: none; } + :root { + --topbar: 88px; + } + .topbar { + align-items: flex-start; + flex-wrap: wrap; + } + .toolbar-left { + flex: 1 1 100%; + overflow: visible; + flex-wrap: wrap; + } + .toolbar-right { + flex: 1 1 100%; + justify-content: flex-end; + } + .search { + flex: 1 1 220px; + width: auto; + min-width: 160px; + max-width: none; + } } @media (max-width: 900px) { - :root { --sidebar: 0px; } - .sidebar { display: none; } - .general-grid { grid-template-columns: 1fr; } + :root { + --sidebar: 0px; + } + .sidebar { + display: none; + } + .general-grid { + grid-template-columns: 1fr; + } } @media (max-width: 640px) { - :root { --topbar: 132px; } - .toolbar-right { width: 100%; justify-content: flex-start; flex-wrap: nowrap; gap: .35rem; } - .search { flex: 1 1 0; width: auto; min-width: 0; max-width: none; } - .preset-grid { grid-template-columns: 1fr 1fr; } + :root { + --topbar: 132px; + } + .toolbar-right { + width: 100%; + justify-content: flex-start; + flex-wrap: nowrap; + gap: 0.35rem; + } + .search { + flex: 1 1 0; + width: auto; + min-width: 0; + max-width: none; + } + .preset-grid { + grid-template-columns: 1fr 1fr; + } } - .preferences-grid { display: grid; grid-template-columns: repeat(2, minmax(220px, 1fr)); - gap: .75rem; + gap: 0.75rem; +} +.form-field { + display: grid; + gap: 0.3rem; +} +.form-field > span { + color: var(--bs-secondary-color); + font-size: 0.78rem; + font-weight: 700; + text-transform: uppercase; +} +@media (max-width: 640px) { + .preferences-grid { + grid-template-columns: 1fr; + } } -.form-field { display: grid; gap: .3rem; } -.form-field > span { color: var(--bs-secondary-color); font-size: .78rem; font-weight: 700; text-transform: uppercase; } -@media (max-width: 640px) { .preferences-grid { grid-template-columns: 1fr; } } /* Feature additions without changing the existing visual shell */ .date-compact { white-space: nowrap; } .btn-xs { - --bs-btn-padding-y: .18rem; - --bs-btn-padding-x: .42rem; - --bs-btn-font-size: .78rem; - --bs-btn-border-radius: .35rem; + --bs-btn-padding-y: 0.18rem; + --bs-btn-padding-x: 0.42rem; + --bs-btn-font-size: 0.78rem; + --bs-btn-border-radius: 0.35rem; } .nav-btn { - border-radius: .45rem !important; + border-radius: 0.45rem !important; margin: 0 !important; display: inline-flex; align-items: center; justify-content: center; - gap: .25rem; + gap: 0.25rem; } .nav-btn + .nav-btn, -.torrent-action + .torrent-action { margin-left: .08rem !important; } +.torrent-action + .torrent-action { + margin-left: 0.08rem !important; +} .path-list { height: 360px; overflow: auto; border: 1px solid var(--bs-border-color); - border-radius: .6rem; - background: rgba(var(--bs-secondary-bg-rgb), .35); + border-radius: 0.6rem; + background: rgba(var(--bs-secondary-bg-rgb), 0.35); } .path-row { display: flex; align-items: center; - gap: .5rem; - padding: .42rem .6rem; + gap: 0.5rem; + padding: 0.42rem 0.6rem; border-bottom: 1px solid var(--bs-border-color); cursor: pointer; } -.path-row:hover { background: var(--bs-primary-bg-subtle); color: var(--bs-primary-text-emphasis); } -.chips { display: flex; gap: .35rem; flex-wrap: wrap; } +.path-row:hover { + background: var(--bs-primary-bg-subtle); + color: var(--bs-primary-text-emphasis); +} +.chips { + display: flex; + gap: 0.35rem; + flex-wrap: wrap; +} .chip { border: 1px solid var(--bs-border-color); - background: rgba(var(--bs-secondary-bg-rgb), .6); + background: rgba(var(--bs-secondary-bg-rgb), 0.6); color: var(--bs-body-color); border-radius: 999px; - padding: .22rem .6rem; - font-size: .78rem; + padding: 0.22rem 0.6rem; + font-size: 0.78rem; +} +.mobile-list { + overflow: auto; + padding: 0.55rem; + background: var(--bs-body-bg); } -.mobile-list { overflow: auto; padding: .55rem; background: var(--bs-body-bg); } .mobile-card { border: 1px solid var(--bs-border-color); - background: rgba(var(--bs-secondary-bg-rgb), .72); - border-radius: .75rem; - padding: .65rem; - margin-bottom: .55rem; + background: rgba(var(--bs-secondary-bg-rgb), 0.72); + border-radius: 0.75rem; + padding: 0.65rem; + margin-bottom: 0.55rem; +} +.mobile-card.selected { + outline: 2px solid var(--bs-primary); +} +.mobile-card .name { + font-weight: 700; + word-break: break-word; +} +.mobile-actions { + display: flex; + gap: 0.35rem; + margin-top: 0.45rem; } -.mobile-card.selected { outline: 2px solid var(--bs-primary); } -.mobile-card .name { font-weight: 700; word-break: break-word; } -.mobile-actions { display: flex; gap: .35rem; margin-top: .45rem; } #systemChart { width: 140px; height: 24px; border: 1px solid var(--bs-border-color); - border-radius: .35rem; - background: rgba(var(--bs-secondary-bg-rgb), .85); + border-radius: 0.35rem; + background: rgba(var(--bs-secondary-bg-rgb), 0.85); +} +.badge-degraded { + background: #f59e0b !important; + color: #111 !important; } -.badge-degraded { background: #f59e0b !important; color: #111 !important; } /* Note: Manual mobile mode is defined once here; media queries below only adapt breakpoints. */ body.mobile-mode .table-wrap, body.mobile-mode .details { @@ -405,41 +769,76 @@ body.mobile-mode .content { min-height: 0; overflow: hidden; } -body.mobile-mode .torrent-table { display: none; } +body.mobile-mode .torrent-table { + display: none; +} body.mobile-mode .main-grid { min-height: 0; overflow: hidden; } @media (max-width: 640px) { - .nav-btn span { display: none; } + .nav-btn span { + display: none; + } } /* Fixes: compact one-line progress cell and readable percent inside the bar. */ -.torrent-table td:nth-child(5) { min-width: 92px; width: 110px; white-space: nowrap; } -.hidden-col{display:none!important} -.status-docs{margin-left:auto;color:inherit;text-decoration:none;font-weight:600;opacity:.9;white-space:nowrap} -.status-docs:hover{opacity:1;text-decoration:underline} -.column-check{padding:.35rem .5rem;border:1px solid var(--bs-border-color);border-radius:.5rem;background:var(--bs-body-bg)} -.label-filters .label-filter{font-size:.82rem;padding:.34rem .5rem;margin-bottom:.15rem} -.label-filters .label-filter i{opacity:.75;margin-right:.25rem} +.torrent-table td:nth-child(5) { + min-width: 92px; + width: 110px; + white-space: nowrap; +} +.hidden-col { + display: none !important; +} +.status-docs { + margin-left: auto; + color: inherit; + text-decoration: none; + font-weight: 600; + opacity: 0.9; + white-space: nowrap; +} +.status-docs:hover { + opacity: 1; + text-decoration: underline; +} +.column-check { + padding: 0.35rem 0.5rem; + border: 1px solid var(--bs-border-color); + border-radius: 0.5rem; + background: var(--bs-body-bg); +} +.label-filters .label-filter { + font-size: 0.82rem; + padding: 0.34rem 0.5rem; + margin-bottom: 0.15rem; +} +.label-filters .label-filter i { + opacity: 0.75; + margin-right: 0.25rem; +} .column-manager { display: grid; grid-template-columns: repeat(auto-fill, minmax(170px, 1fr)); - gap: .55rem; + gap: 0.55rem; } .column-card { display: flex; align-items: center; - gap: .55rem; + gap: 0.55rem; margin: 0; - padding: .55rem .65rem; + padding: 0.55rem 0.65rem; border: 1px solid var(--bs-border-color); - border-radius: .7rem; - background: rgba(var(--bs-secondary-bg-rgb), .45); + border-radius: 0.7rem; + background: rgba(var(--bs-secondary-bg-rgb), 0.45); cursor: pointer; user-select: none; - transition: background .15s, border-color .15s, transform .15s; + transition: + background 0.15s, + border-color 0.15s, + transform 0.15s; } .column-card:hover, @@ -452,7 +851,7 @@ body.mobile-mode .main-grid { } .column-card.active { - border-color: rgba(var(--bs-primary-rgb), .55); + border-color: rgba(var(--bs-primary-rgb), 0.55); } .column-card .form-check-input { @@ -462,19 +861,46 @@ body.mobile-mode .main-grid { .column-card .form-check-label { display: flex; align-items: center; - gap: .45rem; + gap: 0.45rem; font-weight: 600; } .column-card i { - opacity: .72; + opacity: 0.72; +} +.path-row::before { + content: "\f07b"; + font-family: "Font Awesome 6 Free"; + font-weight: 900; + color: var(--bs-warning); +} +body.mobile-mode .mobile-card { + display: block; +} +.mobile-card .mobile-actions button { + min-width: 34px; +} +#toolSmart .form-label { + font-size: 0.75rem; + color: var(--bs-secondary-color); + margin-bottom: 0.2rem; +} +#toolSmart .btn { + padding: 0.25rem 0.55rem; + border-radius: 0.5rem; + white-space: nowrap; +} +#toolSmart .row .d-flex { + align-items: end; + justify-content: flex-start; +} +#trafficHistoryChart { + width: 100%; + height: 420px; + border: 1px solid var(--bs-border-color); + border-radius: 0.75rem; + background: var(--bs-body-bg); } -.path-row::before{content:'\f07b';font-family:'Font Awesome 6 Free';font-weight:900;color:var(--bs-warning)} -body.mobile-mode .mobile-card{display:block}.mobile-card .mobile-actions button{min-width:34px} -#toolSmart .form-label{font-size:.75rem;color:var(--bs-secondary-color);margin-bottom:.2rem} -#toolSmart .btn{padding:.25rem .55rem;border-radius:.5rem;white-space:nowrap} -#toolSmart .row .d-flex{align-items:end;justify-content:flex-start} -#trafficHistoryChart{width:100%;height:420px;border:1px solid var(--bs-border-color);border-radius:.75rem;background:var(--bs-body-bg)} @media (max-width: 992px) { .profile-form-grid { grid-template-columns: 1fr; @@ -485,41 +911,188 @@ body.mobile-mode .mobile-card{display:block}.mobile-card .mobile-actions button{ } /* Requested fixes: stable charts, Smart Queue exceptions, label actions, mobile readability */ -.history-grid{display:grid;grid-template-columns:1fr;gap:1rem} -.history-card{border:1px solid var(--bs-border-color);border-radius:.8rem;background:rgba(var(--bs-secondary-bg-rgb),.35);padding:.75rem;min-width:0;overflow:hidden} -.history-title{font-weight:700;font-size:.9rem;margin-bottom:.45rem;color:var(--bs-body-color)} -#trafficHistoryChart,#trafficSpeedChart{display:block;width:100%;height:420px;max-width:100%;border:0;border-radius:.55rem;background:var(--bs-body-bg)} -@media (min-width: 992px){.history-grid{grid-template-columns:1fr}} -.smart-actions{display:flex;align-items:center;gap:.45rem;flex-wrap:wrap} -.empty-mini{padding:.7rem .8rem;border:1px dashed var(--bs-border-color);border-radius:.7rem;color:var(--bs-secondary-color);background:rgba(var(--bs-secondary-bg-rgb),.35)} -.label-manager-row{display:flex;align-items:center;justify-content:space-between;gap:.5rem;border:1px solid var(--bs-border-color);border-radius:.65rem;padding:.4rem .5rem;margin-bottom:.4rem;background:rgba(var(--bs-secondary-bg-rgb),.35)} -.tool-tab i{margin-right:.25rem;opacity:.82} -@media (max-width:640px){.history-card{padding:.5rem}#trafficHistoryChart,#trafficSpeedChart{height:320px}.statusbar{font-size:.75rem;gap:.6rem}.mobile-list{padding:.45rem}.mobile-card{margin-bottom:.45rem}} +.history-grid { + display: grid; + grid-template-columns: 1fr; + gap: 1rem; +} +.history-card { + border: 1px solid var(--bs-border-color); + border-radius: 0.8rem; + background: rgba(var(--bs-secondary-bg-rgb), 0.35); + padding: 0.75rem; + min-width: 0; + overflow: hidden; +} +.history-title { + font-weight: 700; + font-size: 0.9rem; + margin-bottom: 0.45rem; + color: var(--bs-body-color); +} +#trafficHistoryChart, +#trafficSpeedChart { + display: block; + width: 100%; + height: 420px; + max-width: 100%; + border: 0; + border-radius: 0.55rem; + background: var(--bs-body-bg); +} +@media (min-width: 992px) { + .history-grid { + grid-template-columns: 1fr; + } +} +.smart-actions { + display: flex; + align-items: center; + gap: 0.45rem; + flex-wrap: wrap; +} +.empty-mini { + padding: 0.7rem 0.8rem; + border: 1px dashed var(--bs-border-color); + border-radius: 0.7rem; + color: var(--bs-secondary-color); + background: rgba(var(--bs-secondary-bg-rgb), 0.35); +} +.label-manager-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + border: 1px solid var(--bs-border-color); + border-radius: 0.65rem; + padding: 0.4rem 0.5rem; + margin-bottom: 0.4rem; + background: rgba(var(--bs-secondary-bg-rgb), 0.35); +} +.tool-tab i { + margin-right: 0.25rem; + opacity: 0.82; +} +@media (max-width: 640px) { + .history-card { + padding: 0.5rem; + } + #trafficHistoryChart, + #trafficSpeedChart { + height: 320px; + } + .statusbar { + font-size: 0.75rem; + gap: 0.6rem; + } + .mobile-list { + padding: 0.45rem; + } + .mobile-card { + margin-bottom: 0.45rem; + } +} /* Requested fixes: clean progress, mobile auto list, pagers, rTorrent config, peers refresh */ -.torrent-progress{height:16px;min-width:92px;position:relative;margin:0;overflow:hidden;background:rgba(var(--bs-secondary-bg-rgb),.8)!important} -.torrent-progress .progress-bar{min-width:0!important;position:relative;transition:width .25s ease,background-color .25s ease} -.torrent-progress>span{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;font-size:10px;font-weight:700;line-height:1;color:var(--bs-body-color);text-shadow:none;white-space:nowrap;pointer-events:none} -.torrent-progress .progress-bar+span{color:var(--bs-body-color)} -@media (max-width:700px){ - body:not(.desktop-mode) .table-wrap{display:none!important} - body:not(.desktop-mode) #mobileList{display:block!important;min-height:260px;height:100%;overflow:auto} - body:not(.desktop-mode) .content{display:grid!important;grid-template-rows:minmax(0,1fr)!important;min-height:0;overflow:hidden} - body:not(.desktop-mode) .details{display:none!important} +.torrent-progress { + height: 16px; + min-width: 92px; + position: relative; + margin: 0; + overflow: hidden; + background: rgba(var(--bs-secondary-bg-rgb), 0.8) !important; +} +.torrent-progress .progress-bar { + min-width: 0 !important; + position: relative; + transition: + width 0.25s ease, + background-color 0.25s ease; +} +.torrent-progress > span { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + font-size: 10px; + font-weight: 700; + line-height: 1; + color: var(--bs-body-color); + text-shadow: none; + white-space: nowrap; + pointer-events: none; +} +.torrent-progress .progress-bar + span { + color: var(--bs-body-color); +} +@media (max-width: 700px) { + body:not(.desktop-mode) .table-wrap { + display: none !important; + } + body:not(.desktop-mode) #mobileList { + display: block !important; + min-height: 260px; + height: 100%; + overflow: auto; + } + body:not(.desktop-mode) .content { + display: grid !important; + grid-template-rows: minmax(0, 1fr) !important; + min-height: 0; + overflow: hidden; + } + body:not(.desktop-mode) .details { + display: none !important; + } +} +.pager-row { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 0.5rem; +} +.peers-refresh { + display: flex; + align-items: center; + gap: 0.5rem; + justify-content: flex-end; + padding: 0.35rem 0.75rem; + border-bottom: 1px solid var(--bs-border-color); + background: rgba(var(--bs-secondary-bg-rgb), 0.35); +} +.peers-refresh select { + width: auto; + min-width: 96px; } -.pager-row{display:flex;align-items:center;justify-content:flex-end;gap:.5rem} -.peers-refresh{display:flex;align-items:center;gap:.5rem;justify-content:flex-end;padding:.35rem .75rem;border-bottom:1px solid var(--bs-border-color);background:rgba(var(--bs-secondary-bg-rgb),.35)} -.peers-refresh select{width:auto;min-width:96px} /* Mobile list: force visible on narrow screens even without manual toggle. */ @media (max-width: 900px) { - body:not(.modal-open) .table-wrap { display: none !important; } - body:not(.modal-open) #mobileList { display: block !important; height: 100% !important; min-height: 260px; overflow: auto; } - body:not(.modal-open) .content { display: grid !important; grid-template-rows: minmax(0,1fr) !important; min-height: 0; overflow: hidden; } - body:not(.modal-open) .details { display: none !important; } + body:not(.modal-open) .table-wrap { + display: none !important; + } + body:not(.modal-open) #mobileList { + display: block !important; + height: 100% !important; + min-height: 260px; + overflow: auto; + } + body:not(.modal-open) .content { + display: grid !important; + grid-template-rows: minmax(0, 1fr) !important; + min-height: 0; + overflow: hidden; + } + body:not(.modal-open) .details { + display: none !important; + } +} +.torrent-paused td { + opacity: 0.82; +} +.torrent-paused .name { + font-style: italic; } -.torrent-paused td{opacity:.82} -.torrent-paused .name{font-style:italic} /* Mobile blank-view fix: sidebar disappears at 900px, so the mobile list must also be forced from 900px down. */ @media (max-width: 900px) { @@ -530,7 +1103,9 @@ body.mobile-mode .mobile-card{display:block}.mobile-card .mobile-actions button{ height: 100% !important; overflow: hidden !important; } - .sidebar { display: none !important; } + .sidebar { + display: none !important; + } .content { display: grid !important; grid-template-rows: minmax(0, 1fr) !important; @@ -538,7 +1113,9 @@ body.mobile-mode .mobile-card{display:block}.mobile-card .mobile-actions button{ height: 100% !important; overflow: hidden !important; } - .table-wrap { display: none !important; } + .table-wrap { + display: none !important; + } #mobileList { display: block !important; height: 100% !important; @@ -547,80 +1124,235 @@ body.mobile-mode .mobile-card{display:block}.mobile-card .mobile-actions button{ position: relative !important; z-index: 10 !important; background: var(--bs-body-bg) !important; - padding: .55rem !important; + padding: 0.55rem !important; + } + .details { + display: none !important; + } + .toolbar-right { + width: 100% !important; + min-width: 0 !important; + flex-wrap: nowrap !important; + gap: 0.35rem !important; + } + .search { + min-width: 0 !important; + width: auto !important; + flex: 1 1 0 !important; + max-width: none !important; + } + .mobile-speed-stats { + display: inline-flex; } - .details { display: none !important; } - .toolbar-right { width: 100% !important; min-width: 0 !important; flex-wrap: nowrap !important; gap: .35rem !important; } - .search { min-width: 0 !important; width: auto !important; flex: 1 1 0 !important; max-width: none !important; } - .mobile-speed-stats { display: inline-flex; } } @media (max-width: 640px) { - .toolbar-right { flex-wrap: nowrap !important; gap: .3rem !important; } - .search { min-width: 0 !important; width: auto !important; flex: 1 1 0 !important; max-width: none !important; } - .mobile-speed-stats { gap: .25rem; font-size: .66rem; } + .toolbar-right { + flex-wrap: nowrap !important; + gap: 0.3rem !important; + } + .search { + min-width: 0 !important; + width: auto !important; + flex: 1 1 0 !important; + max-width: none !important; + } + .mobile-speed-stats { + gap: 0.25rem; + font-size: 0.66rem; + } } -.files-toolbar{display:flex;gap:.75rem;align-items:center;justify-content:space-between;flex-wrap:wrap;margin-bottom:.5rem} -.file-priority-table .path{max-width:520px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} -.file-priority-table .file-priority{min-width:110px} -@media (max-width:900px){.files-toolbar{align-items:stretch}.files-toolbar .btn-group{display:grid;grid-template-columns:1fr;width:100%}.file-priority-table{font-size:.82rem}.file-priority-table .path{max-width:180px}} +.files-toolbar { + display: flex; + gap: 0.75rem; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + margin-bottom: 0.5rem; +} +.file-priority-table .path { + max-width: 520px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.file-priority-table .file-priority { + min-width: 110px; +} +@media (max-width: 900px) { + .files-toolbar { + align-items: stretch; + } + .files-toolbar .btn-group { + display: grid; + grid-template-columns: 1fr; + width: 100%; + } + .file-priority-table { + font-size: 0.82rem; + } + .file-priority-table .path { + max-width: 180px; + } +} .bulk-bar { height: 38px; display: flex; align-items: center; - gap: .35rem; + gap: 0.35rem; flex-wrap: nowrap; overflow-x: auto; overflow-y: hidden; - padding: .35rem .55rem; + padding: 0.35rem 0.55rem; border-bottom: 1px solid var(--bs-border-color); - background: rgba(var(--bs-secondary-bg-rgb), .95); + background: rgba(var(--bs-secondary-bg-rgb), 0.95); z-index: 4; } -.bulk-bar.d-none { display: none !important; } -.bulk-bar span { color: var(--bs-secondary-color); margin-right: .3rem; white-space: nowrap; } -.bulk-bar .btn { white-space: nowrap; flex: 0 0 auto; } +.bulk-bar.d-none { + display: none !important; +} +.bulk-bar span { + color: var(--bs-secondary-color); + margin-right: 0.3rem; + white-space: nowrap; +} +.bulk-bar .btn { + white-space: nowrap; + flex: 0 0 auto; +} .move-options { border: 1px solid var(--bs-border-color); - border-radius: .6rem; - padding: .75rem; + border-radius: 0.6rem; + padding: 0.75rem; background: var(--bs-tertiary-bg); } /* Note: Bulk actions overlay the list area; base .content/.details rules keep the layout pinned. */ -#bulkBar { grid-row: 1; grid-column: 1; align-self: start; } -#tableWrap, #mobileList { grid-row: 1; grid-column: 1; min-height: 0; } -.bulk-bar:not(.d-none) + .table-wrap { padding-top: 38px; } +#bulkBar { + grid-row: 1; + grid-column: 1; + align-self: start; +} +#tableWrap, +#mobileList { + grid-row: 1; + grid-column: 1; + min-height: 0; +} +.bulk-bar:not(.d-none) + .table-wrap { + padding-top: 38px; +} @media (max-width: 900px) { - .bulk-bar { gap: .3rem; } + .bulk-bar { + gap: 0.3rem; + } } +.label-mini { + font-size: 0.72rem; + padding: 0.12rem 0.38rem; + margin-right: 0.15rem; +} +.label-chip.active { + border-color: var(--bs-primary); + background: var(--bs-primary-bg-subtle); + color: var(--bs-primary-text-emphasis); +} +.label-selected { + border-color: var(--bs-primary); + background: var(--bs-primary-bg-subtle); + color: var(--bs-primary-text-emphasis); +} -.label-mini{font-size:.72rem;padding:.12rem .38rem;margin-right:.15rem} -.label-chip.active{border-color:var(--bs-primary);background:var(--bs-primary-bg-subtle);color:var(--bs-primary-text-emphasis)} -.label-selected{border-color:var(--bs-primary);background:var(--bs-primary-bg-subtle);color:var(--bs-primary-text-emphasis)} +.automation-form-grid { + display: grid; + grid-template-columns: repeat(4, minmax(160px, 1fr)); + gap: 0.5rem; + align-items: center; +} +.automation-row { + display: flex; + justify-content: space-between; + gap: 0.75rem; + align-items: center; + padding: 0.55rem 0.65rem; + border: 1px solid var(--bs-border-color); + border-radius: 0.6rem; + margin-bottom: 0.45rem; + background: var(--bs-body-bg); +} +@media (max-width: 900px) { + .automation-form-grid { + grid-template-columns: 1fr; + } +} +.disk-status { + display: inline-flex; + align-items: center; + gap: 0.35rem; + min-width: 110px; +} +.disk-status canvas { + border-radius: 999px; + background: rgba(var(--bs-secondary-bg-rgb), 0.65); +} +.disk-status.disk-warn b { + color: var(--bs-warning) !important; +} -.automation-form-grid { display:grid; grid-template-columns: repeat(4, minmax(160px, 1fr)); gap:.5rem; align-items:center; } -.automation-row { display:flex; justify-content:space-between; gap:.75rem; align-items:center; padding:.55rem .65rem; border:1px solid var(--bs-border-color); border-radius:.6rem; margin-bottom:.45rem; background:var(--bs-body-bg); } -@media (max-width: 900px){ .automation-form-grid { grid-template-columns: 1fr; } } -.disk-status{display:inline-flex;align-items:center;gap:.35rem;min-width:110px} -.disk-status canvas{border-radius:999px;background:rgba(var(--bs-secondary-bg-rgb),.65)} -.disk-status.disk-warn b{color:var(--bs-warning)!important} - -.system-chart{width:96px;height:24px;border-radius:.35rem;background:rgba(var(--bs-secondary-bg-rgb),.45)} -.torrent-progress.is-complete>span{color:#fff;text-shadow:0 1px 2px rgba(0,0,0,.35)} -.peer-progress{min-width:86px;width:96px} -.loading-center{justify-content:center;min-height:80px} -.loading-cell{padding:0!important} -.mobile-list .loading-center{min-height:160px} +.system-chart { + width: 96px; + height: 24px; + border-radius: 0.35rem; + background: rgba(var(--bs-secondary-bg-rgb), 0.45); +} +.torrent-progress.is-complete > span { + color: #fff; + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.35); +} +.peer-progress { + min-width: 86px; + width: 96px; +} +.loading-center { + justify-content: center; + min-height: 80px; +} +.loading-cell { + padding: 0 !important; +} +.mobile-list .loading-center { + min-height: 160px; +} /* Torrent warning and mobile controls */ -.torrent-warning td { background: rgba(245, 158, 11, .075) !important; } -.torrent-warning:hover td { background: rgba(245, 158, 11, .11) !important; } -.torrent-warning.selected td { background: color-mix(in srgb, var(--bs-primary-bg-subtle) 82%, rgba(245, 158, 11, .16)) !important; } -.mobile-card.torrent-warning { background: rgba(245, 158, 11, .075); } -.mobile-card.torrent-warning.selected { background: color-mix(in srgb, var(--bs-primary-bg-subtle) 82%, rgba(245, 158, 11, .16)); } -.torrent-warning-icon { color: var(--bs-warning); margin-right: .2rem; } +.torrent-warning td { + background: rgba(245, 158, 11, 0.075) !important; +} +.torrent-warning:hover td { + background: rgba(245, 158, 11, 0.11) !important; +} +.torrent-warning.selected td { + background: color-mix( + in srgb, + var(--bs-primary-bg-subtle) 82%, + rgba(245, 158, 11, 0.16) + ) !important; +} +.mobile-card.torrent-warning { + background: rgba(245, 158, 11, 0.075); +} +.mobile-card.torrent-warning.selected { + background: color-mix( + in srgb, + var(--bs-primary-bg-subtle) 82%, + rgba(245, 158, 11, 0.16) + ); +} +.torrent-warning-icon { + color: var(--bs-warning); + margin-right: 0.2rem; +} .mobile-filter-bar { display: none; grid-row: 1; @@ -629,82 +1361,98 @@ body.mobile-mode .mobile-card{display:block}.mobile-card .mobile-actions button{ position: sticky; top: 0; z-index: 12; - padding: .45rem .55rem; + padding: 0.45rem 0.55rem; border-bottom: 1px solid var(--bs-border-color); - background: rgba(var(--bs-body-bg-rgb), .96); + background: rgba(var(--bs-body-bg-rgb), 0.96); } .mobile-filter-actions, .mobile-filter-select-row { display: flex; align-items: center; - gap: .35rem; + gap: 0.35rem; +} +.mobile-filter-actions { + margin-bottom: 0.4rem; +} +.mobile-filter-actions span { + color: var(--bs-secondary-color); + font-size: 0.78rem; + white-space: nowrap; } -.mobile-filter-actions { margin-bottom: .4rem; } -.mobile-filter-actions span { color: var(--bs-secondary-color); font-size: .78rem; white-space: nowrap; } .mobile-filter-select-row label { color: var(--bs-secondary-color); - font-size: .78rem; + font-size: 0.78rem; white-space: nowrap; } .mobile-filter-select-row select { min-width: 0; flex: 1 1 auto; } -body.mobile-mode .mobile-filter-bar { display: block !important; } -body.mobile-mode #mobileList { padding-top: 5.2rem !important; } +body.mobile-mode .mobile-filter-bar { + display: block !important; +} +body.mobile-mode #mobileList { + padding-top: 5.2rem !important; +} @media (max-width: 900px) { - #mobileFilterBar { display: block !important; } - #mobileList { padding-top: 5.2rem !important; } + #mobileFilterBar { + display: block !important; + } + #mobileList { + padding-top: 5.2rem !important; + } .topbar .badge { - width: .72rem; - height: .72rem; - min-width: .72rem; + width: 0.72rem; + height: 0.72rem; + min-width: 0.72rem; padding: 0 !important; border-radius: 999px; overflow: hidden; color: transparent !important; text-indent: -999px; - box-shadow: 0 0 0 1px rgba(255,255,255,.22); + box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.22); + } + .topbar .badge .spinner-border { + display: none; } - .topbar .badge .spinner-border { display: none; } } /* rTorrent config */ .rt-config-grid { display: grid; - gap: .6rem; + gap: 0.6rem; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); } .rt-config-group { grid-column: 1 / -1; - padding: .45rem .2rem .1rem; + padding: 0.45rem 0.2rem 0.1rem; border-bottom: 1px solid var(--bs-border-color); color: var(--bs-primary-text-emphasis); font-weight: 800; } .rt-config-note { - margin-bottom: .75rem; + margin-bottom: 0.75rem; } .rt-config-toolbar { display: flex; align-items: center; flex-wrap: wrap; - gap: .75rem; - margin-bottom: .75rem; + gap: 0.75rem; + margin-bottom: 0.75rem; } .rt-config-row { display: grid; grid-template-columns: 1fr minmax(120px, 190px); align-items: center; - gap: .6rem; - padding: .6rem; + gap: 0.6rem; + padding: 0.6rem; border: 1px solid var(--bs-border-color); - border-radius: .7rem; - background: rgba(var(--bs-secondary-bg-rgb), .35); + border-radius: 0.7rem; + background: rgba(var(--bs-secondary-bg-rgb), 0.35); } .rt-config-switch { @@ -719,38 +1467,38 @@ body.mobile-mode #mobileList { padding-top: 5.2rem !important; } .rt-config-switch .form-check-label { min-width: 2rem; color: var(--bs-secondary-color); - font-size: .78rem; + font-size: 0.78rem; font-weight: 700; } .rt-config-row b { - font-size: .88rem; + font-size: 0.88rem; } .rt-config-row small { display: block; overflow-wrap: anywhere; color: var(--bs-secondary-color); - font-size: .72rem; + font-size: 0.72rem; } .rt-config-row.disabled { - opacity: .58; + opacity: 0.58; } .rt-config-row.changed, .rt-config-row.changed-live { border-color: var(--bs-danger); - box-shadow: 0 0 0 .12rem rgba(220, 53, 69, .2); + box-shadow: 0 0 0 0.12rem rgba(220, 53, 69, 0.2); } .rt-config-value-note { - margin-top: .15rem; + margin-top: 0.15rem; } .rt-config-output { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; - font-size: .82rem; + font-size: 0.82rem; } /* Tracker management */ @@ -759,12 +1507,12 @@ body.mobile-mode #mobileList { padding-top: 5.2rem !important; } display: flex; align-items: center; flex-wrap: wrap; - gap: .45rem; + gap: 0.45rem; } .tracker-toolbar { justify-content: space-between; - margin-bottom: .55rem; + margin-bottom: 0.55rem; } .tracker-url { @@ -785,30 +1533,30 @@ body.mobile-mode #mobileList { padding-top: 5.2rem !important; } /* Cleanup and app diagnostics */ .tool-note { color: var(--bs-secondary-color); - font-size: .82rem; + font-size: 0.82rem; } .cleanup-grid, .diag-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); - gap: .6rem; + gap: 0.6rem; } .cleanup-card, .diag-card { - padding: .65rem; + padding: 0.65rem; border: 1px solid var(--bs-border-color); - border-radius: .7rem; - background: rgba(var(--bs-secondary-bg-rgb), .35); + border-radius: 0.7rem; + background: rgba(var(--bs-secondary-bg-rgb), 0.35); } .cleanup-card b, .diag-card b { display: block; - margin-bottom: .2rem; + margin-bottom: 0.2rem; color: var(--bs-secondary-color); - font-size: .78rem; + font-size: 0.78rem; } .cleanup-card span, @@ -818,7 +1566,7 @@ body.mobile-mode #mobileList { padding-top: 5.2rem !important; } .cleanup-card small { display: block; - margin-top: .2rem; + margin-top: 0.2rem; overflow-wrap: anywhere; color: var(--bs-secondary-color); } @@ -826,53 +1574,53 @@ body.mobile-mode #mobileList { padding-top: 5.2rem !important; } .cleanup-actions { display: flex; flex-wrap: wrap; - gap: .5rem; + gap: 0.5rem; } .diag-error { - border-color: rgba(var(--bs-danger-rgb), .45); - background: rgba(var(--bs-danger-rgb), .08); + border-color: rgba(var(--bs-danger-rgb), 0.45); + background: rgba(var(--bs-danger-rgb), 0.08); } .port-status { display: inline-flex; align-items: center; - gap: .3rem; - padding: .12rem .4rem; - border-radius: .45rem; + gap: 0.3rem; + padding: 0.12rem 0.4rem; + border-radius: 0.45rem; } .port-ok { - background: rgba(34, 197, 94, .14); + background: rgba(34, 197, 94, 0.14); color: var(--bs-success); } .port-bad { - background: rgba(239, 68, 68, .14); + background: rgba(239, 68, 68, 0.14); color: var(--bs-danger); } .port-secondary { - background: rgba(148, 163, 184, .12); + background: rgba(148, 163, 184, 0.12); color: var(--bs-secondary-color); } .limit-slider-panel { - padding: .65rem; + padding: 0.65rem; border: 1px solid var(--bs-border-color); - border-radius: .7rem; - background: rgba(var(--bs-secondary-bg-rgb), .32); + border-radius: 0.7rem; + background: rgba(var(--bs-secondary-bg-rgb), 0.32); } .limit-slider-row + .limit-slider-row { - margin-top: .65rem; + margin-top: 0.65rem; } .limit-slider-row .form-label { display: flex; justify-content: space-between; - gap: .75rem; - margin-bottom: .25rem; + gap: 0.75rem; + margin-bottom: 0.25rem; } @media (max-width: 640px) { @@ -900,24 +1648,32 @@ body.mobile-mode #mobileList { padding-top: 5.2rem !important; } /* Operation status, mobile progress and separated preferences */ .torrent-operating td { - background: rgba(13, 202, 240, .085) !important; + background: rgba(13, 202, 240, 0.085) !important; } .torrent-operating:hover td { - background: rgba(13, 202, 240, .13) !important; + background: rgba(13, 202, 240, 0.13) !important; } .torrent-operating.selected td { - background: color-mix(in srgb, var(--bs-primary-bg-subtle) 78%, rgba(13, 202, 240, .18)) !important; + background: color-mix( + in srgb, + var(--bs-primary-bg-subtle) 78%, + rgba(13, 202, 240, 0.18) + ) !important; } .mobile-card.torrent-operating { - background: rgba(13, 202, 240, .085); - border-color: rgba(13, 202, 240, .45); + background: rgba(13, 202, 240, 0.085); + border-color: rgba(13, 202, 240, 0.45); } .mobile-card.torrent-operating.selected { - background: color-mix(in srgb, var(--bs-primary-bg-subtle) 78%, rgba(13, 202, 240, .18)); + background: color-mix( + in srgb, + var(--bs-primary-bg-subtle) 78%, + rgba(13, 202, 240, 0.18) + ); } .operation-status-badge { @@ -925,7 +1681,7 @@ body.mobile-mode #mobileList { padding-top: 5.2rem !important; } } .mobile-progress { - margin-top: .45rem; + margin-top: 0.45rem; } .mobile-progress .torrent-progress { @@ -939,7 +1695,7 @@ body.mobile-mode #mobileList { padding-top: 5.2rem !important; } } .preference-section { - border-left: .25rem solid var(--bs-primary); + border-left: 0.25rem solid var(--bs-primary); } /* Note: Empty first-run state is grouped separately to keep setup styles isolated and avoid duplicated table overrides. */ @@ -947,13 +1703,13 @@ body.mobile-mode #mobileList { padding-top: 5.2rem !important; } display: inline-flex; flex-direction: column; align-items: center; - gap: .45rem; + gap: 0.45rem; max-width: 34rem; white-space: normal; } .empty-state b { color: var(--bs-body-color); - font-size: .95rem; + font-size: 0.95rem; } .empty-state span { color: var(--bs-secondary-color); @@ -967,23 +1723,25 @@ body.mobile-mode #mobileList { padding-top: 5.2rem !important; } .footer-preferences { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); - gap: .5rem; + gap: 0.5rem; } /* Note: Footer switch cards mirror column cards so Bootstrap form-switch margins cannot push toggles outside the field. */ .footer-pref-card { display: flex; align-items: center; - gap: .55rem; + gap: 0.55rem; min-width: 0; margin: 0; - padding: .6rem .7rem; + padding: 0.6rem 0.7rem; border: 1px solid var(--bs-border-color); - border-radius: .75rem; - background: rgba(var(--bs-secondary-bg-rgb), .45); + border-radius: 0.75rem; + background: rgba(var(--bs-secondary-bg-rgb), 0.45); cursor: pointer; user-select: none; - transition: background .15s, border-color .15s; + transition: + background 0.15s, + border-color 0.15s; } .footer-pref-card:hover, @@ -996,7 +1754,7 @@ body.mobile-mode #mobileList { padding-top: 5.2rem !important; } } .footer-pref-card.active { - border-color: rgba(var(--bs-primary-rgb), .55); + border-color: rgba(var(--bs-primary-rgb), 0.55); } .footer-pref-card .form-check-input { @@ -1016,4 +1774,3 @@ body.mobile-mode #mobileList { padding-top: 5.2rem !important; } #statusSockets { white-space: nowrap; } - From 0dcdf0e22bc1d86b501ab79bc063a591a8f52cfe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Mon, 4 May 2026 22:57:39 +0200 Subject: [PATCH 8/8] filters and jobs finished date --- pytorrent/static/app.js | 64 ++++++++++++++++++++++++++++++---- pytorrent/templates/index.html | 1 + 2 files changed, 58 insertions(+), 7 deletions(-) diff --git a/pytorrent/static/app.js b/pytorrent/static/app.js index 06a6523..65b78ac 100644 --- a/pytorrent/static/app.js +++ b/pytorrent/static/app.js @@ -69,7 +69,7 @@ function progressBar(value, extraClass=''){ const pct=Math.max(0,Math.min(100,Number(value||0))); const hue=Math.round((pct/100)*120); const light=30+Math.round((pct/100)*5); const bg=pct<=0?'transparent':pct>=100?'var(--torrent-progress-complete)':`hsl(${hue} 52% ${light}%)`; const done=pct>=100?' is-complete':''; const cls=extraClass?` ${extraClass}`:''; return `
${esc(pct)}%
`; } function progress(t){ return progressBar(t.progress); } // Note: Displays status filter summaries calculated and cached by the backend API. - const FILTER_COUNT_IDS = {all:'countAll', downloading:'countDownloading', seeding:'countSeeding', paused:'countPaused', checking:'countChecking', error:'countError', stopped:'countStopped'}; + const FILTER_COUNT_IDS = {all:'countAll', downloading:'countDownloading', seeding:'countSeeding', paused:'countPaused', checking:'countChecking', error:'countError', stopped:'countStopped', moving:'countMoving'}; function formatFilterBytes(value){ return fmtBytes(value).replace(/\.0 (?=GiB|TiB)/, ' '); } function filterMetaLine(bucket){ if(!bucket || !Number(bucket.count||0)) return ''; @@ -137,16 +137,25 @@ } applyFilterTooltip(button, tooltip, ariaLabel); } + function movingOperationRows(){ + // Note: Filtr Moving bazuje tylko na trwajacych operacjach move, a nie na oczekujacych zadaniach. + return [...torrents.values()].filter(t=>{ + const op=activeOperationFor(t); + return op?.action==='move' && op?.state==='running'; + }); + } + function movingFilterCount(){ return movingOperationRows().length; } function setFilterSummary(type){ const el=$(FILTER_COUNT_IDS[type]); if(!el) return; - const bucket=torrentSummary?.filters?.[type] || {count:0}; - const meta=filterMetaLine(bucket, type); - const tooltip=filterTooltipLine(bucket, type); + const bucket=type==='moving' ? {count:movingFilterCount()} : (torrentSummary?.filters?.[type] || {count:0}); + const meta=type==='moving' ? '' : filterMetaLine(bucket, type); + const tooltip=type==='moving' && bucket.count ? 'Active moving operations' : filterTooltipLine(bucket, type); el.innerHTML=`${esc(bucket.count||0)}${meta?`${esc(meta)}`:''}`; const button=el.closest('.filter'); if(button){ const ariaLabel = tooltip ? `${button.dataset.filter || type}: ${tooltip.replace(/\n/g, ', ')}` : ''; + button.classList.toggle('d-none', type==='moving' && !Number(bucket.count||0)); setStableFilterTooltip(button, tooltip, ariaLabel); } } @@ -155,12 +164,19 @@ function rowHasLabel(t,label){ return labelNames(t.label).includes(label); } function torrentHasError(t){ return !!torrentWarning(t); } function isChecking(t){ return t?.status==='Checking' || Number(t?.hashing||0)>0; } - function rowVisible(t){ const q=($('searchBox')?.value||'').toLowerCase(); if(q && ![t.name,t.path,t.label,t.hash,t.ratio_group].join(' ').toLowerCase().includes(q)) return false; if(activeFilter==='downloading') return !isChecking(t) && !t.complete && t.state && !t.paused; if(activeFilter==='seeding') return !isChecking(t) && t.complete && t.state && !t.paused; if(activeFilter==='paused') return !!t.paused || t.status==='Paused'; if(activeFilter==='checking') return isChecking(t); if(activeFilter==='error') return torrentHasError(t); if(activeFilter==='stopped') return !t.state && !isChecking(t); if(activeFilter.startsWith('label:')) return rowHasLabel(t,activeFilter.slice(6)); return true; } + function rowVisible(t){ const q=($('searchBox')?.value||'').toLowerCase(); if(q && ![t.name,t.path,t.label,t.hash,t.ratio_group].join(' ').toLowerCase().includes(q)) return false; if(activeFilter==='downloading') return !isChecking(t) && !t.complete && t.state && !t.paused; if(activeFilter==='seeding') return !isChecking(t) && t.complete && t.state && !t.paused; if(activeFilter==='paused') return !!t.paused || t.status==='Paused'; if(activeFilter==='checking') return isChecking(t); if(activeFilter==='error') return torrentHasError(t); if(activeFilter==='stopped') return !t.state && !isChecking(t); if(activeFilter==='moving') { const op=activeOperationFor(t); return op?.action==='move' && op?.state==='running'; } if(activeFilter.startsWith('label:')) return rowHasLabel(t,activeFilter.slice(6)); return true; } function compareRows(a,b){ const k=sortState.key; let av=a[k], bv=b[k]; if(typeof av==='string'||typeof bv==='string') return String(av||'').localeCompare(String(bv||''))*sortState.dir; return ((Number(av||0)>Number(bv||0))?1:(Number(av||0)0?" ":" "; } function updateSortHeaders(){ document.querySelectorAll('.torrent-table thead th[data-sort]').forEach(th=>{ const base=th.dataset.baseText||th.textContent.trim(); th.dataset.baseText=base; th.innerHTML=`${esc(base)}${sortIcon(th.dataset.sort)}`; th.classList.toggle('sorted',sortState.key===th.dataset.sort); }); } // Note: Refreshes sidebar counters from the cached API summary, not from browser-side aggregation. + function syncFilterButtons(){ + // Note: Klasa active jest synchronizowana po automatycznym powrocie z Moving do All. + document.querySelectorAll('.filter').forEach(x=>x.classList.toggle('active', x.dataset.filter===activeFilter)); + } function renderCounts(){ + // Note: Gdy ostatnia operacja move sie skonczy, ukryty filtr nie zostawia pustej listy jako aktywnej. + if(activeFilter==='moving' && !movingFilterCount()) activeFilter='all'; + syncFilterButtons(); Object.keys(FILTER_COUNT_IDS).forEach(setFilterSummary); $('statSelected').textContent=selected.size; } @@ -206,7 +222,7 @@ function torrentWarning(t){ const msg=String(t.message||'').trim(); if(!msg) return null; const l=msg.toLowerCase(); const patterns=['error','failed','failure','timeout','timed out','tracker','could not','cannot','refused','unreachable','denied']; return patterns.some(p=>l.includes(p)) ? msg : null; } function torrentNameIcon(t){ const m=statusMeta(t); return ``; } function renderRow(t){ const labels=labelNames(t.label).map(l=>` ${esc(l)}`).join(' '); const warn=torrentWarning(t); const op=activeOperationFor(t); const classes=[selected.has(t.hash)?'selected':'', t.paused?'torrent-paused':'', op?'torrent-operating':'', warn?'torrent-warning':''].filter(Boolean).join(' '); const title=[t.name,warn,op?op.label:''].filter(Boolean).join('\n'); return `${warn?' ':''}${torrentNameIcon(t)} ${esc(t.name)}${statusBadge(t)}${esc(t.size_h)}${progress(t)}${esc(t.down_rate_h)}${esc(t.up_rate_h)}${esc(t.seeds)}${esc(t.peers)}${esc(t.ratio)}${esc(t.path)}${labels||'-'}${esc(t.ratio_group||'')}`; } - function mobileFilterDefs(){ const arr=[...torrents.values()]; const f=torrentSummary?.filters||{}; const defs=[['all','All',f.all?.count??0],['downloading','Downloading',f.downloading?.count??0],['seeding','Seeding',f.seeding?.count??0],['paused','Paused',f.paused?.count??0],['checking','Checking',f.checking?.count??0],['error','With error',f.error?.count??0],['stopped','Stopped',f.stopped?.count??0]]; const counts=new Map(); arr.forEach(t=>labelNames(t.label).forEach(l=>counts.set(l,(counts.get(l)||0)+1))); [...counts.keys()].sort((a,b)=>a.localeCompare(b)).forEach(l=>defs.push([`label:${l}`,l,counts.get(l),'label'])); return defs; } + function mobileFilterDefs(){ const arr=[...torrents.values()]; const f=torrentSummary?.filters||{}; const defs=[['all','All',f.all?.count??0],['downloading','Downloading',f.downloading?.count??0],['seeding','Seeding',f.seeding?.count??0],['paused','Paused',f.paused?.count??0],['checking','Checking',f.checking?.count??0],['error','With error',f.error?.count??0],['stopped','Stopped',f.stopped?.count??0]]; const movingCount=movingFilterCount(); if(movingCount) defs.push(['moving','Moving',movingCount]); const counts=new Map(); arr.forEach(t=>labelNames(t.label).forEach(l=>counts.set(l,(counts.get(l)||0)+1))); [...counts.keys()].sort((a,b)=>a.localeCompare(b)).forEach(l=>defs.push([`label:${l}`,l,counts.get(l),'label'])); return defs; } function renderMobileFilters(){ const bar=$('mobileFilterBar'); if(!bar) return; const allVisible=visibleRows.length>0 && visibleRows.every(t=>selected.has(t.hash)); const someVisible=visibleRows.some(t=>selected.has(t.hash)); const opts=mobileFilterDefs().map(([key,label,count,type])=>``).join(''); bar.innerHTML=`
${selected.size} selected
`; } function renderMobile(){ const list=$('mobileList'); if(!list) return; const src=visibleRows.length?visibleRows:[...torrents.values()].filter(rowVisible).sort(compareRows); const rows=src.slice(0,250); renderMobileFilters(); list.innerHTML=rows.map(t=>{ const warn=torrentWarning(t); const op=activeOperationFor(t); const classes=[selected.has(t.hash)?'selected':'', op?'torrent-operating':'', warn?'torrent-warning':''].filter(Boolean).join(' '); return `
${warn?' ':''}${torrentNameIcon(t)} ${esc(t.name)}
${statusBadge(t)} · ${esc(t.progress)}% · Ratio ${esc(t.ratio)}
DL ${esc(t.down_rate_h)} / UL ${esc(t.up_rate_h)}
${esc(t.path)}
${progress(t)}
`; }).join('') || (hasTorrentSnapshot ? `
No torrents.
` : loadingMarkup('Loading torrents...')); } function renderTable(){ updateBulkBar(); renderCounts(); renderLabelFilters(); updateSortHeaders(); buildVisibleRows(); renderMobile(); const body=$('torrentBody'); if(!visibleRows.length){ body.innerHTML=hasTorrentSnapshot?'No torrents for this filter.':loadingTableRow('Loading torrents...'); return; } const wrap=$('tableWrap'); const start=Math.max(0,Math.floor((wrap?.scrollTop||0)/ROW_HEIGHT)-OVERSCAN); const count=Math.ceil((wrap?.clientHeight||500)/ROW_HEIGHT)+OVERSCAN*2; const end=Math.min(visibleRows.length,start+count); const sig=`${renderVersion}:${start}:${end}:${visibleRows.length}:${sortState.key}:${sortState.dir}:${selected.size}:${activeFilter}:${$('searchBox')?.value||''}:${[...selected].slice(0,30).join(',')}`; if(sig===lastRenderSignature) return; lastRenderSignature=sig; const top=start*ROW_HEIGHT,bottom=Math.max(0,(visibleRows.length-end)*ROW_HEIGHT); body.innerHTML=(top?``:'')+visibleRows.slice(start,end).map(renderRow).join('')+(bottom?``:''); applyColumnVisibility(); } @@ -323,7 +339,41 @@ const classes={done:'success',failed:'danger',running:'primary',cancelled:'secondary',pending:'warning'}; return classes[String(status||'')] || 'warning'; } - async function loadJobs(page=jobsPage){ const box=$('jobsTable'); if(!box)return; jobsPage=Math.max(0,page|0); box.innerHTML=' Loading jobs...'; const offset=jobsPage*jobsLimit; const j=await (await fetch(`/api/jobs?limit=${jobsLimit}&offset=${offset}`)).json(); const rows=j.jobs||[]; jobsTotal=Number(j.total||rows.length); const details=r=>{ const count=Number(r.hash_count||0); if(r.is_bulk || count>1) return `bulk
${esc(count)} torrent(s), details hidden`; const bits=[]; if(count) bits.push(`${esc(count)} torrent`); if(r.summary) bits.push(esc(r.summary)); return bits.join('
') || '-'; }; box.innerHTML=table(['Status','Action','Profile','Count','Details','Attempts','Started','Finished','Error','Actions'],rows.map(r=>[`${esc(r.status)}`,esc(r.action),esc(r.profile_id),esc(r.hash_count||0),details(r),esc(r.attempts||0),dateCell(r.started_at||r.created_at),dateCell(r.finished_at||r.updated_at),compactCell(r.error||'',140),jobActions(r)])); renderJobsPager(); } + async function loadJobs(page=jobsPage){ + const box=$('jobsTable'); + // Note: Finished pokazuje tylko realne finished_at; running/pending nie dostaja daty z updated_at. + if(!box) return; + jobsPage=Math.max(0,page|0); + box.innerHTML=' Loading jobs...'; + const offset=jobsPage*jobsLimit; + const j=await (await fetch(`/api/jobs?limit=${jobsLimit}&offset=${offset}`)).json(); + const rows=j.jobs||[]; + jobsTotal=Number(j.total||rows.length); + const details=r=>{ + const count=Number(r.hash_count||0); + if(r.is_bulk || count>1) return `bulk
${esc(count)} torrent(s), details hidden`; + const bits=[]; + if(count) bits.push(`${esc(count)} torrent`); + if(r.summary) bits.push(esc(r.summary)); + return bits.join('
') || '-'; + }; + box.innerHTML=table( + ['Status','Action','Profile','Count','Details','Attempts','Started','Finished','Error','Actions'], + rows.map(r=>[ + `${esc(r.status)}`, + esc(r.action), + esc(r.profile_id), + esc(r.hash_count||0), + details(r), + esc(r.attempts||0), + dateCell(r.started_at||r.created_at), + dateCell(r.finished_at), + compactCell(r.error||'',140), + jobActions(r), + ]) + ); + renderJobsPager(); + } function renderJobsPager(){ const p=$('jobsPager'); if(!p)return; const pages=Math.max(1,Math.ceil(jobsTotal/jobsLimit)); p.innerHTML=`
Page ${jobsPage+1} / ${pages} · ${jobsTotal} jobs
`; $('jobsPrev')?.addEventListener('click',()=>loadJobs(jobsPage-1)); $('jobsNext')?.addEventListener('click',()=>loadJobs(jobsPage+1)); } // Note: Przyciski w job logu sa zalezne od statusu: failed ma retry, a emergency cancel tylko pending/running. $('jobsModal')?.addEventListener('show.bs.modal',loadJobs); $('refreshJobsBtn')?.addEventListener('click',loadJobs); $('jobsTable')?.addEventListener('click',async e=>{ const btn=e.target.closest('.job-retry,.job-cancel'); if(!btn)return; const id=btn.dataset.id; if(!id)return; if(btn.classList.contains('job-retry')) await post(`/api/jobs/${id}/retry`,{}).catch(x=>toast(x.message,'danger')); if(btn.classList.contains('job-cancel')){ const st=btn.dataset.status||''; if((st==='pending'||st==='running') && !confirm('Emergency cancel this unfinished job?')) return; await post(`/api/jobs/${id}/cancel`,{}).catch(x=>toast(x.message,'danger')); } loadJobs(); }); diff --git a/pytorrent/templates/index.html b/pytorrent/templates/index.html index acb00c3..ab36752 100644 --- a/pytorrent/templates/index.html +++ b/pytorrent/templates/index.html @@ -54,6 +54,7 @@ +

Shortcuts