favicons
This commit is contained in:
@@ -13,7 +13,7 @@ import socket
|
|||||||
import json
|
import json
|
||||||
import psutil
|
import psutil
|
||||||
import xml.etree.ElementTree as ET
|
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 ..config import DB_PATH, JOBS_RETENTION_DAYS, SMART_QUEUE_HISTORY_RETENTION_DAYS, WORKERS
|
||||||
from ..db import connect, utcnow
|
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
|
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.
|
# 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)))
|
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")]
|
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})
|
return ok({"summary": summary})
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
return ok({"summary": {"hashes": {}, "trackers": [], "errors": [{"error": str(exc)}], "scanned": 0, "pending": 0}, "error": str(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):
|
def tracker_favicon(domain: str):
|
||||||
prefs = preferences.get_preferences()
|
prefs = preferences.get_preferences()
|
||||||
enabled = bool(prefs and prefs.get("tracker_favicons_enabled"))
|
enabled = bool(prefs and prefs.get("tracker_favicons_enabled"))
|
||||||
path, mime = tracker_cache.favicon_path(domain, enabled=enabled)
|
static_url = tracker_cache.favicon_public_url(domain, enabled=enabled, create=True)
|
||||||
if not path:
|
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)
|
abort(404)
|
||||||
return send_file(path, mimetype=mime or "image/x-icon", max_age=7 * 24 * 60 * 60)
|
|
||||||
|
|
||||||
@bp.get("/torrent-stats")
|
@bp.get("/torrent-stats")
|
||||||
def torrent_stats_get():
|
def torrent_stats_get():
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ TRACKER_CACHE_TTL_SECONDS = 7 * 24 * 60 * 60
|
|||||||
FAVICON_CACHE_TTL_SECONDS = 7 * 24 * 60 * 60
|
FAVICON_CACHE_TTL_SECONDS = 7 * 24 * 60 * 60
|
||||||
TRACKER_SCAN_LIMIT = 80
|
TRACKER_SCAN_LIMIT = 80
|
||||||
FAVICON_DIR = BASE_DIR / "data" / "tracker_favicons"
|
FAVICON_DIR = BASE_DIR / "data" / "tracker_favicons"
|
||||||
|
PUBLIC_FAVICON_BASE = "/static/data/tracker_favicons"
|
||||||
|
|
||||||
|
|
||||||
class _IconParser(HTMLParser):
|
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."""
|
"""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.
|
# 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)
|
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"]
|
bucket["url"] = row["url"]
|
||||||
by_hash[h] = items
|
by_hash[h] = items
|
||||||
trackers = sorted(counts.values(), key=lambda x: (-int(x.get("count") or 0), str(x.get("domain") or "")))
|
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]))
|
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}
|
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]:
|
def _fetch(url: str, limit: int = 262144) -> tuple[bytes, str, str]:
|
||||||
req = urllib.request.Request(url, headers={"User-Agent": "pyTorrent/1.0 favicon-cache"})
|
req = urllib.request.Request(url, headers={"User-Agent": "pyTorrent/1.0 favicon-cache"})
|
||||||
with urllib.request.urlopen(req, timeout=5) as resp:
|
with urllib.request.urlopen(req, timeout=5) as resp:
|
||||||
|
|||||||
@@ -236,10 +236,11 @@
|
|||||||
box.innerHTML=labels.length?`<div class="small text-muted px-2 mb-1">Labels</div>${labels.map(l=>`<button class="filter label-filter ${activeFilter==='label:'+l?'active':''}" data-filter="label:${esc(l)}"><span><i class="fa-solid fa-tag"></i> ${esc(l)}</span><span>${counts.get(l)}</span></button>`).join('')}`:'';
|
box.innerHTML=labels.length?`<div class="small text-muted px-2 mb-1">Labels</div>${labels.map(l=>`<button class="filter label-filter ${activeFilter==='label:'+l?'active':''}" data-filter="label:${esc(l)}"><span><i class="fa-solid fa-tag"></i> ${esc(l)}</span><span>${counts.get(l)}</span></button>`).join('')}`:'';
|
||||||
bindSidebarFilterClicks(box);
|
bindSidebarFilterClicks(box);
|
||||||
}
|
}
|
||||||
function trackerFavicon(domain){
|
function trackerFavicon(tracker){
|
||||||
|
const domain=typeof tracker==='string'?tracker:(tracker?.domain||'');
|
||||||
if(!trackerFaviconsEnabled || !domain) return '<i class="fa-solid fa-bullseye"></i>';
|
if(!trackerFaviconsEnabled || !domain) return '<i class="fa-solid fa-bullseye"></i>';
|
||||||
// Note: Favicony trackerów idą przez lokalny cache backendu, więc przeglądarka nie odpytuje tysięcy domen bezpośrednio.
|
// Note: Cached favicons are served from the static/data symlink; the API path is only a one-time cache warmer fallback.
|
||||||
const src=`/api/trackers/favicon/${encodeURIComponent(domain)}`;
|
const src=(typeof tracker==='object' && tracker?.favicon_url) ? tracker.favicon_url : `/api/trackers/favicon/${encodeURIComponent(domain)}`;
|
||||||
return `<img class="tracker-favicon" src="${esc(src)}" alt="" loading="lazy" onerror="this.classList.add('d-none')"><i class="fa-solid fa-bullseye tracker-fallback-icon"></i>`;
|
return `<img class="tracker-favicon" src="${esc(src)}" alt="" loading="lazy" onerror="this.classList.add('d-none')"><i class="fa-solid fa-bullseye tracker-fallback-icon"></i>`;
|
||||||
}
|
}
|
||||||
function trackerFilterPlaceholder(){
|
function trackerFilterPlaceholder(){
|
||||||
@@ -256,7 +257,7 @@
|
|||||||
if(activeFilter.startsWith('tracker:') && !trackers.some(t=>t.domain===activeFilter.slice(8))) activeFilter='all';
|
if(activeFilter.startsWith('tracker:') && !trackers.some(t=>t.domain===activeFilter.slice(8))) activeFilter='all';
|
||||||
// Note: Tracker filter section is always visible, so an empty or failed tracker scan does not look like a missing feature.
|
// Note: Tracker filter section is always visible, so an empty or failed tracker scan does not look like a missing feature.
|
||||||
const rows=trackers.length
|
const rows=trackers.length
|
||||||
? trackers.map(t=>`<button class="filter tracker-filter ${activeFilter==='tracker:'+t.domain?'active':''}" data-filter="tracker:${esc(t.domain)}"><span>${trackerFavicon(t.domain)} ${esc(t.domain)}</span><span>${esc(t.count||0)}</span></button>`).join('')
|
? trackers.map(t=>`<button class="filter tracker-filter ${activeFilter==='tracker:'+t.domain?'active':''}" data-filter="tracker:${esc(t.domain)}"><span>${trackerFavicon(t)} ${esc(t.domain)}</span><span>${esc(t.count||0)}</span></button>`).join('')
|
||||||
: trackerFilterPlaceholder();
|
: trackerFilterPlaceholder();
|
||||||
box.innerHTML=`<div class="small text-muted px-2 mb-1">Trackers</div>${rows}`;
|
box.innerHTML=`<div class="small text-muted px-2 mb-1">Trackers</div>${rows}`;
|
||||||
bindSidebarFilterClicks(box);
|
bindSidebarFilterClicks(box);
|
||||||
|
|||||||
1
pytorrent/static/tracker_favicons
Symbolic link
1
pytorrent/static/tracker_favicons
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
../../data/tracker_favicons
|
||||||
Reference in New Issue
Block a user