gunicorn #1

Merged
gru merged 8 commits from gunicorn into master 2026-05-05 07:23:24 +02:00
9 changed files with 94 additions and 27 deletions
Showing only changes of commit 31bba1269d - Show all commits

View File

@@ -6,6 +6,7 @@ PYTORRENT_DEBUG=0
PYTORRENT_POLL_INTERVAL=1.0 PYTORRENT_POLL_INTERVAL=1.0
PYTORRENT_WORKERS=16 PYTORRENT_WORKERS=16
PYTORRENT_GEOIP_DB=data/GeoLite2-City.mmdb PYTORRENT_GEOIP_DB=data/GeoLite2-City.mmdb
PYTORRENT_ALLOW_UNSAFE_WERKZEUG=0
# Retention / Smart Queue # Retention / Smart Queue
PYTORRENT_TRAFFIC_HISTORY_RETENTION_DAYS=90 PYTORRENT_TRAFFIC_HISTORY_RETENTION_DAYS=90

View File

@@ -37,6 +37,23 @@ python app.py
Domyślnie: `http://127.0.0.1:8090`. 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 ## Profil SCGI
Przykład: Przykład:

11
app.py
View File

@@ -1,7 +1,14 @@
from pytorrent import create_app, socketio 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() app = create_app()
if __name__ == "__main__": 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,
)

View File

@@ -8,20 +8,18 @@ Wants=network-online.target
[Service] [Service]
Type=simple Type=simple
#User=root
#Group=root
User=pytorrent User=pytorrent
Group=pytorrent Group=pytorrent
WorkingDirectory=/opt/pyTorrent WorkingDirectory=/opt/pyTorrent
Environment="PYTHONUNBUFFERED=1" Environment="PYTHONUNBUFFERED=1"
EnvironmentFile=/opt/pyTorrent/.env 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 Restart=always
RestartSec=3 RestartSec=3
KillSignal=SIGINT KillSignal=SIGINT
TimeoutStopSec=20 TimeoutStopSec=20
# opcjonalnie
NoNewPrivileges=true NoNewPrivileges=true
PrivateTmp=true PrivateTmp=true

View File

@@ -7,6 +7,14 @@ from dotenv import load_dotenv
BASE_DIR = Path(__file__).resolve().parent.parent BASE_DIR = Path(__file__).resolve().parent.parent
load_dotenv(BASE_DIR / ".env") 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") SECRET_KEY = os.getenv("PYTORRENT_SECRET_KEY", "dev-change-me")
DB_PATH = Path(os.getenv("PYTORRENT_DB_PATH", str(BASE_DIR / "data" / "pytorrent.sqlite3"))) DB_PATH = Path(os.getenv("PYTORRENT_DB_PATH", str(BASE_DIR / "data" / "pytorrent.sqlite3")))
if not DB_PATH.is_absolute(): 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") HOST = os.getenv("PYTORRENT_HOST", "0.0.0.0")
PORT = int(os.getenv("PYTORRENT_PORT", "8090")) 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")) POLL_INTERVAL = float(os.getenv("PYTORRENT_POLL_INTERVAL", "1.0"))
WORKERS = int(os.getenv("PYTORRENT_WORKERS", "16")) WORKERS = int(os.getenv("PYTORRENT_WORKERS", "16"))
GEOIP_DB = Path(os.getenv("PYTORRENT_GEOIP_DB", str(BASE_DIR / "data" / "GeoLite2-City.mmdb"))) GEOIP_DB = Path(os.getenv("PYTORRENT_GEOIP_DB", str(BASE_DIR / "data" / "GeoLite2-City.mmdb")))

View File

@@ -180,7 +180,13 @@ body {
opacity: .78; opacity: .78;
} }
.shortcut { font-size: .78rem; color: var(--bs-secondary-color); padding: .15rem .5rem; } .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; } .table-wrap { overflow: auto; contain: content; }
.torrent-table { margin: 0; white-space: nowrap; table-layout: auto; } .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 { 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; } .virtual-spacer td { padding: 0 !important; border: 0 !important; }
.empty { height: 120px; text-align: center; vertical-align: middle; color: var(--bs-secondary-color); } .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); } .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; } .detail-pane { height: 210px; overflow: auto; padding: .65rem; }
.loading-line { display: flex; align-items: center; gap: .5rem; color: var(--bs-secondary-color); padding: .75rem; } .loading-line { display: flex; align-items: center; gap: .5rem; color: var(--bs-secondary-color); padding: .75rem; }
.muted-pane { color: var(--bs-secondary-color); } .muted-pane { color: var(--bs-secondary-color); }
@@ -373,10 +385,31 @@ body {
background: rgba(var(--bs-secondary-bg-rgb), .85); background: rgba(var(--bs-secondary-bg-rgb), .85);
} }
.badge-degraded { background: #f59e0b !important; color: #111 !important; } .badge-degraded { background: #f59e0b !important; color: #111 !important; }
body.mobile-mode .table-wrap { display: none !important; } /* Note: Manual mobile mode is defined once here; media queries below only adapt breakpoints. */
body.mobile-mode #mobileList { display: block !important; } body.mobile-mode .table-wrap,
body.mobile-mode .content { grid-template-rows: 1fr 210px; } 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 .torrent-table { display: none; }
body.mobile-mode .main-grid {
min-height: 0;
overflow: hidden;
}
@media (max-width: 640px) { @media (max-width: 640px) {
.nav-btn span { display: none; } .nav-btn span { display: none; }
} }
@@ -437,7 +470,6 @@ body.mobile-mode .torrent-table { display: none; }
opacity: .72; opacity: .72;
} }
.path-row::before{content:'\f07b';font-family:'Font Awesome 6 Free';font-weight:900;color:var(--bs-warning)} .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} 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 .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 .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)} .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)} .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} .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}} @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 */ /* 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 .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>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)} .torrent-progress .progress-bar+span{color:var(--bs-body-color)}
body.mobile-mode #mobileList{display:block!important}
@media (max-width:700px){ @media (max-width:700px){
body:not(.desktop-mode) .table-wrap{display:none!important} 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) #mobileList{display:block!important;min-height:260px;height:100%;overflow:auto}
@@ -560,14 +587,9 @@ body.mobile-mode #mobileList{display:block!important}
padding: .75rem; padding: .75rem;
background: var(--bs-tertiary-bg); background: var(--bs-tertiary-bg);
} }
/* Stable main layout: bulk actions overlay the list area, details stay pinned at the bottom. */ /* Note: Bulk actions overlay the list area; base .content/.details rules keep the layout pinned. */
.content {
position: relative;
grid-template-rows: minmax(0, 1fr) 255px !important;
}
#bulkBar { grid-row: 1; grid-column: 1; align-self: start; } #bulkBar { grid-row: 1; grid-column: 1; align-self: start; }
#tableWrap, #mobileList { grid-row: 1; grid-column: 1; min-height: 0; } #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; } .bulk-bar:not(.d-none) + .table-wrap { padding-top: 38px; }
@media (max-width: 900px) { @media (max-width: 900px) {
.bulk-bar { gap: .3rem; } .bulk-bar { gap: .3rem; }

View File

@@ -4,3 +4,4 @@ python-dotenv>=1.0
geoip2>=4.8 geoip2>=4.8
psutil>=5.9 psutil>=5.9
simple-websocket>=1.0 simple-websocket>=1.0
gunicorn>=22.0

View File

@@ -1,16 +1,23 @@
[Unit] [Unit]
Description=pyTorrent web UI for rTorrent Description=pyTorrent web UI for rTorrent
After=network.target After=network-online.target
Wants=network-online.target
[Service] [Service]
Type=simple Type=simple
WorkingDirectory=/opt/pytorrent WorkingDirectory=/opt/pytorrent
Environment="PYTHONUNBUFFERED=1"
EnvironmentFile=/opt/pytorrent/.env 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 Restart=always
RestartSec=3 RestartSec=3
KillSignal=SIGINT
TimeoutStopSec=20
User=www-data User=www-data
Group=www-data Group=www-data
NoNewPrivileges=true
PrivateTmp=true
[Install] [Install]
WantedBy=multi-user.target WantedBy=multi-user.target

4
wsgi.py Normal file
View File

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