diff --git a/.env.example b/.env.example
index 6fffe90..ba07c7c 100644
--- a/.env.example
+++ b/.env.example
@@ -9,6 +9,9 @@ PYTORRENT_GEOIP_DB=data/GeoLite2-City.mmdb
PYTORRENT_ALLOW_UNSAFE_WERKZEUG=0
PYTORRENT_SCGI_RETRIES=8
+# css/js libs
+PYTORRENT_USE_OFFLINE_LIBS=false
+
# python -m pytorrent.cli reset-password admin new_Pass
PYTORRENT_AUTH_ENABLE=false
diff --git a/.gitignore b/.gitignore
index 4fbc654..0f0aeca 100644
--- a/.gitignore
+++ b/.gitignore
@@ -37,3 +37,4 @@ data/*
logs/*
todo.txt
+pytorrent/static/libs/*
\ No newline at end of file
diff --git a/install.sh b/install.sh
index 3591a24..b4303e5 100755
--- a/install.sh
+++ b/install.sh
@@ -5,6 +5,8 @@ python3 -m venv venv
pip install --upgrade pip
pip install -r requirements.txt
cp -n .env.example .env || true
+grep -q '^PYTORRENT_USE_OFFLINE_LIBS=' .env || echo 'PYTORRENT_USE_OFFLINE_LIBS=true' >> .env
+./scripts/download_frontend_libs.py
mkdir -p data
chmod 755 data
./scripts/download_geoip.sh data/GeoLite2-City.mmdb
diff --git a/pytorrent/__init__.py b/pytorrent/__init__.py
index 383076f..3d6ec4d 100644
--- a/pytorrent/__init__.py
+++ b/pytorrent/__init__.py
@@ -16,6 +16,7 @@ from .config import (
SOCKETIO_CORS_ALLOWED_ORIGINS,
)
from .db import init_db
+from .services.frontend_assets import asset_path, bootstrap_css_path, validate_offline_assets
from .utils import file_md5
socketio = SocketIO(cors_allowed_origins=SOCKETIO_CORS_ALLOWED_ORIGINS, ping_timeout=30, async_mode="threading")
@@ -56,6 +57,7 @@ def register_error_pages(app: Flask) -> None:
def create_app() -> Flask:
+ validate_offline_assets()
app = Flask(__name__)
if PROXY_FIX_ENABLE:
app.wsgi_app = ProxyFix(
@@ -88,7 +90,21 @@ def create_app() -> Flask:
return url_for("static", filename=filename, v=version)
except OSError:
return url_for("static", filename=filename)
- return {"static_url": static_url}
+
+ def frontend_asset_url(key: str) -> str:
+ # Notatka: helper przełącza szablony między CDN i lokalnymi plikami bez duplikowania logiki.
+ path = asset_path(key)
+ return path if path.startswith("http") else static_url(path)
+
+ def bootstrap_theme_url(theme: str | None = None) -> str:
+ path = bootstrap_css_path(theme)
+ return path if path.startswith("http") else static_url(path)
+
+ return {
+ "static_url": static_url,
+ "frontend_asset_url": frontend_asset_url,
+ "bootstrap_theme_url": bootstrap_theme_url,
+ }
@app.after_request
def cache_headers(response):
diff --git a/pytorrent/config.py b/pytorrent/config.py
index 31d3dff..751db11 100644
--- a/pytorrent/config.py
+++ b/pytorrent/config.py
@@ -25,6 +25,8 @@ if not DB_PATH.is_absolute():
HOST = os.getenv("PYTORRENT_HOST", "0.0.0.0")
PORT = int(os.getenv("PYTORRENT_PORT", "8090"))
DEBUG = _env_bool("PYTORRENT_DEBUG", False)
+# Notatka: tryb offline wymusza lokalne JS/CSS i wyłącza zależność od CDN.
+USE_OFFLINE_LIBS = _env_bool("PYTORRENT_USE_OFFLINE_LIBS", False)
# Note: Optional authentication remains disabled unless explicitly enabled in .env.
AUTH_ENABLE = _env_bool("PYTORRENT_AUTH_ENABLE", False)
if AUTH_ENABLE and (not _SECRET_KEY_ENV or SECRET_KEY == "dev-change-me"):
diff --git a/pytorrent/routes/main.py b/pytorrent/routes/main.py
index f1f1ffa..578fadd 100644
--- a/pytorrent/routes/main.py
+++ b/pytorrent/routes/main.py
@@ -1,12 +1,20 @@
from __future__ import annotations
from flask import Blueprint, render_template, jsonify, Response, request, redirect, url_for, abort
-from ..services.preferences import get_preferences, list_profiles, active_profile, BOOTSTRAP_THEMES, FONT_FAMILIES, bootstrap_css_url
+from ..services.preferences import get_preferences, list_profiles, active_profile, BOOTSTRAP_THEMES, FONT_FAMILIES
from ..services import auth
+from ..services.frontend_assets import asset_path
bp = Blueprint("main", __name__)
+def _asset_url(key: str) -> str:
+ # Notatka: API docs korzysta z tego samego przełącznika CDN/offline co reszta aplikacji.
+ path = asset_path(key)
+ return path if path.startswith("http") else url_for("static", filename=path)
+
+
+
@bp.route("/login", methods=["GET", "POST"])
def login():
# Note: When optional authentication is disabled, /login is intentionally unavailable.
@@ -39,7 +47,6 @@ def index():
active_profile=active_profile(),
bootstrap_themes=BOOTSTRAP_THEMES,
font_families=FONT_FAMILIES,
- bootstrap_css_url=bootstrap_css_url((prefs or {}).get("bootstrap_theme")),
auth_enabled=auth.enabled(),
current_user=auth.current_user(),
)
@@ -47,7 +54,7 @@ def index():
@bp.get("/docs")
def docs():
- html = """
pyTorrent API Docs"""
+ html = f"""pyTorrent API Docs"""
return Response(html, mimetype="text/html")
diff --git a/pytorrent/services/frontend_assets.py b/pytorrent/services/frontend_assets.py
new file mode 100644
index 0000000..97ce593
--- /dev/null
+++ b/pytorrent/services/frontend_assets.py
@@ -0,0 +1,111 @@
+from __future__ import annotations
+
+from pathlib import Path
+
+from ..config import BASE_DIR, USE_OFFLINE_LIBS
+
+# Notatka: jeden manifest utrzymuje spójne adresy CDN i ścieżki lokalne dla trybu offline.
+LIBS_STATIC_DIR = "libs"
+LIBS_DIR = BASE_DIR / "pytorrent" / "static" / LIBS_STATIC_DIR
+BOOTSTRAP_VERSION = "5.3.3"
+BOOTSWATCH_VERSION = "5.3.3"
+FONTAWESOME_VERSION = "6.5.2"
+FLAG_ICONS_VERSION = "7.2.3"
+SWAGGER_UI_VERSION = "5"
+SOCKET_IO_VERSION = "4.7.5"
+
+BOOTSTRAP_THEMES = (
+ "default",
+ "flatly",
+ "litera",
+ "lumen",
+ "minty",
+ "sketchy",
+ "solar",
+ "spacelab",
+ "united",
+ "zephyr",
+)
+
+STATIC_ASSETS = {
+ "bootstrap_js": {
+ "local": f"{LIBS_STATIC_DIR}/bootstrap/{BOOTSTRAP_VERSION}/js/bootstrap.bundle.min.js",
+ "cdn": f"https://cdn.jsdelivr.net/npm/bootstrap@{BOOTSTRAP_VERSION}/dist/js/bootstrap.bundle.min.js",
+ },
+ "fontawesome_css": {
+ "local": f"{LIBS_STATIC_DIR}/fontawesome/{FONTAWESOME_VERSION}/css/all.min.css",
+ "cdn": f"https://cdnjs.cloudflare.com/ajax/libs/font-awesome/{FONTAWESOME_VERSION}/css/all.min.css",
+ },
+ "flag_icons_css": {
+ "local": f"{LIBS_STATIC_DIR}/flag-icons/{FLAG_ICONS_VERSION}/css/flag-icons.min.css",
+ "cdn": f"https://cdn.jsdelivr.net/gh/lipis/flag-icons@{FLAG_ICONS_VERSION}/css/flag-icons.min.css",
+ },
+ "socket_io_js": {
+ "local": f"{LIBS_STATIC_DIR}/socket.io/{SOCKET_IO_VERSION}/socket.io.min.js",
+ "cdn": f"https://cdn.socket.io/{SOCKET_IO_VERSION}/socket.io.min.js",
+ },
+ "swagger_css": {
+ "local": f"{LIBS_STATIC_DIR}/swagger-ui/{SWAGGER_UI_VERSION}/swagger-ui.css",
+ "cdn": f"https://cdn.jsdelivr.net/npm/swagger-ui-dist@{SWAGGER_UI_VERSION}/swagger-ui.css",
+ },
+ "swagger_js": {
+ "local": f"{LIBS_STATIC_DIR}/swagger-ui/{SWAGGER_UI_VERSION}/swagger-ui-bundle.js",
+ "cdn": f"https://cdn.jsdelivr.net/npm/swagger-ui-dist@{SWAGGER_UI_VERSION}/swagger-ui-bundle.js",
+ },
+}
+
+
+def bootstrap_css_asset(theme: str | None = None) -> dict[str, str]:
+ theme = theme if theme in BOOTSTRAP_THEMES else "default"
+ if theme == "default":
+ return {
+ "local": f"{LIBS_STATIC_DIR}/bootstrap/{BOOTSTRAP_VERSION}/css/bootstrap.min.css",
+ "cdn": f"https://cdn.jsdelivr.net/npm/bootstrap@{BOOTSTRAP_VERSION}/dist/css/bootstrap.min.css",
+ }
+ return {
+ "local": f"{LIBS_STATIC_DIR}/bootswatch/{BOOTSWATCH_VERSION}/{theme}/bootstrap.min.css",
+ "cdn": f"https://cdn.jsdelivr.net/npm/bootswatch@{BOOTSWATCH_VERSION}/dist/{theme}/bootstrap.min.css",
+ }
+
+
+def asset_path(key: str) -> str:
+ return STATIC_ASSETS[key]["local" if USE_OFFLINE_LIBS else "cdn"]
+
+
+def bootstrap_css_path(theme: str | None = None) -> str:
+ return bootstrap_css_asset(theme)["local" if USE_OFFLINE_LIBS else "cdn"]
+
+
+def required_offline_paths() -> list[Path]:
+ paths = [LIBS_DIR.parent / item["local"] for item in STATIC_ASSETS.values()]
+ paths.extend(LIBS_DIR.parent / bootstrap_css_asset(theme)["local"] for theme in BOOTSTRAP_THEMES)
+ return paths
+
+
+def missing_offline_paths() -> list[Path]:
+ missing = [path for path in required_offline_paths() if not path.is_file() or path.stat().st_size <= 0]
+ # Notatka: sprawdzane są też zasoby referencjonowane przez CSS, np. fonty ikon i pliki flag.
+ required_dirs = [
+ LIBS_DIR / f"fontawesome/{FONTAWESOME_VERSION}/webfonts",
+ LIBS_DIR / f"flag-icons/{FLAG_ICONS_VERSION}/flags/4x3",
+ LIBS_DIR / f"flag-icons/{FLAG_ICONS_VERSION}/flags/1x1",
+ ]
+ for directory in required_dirs:
+ if not directory.is_dir() or not any(directory.iterdir()):
+ missing.append(directory)
+ return missing
+
+
+def validate_offline_assets() -> None:
+ # Notatka: aplikacja zatrzymuje start, gdy tryb offline jest aktywny, a pliki nie są zainstalowane.
+ if not USE_OFFLINE_LIBS:
+ return
+ missing = missing_offline_paths()
+ if missing:
+ preview = "\n".join(f"- {path.relative_to(BASE_DIR)}" for path in missing[:20])
+ extra = "" if len(missing) <= 20 else f"\n- ... and {len(missing) - 20} more"
+ raise RuntimeError(
+ "PYTORRENT_USE_OFFLINE_LIBS=true, but frontend libraries are missing. "
+ "Run: ./scripts/download_frontend_libs.py or ./install.sh\n"
+ f"Missing files:\n{preview}{extra}"
+ )
diff --git a/pytorrent/services/preferences.py b/pytorrent/services/preferences.py
index edabac3..e5e9bfa 100644
--- a/pytorrent/services/preferences.py
+++ b/pytorrent/services/preferences.py
@@ -28,11 +28,10 @@ FONT_FAMILIES = {
}
def bootstrap_css_url(theme: str | None) -> str:
- theme = theme if theme in BOOTSTRAP_THEMES else "default"
- if theme == "default":
- return "https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css"
- return f"https://cdn.jsdelivr.net/npm/bootswatch@5.3.3/dist/{theme}/bootstrap.min.css"
+ # Notatka: zachowana funkcja zwraca aktualny adres motywu, ale źródło wybiera konfiguracja offline.
+ from .frontend_assets import bootstrap_css_path
+ return bootstrap_css_path(theme)
def list_profiles(user_id: int | None = None):
user_id = user_id or auth.current_user_id() or default_user_id()
diff --git a/pytorrent/static/app.js b/pytorrent/static/app.js
index 2f5a01f..5ace8d4 100644
--- a/pytorrent/static/app.js
+++ b/pytorrent/static/app.js
@@ -526,7 +526,7 @@
}
async function generateRtConfig(){ const values=collectRtConfigChanges(); try{ const res=await fetch('/api/rtorrent-config/generate',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({values})}); const j=await res.json(); if(!j.ok) throw new Error(j.error||'Generate failed'); if($('rtConfigOutput')) $('rtConfigOutput').value=j.config_text||''; toast('Config generated','success'); }catch(e){ toast(e.message,'danger'); } }
- function bootstrapThemeUrl(theme){ return theme && theme !== "default" ? `https://cdn.jsdelivr.net/npm/bootswatch@5.3.3/dist/${encodeURIComponent(theme)}/bootstrap.min.css` : "https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css"; }
+ function bootstrapThemeUrl(theme){ /* Notatka: motywy korzystają z mapy URL wygenerowanej przez backend, więc działają także offline. */ const key=theme||"default"; return window.PYTORRENT?.bootstrapThemeUrls?.[key] || window.PYTORRENT?.bootstrapThemeUrls?.default || ""; }
function applyBootstrapTheme(theme){ bootstrapTheme = theme || "default"; const link=$("bootstrapThemeStylesheet"); if(link) link.href = bootstrapThemeUrl(bootstrapTheme); if($("bootstrapThemeSelect")) $("bootstrapThemeSelect").value = bootstrapTheme; }
function applyFontFamily(font){ fontFamily = font || "default"; document.documentElement.dataset.appFont = fontFamily; if($("fontFamilySelect")) $("fontFamilySelect").value = fontFamily; }
async function saveAppearancePreferences(){ applyBootstrapTheme($("bootstrapThemeSelect")?.value || "default"); applyFontFamily($("fontFamilySelect")?.value || "default"); try{ await post("/api/preferences",{bootstrap_theme:bootstrapTheme,font_family:fontFamily}); toast("Appearance preferences saved","success"); }catch(e){ toast(e.message,"danger"); } }
diff --git a/pytorrent/static/styles.css b/pytorrent/static/styles.css
index ac421d4..95f48ae 100644
--- a/pytorrent/static/styles.css
+++ b/pytorrent/static/styles.css
@@ -750,6 +750,7 @@ body.mobile-mode .table-wrap,
body.mobile-mode .details {
display: none !important;
}
+/* Notatka: scalone reguły listy mobilnej eliminują powtórzone selektory CSS. */
body.mobile-mode #mobileList {
display: block !important;
min-height: 0;
@@ -757,6 +758,7 @@ body.mobile-mode #mobileList {
overflow: auto;
position: relative;
z-index: 2;
+ padding-top: 5.2rem !important;
padding-bottom: 1rem;
}
body.mobile-mode .content {
@@ -1374,9 +1376,6 @@ body.mobile-mode .mobile-card {
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;
diff --git a/pytorrent/templates/error.html b/pytorrent/templates/error.html
index 46451a0..e1e6dd0 100644
--- a/pytorrent/templates/error.html
+++ b/pytorrent/templates/error.html
@@ -4,12 +4,11 @@
pyTorrent {{ code }}
-
-
+
+
-
pyTorrent
diff --git a/pytorrent/templates/index.html b/pytorrent/templates/index.html
index 5c71060..ba4fd4a 100644
--- a/pytorrent/templates/index.html
+++ b/pytorrent/templates/index.html
@@ -4,9 +4,9 @@
pyTorrent
-
-
-
+
+
+
@@ -42,7 +42,6 @@
offline
-
{% if auth_enabled %} {{ current_user.username if current_user else 'logout' }}{% endif %}
@@ -162,7 +161,6 @@
-
@@ -183,8 +181,8 @@
-
-
-
+
+
+