diff --git a/pytorrent/routes/api.py b/pytorrent/routes/api.py index 26e5751..9b26421 100644 --- a/pytorrent/routes/api.py +++ b/pytorrent/routes/api.py @@ -13,7 +13,7 @@ import socket import json import psutil import xml.etree.ElementTree as ET -from flask import Blueprint, jsonify, request, abort, send_file +from flask import Blueprint, jsonify, request, abort, send_file, redirect from ..config import DB_PATH, JOBS_RETENTION_DAYS, SMART_QUEUE_HISTORY_RETENTION_DAYS, WORKERS from ..db import connect, utcnow from ..services.auth import current_user_id as default_user_id, current_user, list_users, save_user, delete_user, login_user, logout_user, enabled as auth_enabled, require_profile_write @@ -494,7 +494,9 @@ def trackers_summary(): # Note: Tracker summary uses the local torrent snapshot and refreshes only a small cache batch per request. scan_limit = min(250, max(0, int(request.args.get("scan_limit") or 80))) hashes = [t.get("hash") for t in torrent_cache.snapshot(profile["id"]) if t.get("hash")] - summary = tracker_cache.summary(profile, hashes, lambda h: rtorrent.torrent_trackers(profile, h), scan_limit=scan_limit) + prefs = preferences.get_preferences() + include_favicons = bool(prefs and prefs.get("tracker_favicons_enabled")) + summary = tracker_cache.summary(profile, hashes, lambda h: rtorrent.torrent_trackers(profile, h), scan_limit=scan_limit, include_favicons=include_favicons) return ok({"summary": summary}) except Exception as exc: return ok({"summary": {"hashes": {}, "trackers": [], "errors": [{"error": str(exc)}], "scanned": 0, "pending": 0}, "error": str(exc)}) @@ -504,10 +506,11 @@ def trackers_summary(): def tracker_favicon(domain: str): prefs = preferences.get_preferences() enabled = bool(prefs and prefs.get("tracker_favicons_enabled")) - path, mime = tracker_cache.favicon_path(domain, enabled=enabled) - if not path: - abort(404) - return send_file(path, mimetype=mime or "image/x-icon", max_age=7 * 24 * 60 * 60) + static_url = tracker_cache.favicon_public_url(domain, enabled=enabled, create=True) + if static_url: + # Note: The API only discovers/cache-warms the icon; the browser receives the file from /static/data/tracker_favicons/. + return redirect(static_url, code=302) + abort(404) @bp.get("/torrent-stats") def torrent_stats_get(): diff --git a/pytorrent/services/tracker_cache.py b/pytorrent/services/tracker_cache.py index 75694f8..68099cb 100644 --- a/pytorrent/services/tracker_cache.py +++ b/pytorrent/services/tracker_cache.py @@ -17,6 +17,7 @@ TRACKER_CACHE_TTL_SECONDS = 7 * 24 * 60 * 60 FAVICON_CACHE_TTL_SECONDS = 7 * 24 * 60 * 60 TRACKER_SCAN_LIMIT = 80 FAVICON_DIR = BASE_DIR / "data" / "tracker_favicons" +PUBLIC_FAVICON_BASE = "/static/data/tracker_favicons" class _IconParser(HTMLParser): @@ -113,7 +114,7 @@ def _store(profile_id: int, torrent_hash: str, trackers: list[dict]) -> None: ) -def summary(profile: dict, hashes: list[str], loader, scan_limit: int = TRACKER_SCAN_LIMIT) -> dict: +def summary(profile: dict, hashes: list[str], loader, scan_limit: int = TRACKER_SCAN_LIMIT, include_favicons: bool = False) -> dict: """Build tracker sidebar data from disk cache and refresh a small batch per request.""" # Note: Tracker data is cached per torrent hash, so huge rTorrent libraries are never scanned in one UI request. profile_id = int(profile.get("id") or 0) @@ -149,10 +150,36 @@ def summary(profile: dict, hashes: list[str], loader, scan_limit: int = TRACKER_ bucket["url"] = row["url"] by_hash[h] = items trackers = sorted(counts.values(), key=lambda x: (-int(x.get("count") or 0), str(x.get("domain") or ""))) + if include_favicons: + # Note: Summary returns only already cached static favicon URLs; network favicon discovery stays outside the hot tracker count path. + for item in trackers: + item["favicon_url"] = favicon_public_url(str(item.get("domain") or ""), enabled=True, create=False) pending = max(0, len([h for h in clean_hashes if h not in fresh])) return {"hashes": by_hash, "trackers": trackers, "errors": errors[:25], "scanned": len(clean_hashes), "scanned_now": scanned_now, "pending": pending, "cached": len(clean_hashes) - pending} + +def favicon_public_url(domain: str, enabled: bool = True, create: bool = False) -> str: + """Return the static URL for a cached tracker favicon, optionally creating it first.""" + # Note: Favicon files are stored under data/tracker_favicons and can be served by the static/data symlink. + clean = tracker_domain(domain) + if not enabled or not clean: + return "" + if create: + favicon_path(clean, enabled=True) + cached = _cached_favicon(clean) + now = _now_epoch() + if not cached or now - float(cached.get("updated_epoch") or 0) >= FAVICON_CACHE_TTL_SECONDS: + return "" + path = Path(str(cached.get("file_path") or "")) + if not path.exists() or not path.is_file(): + return "" + try: + rel = path.resolve().relative_to(FAVICON_DIR.resolve()) + except Exception: + rel = Path(path.name) + return f"{PUBLIC_FAVICON_BASE}/{urllib.parse.quote(str(rel).replace(chr(92), '/'))}" + def _fetch(url: str, limit: int = 262144) -> tuple[bytes, str, str]: req = urllib.request.Request(url, headers={"User-Agent": "pyTorrent/1.0 favicon-cache"}) with urllib.request.urlopen(req, timeout=5) as resp: diff --git a/pytorrent/static/app.js b/pytorrent/static/app.js index a122ee0..c8a5b9e 100644 --- a/pytorrent/static/app.js +++ b/pytorrent/static/app.js @@ -236,10 +236,11 @@ box.innerHTML=labels.length?`