From 3eee5be37acf98535e8f014df80c9be33caf0a18 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Fri, 8 May 2026 19:53:06 +0200 Subject: [PATCH] favicons --- pytorrent/routes/api.py | 3 +- pytorrent/services/tracker_cache.py | 67 ++++++++++++++++++----------- pytorrent/static/app.js | 8 ++-- 3 files changed, 49 insertions(+), 29 deletions(-) diff --git a/pytorrent/routes/api.py b/pytorrent/routes/api.py index 535c850..5526df2 100644 --- a/pytorrent/routes/api.py +++ b/pytorrent/routes/api.py @@ -506,7 +506,8 @@ def trackers_summary(): def tracker_favicon(domain: str): prefs = preferences.get_preferences() enabled = bool(prefs and prefs.get("tracker_favicons_enabled")) - static_url = tracker_cache.favicon_public_url(domain, enabled=enabled, create=True) + force = str(request.args.get("refresh") or "").lower() in {"1", "true", "yes", "force"} + static_url = tracker_cache.favicon_public_url(domain, enabled=enabled, create=True, force=force) if static_url: # Note: The API only discovers/cache-warms the icon; the browser receives the file from /static/tracker_favicons/. return redirect(static_url, code=302) diff --git a/pytorrent/services/tracker_cache.py b/pytorrent/services/tracker_cache.py index 7ca6160..830ce1d 100644 --- a/pytorrent/services/tracker_cache.py +++ b/pytorrent/services/tracker_cache.py @@ -159,27 +159,20 @@ def summary(profile: dict, hashes: list[str], loader, scan_limit: int = TRACKER_ -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.""" +def favicon_public_url(domain: str, enabled: bool = True, create: bool = False, force: bool = False) -> str: + """Return the static URL for a cached tracker favicon, optionally creating or refreshing it first.""" # Note: Favicon files stay in data/tracker_favicons, but the browser loads them via the static/tracker_favicons symlink. clean = tracker_domain(domain) if not enabled or not clean: return "" if create: - favicon_path(clean, enabled=True) + favicon_path(clean, enabled=True, force=force) cached = _cached_favicon(clean) now = _now_epoch() - path = None - if cached and now - float(cached.get("updated_epoch") or 0) < FAVICON_CACHE_TTL_SECONDS: - cached_path = Path(str(cached.get("file_path") or "")) - if cached_path.exists() and cached_path.is_file(): - path = cached_path - if path is None: - # Note: Existing symlinked .ico files are still linked directly even when the DB favicon row is missing or stale. - direct_path = FAVICON_DIR / f"{_safe_filename(clean)}.ico" - if direct_path.exists() and direct_path.is_file(): - path = direct_path - if path is None: + 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()) @@ -199,12 +192,32 @@ def _fetch(url: str, limit: int = 262144) -> tuple[bytes, str, str]: def _is_icon(data: bytes, content_type: str, url: str) -> bool: - if not data: + """Validate that downloaded bytes are a browser-readable image, not only an image-like HTTP header.""" + # Note: Some trackers serve a broken /favicon.ico with image/vnd.microsoft.icon; pyTorrent now validates bytes before caching it. + if not data or len(data) < 16: return False - ctype = content_type.lower() - if ctype.startswith("image/") or ctype in {"application/octet-stream", "binary/octet-stream"}: + head = data[:32] + lower = data[:512].lstrip().lower() + if head.startswith(b"\x00\x00\x01\x00") or head.startswith(b"\x00\x00\x02\x00"): + try: + count = int.from_bytes(data[4:6], "little") + except Exception: + count = 0 + return 0 < count <= 256 and len(data) >= 6 + (16 * count) + if head.startswith(b"\x89PNG\r\n\x1a\n"): return True - return urllib.parse.urlparse(url).path.lower().endswith((".ico", ".png", ".jpg", ".jpeg", ".svg", ".webp")) + if head.startswith(b"\xff\xd8\xff"): + return True + if head.startswith((b"GIF87a", b"GIF89a")): + return True + if head.startswith(b"RIFF") and data[8:12] == b"WEBP": + return True + if lower.startswith(b" list[str]: @@ -250,22 +263,28 @@ def _cached_favicon(domain: str): return conn.execute("SELECT * FROM tracker_favicon_cache WHERE domain=?", (clean,)).fetchone() -def favicon_path(domain: str, enabled: bool = True) -> tuple[Path | None, str | None]: +def favicon_path(domain: str, enabled: bool = True, force: bool = False) -> tuple[Path | None, str | None]: clean = tracker_domain(domain) if not enabled or not clean: return None, None cached = _cached_favicon(clean) now = _now_epoch() - if cached and now - float(cached.get("updated_epoch") or 0) < FAVICON_CACHE_TTL_SECONDS: + if cached and not force and now - float(cached.get("updated_epoch") or 0) < FAVICON_CACHE_TTL_SECONDS: path = Path(str(cached.get("file_path") or "")) - if path.exists(): - return path, str(cached.get("mime_type") or mimetypes.guess_type(path.name)[0] or "image/x-icon") + mime = str(cached.get("mime_type") or mimetypes.guess_type(path.name)[0] or "image/x-icon") + if path.exists() and path.is_file(): + try: + if _is_icon(path.read_bytes()[:524288], mime, str(cached.get("source_url") or path.name)): + return path, mime + except Exception: + pass if cached.get("error"): return None, None - # Note: Favicon lookup tries tracker host, root domain, then HTML and stores the result for a week. + # Note: Favicon lookup prefers HTML over generic /favicon.ico, because some trackers serve a broken default icon there. FAVICON_DIR.mkdir(parents=True, exist_ok=True) errors = [] - candidates = _favicon_candidates(clean) + candidates = _html_icon_candidates(clean) + _favicon_candidates(clean) + candidates = list(dict.fromkeys(candidates)) checked_html = False idx = 0 while idx < len(candidates): diff --git a/pytorrent/static/app.js b/pytorrent/static/app.js index 541cdc0..260f644 100644 --- a/pytorrent/static/app.js +++ b/pytorrent/static/app.js @@ -239,10 +239,10 @@ function trackerFavicon(tracker){ const domain=typeof tracker==='string'?tracker:(tracker?.domain||''); if(!trackerFaviconsEnabled || !domain) return ''; - const safeName=String(domain).toLowerCase().replace(/[^a-z0-9_.-]+/g,'_').replace(/^[._]+|[._]+$/g,'')||'tracker'; - // Note: Tracker favicon links are direct static URLs matching the tracker_favicons symlink. - const src=(typeof tracker==='object' && tracker?.favicon_url) ? tracker.favicon_url : `/static/tracker_favicons/${encodeURIComponent(safeName)}.ico`; - return ``; + // Note: Cached favicons are served from the static/tracker_favicons symlink; the API path is only a one-time cache warmer fallback. + const fallback=`/api/trackers/favicon/${encodeURIComponent(domain)}?refresh=1`; + const src=(typeof tracker==='object' && tracker?.favicon_url) ? tracker.favicon_url : fallback; + return ``; } function trackerFilterPlaceholder(){ if(trackerSummaryStatus==='loading') return '
Loading trackers...
';