diff --git a/.gitignore b/.gitignore index 8f41eb9..07873b3 100644 --- a/.gitignore +++ b/.gitignore @@ -42,4 +42,6 @@ data/logs/* todo.txt -pytorrent/static/libs/* \ No newline at end of file +pytorrent/static/libs/* +!pytorrent/static/libs/pytorrent-themes/ +!pytorrent/static/libs/pytorrent-themes/** diff --git a/pytorrent/routes/torrents.py b/pytorrent/routes/torrents.py index 234df0e..02eecbd 100644 --- a/pytorrent/routes/torrents.py +++ b/pytorrent/routes/torrents.py @@ -257,9 +257,7 @@ def torrent_file_export_link(torrent_hash: str): if not profile: return jsonify({"ok": False, "error": "No profile"}), 400 try: - # Note: Export availability is checked before the UI receives a temporary /download URL. - item = rtorrent.export_torrent_file(profile, torrent_hash) - _cleanup_staged_file(profile, item["path"], bool(item.get("local"))) + # Note: Create only a short-lived link here; the actual .torrent export runs once when the browser opens /download/. link = pdf_preview_links.create_torrent_file_download_link(torrent_hash, int(profile.get("id") or 0), int(default_user_id() or 0)) return ok({"url": url_for("main.temporary_download", token=link["token"]), "expires_in": link["expires_in"]}) except Exception as exc: @@ -276,15 +274,7 @@ def torrent_files_export_zip_link(): if not hashes: return jsonify({"ok": False, "error": "No torrents selected"}), 400 try: - # Note: Each hash is checked before the temporary ZIP export link is returned to the UI. - staged_paths = [] - try: - for h in hashes: - item = rtorrent.export_torrent_file(profile, h) - staged_paths.append((item["path"], bool(item.get("local")))) - finally: - for path, is_local in staged_paths: - _cleanup_staged_file(profile, path, is_local) + # Note: Store only the selected hashes in the temporary token; exporting each .torrent now happens once during the real ZIP download. link = pdf_preview_links.create_torrent_files_zip_download_link(hashes, int(profile.get("id") or 0), int(default_user_id() or 0)) return ok({"url": url_for("main.temporary_download", token=link["token"]), "expires_in": link["expires_in"]}) except Exception as exc: diff --git a/pytorrent/services/frontend_assets.py b/pytorrent/services/frontend_assets.py index ff66927..778721a 100644 --- a/pytorrent/services/frontend_assets.py +++ b/pytorrent/services/frontend_assets.py @@ -40,18 +40,72 @@ def google_fonts_css_url() -> str: return f"https://fonts.googleapis.com/css2?{families}&display=swap" -BOOTSTRAP_THEMES = ( - "default", - "flatly", - "litera", - "lumen", - "minty", - "sketchy", - "solar", - "spacelab", - "united", - "zephyr", -) +DEVEXPRESS_BOOTSTRAP_THEMES = { + "blazing-berry": "Blazing Berry", + "office-white": "Office White", + "purple": "Purple", +} + +PYTORRENT_APP_THEMES = { + "adaptive": "pyTorrent Adaptive", + "ocean": "pyTorrent Ocean", + "graphite": "pyTorrent Graphite", + "forest": "pyTorrent Forest", + "amber": "pyTorrent Amber", + "nord": "pyTorrent Nord", + "crimson": "pyTorrent Crimson", + "sky": "pyTorrent Sky", +} + + +BOOTSTRAP_THEME_DEFINITIONS = { + "default": { + "label": "Default Bootstrap", + "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", + }, + # Bootswatch themes. + "flatly": {"label": "Bootswatch: Flatly", "provider": "bootswatch"}, + "litera": {"label": "Bootswatch: Litera", "provider": "bootswatch"}, + "lumen": {"label": "Bootswatch: Lumen", "provider": "bootswatch"}, + "minty": {"label": "Bootswatch: Minty", "provider": "bootswatch"}, + "sketchy": {"label": "Bootswatch: Sketchy", "provider": "bootswatch"}, + "spacelab": {"label": "Bootswatch: Spacelab", "provider": "bootswatch"}, + "united": {"label": "Bootswatch: United", "provider": "bootswatch"}, + "zephyr": {"label": "Bootswatch: Zephyr", "provider": "bootswatch"}, + # Complete DevExpress Bootstrap v5 dist.v5 set. + **{ + f"dx-{theme}": { + "label": f"DevExpress: {label}", + "provider": "devexpress", + "local": f"{LIBS_STATIC_DIR}/devexpress-bootstrap-themes/dist.v5/{theme}/bootstrap.min.css", + "cdn": f"https://cdn.jsdelivr.net/gh/DevExpress/bootstrap-themes@master/dist.v5/{theme}/bootstrap.min.css", + } + for theme, label in DEVEXPRESS_BOOTSTRAP_THEMES.items() + }, + # App-specific Bootstrap variable overrides. These sit on top of default Bootstrap. + **{ + f"pytorrent-{theme}": { + "label": f"Custom: {label}", + "provider": "pytorrent", + "local": f"{LIBS_STATIC_DIR}/pytorrent-themes/{theme}/bootstrap.min.css", + "cdn": f"/static/{LIBS_STATIC_DIR}/pytorrent-themes/{theme}/bootstrap.min.css", + } + for theme, label in PYTORRENT_APP_THEMES.items() + }, +} + +def _theme_definition(theme: str | None) -> dict[str, str]: + theme = theme if theme in BOOTSTRAP_THEME_DEFINITIONS else "default" + item = dict(BOOTSTRAP_THEME_DEFINITIONS[theme]) + if item.get("provider") == "bootswatch": + item["local"] = f"{LIBS_STATIC_DIR}/bootswatch/{BOOTSWATCH_VERSION}/{theme}/bootstrap.min.css" + item["cdn"] = f"https://cdn.jsdelivr.net/npm/bootswatch@{BOOTSWATCH_VERSION}/dist/{theme}/bootstrap.min.css" + return item + + +BOOTSTRAP_THEMES = tuple(BOOTSTRAP_THEME_DEFINITIONS.keys()) +BOOTSTRAP_THEME_LABELS = {key: value["label"] for key, value in BOOTSTRAP_THEME_DEFINITIONS.items()} STATIC_ASSETS = { "bootstrap_js": { @@ -86,16 +140,8 @@ STATIC_ASSETS = { 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", - } + item = _theme_definition(theme) + return {"local": item["local"], "cdn": item["cdn"]} def asset_path(key: str) -> str: diff --git a/pytorrent/services/operation_logs.py b/pytorrent/services/operation_logs.py index de39fe8..7c52ce8 100644 --- a/pytorrent/services/operation_logs.py +++ b/pytorrent/services/operation_logs.py @@ -98,7 +98,10 @@ def record_job_event(profile_id: int, action: str, status: str, payload: dict | severity = "danger" if status == "failed" else "info" if action in {"add_magnet", "add_torrent_raw"}: name = str(payload.get("name") or payload.get("filename") or payload.get("uri") or "torrent")[:300] - msg = f"{action} {status}: {name}" + # Note: Keep the internal action name stable, but show a user-facing label instead of raw worker identifiers. + source_label = "Torrent file" if action == "add_torrent_raw" else "Magnet link" + status_label = {"started": "queued", "done": "added", "failed": "failed"}.get(str(status), str(status)) + msg = f"{source_label} {status_label}: {name}" record(profile_id, "torrent_added" if status == "done" else event_type, msg, severity=severity, source="job", action=action, details={"job_id": job_id, "status": status, "directory": payload.get("directory"), "label": payload.get("label"), "error": error, "result": result}, user_id=user_id) return if not hashes: diff --git a/pytorrent/services/preferences.py b/pytorrent/services/preferences.py index 81abe6e..2399fba 100644 --- a/pytorrent/services/preferences.py +++ b/pytorrent/services/preferences.py @@ -4,19 +4,9 @@ import json from ..db import connect, utcnow, default_user_id from . import auth +from .frontend_assets import BOOTSTRAP_THEME_LABELS -BOOTSTRAP_THEMES = { - "default": "Default Bootstrap", - "flatly": "Flatly", - "litera": "Litera", - "lumen": "Lumen", - "minty": "Minty", - "sketchy": "Sketchy", - "solar": "Solar", - "spacelab": "Spacelab", - "united": "United", - "zephyr": "Zephyr", -} +BOOTSTRAP_THEMES = BOOTSTRAP_THEME_LABELS FONT_FAMILIES = { "default": "Theme default", diff --git a/pytorrent/services/rtorrent/files.py b/pytorrent/services/rtorrent/files.py index f9ac10f..93cd6aa 100644 --- a/pytorrent/services/rtorrent/files.py +++ b/pytorrent/services/rtorrent/files.py @@ -59,10 +59,17 @@ def _torrent_file_remote_path(profile: dict, torrent_hash: str, index: int) -> t if selected is None: available = ", ".join(str(f.get("index")) for f in files[:20]) or "none" raise ValueError(f"File index {index} not found. Available indexes: {available}") + base = _remote_clean_path(_torrent_data_path(c, torrent_hash)) rel = str(selected.get("path") or "").lstrip("/") - if len(files) == 1 and base and not base.endswith("/"): - path = base + + # Note: rTorrent can report d.base_path as either the payload file or the + # containing data directory for a one-file torrent. Keep both existing + # layouts working and avoid treating a directory as the media file. + if len(files) == 1 and base and rel: + base_name = posixpath.basename(base.rstrip("/")) + rel_name = posixpath.basename(rel.rstrip("/")) + path = base if base_name == rel_name else _remote_join(base, rel) else: path = _remote_join(base, rel) return selected, path @@ -176,25 +183,16 @@ def _media_info_sample_suffix(source_path: str) -> str: def _read_file_prefix(profile: dict, source_path: str, max_bytes: int) -> bytes: - # Note: Small previews use a bounded prefix read, so text and image preview actions never load an entire large file into RAM. + # Note: File info must read through rTorrent, not the pyTorrent process, because torrents may live on a remote host or under rTorrent-only permissions. limit = max(0, int(max_bytes or 0)) chunks: list[bytes] = [] collected = 0 - if int(profile.get("is_remote") or 0): - for chunk in iter_remote_file_chunks(profile, source_path, size=limit, chunk_size=_MEDIA_INFO_CHUNK_BYTES): - if collected >= limit: - break - data = bytes(chunk[: max(0, limit - collected)]) - chunks.append(data) - collected += len(data) - else: - with open(source_path, "rb") as src: - while collected < limit: - data = src.read(min(_MEDIA_INFO_CHUNK_BYTES, limit - collected)) - if not data: - break - chunks.append(data) - collected += len(data) + for chunk in iter_remote_file_chunks(profile, source_path, size=limit, chunk_size=_MEDIA_INFO_CHUNK_BYTES): + if collected >= limit: + break + data = bytes(chunk[: max(0, limit - collected)]) + chunks.append(data) + collected += len(data) return b"".join(chunks) @@ -340,21 +338,12 @@ def _media_info_temp_sample(profile: dict, source_path: str, max_bytes: int) -> written = 0 try: with os.fdopen(fd, "wb") as tmp: - if int(profile.get("is_remote") or 0): - for chunk in iter_remote_file_chunks(profile, source_path, size=max_bytes, chunk_size=_MEDIA_INFO_CHUNK_BYTES): - if written >= max_bytes: - break - data = bytes(chunk[: max(0, max_bytes - written)]) - tmp.write(data) - written += len(data) - else: - with open(source_path, "rb") as src: - while written < max_bytes: - data = src.read(min(_MEDIA_INFO_CHUNK_BYTES, max_bytes - written)) - if not data: - break - tmp.write(data) - written += len(data) + for chunk in iter_remote_file_chunks(profile, source_path, size=max_bytes, chunk_size=_MEDIA_INFO_CHUNK_BYTES): + if written >= max_bytes: + break + data = bytes(chunk[: max(0, max_bytes - written)]) + tmp.write(data) + written += len(data) return tmp_path, written except Exception: try: @@ -447,13 +436,25 @@ def _media_info_hachoir_imports(): ) from exc +def _torrent_file_is_complete(selected: dict) -> bool: + # Note: File info reads real file bytes, so incomplete payload files are blocked before any parser touches them. + size = int(selected.get("size") or 0) + completed_chunks = int(selected.get("completed_chunks") or 0) + size_chunks = int(selected.get("size_chunks") or 0) + progress = float(selected.get("progress") or 0) + return size <= 0 or progress >= 100.0 or (size_chunks > 0 and completed_chunks >= size_chunks) + + def torrent_file_media_info(profile: dict, torrent_hash: str, index: int, max_bytes: int = _MEDIA_INFO_SAMPLE_BYTES) -> dict: # Note: This additive endpoint now acts as a smart file preview: media metadata, text/NFO reader, or image preview depending on file type. selected, remote_path = _torrent_file_remote_path(profile, torrent_hash, index) name = str(selected.get("path") or remote_path) size = int(selected.get("size") or 0) - err = remote_file_readability_error(profile, remote_path) if int(profile.get("is_remote") or 0) else None + if not _torrent_file_is_complete(selected): + raise RuntimeError("File info is available only after this file is fully downloaded.") + + err = remote_file_readability_error(profile, remote_path) if err: raise RuntimeError(err) @@ -541,17 +542,31 @@ def torrent_download_zip_items(profile: dict, torrent_hash: str, indexes: list[i return items +def _remote_file_exists(c: ScgiRtorrentClient, source_path: str) -> bool: + # Note: Export fallback checks candidate .torrent files on the rTorrent host before staging, avoiding stale tied-file paths. + clean = _remote_clean_path(source_path) + if not clean: + return False + script = 'p=$1; [ -f "$p" ] && [ -r "$p" ] && printf OK || true' + try: + return str(_rt_execute(c, "execute.capture", "sh", "-c", script, "pytorrent-file-exists", clean) or "").strip() == "OK" + except Exception: + return False + + def _remote_stage_path(c: ScgiRtorrentClient, source_path: str, suffix: str = "") -> str: token = uuid.uuid4().hex safe_suffix = ''.join(ch if ch.isalnum() or ch in '.-_' else '_' for ch in str(suffix or ''))[:80] target = f"{download_tmp_dir().rstrip('/')}/pytorrent-download-{token}{safe_suffix}" script = ( 'src=$1; dst=$2; ' - 'if [ ! -f "$src" ]; then echo "ERR\tmissing source"; exit 0; fi; ' + 'if [ ! -f "$src" ]; then printf "ERR\tmissing source: %s\n" "$src"; exit 0; fi; ' + 'if [ ! -r "$src" ]; then printf "ERR\tsource is not readable: %s\n" "$src"; exit 0; fi; ' 'cp -- "$src" "$dst" 2>/tmp/pytorrent-cp-err-$$ || { rc=$?; err=$(cat /tmp/pytorrent-cp-err-$$ 2>/dev/null); rm -f /tmp/pytorrent-cp-err-$$; printf "ERR\t%s\t%s\n" "$rc" "$err"; exit 0; }; ' 'rm -f /tmp/pytorrent-cp-err-$$; chmod 0644 "$dst" 2>/dev/null || true; printf "OK\t%s\n" "$dst"' ) - output = str(_rt_execute(c, "execute.capture", "sh", "-c", script, "pytorrent-stage-file", source_path, target) or "").strip() + clean_source = _remote_clean_path(source_path) + output = str(_rt_execute(c, "execute.capture", "sh", "-c", script, "pytorrent-stage-file", clean_source, target) or "").strip() parts = (output.splitlines()[0] if output else "").split("\t", 2) if len(parts) >= 2 and parts[0] == "OK": return parts[1] @@ -643,14 +658,48 @@ def _torrent_raw_from_method(c: ScgiRtorrentClient, torrent_hash: str) -> bytes return None -def _torrent_source_file(c: ScgiRtorrentClient, torrent_hash: str) -> str: +def _rtorrent_session_path(c: ScgiRtorrentClient) -> str: + for method in ("session.path", "get_session"): + try: + value = str(c.call(method) or "").strip() + except Exception: + continue + if value: + return _remote_clean_path(value) + return "" + + +def _torrent_source_file_candidates(c: ScgiRtorrentClient, torrent_hash: str) -> list[str]: + # Note: rTorrent may keep stale watch/tied paths; session candidates preserve .torrent export when the original source was moved. + candidates: list[str] = [] for method in ("d.tied_to_file", "d.get_tied_to_file", "d.loaded_file", "d.get_loaded_file", "d.session_file", "d.get_session_file"): try: value = str(c.call(method, torrent_hash) or "").strip() except Exception: continue if value: - return value + candidates.append(value) + session_path = _rtorrent_session_path(c) + hash_values = [] + clean_hash = str(torrent_hash or "").strip() + if clean_hash: + hash_values.extend([clean_hash, clean_hash.upper(), clean_hash.lower()]) + for h in dict.fromkeys(hash_values): + if session_path: + candidates.append(_remote_join(session_path, f"{h}.torrent")) + candidates.append(f"/tmp/{h}.torrent") + result = [] + for item in candidates: + clean = _remote_clean_path(item) + if clean and clean not in result: + result.append(clean) + return result + + +def _torrent_source_file(c: ScgiRtorrentClient, torrent_hash: str) -> str: + for source in _torrent_source_file_candidates(c, torrent_hash): + if _remote_file_exists(c, source): + return source return "" @@ -658,16 +707,16 @@ def export_torrent_file(profile: dict, torrent_hash: str) -> dict: c = client_for(profile) name = str(c.call("d.name", torrent_hash) or torrent_hash).strip() or torrent_hash filename = f"{name}.torrent" if not name.lower().endswith(".torrent") else name + source = _torrent_source_file(c, torrent_hash) + if source: + # Note: Stream the existing .torrent source directly instead of copying it to a temporary staged file first. + return {"path": source, "download_name": filename, "local": False} raw = _torrent_raw_from_method(c, torrent_hash) if raw: target = LocalPath(download_tmp_dir()) / f"pytorrent-download-{uuid.uuid4().hex}.torrent" target.write_bytes(raw) return {"path": str(target), "download_name": filename, "local": True} - source = _torrent_source_file(c, torrent_hash) - if not source: - raise RuntimeError("Cannot find torrent source file in rTorrent") - staged = _remote_stage_path(c, source, ".torrent") - return {"path": staged, "download_name": filename, "local": False} + raise RuntimeError("Cannot find torrent source file in rTorrent") def set_file_priorities(profile: dict, torrent_hash: str, files: list[dict]) -> dict: diff --git a/pytorrent/static/js/api.js b/pytorrent/static/js/api.js index ff15cfb..2edebbd 100644 --- a/pytorrent/static/js/api.js +++ b/pytorrent/static/js/api.js @@ -1 +1 @@ -export const apiSource = " async function post(url,data,method='POST'){\n const res=await fetch(url,{method,headers:{'Content-Type':'application/json','Accept':'application/json'},body:JSON.stringify(data||{})});\n const text=await res.text();\n let json;\n try{ json=JSON.parse(text); }\n catch(e){\n const clean=(text||'').replace(/<[^>]+>/g,' ').replace(/\\s+/g,' ').trim().slice(0,180);\n throw new Error(clean?`Invalid server response (${res.status}): ${clean}`:`Invalid server response (${res.status})`);\n }\n if(!res.ok || !json.ok) throw new Error(json.error||`Operation failed (${res.status})`);\n return json;\n }\n\n async function runAction(action, extra={}){ const hashes=selectedHashes(); if(!hashes.length) return toastMessage('toast.noTorrentsSelected','warning'); let payload={hashes,...extra}; if(action==='move'){ openPathPicker('move'); return; } setBusy(true); try{ const j=await post(`/api/torrents/${action}`,payload); markQueuedJobs(j, hashes, action); if(action==='recheck'){ hashes.forEach(h=>{ const t=torrents.get(h); if(t) torrents.set(h,{...t,status:'Checking',hashing:1,message:'Force recheck queued'}); }); scheduleRender(true); } const parts=Number(j.bulk_parts||1); toastMessage('toast.actionQueued','success',{action,parts}); if(action==='set_label') await loadLabels(); }catch(e){toast(e.message,'danger');} finally{setBusy(false);} }\n function flag(iso){ const code=String(iso||'').toLowerCase(); return code?` ${esc(code.toUpperCase())}`:'-'; }\n function table(headers,rows,extraClass=''){ const cls=extraClass?` ${extraClass}`:''; return `${headers.map(h=>``).join('')}${rows.map(r=>`${r.map(c=>``).join('')}`).join('')}
${esc(h)}
${c}
`; }\n function responsiveTable(headers,rows,extraClass=''){ return `
${table(headers,rows,extraClass)}
`; }\n function downloadJson(filename, data){ const blob=new Blob([JSON.stringify(data,null,2)],{type:'application/json'}); const url=URL.createObjectURL(blob); const a=document.createElement('a'); a.href=url; a.download=filename; document.body.appendChild(a); a.click(); a.remove(); setTimeout(()=>URL.revokeObjectURL(url),500); }\n function filenameFromResponse(res, fallback){ const cd=res.headers.get('Content-Disposition')||''; const m=cd.match(/filename\\*=UTF-8''([^;]+)|filename=\"?([^\";]+)\"?/i); try{ return decodeURIComponent(m?.[1]||m?.[2]||fallback); }catch(e){ return m?.[1]||m?.[2]||fallback; } }\n async function openTemporaryDownload(url, data=null, method='POST'){\n const options = {method, headers:{'Accept':'application/json'}};\n if(data !== null){\n options.headers['Content-Type']='application/json';\n options.body=JSON.stringify(data || {});\n }\n const res = await fetch(url, options);\n const json = await res.json().catch(()=>({}));\n if(!res.ok || !json.ok) throw new Error(json.error || `Download link failed (${res.status})`);\n if(!json.url) throw new Error('Download link response did not include a URL');\n // Note: API creates the temporary link, while the browser-visible download target stays under /download/.\n window.location.href = json.url;\n toastMessage('toast.downloadStarted','success');\n return json;\n }\n async function downloadResponse(url, options={}, fallback='download.bin', label='Preparing download...'){\n setBusy(true,label);\n try{\n const res=await fetch(url,options);\n if(!res.ok){ const j=await res.json().catch(()=>({})); throw new Error(j.error||`Download failed: HTTP ${res.status}`); }\n const total=Number(res.headers.get('Content-Length')||0);\n const name=filenameFromResponse(res,fallback);\n let blob;\n if(res.body){\n const reader=res.body.getReader();\n const chunks=[]; let received=0;\n while(true){\n const {done,value}=await reader.read();\n if(done) break;\n chunks.push(value); received += value.length;\n const loader=$('globalLoader');\n const span=loader?.querySelector('span:last-child');\n if(span){\n if(total){\n const pct=Math.max(0,Math.min(100,Math.round((received/total)*100)));\n span.textContent=`Downloading ${pct}%`;\n } else {\n span.textContent=`Downloading ${(received/1024/1024).toFixed(1)} MB`;\n }\n }\n }\n blob=new Blob(chunks);\n } else {\n blob=await res.blob();\n }\n const obj=URL.createObjectURL(blob);\n const a=document.createElement('a'); a.href=obj; a.download=name; document.body.appendChild(a); a.click(); a.remove(); setTimeout(()=>URL.revokeObjectURL(obj),1000);\n toastMessage('toast.downloadStarted','success');\n } finally { setBusy(false); }\n }\n async function downloadTorrentFiles(hashes=null){\n const list=hashes||selectedHashes();\n if(!list.length) return toastMessage('toast.noTorrentsSelected','warning');\n if(list.length===1) return openTemporaryDownload(`/api/torrents/${encodeURIComponent(list[0])}/torrent-file/link`, null, 'GET').catch(e=>toast(e.message,'danger'));\n return openTemporaryDownload('/api/torrents/torrent-files.zip/link',{hashes:list}).catch(e=>toast(e.message,'danger'));\n }\n"; +export const apiSource = " async function post(url,data,method='POST'){\n const res=await fetch(url,{method,headers:{'Content-Type':'application/json','Accept':'application/json'},body:JSON.stringify(data||{})});\n const text=await res.text();\n let json;\n try{ json=JSON.parse(text); }\n catch(e){\n const clean=(text||'').replace(/<[^>]+>/g,' ').replace(/\\s+/g,' ').trim().slice(0,180);\n throw new Error(clean?`Invalid server response (${res.status}): ${clean}`:`Invalid server response (${res.status})`);\n }\n if(!res.ok || !json.ok) throw new Error(json.error||`Operation failed (${res.status})`);\n return json;\n }\n\n async function runAction(action, extra={}){ const hashes=selectedHashes(); if(!hashes.length) return toastMessage('toast.noTorrentsSelected','warning'); let payload={hashes,...extra}; if(action==='move'){ openPathPicker('move'); return; } setBusy(true); try{ const j=await post(`/api/torrents/${action}`,payload); markQueuedJobs(j, hashes, action); if(action==='recheck'){ hashes.forEach(h=>{ const t=torrents.get(h); if(t) torrents.set(h,{...t,status:'Checking',hashing:1,message:'Force recheck queued'}); }); scheduleRender(true); } const parts=Number(j.bulk_parts||1); toastMessage('toast.actionQueued','success',{action,parts}); if(action==='set_label') await loadLabels(); }catch(e){toast(e.message,'danger');} finally{setBusy(false);} }\n function flag(iso){ const code=String(iso||'').toLowerCase(); return code?` ${esc(code.toUpperCase())}`:'-'; }\n function table(headers,rows,extraClass=''){ const cls=extraClass?` ${extraClass}`:''; return `${headers.map(h=>``).join('')}${rows.map(r=>`${r.map(c=>``).join('')}`).join('')}
${esc(h)}
${c}
`; }\n function responsiveTable(headers,rows,extraClass=''){ return `
${table(headers,rows,extraClass)}
`; }\n function downloadJson(filename, data){ const blob=new Blob([JSON.stringify(data,null,2)],{type:'application/json'}); const url=URL.createObjectURL(blob); const a=document.createElement('a'); a.href=url; a.download=filename; document.body.appendChild(a); a.click(); a.remove(); setTimeout(()=>URL.revokeObjectURL(url),500); }\n function filenameFromResponse(res, fallback){ const cd=res.headers.get('Content-Disposition')||''; const m=cd.match(/filename\\*=UTF-8''([^;]+)|filename=\"?([^\";]+)\"?/i); try{ return decodeURIComponent(m?.[1]||m?.[2]||fallback); }catch(e){ return m?.[1]||m?.[2]||fallback; } }\n async function openTemporaryDownload(url, data=null, method='POST', label='Preparing download...'){\n // Note: Link creation is intentionally light; real file work starts when the browser opens the temporary /download URL.\n setBusy(true, label);\n try{\n const options = {method, headers:{'Accept':'application/json'}};\n if(data !== null){\n options.headers['Content-Type']='application/json';\n options.body=JSON.stringify(data || {});\n }\n const res = await fetch(url, options);\n const json = await res.json().catch(()=>({}));\n if(!res.ok || !json.ok) throw new Error(json.error || `Download link failed (${res.status})`);\n if(!json.url) throw new Error('Download link response did not include a URL');\n const loader=$('globalLoader');\n const span=loader?.querySelector('span:last-child');\n if(span) span.textContent='Starting browser download...';\n // Note: Do not call setBusy(true) again here; this updates the active loader without increasing the busy counter.\n window.location.href = json.url;\n toastMessage('toast.downloadStarted','success');\n setTimeout(()=>setBusy(false), 1200);\n return json;\n } catch(e) {\n setBusy(false);\n throw e;\n }\n }\n async function downloadResponse(url, options={}, fallback='download.bin', label='Preparing download...'){\n setBusy(true,label);\n try{\n const res=await fetch(url,options);\n if(!res.ok){ const j=await res.json().catch(()=>({})); throw new Error(j.error||`Download failed: HTTP ${res.status}`); }\n const total=Number(res.headers.get('Content-Length')||0);\n const name=filenameFromResponse(res,fallback);\n let blob;\n if(res.body){\n const reader=res.body.getReader();\n const chunks=[]; let received=0;\n while(true){\n const {done,value}=await reader.read();\n if(done) break;\n chunks.push(value); received += value.length;\n const loader=$('globalLoader');\n const span=loader?.querySelector('span:last-child');\n if(span){\n if(total){\n const pct=Math.max(0,Math.min(100,Math.round((received/total)*100)));\n span.textContent=`Downloading ${pct}%`;\n } else {\n span.textContent=`Downloading ${(received/1024/1024).toFixed(1)} MB`;\n }\n }\n }\n blob=new Blob(chunks);\n } else {\n blob=await res.blob();\n }\n const obj=URL.createObjectURL(blob);\n const a=document.createElement('a'); a.href=obj; a.download=name; document.body.appendChild(a); a.click(); a.remove(); setTimeout(()=>URL.revokeObjectURL(obj),1000);\n toastMessage('toast.downloadStarted','success');\n } finally { setBusy(false); }\n }\n async function downloadTorrentFiles(hashes=null){\n const list=hashes||selectedHashes();\n if(!list.length) return toastMessage('toast.noTorrentsSelected','warning');\n if(list.length===1){\n return openTemporaryDownload(\n `/api/torrents/${encodeURIComponent(list[0])}/torrent-file/link`,\n null,\n 'GET',\n 'Preparing .torrent file...'\n ).catch(e=>toast(e.message,'danger'));\n }\n return openTemporaryDownload(\n '/api/torrents/torrent-files.zip/link',\n {hashes:list},\n 'POST',\n `Preparing torrent ZIP (${list.length})...`\n ).catch(e=>toast(e.message,'danger'));\n }\n"; diff --git a/pytorrent/static/js/messages.js b/pytorrent/static/js/messages.js index b0c4c79..6e5c220 100644 --- a/pytorrent/static/js/messages.js +++ b/pytorrent/static/js/messages.js @@ -3,6 +3,8 @@ export const messagesSource = ` const APP_MESSAGES = { actions: { raw_torrent: 'Add torrent', + add_torrent_raw: 'Add torrent file', + add_magnet: 'Add magnet link', add: 'Add torrent', start: 'Start torrent', pause: 'Pause torrent', diff --git a/pytorrent/static/js/state.js b/pytorrent/static/js/state.js index 6a74dc1..95abf52 100644 --- a/pytorrent/static/js/state.js +++ b/pytorrent/static/js/state.js @@ -1 +1 @@ -export const stateSource = " const $ = (id) => document.getElementById(id);\n const esc = (s) => String(s ?? \"\").replace(/[&<>'\"]/g, c => ({\"&\":\"&\",\"<\":\"<\",\">\":\">\",\"'\":\"'\",'\"':\""\"}[c]));\n // Note: Footer transfer totals can arrive as already formatted strings, so keep this helper tolerant and side-effect free.\n function compactTransferText(value){\n const text = String(value ?? \"\").trim();\n if(!text) return \"-\";\n return text.replace(/\\\\s+/g, \" \");\n }\n const ROW_HEIGHT = 32, OVERSCAN = 14;\n const torrents = new Map();\n const browserViewPrefs = (()=>{ try{return JSON.parse(localStorage.getItem('pyTorrent.mobileViewPrefs')||'{}')||{};}catch(e){return {};} })();\n const savedFilter = String(browserViewPrefs.activeFilter || window.PYTORRENT?.activeFilter || \"all\");\n // Note: Mobile has both \"All\" and \"All trackers\" options, so keep the exact selected option separate from the shared filter state.\n let mobileActiveFilterKey = String(browserViewPrefs.mobileFilterKey || savedFilter || \"all\");\n let visibleRows = [], selected = new Set(), selectedHash = null, lastSelectedHash = null, activeFilter = savedFilter.startsWith(\"tracker:\") ? \"all\" : (savedFilter || \"all\");\n let activeTrackerFilter = savedFilter.startsWith(\"tracker:\") ? savedFilter.slice(8) : \"\";\n const SORT_KEYS = new Set([\"name\", \"status\", \"size\", \"progress\", \"down_rate\", \"up_rate\", \"eta\", \"seeds\", \"peers\", \"ratio\", \"path\", \"label\", \"ratio_group\", \"down_total\", \"to_download\", \"up_total\", \"created\", \"priority\", \"state\", \"active\", \"complete\", \"hashing\", \"message\", \"hash\"]);\n const savedSort = browserViewPrefs.sortState || window.PYTORRENT?.torrentSort || {};\n let sortState = {key: SORT_KEYS.has(savedSort.key) ? savedSort.key : \"name\", dir: Number(savedSort.dir) < 0 ? -1 : 1}, renderPending = false, renderVersion = 0, lastRenderSignature = \"\";\n // Note: Mobile sort filters are configurable because the full sortable list is too large for quick phone use.\n const DEFAULT_MOBILE_SORT_FILTER_IDS = new Set([\"seeds:-1\", \"up_rate:-1\", \"down_rate:-1\", \"progress:-1\"]);\n const MOBILE_SORT_STEPS = [\n {key:\"down_rate\", dir:-1, label:\"DL\"},\n {key:\"down_rate\", dir:1, label:\"DL\"},\n {key:\"up_rate\", dir:-1, label:\"UL\"},\n {key:\"up_rate\", dir:1, label:\"UL\"},\n {key:\"progress\", dir:-1, label:\"Progress\"},\n {key:\"progress\", dir:1, label:\"Progress\"},\n {key:\"eta\", dir:-1, label:\"ETA\"},\n {key:\"eta\", dir:1, label:\"ETA\"},\n {key:\"ratio\", dir:-1, label:\"Ratio\"},\n {key:\"ratio\", dir:1, label:\"Ratio\"},\n {key:\"size\", dir:-1, label:\"Size\"},\n {key:\"size\", dir:1, label:\"Size\"},\n {key:\"seeds\", dir:-1, label:\"Seeds\"},\n {key:\"seeds\", dir:1, label:\"Seeds\"},\n {key:\"peers\", dir:-1, label:\"Peers\"},\n {key:\"peers\", dir:1, label:\"Peers\"},\n {key:\"status\", dir:1, label:\"Status\"},\n {key:\"status\", dir:-1, label:\"Status\"},\n {key:\"label\", dir:1, label:\"Label\"},\n {key:\"label\", dir:-1, label:\"Label\"},\n {key:\"ratio_group\", dir:1, label:\"Ratio group\"},\n {key:\"ratio_group\", dir:-1, label:\"Ratio group\"},\n {key:\"down_total\", dir:-1, label:\"Downloaded\"},\n {key:\"down_total\", dir:1, label:\"Downloaded\"},\n {key:\"to_download\", dir:-1, label:\"To download\"},\n {key:\"to_download\", dir:1, label:\"To download\"},\n {key:\"up_total\", dir:-1, label:\"Uploaded\"},\n {key:\"up_total\", dir:1, label:\"Uploaded\"},\n {key:\"created\", dir:-1, label:\"Added\"},\n {key:\"created\", dir:1, label:\"Added\"},\n {key:\"priority\", dir:-1, label:\"Priority\"},\n {key:\"priority\", dir:1, label:\"Priority\"},\n {key:\"state\", dir:-1, label:\"State\"},\n {key:\"state\", dir:1, label:\"State\"},\n {key:\"active\", dir:-1, label:\"Active\"},\n {key:\"active\", dir:1, label:\"Active\"},\n {key:\"complete\", dir:-1, label:\"Complete\"},\n {key:\"complete\", dir:1, label:\"Complete\"},\n {key:\"hashing\", dir:-1, label:\"Hashing\"},\n {key:\"hashing\", dir:1, label:\"Hashing\"},\n {key:\"message\", dir:1, label:\"Message\"},\n {key:\"message\", dir:-1, label:\"Message\"},\n {key:\"path\", dir:1, label:\"Path\"},\n {key:\"path\", dir:-1, label:\"Path\"},\n {key:\"hash\", dir:1, label:\"Hash\"},\n {key:\"hash\", dir:-1, label:\"Hash\"},\n {key:\"name\", dir:1, label:\"Name\"},\n {key:\"name\", dir:-1, label:\"Name\"}\n ];\n let lastLimits = {down: 0, up: 0}, pendingBusy = 0, pathTarget = null, lastPathParent = \"/\";\n const traffic = [], systemUsage = [];\n const socket = (typeof io === \"function\") ? io({transports:[\"polling\"], reconnection:true, reconnectionAttempts:Infinity, reconnectionDelay:700, reconnectionDelayMax:5000, timeout:8000}) : {connected:false,on(){},emit(){},io:{on(){}}};\n const COLUMN_DEFS = [[\"status\",\"Status\",false],[\"size\",\"Size\",false],[\"progress\",\"Progressbar\",false],[\"down_rate\",\"DL\",false],[\"up_rate\",\"UL\",false],[\"eta\",\"ETA\",false],[\"seeds\",\"Seeds\",false],[\"peers\",\"Peers\",false],[\"ratio\",\"Ratio\",false],[\"path\",\"Path\",false],[\"label\",\"Label\",false],[\"ratio_group\",\"Ratio group\",false],[\"down_total\",\"Downloaded\",true],[\"to_download\",\"To download\",true],[\"up_total\",\"Uploaded\",true],[\"created\",\"Added\",true],[\"priority\",\"Priority\",true],[\"state\",\"State\",true],[\"active\",\"Active\",true],[\"complete\",\"Complete\",true],[\"hashing\",\"Hashing\",true],[\"message\",\"Message\",true],[\"hash\",\"Hash\",true]];\n const DEFAULT_HIDDEN_COLUMNS = new Set(COLUMN_DEFS.filter(([, , hiddenByDefault]) => hiddenByDefault).map(([key]) => key));\n const savedColumns = window.PYTORRENT?.tableColumns || {};\n const DEFAULT_COLUMN_WIDTHS = {\n select: 34, name: 360, status: 110, size: 90, progress: 120,\n down_rate: 86, up_rate: 86, eta: 92, seeds: 70, peers: 70,\n ratio: 72, path: 300, label: 140, ratio_group: 130,\n down_total: 120, to_download: 120, up_total: 120, created: 150,\n priority: 80, state: 70, active: 70, complete: 82, hashing: 82,\n message: 220, hash: 280\n };\n const COLUMN_WIDTH_MIN = 44;\n const COLUMN_WIDTH_MAX = 720;\n const explicitlyShownColumns = new Set(savedColumns.shown || []);\n let hiddenColumns = new Set([...(savedColumns.hidden || []), ...[...DEFAULT_HIDDEN_COLUMNS].filter(key => !explicitlyShownColumns.has(key))]);\n // Note: Column widths are persisted with the existing column preferences payload, so no database migration is needed.\n function normalizeColumnWidths(value={}){\n const allowed = new Set(['select', ...COLUMN_DEFS.map(([key]) => key)]);\n const normalized = {...DEFAULT_COLUMN_WIDTHS};\n Object.entries(value || {}).forEach(([key, width])=>{\n if(allowed.has(key)) normalized[key] = clampNumber(width, COLUMN_WIDTH_MIN, COLUMN_WIDTH_MAX, DEFAULT_COLUMN_WIDTHS[key] || 120);\n });\n return normalized;\n }\n let columnWidths = normalizeColumnWidths(savedColumns.widths || {});\n if(browserViewPrefs.columnWidths) columnWidths = normalizeColumnWidths({...columnWidths, ...browserViewPrefs.columnWidths});\n function mobileSortStepId(step){ return `${step.key}:${step.dir}`; }\n function normalizeMobileSortFilters(value={}){\n const normalized = Object.fromEntries(MOBILE_SORT_STEPS.map(step => {\n const id = mobileSortStepId(step);\n return [id, DEFAULT_MOBILE_SORT_FILTER_IDS.has(id)];\n }));\n Object.entries(value || {}).forEach(([id, enabled]) => { if(id in normalized) normalized[id] = !!enabled; });\n return normalized;\n }\n let mobileSortFilters = normalizeMobileSortFilters(savedColumns.mobileSortFilters || {});\n if(browserViewPrefs.mobileSortFilters) mobileSortFilters = normalizeMobileSortFilters({...mobileSortFilters, ...browserViewPrefs.mobileSortFilters});\n const DEFAULT_MOBILE_COLUMNS = new Set([\"status\",\"progress\",\"down_rate\",\"up_rate\",\"eta\",\"seeds\",\"peers\",\"ratio\",\"path\"]);\n const MOBILE_COLUMN_DEFS = COLUMN_DEFS.map(([key,label]) => [key, label, DEFAULT_MOBILE_COLUMNS.has(key)]);\n function normalizeMobileColumns(value={}){\n const normalized = {...Object.fromEntries(MOBILE_COLUMN_DEFS.map(([key,,shown])=>[key, shown]))};\n Object.entries(value || {}).forEach(([key, shown])=>{\n if(key === \"speed\"){ normalized.down_rate = !!shown; normalized.up_rate = !!shown; }\n else if(key === \"seed_peer\"){ normalized.seeds = !!shown; normalized.peers = !!shown; }\n else if(key in normalized) normalized[key] = !!shown;\n });\n return normalized;\n }\n let mobileColumns = normalizeMobileColumns(savedColumns.mobile || {});\n if(browserViewPrefs.mobileColumns) mobileColumns = normalizeMobileColumns({...mobileColumns, ...browserViewPrefs.mobileColumns});\n let mobileSmartFiltersEnabled = browserViewPrefs.mobileSmartFiltersEnabled ?? savedColumns.mobileSmartFiltersEnabled ?? true;\n let knownLabels = [];\n let jobsPage = 0, jobsLimit = 25, jobsTotal = 0, smartHistoryExpanded = false, plannerHistoryExpanded = false;\n let automationSmartQueueStats = null;\n let peersRefreshTimer = null;\n let peersRefreshSeconds = Number(window.PYTORRENT?.peersRefreshSeconds || 0);\n let portCheckEnabled = !!Number(window.PYTORRENT?.portCheckEnabled || 0);\n let bootstrapTheme = window.PYTORRENT?.bootstrapTheme || \"default\";\n let fontFamily = window.PYTORRENT?.fontFamily || \"default\";\n let interfaceScale = Number(window.PYTORRENT?.interfaceScale || 100);\n let titleSpeedEnabled = !!Number(window.PYTORRENT?.titleSpeedEnabled || 0);\n let trackerFaviconsEnabled = !!Number(window.PYTORRENT?.trackerFaviconsEnabled || 0);\n // Note: Reverse DNS is opt-in because PTR lookups can be slower than normal peer refreshes.\n let reverseDnsEnabled = !!Number(window.PYTORRENT?.reverseDnsEnabled || 0);\n let automationToastsEnabled = window.PYTORRENT?.automationToastsEnabled !== false && Number(window.PYTORRENT?.automationToastsEnabled ?? 1) !== 0;\n let smartQueueToastsEnabled = window.PYTORRENT?.smartQueueToastsEnabled !== false && Number(window.PYTORRENT?.smartQueueToastsEnabled ?? 1) !== 0;\n let diskMonitorPaths = Array.isArray(window.PYTORRENT?.diskMonitorPaths) ? [...window.PYTORRENT.diskMonitorPaths] : [];\n let diskMonitorMode = window.PYTORRENT?.diskMonitorMode || \"default\";\n let diskMonitorSelectedPath = window.PYTORRENT?.diskMonitorSelectedPath || \"\";\n let lastUserDiskFetchAt = 0;\n let userDiskFetchInFlight = false;\n let userDiskFetchSeq = 0;\n let activeProfileId = window.PYTORRENT?.activeProfile || null;\n let trackerSummary = {hashes:{}, trackers:[], scanned:0, errors:[]};\n let trackerSummaryStatus = 'idle';\n let trackerSummarySignature = \"\";\n let trackerSummaryTimer = null;\n let lastLabelFiltersSignature = \"\";\n let lastTrackerFiltersSignature = \"\";\n let lastMobileFiltersSignature = \"\";\n const BASE_TITLE = document.title || \"pyTorrent\";\n const lastBrowserSpeed = {down: \"0 B/s\", up: \"0 B/s\"};\n const FOOTER_STATUS_STORAGE_KEY = \"pytorrent.footerStatus.v1\";\n const FOOTER_RT_METRIC_KEYS = new Set([\"sockets\", \"rt_downloads\", \"rt_uploads\", \"rt_http\", \"rt_files\", \"rt_port\"]);\n const FOOTER_ITEM_DEFS = [\n [\"cpu\", \"CPU\"], [\"ram\", \"RAM\"], [\"usage_chart\", \"CPU/RAM chart\"], [\"disk\", \"Disk\"],\n [\"version\", \"rTorrent version\"], [\"speed_down\", \"Download speed\"], [\"speed_up\", \"Upload speed\"],\n [\"speed_peaks\", \"Peak speeds\"], [\"limits\", \"Speed limits\"], [\"totals\", \"Total transfer\"], [\"port_check\", \"Port check\"],\n [\"clock\", \"Clock\"], [\"sockets\", \"Open sockets\"], [\"rt_downloads\", \"Downloads (D)\"], [\"rt_uploads\", \"Uploads (U)\"], [\"rt_http\", \"HTTP (H)\"], [\"rt_files\", \"Files (F)\"], [\"rt_port\", \"Incoming port\"], [\"shown\", \"Shown torrents\"], [\"selected\", \"Selected torrents\"], [\"docs\", \"API docs\"]\n ];\n const DEFAULT_FOOTER_ITEMS = Object.fromEntries(FOOTER_ITEM_DEFS.map(([key]) => [key, !FOOTER_RT_METRIC_KEYS.has(key)]));\n let footerItems = {...DEFAULT_FOOTER_ITEMS, ...(window.PYTORRENT?.footerItems || {})};\n let modalLabels = new Set(), defaultDownloadPath = null;\n let hasTorrentSnapshot = false, initialLoaderDone = false, rtConfigOriginal = new Map(), rtConfigFieldTypes = new Map(), rtConfigOriginalApplyOnStart = false;\n let rtorrentStartingMessage = '';\n let rtorrentStartingTimer = null, rtorrentStartingSince = 0;\n const RTORRENT_STALE_GRACE_MS = 30000;\n let torrentSummary = null;\n let profileCache = new Map();\n const hasActiveProfile = !!window.PYTORRENT?.activeProfile;\n let firstRunSetupShown = false;\n const activeOperations = new Map();\n // Note: Keeps live filter tooltips stable while the pointer is over a filter button.\n const filterTooltipState = new WeakMap();\n\n const toastGroups = new Map();\n const preferenceSaveTimers = new Map();\n function clampNumber(value, min, max, fallback){\n const num = Number(value);\n if(!Number.isFinite(num)) return fallback;\n return Math.max(min, Math.min(max, Math.round(num)));\n }\n function debounce(fn, delay=250){\n let timer = null;\n return (...args) => {\n clearTimeout(timer);\n timer = setTimeout(() => fn(...args), delay);\n };\n }\n function savePreferencePatch(payload, delay=350){\n const key = Object.keys(payload).sort().join('|');\n clearTimeout(preferenceSaveTimers.get(key));\n preferenceSaveTimers.set(key, setTimeout(async()=>{\n try{ await post('/api/preferences', payload); }catch(e){ console.warn('Preference save failed', e); }\n finally{ preferenceSaveTimers.delete(key); }\n }, delay));\n }\n function currentActiveFilterPreference(){\n return activeTrackerFilter ? `tracker:${activeTrackerFilter}` : activeFilter;\n }\n function saveTorrentSortPreference(){\n // Note: Sorting is persisted together with the current filter so mobile tracker scope cannot fall back to All trackers after a quick sort change.\n saveBrowserViewPrefs();\n savePreferencePatch({torrent_sort_json:{key:sortState.key, dir:sortState.dir}, active_filter:currentActiveFilterPreference()}, 200);\n }\n function saveBrowserViewPrefs(extra={}){\n try{\n const prev=JSON.parse(localStorage.getItem('pyTorrent.mobileViewPrefs')||'{}')||{};\n localStorage.setItem('pyTorrent.mobileViewPrefs', JSON.stringify({...prev, activeFilter:currentActiveFilterPreference(), mobileFilterKey:mobileActiveFilterKey, sortState, mobileColumns, columnWidths, ...extra}));\n }catch(e){}\n }\n function saveActiveFilterPreference(){\n saveBrowserViewPrefs();\n savePreferencePatch({active_filter:currentActiveFilterPreference()}, 250);\n }\n function cleanColumnPrefsHidden(values){ return [...values].filter(key => key !== \"progressbar\"); }\n async function resetViewPreferences(){\n activeFilter = \"all\";\n activeTrackerFilter = \"\";\n mobileActiveFilterKey = \"all\";\n sortState = {key:\"name\", dir:1};\n mobileColumns = normalizeMobileColumns();\n hiddenColumns = new Set(DEFAULT_HIDDEN_COLUMNS);\n columnWidths = normalizeColumnWidths();\n const height = applyDetailPanelHeight(255);\n renderColumnManager();\n document.querySelectorAll('.filter').forEach(x=>x.classList.toggle('active', x.dataset.filter === 'all'));\n if($('tableWrap')) $('tableWrap').scrollTop = 0;\n if($('mobileList')) $('mobileList').scrollTop = 0;\n try{\n await post('/api/preferences', {active_filter:\"all\", torrent_sort_json:{key:\"name\", dir:1}, detail_panel_height:height, table_columns_json:JSON.stringify({hidden:cleanColumnPrefsHidden(DEFAULT_HIDDEN_COLUMNS), shown:[], mobile:mobileColumns, mobileSmartFiltersEnabled:true, widths:columnWidths})});\n toast('View preferences reset','success');\n }catch(e){ toast(e.message,'danger'); }\n scheduleRender(true);\n }\n function applyDetailPanelHeight(height){\n const safeHeight = clampNumber(height, 160, 720, 255);\n document.documentElement.style.setProperty('--detail-panel-height', `${safeHeight}px`);\n const handle = $('detailResizeHandle');\n if(handle) handle.setAttribute('aria-valuenow', String(safeHeight));\n return safeHeight;\n }\n function saveDetailPanelHeight(height){\n const safeHeight = applyDetailPanelHeight(height);\n savePreferencePatch({detail_panel_height:safeHeight}, 250);\n }\n function setupDetailResizer(){\n const handle = $('detailResizeHandle');\n const content = document.querySelector('.content');\n if(!handle || !content) return;\n applyDetailPanelHeight(window.PYTORRENT?.detailPanelHeight || 255);\n let startY = 0, startHeight = 0;\n const onMove = (event) => {\n const pointerY = event.clientY ?? event.touches?.[0]?.clientY ?? startY;\n applyDetailPanelHeight(startHeight - (pointerY - startY));\n scheduleRender(false);\n };\n const onUp = () => {\n document.body.classList.remove('resizing-details');\n document.removeEventListener('pointermove', onMove);\n document.removeEventListener('pointerup', onUp);\n const value = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--detail-panel-height'), 10);\n saveDetailPanelHeight(value);\n };\n handle.addEventListener('pointerdown', (event) => {\n event.preventDefault();\n startY = event.clientY;\n startHeight = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--detail-panel-height'), 10) || 255;\n document.body.classList.add('resizing-details');\n document.addEventListener('pointermove', onMove);\n document.addEventListener('pointerup', onUp);\n });\n }\n function toastKey(msg, type){ return `${type}::${String(msg ?? '')}`; }\n function isAutomationEvent(msg){ return msg?.automation === true || msg?.source === 'automation'; }\n function shouldShowOperationToast(msg){\n // Note: Automation-created operation toasts follow the Automation toasts preference.\n return !isAutomationEvent(msg) || automationToastsEnabled;\n }\n function toast(msg, type=\"secondary\") {\n // Note: Groups identical toasts fired together, so repeated automation/action events do not flood the UI.\n const h=$('toastHost');\n if(!h) return;\n const text=String(msg ?? '');\n const key=toastKey(text,type);\n const existing=toastGroups.get(key);\n if(existing){\n existing.count += 1;\n const badge=existing.el.querySelector('.toast-count');\n if(badge){ badge.textContent=`\u00d7${existing.count}`; badge.classList.remove('d-none'); }\n clearTimeout(existing.timer);\n existing.timer=setTimeout(()=>{ existing.el.remove(); toastGroups.delete(key); },3500);\n return;\n }\n const el=document.createElement('div');\n el.className=`toast-item text-bg-${type}`;\n el.innerHTML=`${esc(text)}\u00d71`;\n h.appendChild(el);\n const entry={el,count:1,timer:null};\n entry.timer=setTimeout(()=>{ el.remove(); toastGroups.delete(key); },3500);\n toastGroups.set(key,entry);\n }\n function setBusy(on, label='Working...'){ pendingBusy += on ? 1 : -1; if(pendingBusy<0) pendingBusy=0; const loader=$('globalLoader'); if(loader){ loader.classList.toggle('d-none', pendingBusy===0); const span=loader.querySelector('span:last-child'); if(span) span.textContent=label; } $('busyBadge')?.classList.toggle('d-none', pendingBusy===0); }\n function setInitialLoader(title, text){ if(initialLoaderDone) return; if($('initialLoaderTitle') && title) $('initialLoaderTitle').textContent=title; if($('initialLoaderText') && text) $('initialLoaderText').textContent=text; }\n function hideInitialLoader(){ if(initialLoaderDone) return; initialLoaderDone=true; $('initialLoader')?.classList.add('is-hidden'); }\n function buttonBusy(btn,on){ if(!btn)return; btn.disabled=on; const label=btn.querySelector('.btn-label'); if(label){ if(!label.dataset.orig) label.dataset.orig=label.innerHTML; label.innerHTML=on?`Working...`:label.dataset.orig; }}\n function activeTab(){ return document.querySelector('#detailTabs .nav-link.active')?.dataset.tab || 'general'; }\n function loadingMarkup(label='Loading data...'){ return `
${esc(label)}
`; }\n // Note: Keeps empty-state colspans aligned with the desktop torrent table column count.\n function torrentColumnSpan(){ return 25; }\n function loadingTableRow(label='Loading torrents...'){ return `${loadingMarkup(label)}`; }\n // Note: Handles fresh installations with no configured rTorrent profile, so the UI does not wait forever for a snapshot.\n function renderNoProfileState(){\n hasTorrentSnapshot = false;\n torrentSummary = {filters:{all:{count:0},downloading:{count:0},seeding:{count:0},paused:{count:0},checking:{count:0},error:{count:0},stopped:{count:0}}};\n torrents.clear();\n selected.clear();\n renderCounts();\n const body = $('torrentBody');\n if(body){\n body.innerHTML = `
No rTorrent profile configured.Add the first rTorrent profile to start loading torrents.
`;\n }\n if($('detailPane')) $('detailPane').innerHTML = 'Add rTorrent profile first.';\n }\n function clearRtorrentStartingState(){\n rtorrentStartingMessage='';\n rtorrentStartingSince=0;\n if(rtorrentStartingTimer){ clearTimeout(rtorrentStartingTimer); rtorrentStartingTimer=null; }\n }\n function rtorrentStartingHtml(error=''){\n const details=error ? `${esc(error)}` : 'Port can already be open while XML-RPC/SCGI is still warming up. The list will load automatically after rTorrent answers.';\n return `
rTorrent is starting or not responding yet.Waiting for torrent data from the active profile.${details}
`;\n }\n function scheduleRtorrentStartingState(error=''){\n rtorrentStartingMessage = String(error || 'rTorrent is starting or not responding yet.');\n if(!(hasTorrentSnapshot && torrents.size)){\n renderRtorrentStartingState(rtorrentStartingMessage, true);\n return;\n }\n if(!rtorrentStartingSince) rtorrentStartingSince = Date.now();\n if(rtorrentStartingTimer) return;\n rtorrentStartingTimer = setTimeout(() => {\n rtorrentStartingTimer = null;\n if(rtorrentStartingMessage) renderRtorrentStartingState(rtorrentStartingMessage, true);\n }, RTORRENT_STALE_GRACE_MS);\n }\n function renderRtorrentStartingState(error='', force=false){\n rtorrentStartingMessage = String(error || 'rTorrent is starting or not responding yet.');\n if(hasTorrentSnapshot && torrents.size && !force) return;\n hasTorrentSnapshot = false;\n torrentSummary = {filters:{all:{count:0},downloading:{count:0},seeding:{count:0},paused:{count:0},checking:{count:0},error:{count:0},stopped:{count:0}}};\n torrents.clear();\n selected.clear();\n renderCounts();\n const body=$('torrentBody');\n if(body) body.innerHTML = `${rtorrentStartingHtml(rtorrentStartingMessage)}`;\n const list=$('mobileList');\n if(list) list.innerHTML = `
${rtorrentStartingHtml(rtorrentStartingMessage)}
`;\n if($('detailPane')) $('detailPane').innerHTML = 'rTorrent is starting. Details will appear after the first successful response.';\n }\n function parseDate(value){ const raw=String(value||'').trim(); if(!raw) return null; const d=new Date(raw); return Number.isNaN(d.getTime()) ? null : {raw,d}; }\n function formatDate(value, mode='short'){\n const parsed=parseDate(value);\n if(!parsed) return String(value||'');\n const opts=mode==='full'\n ? {year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit',second:'2-digit'}\n : {month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'};\n return new Intl.DateTimeFormat('pl-PL', opts).format(parsed.d).replace(',', '');\n }\n function dateCell(value){ const parsed=parseDate(value); if(!parsed) return esc(value||''); return `${esc(formatDate(value))}`; }\n // Note: Human-readable date cells keep full timestamps visible without squeezing table columns.\n function humanDateCell(value){ const parsed=parseDate(value); if(!parsed) return esc(value||''); const full=formatDate(value,'full'); return `${esc(full)}`; }\n function compactCell(value, max=120){ const text=String(value||\"\"); if(!text) return \"\"; const short=text.length>max ? `${text.slice(0, Math.floor(max*0.62))}\u2026${text.slice(-Math.floor(max*0.28))}` : text; return `${esc(short)}`; }\n function progressBar(value, extraClass=''){ const pct=Math.max(0,Math.min(100,Number(value||0))); const hue=Math.round((pct/100)*120); const light=30+Math.round((pct/100)*5); const bg=pct<=0?'transparent':pct>=100?'var(--torrent-progress-complete)':`hsl(${hue} 52% ${light}%)`; const done=pct>=100?' is-complete':''; const cls=extraClass?` ${extraClass}`:''; return `
${esc(pct)}%
`; }\n function progress(t){ return progressBar(t.progress); }\n"; +export const stateSource = " const $ = (id) => document.getElementById(id);\n const esc = (s) => String(s ?? \"\").replace(/[&<>'\"]/g, c => ({\"&\":\"&\",\"<\":\"<\",\">\":\">\",\"'\":\"'\",'\"':\""\"}[c]));\n // Note: Footer transfer totals can arrive as already formatted strings, so keep this helper tolerant and side-effect free.\n function compactTransferText(value){\n const text = String(value ?? \"\").trim();\n if(!text) return \"-\";\n return text.replace(/\\\\s+/g, \" \");\n }\n const ROW_HEIGHT = 32, OVERSCAN = 14;\n const torrents = new Map();\n const browserViewPrefs = (()=>{ try{return JSON.parse(localStorage.getItem('pyTorrent.mobileViewPrefs')||'{}')||{};}catch(e){return {};} })();\n const savedFilter = String(browserViewPrefs.activeFilter || window.PYTORRENT?.activeFilter || \"all\");\n // Note: Mobile has both \"All\" and \"All trackers\" options, so keep the exact selected option separate from the shared filter state.\n let mobileActiveFilterKey = String(browserViewPrefs.mobileFilterKey || savedFilter || \"all\");\n let visibleRows = [], selected = new Set(), selectedHash = null, lastSelectedHash = null, activeFilter = savedFilter.startsWith(\"tracker:\") ? \"all\" : (savedFilter || \"all\");\n let activeTrackerFilter = savedFilter.startsWith(\"tracker:\") ? savedFilter.slice(8) : \"\";\n const SORT_KEYS = new Set([\"name\", \"status\", \"size\", \"progress\", \"down_rate\", \"up_rate\", \"eta\", \"seeds\", \"peers\", \"ratio\", \"path\", \"label\", \"ratio_group\", \"down_total\", \"to_download\", \"up_total\", \"created\", \"priority\", \"state\", \"active\", \"complete\", \"hashing\", \"message\", \"hash\"]);\n const savedSort = browserViewPrefs.sortState || window.PYTORRENT?.torrentSort || {};\n let sortState = {key: SORT_KEYS.has(savedSort.key) ? savedSort.key : \"name\", dir: Number(savedSort.dir) < 0 ? -1 : 1}, renderPending = false, renderVersion = 0, lastRenderSignature = \"\";\n // Note: Mobile sort filters are configurable because the full sortable list is too large for quick phone use.\n const DEFAULT_MOBILE_SORT_FILTER_IDS = new Set([\"seeds:-1\", \"up_rate:-1\", \"down_rate:-1\", \"progress:-1\"]);\n const MOBILE_SORT_STEPS = [\n {key:\"down_rate\", dir:-1, label:\"DL\"},\n {key:\"down_rate\", dir:1, label:\"DL\"},\n {key:\"up_rate\", dir:-1, label:\"UL\"},\n {key:\"up_rate\", dir:1, label:\"UL\"},\n {key:\"progress\", dir:-1, label:\"Progress\"},\n {key:\"progress\", dir:1, label:\"Progress\"},\n {key:\"eta\", dir:-1, label:\"ETA\"},\n {key:\"eta\", dir:1, label:\"ETA\"},\n {key:\"ratio\", dir:-1, label:\"Ratio\"},\n {key:\"ratio\", dir:1, label:\"Ratio\"},\n {key:\"size\", dir:-1, label:\"Size\"},\n {key:\"size\", dir:1, label:\"Size\"},\n {key:\"seeds\", dir:-1, label:\"Seeds\"},\n {key:\"seeds\", dir:1, label:\"Seeds\"},\n {key:\"peers\", dir:-1, label:\"Peers\"},\n {key:\"peers\", dir:1, label:\"Peers\"},\n {key:\"status\", dir:1, label:\"Status\"},\n {key:\"status\", dir:-1, label:\"Status\"},\n {key:\"label\", dir:1, label:\"Label\"},\n {key:\"label\", dir:-1, label:\"Label\"},\n {key:\"ratio_group\", dir:1, label:\"Ratio group\"},\n {key:\"ratio_group\", dir:-1, label:\"Ratio group\"},\n {key:\"down_total\", dir:-1, label:\"Downloaded\"},\n {key:\"down_total\", dir:1, label:\"Downloaded\"},\n {key:\"to_download\", dir:-1, label:\"To download\"},\n {key:\"to_download\", dir:1, label:\"To download\"},\n {key:\"up_total\", dir:-1, label:\"Uploaded\"},\n {key:\"up_total\", dir:1, label:\"Uploaded\"},\n {key:\"created\", dir:-1, label:\"Added\"},\n {key:\"created\", dir:1, label:\"Added\"},\n {key:\"priority\", dir:-1, label:\"Priority\"},\n {key:\"priority\", dir:1, label:\"Priority\"},\n {key:\"state\", dir:-1, label:\"State\"},\n {key:\"state\", dir:1, label:\"State\"},\n {key:\"active\", dir:-1, label:\"Active\"},\n {key:\"active\", dir:1, label:\"Active\"},\n {key:\"complete\", dir:-1, label:\"Complete\"},\n {key:\"complete\", dir:1, label:\"Complete\"},\n {key:\"hashing\", dir:-1, label:\"Hashing\"},\n {key:\"hashing\", dir:1, label:\"Hashing\"},\n {key:\"message\", dir:1, label:\"Message\"},\n {key:\"message\", dir:-1, label:\"Message\"},\n {key:\"path\", dir:1, label:\"Path\"},\n {key:\"path\", dir:-1, label:\"Path\"},\n {key:\"hash\", dir:1, label:\"Hash\"},\n {key:\"hash\", dir:-1, label:\"Hash\"},\n {key:\"name\", dir:1, label:\"Name\"},\n {key:\"name\", dir:-1, label:\"Name\"}\n ];\n let lastLimits = {down: 0, up: 0}, pendingBusy = 0, pathTarget = null, lastPathParent = \"/\";\n const traffic = [], systemUsage = [];\n const socket = (typeof io === \"function\") ? io({transports:[\"polling\"], reconnection:true, reconnectionAttempts:Infinity, reconnectionDelay:700, reconnectionDelayMax:5000, timeout:8000}) : {connected:false,on(){},emit(){},io:{on(){}}};\n const COLUMN_DEFS = [[\"status\",\"Status\",false],[\"size\",\"Size\",false],[\"progress\",\"Progressbar\",false],[\"down_rate\",\"DL\",false],[\"up_rate\",\"UL\",false],[\"eta\",\"ETA\",false],[\"seeds\",\"Seeds\",false],[\"peers\",\"Peers\",false],[\"ratio\",\"Ratio\",false],[\"path\",\"Path\",false],[\"label\",\"Label\",false],[\"ratio_group\",\"Ratio group\",false],[\"down_total\",\"Downloaded\",true],[\"to_download\",\"To download\",true],[\"up_total\",\"Uploaded\",true],[\"created\",\"Added\",true],[\"priority\",\"Priority\",true],[\"state\",\"State\",true],[\"active\",\"Active\",true],[\"complete\",\"Complete\",true],[\"hashing\",\"Hashing\",true],[\"message\",\"Message\",true],[\"hash\",\"Hash\",true]];\n const DEFAULT_HIDDEN_COLUMNS = new Set(COLUMN_DEFS.filter(([, , hiddenByDefault]) => hiddenByDefault).map(([key]) => key));\n const savedColumns = window.PYTORRENT?.tableColumns || {};\n const DEFAULT_COLUMN_WIDTHS = {\n select: 34, name: 360, status: 110, size: 90, progress: 120,\n down_rate: 86, up_rate: 86, eta: 92, seeds: 70, peers: 70,\n ratio: 72, path: 300, label: 140, ratio_group: 130,\n down_total: 120, to_download: 120, up_total: 120, created: 150,\n priority: 80, state: 70, active: 70, complete: 82, hashing: 82,\n message: 220, hash: 280\n };\n const COLUMN_WIDTH_MIN = 44;\n const COLUMN_WIDTH_MAX = 720;\n const explicitlyShownColumns = new Set(savedColumns.shown || []);\n let hiddenColumns = new Set([...(savedColumns.hidden || []), ...[...DEFAULT_HIDDEN_COLUMNS].filter(key => !explicitlyShownColumns.has(key))]);\n // Note: Column widths are persisted with the existing column preferences payload, so no database migration is needed.\n function normalizeColumnWidths(value={}){\n const allowed = new Set(['select', ...COLUMN_DEFS.map(([key]) => key)]);\n const normalized = {...DEFAULT_COLUMN_WIDTHS};\n Object.entries(value || {}).forEach(([key, width])=>{\n if(allowed.has(key)) normalized[key] = clampNumber(width, COLUMN_WIDTH_MIN, COLUMN_WIDTH_MAX, DEFAULT_COLUMN_WIDTHS[key] || 120);\n });\n return normalized;\n }\n let columnWidths = normalizeColumnWidths(savedColumns.widths || {});\n if(browserViewPrefs.columnWidths) columnWidths = normalizeColumnWidths({...columnWidths, ...browserViewPrefs.columnWidths});\n function mobileSortStepId(step){ return `${step.key}:${step.dir}`; }\n function normalizeMobileSortFilters(value={}){\n const normalized = Object.fromEntries(MOBILE_SORT_STEPS.map(step => {\n const id = mobileSortStepId(step);\n return [id, DEFAULT_MOBILE_SORT_FILTER_IDS.has(id)];\n }));\n Object.entries(value || {}).forEach(([id, enabled]) => { if(id in normalized) normalized[id] = !!enabled; });\n return normalized;\n }\n let mobileSortFilters = normalizeMobileSortFilters(savedColumns.mobileSortFilters || {});\n if(browserViewPrefs.mobileSortFilters) mobileSortFilters = normalizeMobileSortFilters({...mobileSortFilters, ...browserViewPrefs.mobileSortFilters});\n const DEFAULT_MOBILE_COLUMNS = new Set([\"status\",\"progress\",\"down_rate\",\"up_rate\",\"eta\",\"seeds\",\"peers\",\"ratio\",\"path\"]);\n const MOBILE_COLUMN_DEFS = COLUMN_DEFS.map(([key,label]) => [key, label, DEFAULT_MOBILE_COLUMNS.has(key)]);\n function normalizeMobileColumns(value={}){\n const normalized = {...Object.fromEntries(MOBILE_COLUMN_DEFS.map(([key,,shown])=>[key, shown]))};\n Object.entries(value || {}).forEach(([key, shown])=>{\n if(key === \"speed\"){ normalized.down_rate = !!shown; normalized.up_rate = !!shown; }\n else if(key === \"seed_peer\"){ normalized.seeds = !!shown; normalized.peers = !!shown; }\n else if(key in normalized) normalized[key] = !!shown;\n });\n return normalized;\n }\n let mobileColumns = normalizeMobileColumns(savedColumns.mobile || {});\n if(browserViewPrefs.mobileColumns) mobileColumns = normalizeMobileColumns({...mobileColumns, ...browserViewPrefs.mobileColumns});\n let mobileSmartFiltersEnabled = browserViewPrefs.mobileSmartFiltersEnabled ?? savedColumns.mobileSmartFiltersEnabled ?? true;\n let knownLabels = [];\n let jobsPage = 0, jobsLimit = 25, jobsTotal = 0, smartHistoryExpanded = false, plannerHistoryExpanded = false;\n let automationSmartQueueStats = null;\n let peersRefreshTimer = null;\n let peersRefreshSeconds = Number(window.PYTORRENT?.peersRefreshSeconds || 0);\n // Note: Files tab auto-refresh is independent from the peers refresh setting and stops when files are complete.\n const FILES_AUTO_REFRESH_SECONDS = 5;\n let filesRefreshTimer = null;\n let filesRefreshInFlight = false;\n let filesAutoRefreshHash = null;\n let portCheckEnabled = !!Number(window.PYTORRENT?.portCheckEnabled || 0);\n let bootstrapTheme = window.PYTORRENT?.bootstrapTheme || \"default\";\n let fontFamily = window.PYTORRENT?.fontFamily || \"default\";\n let interfaceScale = Number(window.PYTORRENT?.interfaceScale || 100);\n let titleSpeedEnabled = !!Number(window.PYTORRENT?.titleSpeedEnabled || 0);\n let trackerFaviconsEnabled = !!Number(window.PYTORRENT?.trackerFaviconsEnabled || 0);\n // Note: Reverse DNS is opt-in because PTR lookups can be slower than normal peer refreshes.\n let reverseDnsEnabled = !!Number(window.PYTORRENT?.reverseDnsEnabled || 0);\n let automationToastsEnabled = window.PYTORRENT?.automationToastsEnabled !== false && Number(window.PYTORRENT?.automationToastsEnabled ?? 1) !== 0;\n let smartQueueToastsEnabled = window.PYTORRENT?.smartQueueToastsEnabled !== false && Number(window.PYTORRENT?.smartQueueToastsEnabled ?? 1) !== 0;\n let diskMonitorPaths = Array.isArray(window.PYTORRENT?.diskMonitorPaths) ? [...window.PYTORRENT.diskMonitorPaths] : [];\n let diskMonitorMode = window.PYTORRENT?.diskMonitorMode || \"default\";\n let diskMonitorSelectedPath = window.PYTORRENT?.diskMonitorSelectedPath || \"\";\n let lastUserDiskFetchAt = 0;\n let userDiskFetchInFlight = false;\n let userDiskFetchSeq = 0;\n let activeProfileId = window.PYTORRENT?.activeProfile || null;\n let trackerSummary = {hashes:{}, trackers:[], scanned:0, errors:[]};\n let trackerSummaryStatus = 'idle';\n let trackerSummarySignature = \"\";\n let trackerSummaryTimer = null;\n let lastLabelFiltersSignature = \"\";\n let lastTrackerFiltersSignature = \"\";\n let lastMobileFiltersSignature = \"\";\n const BASE_TITLE = document.title || \"pyTorrent\";\n const lastBrowserSpeed = {down: \"0 B/s\", up: \"0 B/s\"};\n const FOOTER_STATUS_STORAGE_KEY = \"pytorrent.footerStatus.v1\";\n const FOOTER_RT_METRIC_KEYS = new Set([\"sockets\", \"rt_downloads\", \"rt_uploads\", \"rt_http\", \"rt_files\", \"rt_port\"]);\n const FOOTER_ITEM_DEFS = [\n [\"cpu\", \"CPU\"], [\"ram\", \"RAM\"], [\"usage_chart\", \"CPU/RAM chart\"], [\"disk\", \"Disk\"],\n [\"version\", \"rTorrent version\"], [\"speed_down\", \"Download speed\"], [\"speed_up\", \"Upload speed\"],\n [\"speed_peaks\", \"Peak speeds\"], [\"limits\", \"Speed limits\"], [\"totals\", \"Total transfer\"], [\"port_check\", \"Port check\"],\n [\"clock\", \"Clock\"], [\"sockets\", \"Open sockets\"], [\"rt_downloads\", \"Downloads (D)\"], [\"rt_uploads\", \"Uploads (U)\"], [\"rt_http\", \"HTTP (H)\"], [\"rt_files\", \"Files (F)\"], [\"rt_port\", \"Incoming port\"], [\"shown\", \"Shown torrents\"], [\"selected\", \"Selected torrents\"], [\"docs\", \"API docs\"]\n ];\n const DEFAULT_FOOTER_ITEMS = Object.fromEntries(FOOTER_ITEM_DEFS.map(([key]) => [key, !FOOTER_RT_METRIC_KEYS.has(key)]));\n let footerItems = {...DEFAULT_FOOTER_ITEMS, ...(window.PYTORRENT?.footerItems || {})};\n let modalLabels = new Set(), defaultDownloadPath = null;\n let hasTorrentSnapshot = false, initialLoaderDone = false, rtConfigOriginal = new Map(), rtConfigFieldTypes = new Map(), rtConfigOriginalApplyOnStart = false;\n let rtorrentStartingMessage = '';\n let rtorrentStartingTimer = null, rtorrentStartingSince = 0;\n const RTORRENT_STALE_GRACE_MS = 30000;\n let torrentSummary = null;\n let profileCache = new Map();\n const hasActiveProfile = !!window.PYTORRENT?.activeProfile;\n let firstRunSetupShown = false;\n const activeOperations = new Map();\n // Note: Keeps live filter tooltips stable while the pointer is over a filter button.\n const filterTooltipState = new WeakMap();\n\n const toastGroups = new Map();\n const preferenceSaveTimers = new Map();\n function clampNumber(value, min, max, fallback){\n const num = Number(value);\n if(!Number.isFinite(num)) return fallback;\n return Math.max(min, Math.min(max, Math.round(num)));\n }\n function debounce(fn, delay=250){\n let timer = null;\n return (...args) => {\n clearTimeout(timer);\n timer = setTimeout(() => fn(...args), delay);\n };\n }\n function savePreferencePatch(payload, delay=350){\n const key = Object.keys(payload).sort().join('|');\n clearTimeout(preferenceSaveTimers.get(key));\n preferenceSaveTimers.set(key, setTimeout(async()=>{\n try{ await post('/api/preferences', payload); }catch(e){ console.warn('Preference save failed', e); }\n finally{ preferenceSaveTimers.delete(key); }\n }, delay));\n }\n function currentActiveFilterPreference(){\n return activeTrackerFilter ? `tracker:${activeTrackerFilter}` : activeFilter;\n }\n function saveTorrentSortPreference(){\n // Note: Sorting is persisted together with the current filter so mobile tracker scope cannot fall back to All trackers after a quick sort change.\n saveBrowserViewPrefs();\n savePreferencePatch({torrent_sort_json:{key:sortState.key, dir:sortState.dir}, active_filter:currentActiveFilterPreference()}, 200);\n }\n function saveBrowserViewPrefs(extra={}){\n try{\n const prev=JSON.parse(localStorage.getItem('pyTorrent.mobileViewPrefs')||'{}')||{};\n localStorage.setItem('pyTorrent.mobileViewPrefs', JSON.stringify({...prev, activeFilter:currentActiveFilterPreference(), mobileFilterKey:mobileActiveFilterKey, sortState, mobileColumns, columnWidths, ...extra}));\n }catch(e){}\n }\n function saveActiveFilterPreference(){\n saveBrowserViewPrefs();\n savePreferencePatch({active_filter:currentActiveFilterPreference()}, 250);\n }\n function cleanColumnPrefsHidden(values){ return [...values].filter(key => key !== \"progressbar\"); }\n async function resetViewPreferences(){\n activeFilter = \"all\";\n activeTrackerFilter = \"\";\n mobileActiveFilterKey = \"all\";\n sortState = {key:\"name\", dir:1};\n mobileColumns = normalizeMobileColumns();\n hiddenColumns = new Set(DEFAULT_HIDDEN_COLUMNS);\n columnWidths = normalizeColumnWidths();\n const height = applyDetailPanelHeight(255);\n renderColumnManager();\n document.querySelectorAll('.filter').forEach(x=>x.classList.toggle('active', x.dataset.filter === 'all'));\n if($('tableWrap')) $('tableWrap').scrollTop = 0;\n if($('mobileList')) $('mobileList').scrollTop = 0;\n try{\n await post('/api/preferences', {active_filter:\"all\", torrent_sort_json:{key:\"name\", dir:1}, detail_panel_height:height, table_columns_json:JSON.stringify({hidden:cleanColumnPrefsHidden(DEFAULT_HIDDEN_COLUMNS), shown:[], mobile:mobileColumns, mobileSmartFiltersEnabled:true, widths:columnWidths})});\n toast('View preferences reset','success');\n }catch(e){ toast(e.message,'danger'); }\n scheduleRender(true);\n }\n function applyDetailPanelHeight(height){\n const safeHeight = clampNumber(height, 160, 720, 255);\n document.documentElement.style.setProperty('--detail-panel-height', `${safeHeight}px`);\n const handle = $('detailResizeHandle');\n if(handle) handle.setAttribute('aria-valuenow', String(safeHeight));\n return safeHeight;\n }\n function saveDetailPanelHeight(height){\n const safeHeight = applyDetailPanelHeight(height);\n savePreferencePatch({detail_panel_height:safeHeight}, 250);\n }\n function setupDetailResizer(){\n const handle = $('detailResizeHandle');\n const content = document.querySelector('.content');\n if(!handle || !content) return;\n applyDetailPanelHeight(window.PYTORRENT?.detailPanelHeight || 255);\n let startY = 0, startHeight = 0;\n const onMove = (event) => {\n const pointerY = event.clientY ?? event.touches?.[0]?.clientY ?? startY;\n applyDetailPanelHeight(startHeight - (pointerY - startY));\n scheduleRender(false);\n };\n const onUp = () => {\n document.body.classList.remove('resizing-details');\n document.removeEventListener('pointermove', onMove);\n document.removeEventListener('pointerup', onUp);\n const value = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--detail-panel-height'), 10);\n saveDetailPanelHeight(value);\n };\n handle.addEventListener('pointerdown', (event) => {\n event.preventDefault();\n startY = event.clientY;\n startHeight = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--detail-panel-height'), 10) || 255;\n document.body.classList.add('resizing-details');\n document.addEventListener('pointermove', onMove);\n document.addEventListener('pointerup', onUp);\n });\n }\n function toastKey(msg, type){ return `${type}::${String(msg ?? '')}`; }\n function isAutomationEvent(msg){ return msg?.automation === true || msg?.source === 'automation'; }\n function shouldShowOperationToast(msg){\n // Note: Automation-created operation toasts follow the Automation toasts preference.\n return !isAutomationEvent(msg) || automationToastsEnabled;\n }\n function toast(msg, type=\"secondary\") {\n // Note: Groups identical toasts fired together, so repeated automation/action events do not flood the UI.\n const h=$('toastHost');\n if(!h) return;\n const text=String(msg ?? '');\n const key=toastKey(text,type);\n const existing=toastGroups.get(key);\n if(existing){\n existing.count += 1;\n const badge=existing.el.querySelector('.toast-count');\n if(badge){ badge.textContent=`\u00d7${existing.count}`; badge.classList.remove('d-none'); }\n clearTimeout(existing.timer);\n existing.timer=setTimeout(()=>{ existing.el.remove(); toastGroups.delete(key); },3500);\n return;\n }\n const el=document.createElement('div');\n el.className=`toast-item text-bg-${type}`;\n el.innerHTML=`${esc(text)}\u00d71`;\n h.appendChild(el);\n const entry={el,count:1,timer:null};\n entry.timer=setTimeout(()=>{ el.remove(); toastGroups.delete(key); },3500);\n toastGroups.set(key,entry);\n }\n function setBusy(on, label='Working...'){ pendingBusy += on ? 1 : -1; if(pendingBusy<0) pendingBusy=0; const loader=$('globalLoader'); if(loader){ loader.classList.toggle('d-none', pendingBusy===0); const span=loader.querySelector('span:last-child'); if(span) span.textContent=label; } $('busyBadge')?.classList.toggle('d-none', pendingBusy===0); }\n function setInitialLoader(title, text){ if(initialLoaderDone) return; if($('initialLoaderTitle') && title) $('initialLoaderTitle').textContent=title; if($('initialLoaderText') && text) $('initialLoaderText').textContent=text; }\n function hideInitialLoader(){ if(initialLoaderDone) return; initialLoaderDone=true; $('initialLoader')?.classList.add('is-hidden'); }\n function buttonBusy(btn,on){ if(!btn)return; btn.disabled=on; const label=btn.querySelector('.btn-label'); if(label){ if(!label.dataset.orig) label.dataset.orig=label.innerHTML; label.innerHTML=on?`Working...`:label.dataset.orig; }}\n function activeTab(){ return document.querySelector('#detailTabs .nav-link.active')?.dataset.tab || 'general'; }\n function loadingMarkup(label='Loading data...'){ return `
${esc(label)}
`; }\n // Note: Keeps empty-state colspans aligned with the desktop torrent table column count.\n function torrentColumnSpan(){ return 25; }\n function loadingTableRow(label='Loading torrents...'){ return `${loadingMarkup(label)}`; }\n // Note: Handles fresh installations with no configured rTorrent profile, so the UI does not wait forever for a snapshot.\n function renderNoProfileState(){\n hasTorrentSnapshot = false;\n torrentSummary = {filters:{all:{count:0},downloading:{count:0},seeding:{count:0},paused:{count:0},checking:{count:0},error:{count:0},stopped:{count:0}}};\n torrents.clear();\n selected.clear();\n renderCounts();\n const body = $('torrentBody');\n if(body){\n body.innerHTML = `
No rTorrent profile configured.Add the first rTorrent profile to start loading torrents.
`;\n }\n if($('detailPane')) $('detailPane').innerHTML = 'Add rTorrent profile first.';\n }\n function clearRtorrentStartingState(){\n rtorrentStartingMessage='';\n rtorrentStartingSince=0;\n if(rtorrentStartingTimer){ clearTimeout(rtorrentStartingTimer); rtorrentStartingTimer=null; }\n }\n function rtorrentStartingHtml(error=''){\n const details=error ? `${esc(error)}` : 'Port can already be open while XML-RPC/SCGI is still warming up. The list will load automatically after rTorrent answers.';\n return `
rTorrent is starting or not responding yet.Waiting for torrent data from the active profile.${details}
`;\n }\n function scheduleRtorrentStartingState(error=''){\n rtorrentStartingMessage = String(error || 'rTorrent is starting or not responding yet.');\n if(!(hasTorrentSnapshot && torrents.size)){\n renderRtorrentStartingState(rtorrentStartingMessage, true);\n return;\n }\n if(!rtorrentStartingSince) rtorrentStartingSince = Date.now();\n if(rtorrentStartingTimer) return;\n rtorrentStartingTimer = setTimeout(() => {\n rtorrentStartingTimer = null;\n if(rtorrentStartingMessage) renderRtorrentStartingState(rtorrentStartingMessage, true);\n }, RTORRENT_STALE_GRACE_MS);\n }\n function renderRtorrentStartingState(error='', force=false){\n rtorrentStartingMessage = String(error || 'rTorrent is starting or not responding yet.');\n if(hasTorrentSnapshot && torrents.size && !force) return;\n hasTorrentSnapshot = false;\n torrentSummary = {filters:{all:{count:0},downloading:{count:0},seeding:{count:0},paused:{count:0},checking:{count:0},error:{count:0},stopped:{count:0}}};\n torrents.clear();\n selected.clear();\n renderCounts();\n const body=$('torrentBody');\n if(body) body.innerHTML = `${rtorrentStartingHtml(rtorrentStartingMessage)}`;\n const list=$('mobileList');\n if(list) list.innerHTML = `
${rtorrentStartingHtml(rtorrentStartingMessage)}
`;\n if($('detailPane')) $('detailPane').innerHTML = 'rTorrent is starting. Details will appear after the first successful response.';\n }\n function parseDate(value){ const raw=String(value||'').trim(); if(!raw) return null; const d=new Date(raw); return Number.isNaN(d.getTime()) ? null : {raw,d}; }\n function formatDate(value, mode='short'){\n const parsed=parseDate(value);\n if(!parsed) return String(value||'');\n const opts=mode==='full'\n ? {year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit',second:'2-digit'}\n : {month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'};\n return new Intl.DateTimeFormat('pl-PL', opts).format(parsed.d).replace(',', '');\n }\n function dateCell(value){ const parsed=parseDate(value); if(!parsed) return esc(value||''); return `${esc(formatDate(value))}`; }\n // Note: Human-readable date cells keep full timestamps visible without squeezing table columns.\n function humanDateCell(value){ const parsed=parseDate(value); if(!parsed) return esc(value||''); const full=formatDate(value,'full'); return `${esc(full)}`; }\n function compactCell(value, max=120){ const text=String(value||\"\"); if(!text) return \"\"; const short=text.length>max ? `${text.slice(0, Math.floor(max*0.62))}\u2026${text.slice(-Math.floor(max*0.28))}` : text; return `${esc(short)}`; }\n function progressBar(value, extraClass=''){ const pct=Math.max(0,Math.min(100,Number(value||0))); const hue=Math.round((pct/100)*120); const light=30+Math.round((pct/100)*5); const bg=pct<=0?'transparent':pct>=100?'var(--torrent-progress-complete)':`hsl(${hue} 52% ${light}%)`; const done=pct>=100?' is-complete':''; const cls=extraClass?` ${extraClass}`:''; return `
${esc(pct)}%
`; }\n function progress(t){ return progressBar(t.progress); }\n"; diff --git a/pytorrent/static/js/torrentDetails.js b/pytorrent/static/js/torrentDetails.js index 3350e09..5b18fb3 100644 --- a/pytorrent/static/js/torrentDetails.js +++ b/pytorrent/static/js/torrentDetails.js @@ -1 +1 @@ -export const torrentDetailsSource = " function formatDateTime(seconds){ const n=Number(seconds||0); if(!n) return '-'; try{ return new Date(n*1000).toLocaleString(); }catch(e){ return '-'; } }\n function joinRemotePath(base,name){\n const b=String(base||'').trim();\n const n=String(name||'').trim();\n if(!b && !n) return '-';\n if(!n) return b || '-';\n if(!b) return n;\n return `${b.replace(/\\/+$/,'')}/${n.replace(/^\\/+/,'')}`;\n }\n function renderGeneral(){\n const t=torrents.get(selectedHash);\n if(!t){ $('detailPane').innerHTML='Select a torrent.'; return; }\n const labels=labelNames(t.label).map(l=>` ${esc(l)}`).join(' ') || '-';\n const ratioGroup=t.ratio_group ? `${esc(t.ratio_group)}` : 'Not assigned';\n const statusClass=t.status==='Seeding'?'success':t.status==='Downloading'?'primary':t.status==='Checking'?'warning':t.status==='Paused'?'secondary':t.status==='Stopped'?'dark':'secondary';\n const fullPath=joinRemotePath(t.path,t.name);\n const cards=[\n ['Size', esc(t.size_h||'-')],\n ['Downloaded', esc(t.down_total_h||'-')],\n ['Uploaded', esc(t.up_total_h||'-')],\n ['Ratio', esc(t.ratio??'-')],\n ['Download speed', esc(t.down_rate_h||'-')],\n ['Upload speed', esc(t.up_rate_h||'-')],\n ['Seeds / Peers', `${esc(t.seeds??0)} / ${esc(t.peers??0)}`],\n ['ETA', esc(t.eta_h||'-')],\n ['Added', esc(formatDateTime(t.created))],\n ['Priority', esc(t.priority??'-')],\n ].map(([label,value])=>`
${label}${value}
`).join('');\n $('detailPane').innerHTML=`\n
\n
\n
${esc(t.name||'-')}
${esc(t.status||'-')}
\n
Directory${esc(t.path||'-')}
\n
Full data path${esc(fullPath)}
\n
\n
Hash${esc(t.hash||'-')}
\n
\n
${cards}
\n
Labels${labels}
Ratio rule${ratioGroup}
Message${esc(t.message||'-')}
`;\n }\n const FILE_PRIORITY_LABELS = {0: \"Skip\", 1: \"Normal\", 2: \"High\"};\n function priorityClass(priority){ priority=Number(priority||0); return priority===2?\"text-bg-success\":priority===0?\"text-bg-secondary\":\"text-bg-primary\"; }\n function renderFilePrioritySelect(f){ const p=Number(f.priority||0); return ``; }\n function selectedFileIndexes(){ return [...document.querySelectorAll('#detailPane .file-check:checked')].map(cb=>Number(cb.dataset.index)); }\n function downloadSelectedFiles(){\n if(!selectedHash) return;\n const indexes=selectedFileIndexes();\n if(!indexes.length) return toastMessage('toast.noFilesSelected','warning');\n if(indexes.length===1){ openTemporaryDownload(`/api/torrents/${encodeURIComponent(selectedHash)}/files/${indexes[0]}/download-link`).catch(e=>toast(e.message,'danger')); return; }\n downloadZip(indexes);\n }\n async function downloadZip(indexes=null){\n if(!selectedHash) return;\n try{\n await openTemporaryDownload(`/api/torrents/${encodeURIComponent(selectedHash)}/files/download.zip/link`, {indexes});\n }catch(e){ toast(e.message,'danger'); }\n }\n\n function mediaInfoValue(value){\n const text = value === null || value === undefined || value === '' ? '-' : String(value);\n return esc(text);\n }\n function mediaInfoSummaryCards(info){\n // Note: Summary cards show the most useful hachoir fields while keeping the full raw list below.\n const summary = info.summary || {};\n const cards = [\n ['Duration', summary.duration],\n ['Bit rate', summary.bit_rate],\n ['Resolution', summary.width && summary.height ? `${summary.width} × ${summary.height}` : null],\n ['Frame rate', summary.frame_rate],\n ['Audio', [summary.channels, summary.sample_rate].filter(Boolean).join(' · ')],\n ['Codec / compression', summary.compression],\n ['Producer', summary.producer],\n ['Created', summary.creation_date],\n ];\n return cards.map(([label,value]) => `
${esc(label)}${mediaInfoValue(value)}
`).join('');\n }\n function mediaInfoFieldsTable(info){\n const rows = (info.fields || []).slice(0, 160).map(field => `${esc(field.key)}${esc(field.value)}`).join('');\n if(rows) return `
Detected metadata
${rows}
`;\n const raw = (info.raw || []).slice(0, 80).map(line => `
  • ${esc(line)}
  • `).join('');\n return `
    Raw parser output
    `;\n }\n function ensureMediaInfoModal(){\n let modal = $('mediaInfoModal');\n if(modal) return modal;\n // Note: The modal is created lazily so existing templates and old modals stay untouched.\n modal = document.createElement('div');\n modal.id = 'mediaInfoModal';\n modal.className = 'modal fade media-info-modal';\n modal.tabIndex = -1;\n modal.innerHTML = `
    File info
    Loading file info...
    `;\n document.body.appendChild(modal);\n return modal;\n }\n function mediaInfoSubtitle(info){\n if(info.kind === 'pdf'){\n const sizeText = info.size_h || (info.size ? fmtBytes(info.size) : 'unknown size');\n return `${info.path || 'File'} · ${sizeText} · inline PDF preview`;\n }\n const sampleText = `${fmtBytes(info.sample_bytes || 0)} / ${fmtBytes(info.sample_limit || 0)} sample${info.partial ? ' · partial preview' : ''}`;\n return `${info.path || 'File'} · ${sampleText}`;\n }\n function renderTextPreview(info){\n const text = esc(info.text || '');\n const note = info.partial ? `
    Preview truncated to ${esc(fmtBytes(info.sample_limit || 0))}. Download the file to read the full content.
    ` : '';\n return `${note}
    ${text || 'No text content was returned.'}
    `;\n }\n function renderImagePreview(info){\n if(info.error){\n return `
    Image preview unavailable${esc(info.error)}
    `;\n }\n return `
    \"${esc(info.path
    ${esc(info.mime_type || 'image')} · ${esc(fmtBytes(info.sample_bytes || 0))}
    `;\n }\n function mediaInfoPdfUrl(info){\n // Note: PDF preview links are created by the backend as short-lived app URLs, so the new-tab button does not expose /api/.\n return String(info.preview_url || '');\n }\n function renderPdfPreview(info){\n // Note: PDF preview uses the browser renderer, preserving images and page layout instead of flattening books to extracted text.\n const src = mediaInfoPdfUrl(info);\n const downloadButton = ``;\n const openButton = src ? ` Open in new tab` : '';\n if(!src){\n return `
    PDF preview unavailableMissing temporary app link for inline preview.
    ${downloadButton}
    `;\n }\n const title = esc(info.path || 'PDF preview');\n const size = info.size_h || (info.size ? fmtBytes(info.size) : 'unknown size');\n const expires = info.preview_expires_in ? ` · temporary link: ${Math.round(Number(info.preview_expires_in) / 60)} min` : '';\n return `
    PDF preview${esc(size)} · rendered by your browser${esc(expires)}
    ${openButton}${downloadButton}
    Inline PDF preview is not availableYour browser blocked the embedded viewer. Open it in a new tab or download the file.
    ${openButton}${downloadButton}
    `;\n }\n function renderMediaInfoModal(info){\n const body = $('mediaInfoBody');\n const subtitle = $('mediaInfoSubtitle');\n if(!body) return;\n if(subtitle) subtitle.textContent = mediaInfoSubtitle(info);\n if(info.kind === 'text'){\n body.innerHTML = `
    ${mediaInfoSummaryCards({...info, summary:{duration:null, bit_rate:null, compression:info.encoding, producer:`${info.line_count || 0} line(s)`, creation_date:null}})}
    ${renderTextPreview(info)}${mediaInfoFieldsTable(info)}`;\n return;\n }\n if(info.kind === 'image'){\n body.innerHTML = `${renderImagePreview(info)}${mediaInfoFieldsTable(info)}`;\n return;\n }\n if(info.kind === 'pdf'){\n body.innerHTML = `
    ${mediaInfoSummaryCards(info)}
    ${renderPdfPreview(info)}${mediaInfoFieldsTable(info)}`;\n return;\n }\n if(info.error){\n body.innerHTML = `
    File info unavailable
    ${esc(info.error)}
    `;\n return;\n }\n body.innerHTML = `
    ${mediaInfoSummaryCards(info)}
    ${mediaInfoFieldsTable(info)}`;\n }\n async function openMediaInfo(index){\n if(!selectedHash) return;\n const modal = ensureMediaInfoModal();\n $('mediaInfoSubtitle').textContent = 'Reading a bounded file sample...';\n $('mediaInfoBody').innerHTML = '
    Loading file info...
    ';\n new bootstrap.Modal(modal).show();\n try{\n const res = await fetch(`/api/torrents/${encodeURIComponent(selectedHash)}/files/${encodeURIComponent(index)}/mediainfo`, {headers:{'Accept':'application/json'}});\n const json = await res.json().catch(() => ({}));\n if(!res.ok || !json.ok) throw new Error(json.error || `HTTP ${res.status}`);\n renderMediaInfoModal(json.media_info || {});\n }catch(e){\n $('mediaInfoBody').innerHTML = `
    File info failed
    ${esc(e.message)}
    `;\n }\n }\n\n function renderFiles(files){\n const pane=$('detailPane');\n const rows=(files||[]).map(f=>`${esc(f.path)}${esc(f.size_h)}${progressBar(f.progress ?? 0, 'file-progress')}${esc(FILE_PRIORITY_LABELS[Number(f.priority||0)]||f.priority)}${renderFilePrioritySelect(f)}
    `).join('');\n // Note: Files use the same responsive table wrapper as peers to keep wide paths usable on small screens.\n pane.innerHTML=`
    Priority
    Download
    Changes are applied immediately in rTorrent.
    ${rows || ''}
    PathSizeDonePrioritySet priorityActions
    No files.
    `;\n }\n function fileTreeNode(node){\n const children=(node.children||[]).map(fileTreeNode).join('');\n if(node.type==='file') return `
  • ${esc(node.name||node.path)} ${esc(node.size_h||'')}
  • `;\n return `
  • ${esc(node.name||'Files')} ${esc(node.size_h||'')}
      ${children}
  • `;\n }\n async function loadFileTree(){\n if(!selectedHash) return;\n const box=$('fileTreePanel');\n if(!box) return;\n box.classList.toggle('d-none');\n if(box.classList.contains('d-none')) return;\n box.innerHTML=' Loading tree...';\n try{ const j=await (await fetch(`/api/torrents/${encodeURIComponent(selectedHash)}/files/tree`)).json(); if(!j.ok) throw new Error(j.error||'Tree failed'); box.innerHTML=``; }\n catch(e){ box.innerHTML=`
    ${esc(e.message)}
    `; }\n }\n async function setFilePriorities(items){\n if(!selectedHash || !items.length) return;\n setBusy(true);\n try{\n const res=await fetch(`/api/torrents/${encodeURIComponent(selectedHash)}/files/priority`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({files:items})});\n const j=await res.json();\n if(!j.ok || (j.errors&&j.errors.length)) throw new Error(j.errors?.[0]?.error || j.error || 'Priority update failed');\n toast(`Updated ${j.updated?.length||items.length} file priority item(s)`,'success');\n await loadDetails('files');\n }catch(e){ toast(e.message,'danger'); } finally{ setBusy(false); }\n }\n\n const CHUNK_DENSITY_OPTIONS = {\n compact: {label: 'Compact', maxCells: 2400},\n normal: {label: 'Normal', maxCells: 1400},\n detailed: {label: 'Detailed', maxCells: 700},\n };\n const CHUNK_FILTER_OPTIONS = [\n ['all', 'All'],\n ['problem', 'Missing + partial'],\n ['missing', 'Missing'],\n ['partial', 'Partial'],\n ['seen', 'Seen by peers'],\n ['complete', 'Complete'],\n ];\n let chunkFilterMode = localStorage.getItem('chunkFilterMode') || 'all';\n let chunkDensityMode = localStorage.getItem('chunkDensityMode') || 'normal';\n let lastChunkData = null;\n\n function chunkMaxCellsForDensity(){\n // Note: Density changes the API grouping level and the CSS cell size together.\n return CHUNK_DENSITY_OPTIONS[chunkDensityMode]?.maxCells || CHUNK_DENSITY_OPTIONS.normal.maxCells;\n }\n function chunkCellsForFilter(cells){\n const list = Array.isArray(cells) ? cells : [];\n if(chunkFilterMode === 'all') return list;\n if(chunkFilterMode === 'problem') return list.filter(cell => ['missing','partial'].includes(cell.status));\n return list.filter(cell => cell.status === chunkFilterMode);\n }\n function chunkStatusLabel(status){\n return ({complete:'Complete', partial:'Partial', missing:'Missing', seen:'Seen by peers'}[status] || 'Unknown');\n }\n function chunkCellTitle(cell){\n const first = cell.first_chunk ?? '-';\n const last = cell.last_chunk ?? first;\n const pct = Number(cell.percent||0).toFixed(1).replace(/\\.0$/,'');\n const completed = Number(cell.completed ?? 0);\n const total = Number(cell.total ?? cell.unit_count ?? 1);\n const grouped = cell.grouped ? `Grouped visual cell: ${cell.unit_count || 1} piece(s)` : 'Single piece';\n return [\n `Pieces: ${first}-${last}`,\n `Status: ${chunkStatusLabel(cell.status)}`,\n `Progress: ${pct}%`,\n `Complete pieces: ${completed}/${total}`,\n grouped,\n ].join(' | ');\n }\n function chunkCellMarkup(cell){\n const pct = Math.max(0, Math.min(100, Number(cell.percent || 0)));\n const cls = `chunk-cell chunk-${esc(cell.status || 'missing')}${cell.grouped ? ' is-grouped' : ''}`;\n return ``;\n }\n function renderChunkLegend(summary){\n const items=[['complete','Complete'],['partial','Partial'],['missing','Missing'],['seen','Seen by peers']];\n return items.map(([key,label])=>`${label} ${esc(summary?.[key]??0)}`).join('');\n }\n function renderChunkControls(){\n const filters = CHUNK_FILTER_OPTIONS.map(([value,label]) => ``).join('');\n const densities = Object.entries(CHUNK_DENSITY_OPTIONS).map(([value,cfg]) => ``).join('');\n return `
    `;\n }\n function selectedChunkRange(){\n const selected=[...document.querySelectorAll('#detailPane .chunk-cell.is-selected')].map(el=>({first:Number(el.dataset.firstChunk||0),last:Number(el.dataset.lastChunk||0)}));\n if(!selected.length) return null;\n return {first_chunk:Math.min(...selected.map(x=>x.first)),last_chunk:Math.max(...selected.map(x=>x.last)),count:selected.length};\n }\n function updateChunkSelectionInfo(){\n const info=$('chunkSelectionInfo');\n if(!info) return;\n const range=selectedChunkRange();\n const filteredCount=document.querySelectorAll('#detailPane .chunk-cell').length;\n const totalCount=lastChunkData?.cells?.length || 0;\n if(range){\n info.textContent=`Selected ${range.count} cell(s), pieces ${range.first_chunk}-${range.last_chunk}.`;\n return;\n }\n const filterText=chunkFilterMode === 'all' ? '' : ` Showing ${filteredCount}/${totalCount} cell(s).`;\n info.textContent=`Select one or more visual cells to prioritize files that overlap that range.${filterText}`;\n }\n function renderChunks(data){\n const pane=$('detailPane');\n const chunks=data||{};\n lastChunkData=chunks;\n const allCells=chunks.cells||[];\n const cells=chunkCellsForFilter(allCells);\n const grouped=chunks.grouped?'grouped for performance':'';\n const meta=[\n ['Piece size', chunks.chunk_size_h || '-'],\n ['Pieces', chunks.size_chunks ?? 0],\n ['Complete pieces', chunks.completed_chunks ?? 0],\n ['Hashed pieces', chunks.chunks_hashed ?? 0],\n ['Visual cells', chunks.visual_cells ?? allCells.length],\n ].map(([label,value])=>`
    ${esc(label)}${esc(value)}
    `).join('');\n pane.innerHTML=`\n
    \n
    \n
    Chunks ${grouped}
    \n
    \n \n \n \n
    \n
    \n
    ${meta}
    \n
    \n
    ${renderChunkLegend(chunks.summary||{})}
    \n ${renderChunkControls()}\n
    \n
    \n
    ${cells.map(chunkCellMarkup).join('') || '
    No chunk cells for this filter.
    '}
    \n
    `;\n updateChunkSelectionInfo();\n }\n async function runChunkAction(action,payload={}){\n if(!selectedHash) return toastMessage('toast.noTorrentSelected','warning');\n setBusy(true);\n try{\n const j=await post(`/api/torrents/${encodeURIComponent(selectedHash)}/chunks/${action}`,payload);\n toast(j.message || appMessage('toast.chunkActionDone',{action}),'success');\n await loadDetails('chunks');\n }catch(e){ toast(e.message,'danger'); }\n finally{ setBusy(false); }\n }\n document.addEventListener('change', e=>{\n const filter=e.target.closest('#chunkFilterMode');\n if(filter){\n chunkFilterMode=filter.value || 'all';\n localStorage.setItem('chunkFilterMode', chunkFilterMode);\n if(lastChunkData && activeTab()==='chunks') renderChunks(lastChunkData);\n return;\n }\n const density=e.target.closest('#chunkDensityMode');\n if(density){\n chunkDensityMode=density.value || 'normal';\n localStorage.setItem('chunkDensityMode', chunkDensityMode);\n if(activeTab()==='chunks') loadDetails('chunks');\n }\n });\n function peerBadges(p){\n const badges=[];\n if(p.encrypted) badges.push('enc');\n if(p.incoming) badges.push('in');\n if(p.snubbed) badges.push('snub');\n if(p.banned) badges.push('ban');\n return badges.join(' ') || '-';\n }\n function peerHostCell(p){\n const host=String(p.host||'').trim();\n if(host) return `${esc(host)}`;\n if(p.host_pending) return 'resolving';\n return '-';\n }\n function renderPeers(peers){\n const headers=['Flag','IP'];\n if(reverseDnsEnabled) headers.push('Host');\n headers.push('Country','City','Client','%','DL','UL','Port','Flags');\n const rows=(peers||[]).map(p=>{\n const row=[flag(p.country_iso),`${esc(p.ip)}`];\n if(reverseDnsEnabled) row.push(peerHostCell(p));\n row.push(esc(p.country),esc(p.city),esc(p.client),progressBar(p.completed,'peer-progress peer-progress-wide'),esc(p.down_rate_h),esc(p.up_rate_h),esc(p.port),peerBadges(p));\n return row;\n });\n $('detailPane').innerHTML=responsiveTable(headers,rows,'peers-table');\n }\n function fmtTs(value){ const n=Number(value||0); if(!n) return '-'; try{return new Date(n*1000).toLocaleString();}catch(e){return String(n);} }\n function trackerSeedsPeers(t){ const hasScrape = t.seeds !== null || t.peers !== null; return hasScrape ? `${t.seeds ?? \"-\"} / ${t.peers ?? \"-\"}` : \"-\"; }\n function renderTrackers(trackers){\n // Note: Tracker URL editing is intentionally replaced by safe deletion; adding trackers remains unchanged.\n const pane=$('detailPane');\n const list=trackers||[];\n const canDelete=list.length>1;\n const rows=list.map(t=>{\n const idx=esc(t.index), url=esc(t.url);\n const deleteDisabled=canDelete ? '' : ' disabled title=\"At least one tracker must remain\"';\n return [`#${idx}`, `${url || '-'}`, t.enabled?'yes':'no', esc(trackerSeedsPeers(t)), esc(t.downloaded ?? '-'), fmtTs(t.last_announce), `
    `];\n });\n // Note: Trackers share the responsive wrapper so long URLs do not break the details pane.\n pane.innerHTML=`
    ${responsiveTable(['#','URL','On','Seeds / Peers','Done','Last announce','Actions'], rows.length?rows:[[ '-','No trackers.','','','','','' ]], 'tracker-table')}`;\n }\n async function trackerAction(action,payload={}){\n if(!selectedHash) return toastMessage('toast.noTorrentSelected','warning');\n setBusy(true);\n try{\n const j=await post(`/api/torrents/${encodeURIComponent(selectedHash)}/trackers/${action}`,payload);\n toast(j.message || appMessage('toast.trackerActionDone',{action}),'success');\n await loadDetails('trackers');\n }catch(e){toast(e.message,'danger');}\n finally{setBusy(false);}\n }\n async function loadDetails(tab){ const t=torrents.get(selectedHash); if($('peersRefreshBox')) $('peersRefreshBox').classList.toggle('d-none', tab!=='peers'); setupPeersRefresh(tab); if(!t)return; if(tab==='general') return renderGeneral(); if(tab==='log'){ $('detailPane').innerHTML=`
    ${esc(t.message||'No logs')}
    `; return; } const pane=$('detailPane'); pane.innerHTML=`
    Loading ${esc(tab)}...
    `; try{ const detailUrl = tab==='chunks' ? `/api/torrents/${encodeURIComponent(selectedHash)}/chunks?max_cells=${chunkMaxCellsForDensity()}` : `/api/torrents/${encodeURIComponent(selectedHash)}/${tab}`; const res=await fetch(detailUrl,{headers:{'Accept':'application/json'}}); const text=await res.text(); let json; try{ json=JSON.parse(text); }catch(parseErr){ throw new Error(`Invalid API response for ${tab}. HTTP ${res.status}`); } if(!res.ok || !json.ok) throw new Error(json.error||`HTTP ${res.status}`); if(tab!==activeTab()) return; if(tab==='files') renderFiles(json.files||[]); if(tab==='chunks') renderChunks(json.chunks||{}); if(tab==='peers') renderPeers(json.peers||[]); if(tab==='trackers') renderTrackers(json.trackers||[]); }catch(e){pane.innerHTML=`
    ${esc(e.message)}
    `;} }\n"; +export const torrentDetailsSource = " function formatDateTime(seconds){ const n=Number(seconds||0); if(!n) return '-'; try{ return new Date(n*1000).toLocaleString(); }catch(e){ return '-'; } }\n function joinRemotePath(base,name){\n const b=String(base||'').trim();\n const n=String(name||'').trim();\n if(!b && !n) return '-';\n if(!n) return b || '-';\n if(!b) return n;\n return `${b.replace(/\\/+$/,'')}/${n.replace(/^\\/+/,'')}`;\n }\n function renderGeneral(){\n const t=torrents.get(selectedHash);\n if(!t){ $('detailPane').innerHTML='Select a torrent.'; return; }\n const labels=labelNames(t.label).map(l=>` ${esc(l)}`).join(' ') || '-';\n const ratioGroup=t.ratio_group ? `${esc(t.ratio_group)}` : 'Not assigned';\n const statusClass=t.status==='Seeding'?'success':t.status==='Downloading'?'primary':t.status==='Checking'?'warning':t.status==='Paused'?'secondary':t.status==='Stopped'?'dark':'secondary';\n const fullPath=joinRemotePath(t.path,t.name);\n const cards=[\n ['Size', esc(t.size_h||'-')],\n ['Downloaded', esc(t.down_total_h||'-')],\n ['Uploaded', esc(t.up_total_h||'-')],\n ['Ratio', esc(t.ratio??'-')],\n ['Download speed', esc(t.down_rate_h||'-')],\n ['Upload speed', esc(t.up_rate_h||'-')],\n ['Seeds / Peers', `${esc(t.seeds??0)} / ${esc(t.peers??0)}`],\n ['ETA', esc(t.eta_h||'-')],\n ['Added', esc(formatDateTime(t.created))],\n ['Priority', esc(t.priority??'-')],\n ].map(([label,value])=>`
    ${label}${value}
    `).join('');\n $('detailPane').innerHTML=`\n
    \n
    \n
    ${esc(t.name||'-')}
    ${esc(t.status||'-')}
    \n
    Directory${esc(t.path||'-')}
    \n
    Full data path${esc(fullPath)}
    \n
    \n
    Hash${esc(t.hash||'-')}
    \n
    \n
    ${cards}
    \n
    Labels${labels}
    Ratio rule${ratioGroup}
    Message${esc(t.message||'-')}
    `;\n }\n const FILE_PRIORITY_LABELS = {0: \"Skip\", 1: \"Normal\", 2: \"High\"};\n function priorityClass(priority){ priority=Number(priority||0); return priority===2?\"text-bg-success\":priority===0?\"text-bg-secondary\":\"text-bg-primary\"; }\n function renderFilePrioritySelect(f){ const p=Number(f.priority||0); return ``; }\n function selectedFileIndexes(){ return [...document.querySelectorAll('#detailPane .file-check:checked')].map(cb=>Number(cb.dataset.index)); }\n function downloadSelectedFiles(){\n if(!selectedHash) return;\n const indexes=selectedFileIndexes();\n if(!indexes.length) return toastMessage('toast.noFilesSelected','warning');\n if(indexes.length===1){ openTemporaryDownload(`/api/torrents/${encodeURIComponent(selectedHash)}/files/${indexes[0]}/download-link`).catch(e=>toast(e.message,'danger')); return; }\n downloadZip(indexes);\n }\n async function downloadZip(indexes=null){\n if(!selectedHash) return;\n try{\n await openTemporaryDownload(`/api/torrents/${encodeURIComponent(selectedHash)}/files/download.zip/link`, {indexes});\n }catch(e){ toast(e.message,'danger'); }\n }\n\n function mediaInfoValue(value){\n const text = value === null || value === undefined || value === '' ? '-' : String(value);\n return esc(text);\n }\n function mediaInfoSummaryCards(info){\n // Note: Summary cards show the most useful hachoir fields while keeping the full raw list below.\n const summary = info.summary || {};\n const cards = [\n ['Duration', summary.duration],\n ['Bit rate', summary.bit_rate],\n ['Resolution', summary.width && summary.height ? `${summary.width} × ${summary.height}` : null],\n ['Frame rate', summary.frame_rate],\n ['Audio', [summary.channels, summary.sample_rate].filter(Boolean).join(' · ')],\n ['Codec / compression', summary.compression],\n ['Producer', summary.producer],\n ['Created', summary.creation_date],\n ];\n return cards.map(([label,value]) => `
    ${esc(label)}${mediaInfoValue(value)}
    `).join('');\n }\n function mediaInfoFieldsTable(info){\n const rows = (info.fields || []).slice(0, 160).map(field => `${esc(field.key)}${esc(field.value)}`).join('');\n if(rows) return `
    Detected metadata
    ${rows}
    `;\n const raw = (info.raw || []).slice(0, 80).map(line => `
  • ${esc(line)}
  • `).join('');\n return `
    Raw parser output
    `;\n }\n function ensureMediaInfoModal(){\n let modal = $('mediaInfoModal');\n if(modal) return modal;\n // Note: The modal is created lazily so existing templates and old modals stay untouched.\n modal = document.createElement('div');\n modal.id = 'mediaInfoModal';\n modal.className = 'modal fade media-info-modal';\n modal.tabIndex = -1;\n modal.innerHTML = `
    File info
    Loading file info...
    `;\n document.body.appendChild(modal);\n return modal;\n }\n function mediaInfoSubtitle(info){\n if(info.kind === 'pdf'){\n const sizeText = info.size_h || (info.size ? fmtBytes(info.size) : 'unknown size');\n return `${info.path || 'File'} · ${sizeText} · inline PDF preview`;\n }\n const sampleText = `${fmtBytes(info.sample_bytes || 0)} / ${fmtBytes(info.sample_limit || 0)} sample${info.partial ? ' · partial preview' : ''}`;\n return `${info.path || 'File'} · ${sampleText}`;\n }\n function renderTextPreview(info){\n const text = esc(info.text || '');\n const note = info.partial ? `
    Preview truncated to ${esc(fmtBytes(info.sample_limit || 0))}. Download the file to read the full content.
    ` : '';\n return `${note}
    ${text || 'No text content was returned.'}
    `;\n }\n function renderImagePreview(info){\n if(info.error){\n return `
    Image preview unavailable${esc(info.error)}
    `;\n }\n return `
    \"${esc(info.path
    ${esc(info.mime_type || 'image')} · ${esc(fmtBytes(info.sample_bytes || 0))}
    `;\n }\n function mediaInfoPdfUrl(info){\n // Note: PDF preview links are created by the backend as short-lived app URLs, so the new-tab button does not expose /api/.\n return String(info.preview_url || '');\n }\n function renderPdfPreview(info){\n // Note: PDF preview uses the browser renderer, preserving images and page layout instead of flattening books to extracted text.\n const src = mediaInfoPdfUrl(info);\n const downloadButton = ``;\n const openButton = src ? ` Open in new tab` : '';\n if(!src){\n return `
    PDF preview unavailableMissing temporary app link for inline preview.
    ${downloadButton}
    `;\n }\n const title = esc(info.path || 'PDF preview');\n const size = info.size_h || (info.size ? fmtBytes(info.size) : 'unknown size');\n const expires = info.preview_expires_in ? ` · temporary link: ${Math.round(Number(info.preview_expires_in) / 60)} min` : '';\n return `
    PDF preview${esc(size)} · rendered by your browser${esc(expires)}
    ${openButton}${downloadButton}
    Inline PDF preview is not availableYour browser blocked the embedded viewer. Open it in a new tab or download the file.
    ${openButton}${downloadButton}
    `;\n }\n function renderMediaInfoModal(info){\n const body = $('mediaInfoBody');\n const subtitle = $('mediaInfoSubtitle');\n if(!body) return;\n if(subtitle) subtitle.textContent = mediaInfoSubtitle(info);\n if(info.kind === 'text'){\n body.innerHTML = `
    ${mediaInfoSummaryCards({...info, summary:{duration:null, bit_rate:null, compression:info.encoding, producer:`${info.line_count || 0} line(s)`, creation_date:null}})}
    ${renderTextPreview(info)}${mediaInfoFieldsTable(info)}`;\n return;\n }\n if(info.kind === 'image'){\n body.innerHTML = `${renderImagePreview(info)}${mediaInfoFieldsTable(info)}`;\n return;\n }\n if(info.kind === 'pdf'){\n body.innerHTML = `
    ${mediaInfoSummaryCards(info)}
    ${renderPdfPreview(info)}${mediaInfoFieldsTable(info)}`;\n return;\n }\n if(info.error){\n body.innerHTML = `
    File info unavailable
    ${esc(info.error)}
    `;\n return;\n }\n body.innerHTML = `
    ${mediaInfoSummaryCards(info)}
    ${mediaInfoFieldsTable(info)}`;\n }\n async function openMediaInfo(index){\n if(!selectedHash) return;\n const button = document.querySelector(`#detailPane .file-media-info[data-index=\"${CSS.escape(String(index))}\"]`);\n if(button?.disabled){\n return toast('File info is available after this file is fully downloaded.','warning');\n }\n const modal = ensureMediaInfoModal();\n $('mediaInfoSubtitle').textContent = 'Reading a bounded file sample...';\n $('mediaInfoBody').innerHTML = '
    Loading file info...
    ';\n new bootstrap.Modal(modal).show();\n try{\n const res = await fetch(`/api/torrents/${encodeURIComponent(selectedHash)}/files/${encodeURIComponent(index)}/mediainfo`, {headers:{'Accept':'application/json'}});\n const json = await res.json().catch(() => ({}));\n if(!res.ok || !json.ok) throw new Error(json.error || `HTTP ${res.status}`);\n renderMediaInfoModal(json.media_info || {});\n }catch(e){\n $('mediaInfoBody').innerHTML = `
    File info failed
    ${esc(e.message)}
    `;\n }\n }\n\n function fileInfoAvailable(f){\n // Note: File info is intentionally locked until rTorrent reports the selected file as fully downloaded.\n const size = Number(f?.size || 0);\n const progress = Number(f?.progress || 0);\n const completedChunks = Number(f?.completed_chunks || 0);\n const sizeChunks = Number(f?.size_chunks || 0);\n return size <= 0 || progress >= 100 || (sizeChunks > 0 && completedChunks >= sizeChunks);\n }\n function renderFileInfoButton(f){\n const available = fileInfoAvailable(f);\n const title = available ? 'File info / preview' : 'File info is available after this file is fully downloaded.';\n const disabled = available ? '' : ' disabled aria-disabled=\"true\"';\n const stateClass = available ? 'btn-outline-info' : 'btn-outline-secondary file-media-info-blocked';\n return ``;\n }\n function filesNeedAutoRefresh(files){\n // Note: The files list keeps refreshing only while at least one visible file is not fully downloaded.\n return (files || []).some(file => !fileInfoAvailable(file));\n }\n function clearFilesAutoRefresh(){\n // Note: Clearing the timer prevents hidden Files tabs and completed torrents from polling rTorrent.\n if(filesRefreshTimer) clearInterval(filesRefreshTimer);\n filesRefreshTimer = null;\n filesAutoRefreshHash = null;\n }\n function setupFilesAutoRefresh(files){\n // Note: Auto-refresh belongs to the open Files tab and is disabled as soon as all files reach 100%.\n const hash = selectedHash;\n if(activeTab() !== 'files' || !hash || !filesNeedAutoRefresh(files)){\n clearFilesAutoRefresh();\n return;\n }\n if(filesRefreshTimer && filesAutoRefreshHash === hash) return;\n clearFilesAutoRefresh();\n filesAutoRefreshHash = hash;\n filesRefreshTimer = setInterval(async () => {\n if(activeTab() !== 'files' || !selectedHash || filesAutoRefreshHash !== selectedHash){\n clearFilesAutoRefresh();\n return;\n }\n if(filesRefreshInFlight) return;\n filesRefreshInFlight = true;\n try{\n await loadDetails('files', {silent: true});\n }finally{\n filesRefreshInFlight = false;\n }\n }, FILES_AUTO_REFRESH_SECONDS * 1000);\n }\n function renderFiles(files){\n const pane=$('detailPane');\n const rows=(files||[]).map(f=>`${esc(f.path)}${esc(f.size_h)}${progressBar(f.progress ?? 0, 'file-progress')}${esc(FILE_PRIORITY_LABELS[Number(f.priority||0)]||f.priority)}${renderFilePrioritySelect(f)}
    ${renderFileInfoButton(f)}
    `).join('');\n // Note: Files use the same responsive table wrapper as peers to keep wide paths usable on small screens.\n pane.innerHTML=`
    Priority
    Download
    Changes are applied immediately in rTorrent. File info becomes available only after a file reaches 100%.
    ${rows || ''}
    PathSizeDonePrioritySet priorityActions
    No files.
    `;\n setupFilesAutoRefresh(files);\n }\n function fileTreeNode(node){\n const children=(node.children||[]).map(fileTreeNode).join('');\n if(node.type==='file') return `
  • ${esc(node.name||node.path)} ${esc(node.size_h||'')}
  • `;\n return `
  • ${esc(node.name||'Files')} ${esc(node.size_h||'')}
      ${children}
  • `;\n }\n async function loadFileTree(){\n if(!selectedHash) return;\n const box=$('fileTreePanel');\n if(!box) return;\n box.classList.toggle('d-none');\n if(box.classList.contains('d-none')) return;\n box.innerHTML=' Loading tree...';\n try{ const j=await (await fetch(`/api/torrents/${encodeURIComponent(selectedHash)}/files/tree`)).json(); if(!j.ok) throw new Error(j.error||'Tree failed'); box.innerHTML=``; }\n catch(e){ box.innerHTML=`
    ${esc(e.message)}
    `; }\n }\n async function setFilePriorities(items){\n if(!selectedHash || !items.length) return;\n setBusy(true);\n try{\n const res=await fetch(`/api/torrents/${encodeURIComponent(selectedHash)}/files/priority`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({files:items})});\n const j=await res.json();\n if(!j.ok || (j.errors&&j.errors.length)) throw new Error(j.errors?.[0]?.error || j.error || 'Priority update failed');\n toast(`Updated ${j.updated?.length||items.length} file priority item(s)`,'success');\n await loadDetails('files');\n }catch(e){ toast(e.message,'danger'); } finally{ setBusy(false); }\n }\n\n const CHUNK_DENSITY_OPTIONS = {\n compact: {label: 'Compact', maxCells: 2400},\n normal: {label: 'Normal', maxCells: 1400},\n detailed: {label: 'Detailed', maxCells: 700},\n };\n const CHUNK_FILTER_OPTIONS = [\n ['all', 'All'],\n ['problem', 'Missing + partial'],\n ['missing', 'Missing'],\n ['partial', 'Partial'],\n ['seen', 'Seen by peers'],\n ['complete', 'Complete'],\n ];\n let chunkFilterMode = localStorage.getItem('chunkFilterMode') || 'all';\n let chunkDensityMode = localStorage.getItem('chunkDensityMode') || 'normal';\n let lastChunkData = null;\n\n function chunkMaxCellsForDensity(){\n // Note: Density changes the API grouping level and the CSS cell size together.\n return CHUNK_DENSITY_OPTIONS[chunkDensityMode]?.maxCells || CHUNK_DENSITY_OPTIONS.normal.maxCells;\n }\n function chunkCellsForFilter(cells){\n const list = Array.isArray(cells) ? cells : [];\n if(chunkFilterMode === 'all') return list;\n if(chunkFilterMode === 'problem') return list.filter(cell => ['missing','partial'].includes(cell.status));\n return list.filter(cell => cell.status === chunkFilterMode);\n }\n function chunkStatusLabel(status){\n return ({complete:'Complete', partial:'Partial', missing:'Missing', seen:'Seen by peers'}[status] || 'Unknown');\n }\n function chunkCellTitle(cell){\n const first = cell.first_chunk ?? '-';\n const last = cell.last_chunk ?? first;\n const pct = Number(cell.percent||0).toFixed(1).replace(/\\.0$/,'');\n const completed = Number(cell.completed ?? 0);\n const total = Number(cell.total ?? cell.unit_count ?? 1);\n const grouped = cell.grouped ? `Grouped visual cell: ${cell.unit_count || 1} piece(s)` : 'Single piece';\n return [\n `Pieces: ${first}-${last}`,\n `Status: ${chunkStatusLabel(cell.status)}`,\n `Progress: ${pct}%`,\n `Complete pieces: ${completed}/${total}`,\n grouped,\n ].join(' | ');\n }\n function chunkCellMarkup(cell){\n const pct = Math.max(0, Math.min(100, Number(cell.percent || 0)));\n const cls = `chunk-cell chunk-${esc(cell.status || 'missing')}${cell.grouped ? ' is-grouped' : ''}`;\n return ``;\n }\n function renderChunkLegend(summary){\n const items=[['complete','Complete'],['partial','Partial'],['missing','Missing'],['seen','Seen by peers']];\n return items.map(([key,label])=>`${label} ${esc(summary?.[key]??0)}`).join('');\n }\n function renderChunkControls(){\n const filters = CHUNK_FILTER_OPTIONS.map(([value,label]) => ``).join('');\n const densities = Object.entries(CHUNK_DENSITY_OPTIONS).map(([value,cfg]) => ``).join('');\n return `
    `;\n }\n function selectedChunkRange(){\n const selected=[...document.querySelectorAll('#detailPane .chunk-cell.is-selected')].map(el=>({first:Number(el.dataset.firstChunk||0),last:Number(el.dataset.lastChunk||0)}));\n if(!selected.length) return null;\n return {first_chunk:Math.min(...selected.map(x=>x.first)),last_chunk:Math.max(...selected.map(x=>x.last)),count:selected.length};\n }\n function updateChunkSelectionInfo(){\n const info=$('chunkSelectionInfo');\n if(!info) return;\n const range=selectedChunkRange();\n const filteredCount=document.querySelectorAll('#detailPane .chunk-cell').length;\n const totalCount=lastChunkData?.cells?.length || 0;\n if(range){\n info.textContent=`Selected ${range.count} cell(s), pieces ${range.first_chunk}-${range.last_chunk}.`;\n return;\n }\n const filterText=chunkFilterMode === 'all' ? '' : ` Showing ${filteredCount}/${totalCount} cell(s).`;\n info.textContent=`Select one or more visual cells to prioritize files that overlap that range.${filterText}`;\n }\n function renderChunks(data){\n const pane=$('detailPane');\n const chunks=data||{};\n lastChunkData=chunks;\n const allCells=chunks.cells||[];\n const cells=chunkCellsForFilter(allCells);\n const grouped=chunks.grouped?'grouped for performance':'';\n const meta=[\n ['Piece size', chunks.chunk_size_h || '-'],\n ['Pieces', chunks.size_chunks ?? 0],\n ['Complete pieces', chunks.completed_chunks ?? 0],\n ['Hashed pieces', chunks.chunks_hashed ?? 0],\n ['Visual cells', chunks.visual_cells ?? allCells.length],\n ].map(([label,value])=>`
    ${esc(label)}${esc(value)}
    `).join('');\n pane.innerHTML=`\n
    \n
    \n
    Chunks ${grouped}
    \n
    \n \n \n \n
    \n
    \n
    ${meta}
    \n
    \n
    ${renderChunkLegend(chunks.summary||{})}
    \n ${renderChunkControls()}\n
    \n
    \n
    ${cells.map(chunkCellMarkup).join('') || '
    No chunk cells for this filter.
    '}
    \n
    `;\n updateChunkSelectionInfo();\n }\n async function runChunkAction(action,payload={}){\n if(!selectedHash) return toastMessage('toast.noTorrentSelected','warning');\n setBusy(true);\n try{\n const j=await post(`/api/torrents/${encodeURIComponent(selectedHash)}/chunks/${action}`,payload);\n toast(j.message || appMessage('toast.chunkActionDone',{action}),'success');\n await loadDetails('chunks');\n }catch(e){ toast(e.message,'danger'); }\n finally{ setBusy(false); }\n }\n document.addEventListener('change', e=>{\n const filter=e.target.closest('#chunkFilterMode');\n if(filter){\n chunkFilterMode=filter.value || 'all';\n localStorage.setItem('chunkFilterMode', chunkFilterMode);\n if(lastChunkData && activeTab()==='chunks') renderChunks(lastChunkData);\n return;\n }\n const density=e.target.closest('#chunkDensityMode');\n if(density){\n chunkDensityMode=density.value || 'normal';\n localStorage.setItem('chunkDensityMode', chunkDensityMode);\n if(activeTab()==='chunks') loadDetails('chunks');\n }\n });\n function peerBadges(p){\n const badges=[];\n if(p.encrypted) badges.push('enc');\n if(p.incoming) badges.push('in');\n if(p.snubbed) badges.push('snub');\n if(p.banned) badges.push('ban');\n return badges.join(' ') || '-';\n }\n function peerHostCell(p){\n const host=String(p.host||'').trim();\n if(host) return `${esc(host)}`;\n if(p.host_pending) return 'resolving';\n return '-';\n }\n function renderPeers(peers){\n const headers=['Flag','IP'];\n if(reverseDnsEnabled) headers.push('Host');\n headers.push('Country','City','Client','%','DL','UL','Port','Flags');\n const rows=(peers||[]).map(p=>{\n const row=[flag(p.country_iso),`${esc(p.ip)}`];\n if(reverseDnsEnabled) row.push(peerHostCell(p));\n row.push(esc(p.country),esc(p.city),esc(p.client),progressBar(p.completed,'peer-progress peer-progress-wide'),esc(p.down_rate_h),esc(p.up_rate_h),esc(p.port),peerBadges(p));\n return row;\n });\n $('detailPane').innerHTML=responsiveTable(headers,rows,'peers-table');\n }\n function fmtTs(value){ const n=Number(value||0); if(!n) return '-'; try{return new Date(n*1000).toLocaleString();}catch(e){return String(n);} }\n function trackerSeedsPeers(t){ const hasScrape = t.seeds !== null || t.peers !== null; return hasScrape ? `${t.seeds ?? \"-\"} / ${t.peers ?? \"-\"}` : \"-\"; }\n function renderTrackers(trackers){\n // Note: Tracker URL editing is intentionally replaced by safe deletion; adding trackers remains unchanged.\n const pane=$('detailPane');\n const list=trackers||[];\n const canDelete=list.length>1;\n const rows=list.map(t=>{\n const idx=esc(t.index), url=esc(t.url);\n const deleteDisabled=canDelete ? '' : ' disabled title=\"At least one tracker must remain\"';\n return [`#${idx}`, `${url || '-'}`, t.enabled?'yes':'no', esc(trackerSeedsPeers(t)), esc(t.downloaded ?? '-'), fmtTs(t.last_announce), `
    `];\n });\n // Note: Trackers share the responsive wrapper so long URLs do not break the details pane.\n pane.innerHTML=`
    ${responsiveTable(['#','URL','On','Seeds / Peers','Done','Last announce','Actions'], rows.length?rows:[[ '-','No trackers.','','','','','' ]], 'tracker-table')}`;\n }\n async function trackerAction(action,payload={}){\n if(!selectedHash) return toastMessage('toast.noTorrentSelected','warning');\n setBusy(true);\n try{\n const j=await post(`/api/torrents/${encodeURIComponent(selectedHash)}/trackers/${action}`,payload);\n toast(j.message || appMessage('toast.trackerActionDone',{action}),'success');\n await loadDetails('trackers');\n }catch(e){toast(e.message,'danger');}\n finally{setBusy(false);}\n }\n async function loadDetails(tab, options={}){\n const t=torrents.get(selectedHash);\n const silent = !!options.silent;\n if(tab !== 'files') clearFilesAutoRefresh();\n if($('peersRefreshBox')) $('peersRefreshBox').classList.toggle('d-none', tab!=='peers');\n setupPeersRefresh(tab);\n if(!t) return;\n if(tab==='general') return renderGeneral();\n if(tab==='log'){\n $('detailPane').innerHTML=`
    ${esc(t.message||'No logs')}
    `;\n return;\n }\n const pane=$('detailPane');\n if(!silent) pane.innerHTML=`
    Loading ${esc(tab)}...
    `;\n try{\n const detailUrl = tab==='chunks' ? `/api/torrents/${encodeURIComponent(selectedHash)}/chunks?max_cells=${chunkMaxCellsForDensity()}` : `/api/torrents/${encodeURIComponent(selectedHash)}/${tab}`;\n const res=await fetch(detailUrl,{headers:{'Accept':'application/json'}});\n const text=await res.text();\n let json;\n try{\n json=JSON.parse(text);\n }catch(parseErr){\n throw new Error(`Invalid API response for ${tab}. HTTP ${res.status}`);\n }\n if(!res.ok || !json.ok) throw new Error(json.error||`HTTP ${res.status}`);\n if(tab!==activeTab()) return;\n if(tab==='files') renderFiles(json.files||[]);\n if(tab==='chunks') renderChunks(json.chunks||{});\n if(tab==='peers') renderPeers(json.peers||[]);\n if(tab==='trackers') renderTrackers(json.trackers||[]);\n }catch(e){\n if(!silent) pane.innerHTML=`
    ${esc(e.message)}
    `;\n }\n }\n"; diff --git a/pytorrent/static/libs/pytorrent-themes/adaptive/bootstrap.min.css b/pytorrent/static/libs/pytorrent-themes/adaptive/bootstrap.min.css new file mode 100644 index 0000000..725313f --- /dev/null +++ b/pytorrent/static/libs/pytorrent-themes/adaptive/bootstrap.min.css @@ -0,0 +1,66 @@ +/* Balanced violet-blue theme with calm defaults. */ +:root, [data-bs-theme="light"] { + color-scheme: light; + --bs-primary: #6750a4; + --bs-primary-rgb: 103,80,164; + --bs-link-color: #6750a4; + --bs-link-color-rgb: 103,80,164; + --bs-link-hover-color: #4b2f8f; + --bs-link-hover-color-rgb: 103,80,164; + --bs-body-bg: #f6f7fb; + --bs-body-bg-rgb: 246,247,251; + --bs-body-color: #1f2937; + --bs-secondary-bg: #ffffff; + --bs-secondary-bg-rgb: 255,255,255; + --bs-tertiary-bg: #eef1f7; + --bs-border-color: #d9deea; + --bs-secondary-color: #667085; + --bs-primary-bg-subtle: #ece7ff; + --bs-primary-text-emphasis: #4b2f8f; + --torrent-progress-complete: #2f9e75; + --pytorrent-page-bg: linear-gradient(180deg, #f7f8fc 0%, #eef1f7 100%); + --pytorrent-shell-shadow: 0 16px 40px rgba(39, 45, 73, 0.12); +} + +[data-bs-theme="dark"] { + color-scheme: dark; + --bs-primary: #8f7cff; + --bs-primary-rgb: 143,124,255; + --bs-link-color: #8f7cff; + --bs-link-color-rgb: 143,124,255; + --bs-link-hover-color: #c6bdff; + --bs-link-hover-color-rgb: 143,124,255; + --bs-body-bg: #080b12; + --bs-body-bg-rgb: 8,11,18; + --bs-body-color: #dce3f0; + --bs-secondary-bg: #101624; + --bs-secondary-bg-rgb: 16,22,36; + --bs-tertiary-bg: #151d2e; + --bs-border-color: #273247; + --bs-secondary-color: #97a4ba; + --bs-primary-bg-subtle: #191735; + --bs-primary-text-emphasis: #c6bdff; + --torrent-progress-complete: #2f9e75; + --pytorrent-page-bg: radial-gradient(circle at top left, rgba(143, 124, 255, 0.16), transparent 34%), #080b12; + --pytorrent-shell-shadow: 0 18px 55px rgba(0, 0, 0, 0.48); +} + +.btn-primary { + --bs-btn-bg: var(--bs-primary); + --bs-btn-border-color: var(--bs-primary); + --bs-btn-hover-bg: var(--bs-primary-text-emphasis); + --bs-btn-hover-border-color: var(--bs-primary-text-emphasis); +} +.btn-outline-primary { + --bs-btn-color: var(--bs-primary); + --bs-btn-border-color: rgba(var(--bs-primary-rgb), 0.72); + --bs-btn-hover-bg: var(--bs-primary); + --bs-btn-hover-border-color: var(--bs-primary); +} +.nav-pills { + --bs-nav-pills-link-active-bg: var(--bs-primary); +} +.progress, +.progress-stacked { + --bs-progress-bg: rgba(var(--bs-secondary-bg-rgb), 0.82); +} diff --git a/pytorrent/static/libs/pytorrent-themes/amber/bootstrap.min.css b/pytorrent/static/libs/pytorrent-themes/amber/bootstrap.min.css new file mode 100644 index 0000000..859273f --- /dev/null +++ b/pytorrent/static/libs/pytorrent-themes/amber/bootstrap.min.css @@ -0,0 +1,66 @@ +/* Warm amber theme, good for light mode and softer dark mode. */ +:root, [data-bs-theme="light"] { + color-scheme: light; + --bs-primary: #b45309; + --bs-primary-rgb: 180,83,9; + --bs-link-color: #b45309; + --bs-link-color-rgb: 180,83,9; + --bs-link-hover-color: #92400e; + --bs-link-hover-color-rgb: 180,83,9; + --bs-body-bg: #fff8ed; + --bs-body-bg-rgb: 255,248,237; + --bs-body-color: #2c1d0b; + --bs-secondary-bg: #ffffff; + --bs-secondary-bg-rgb: 255,255,255; + --bs-tertiary-bg: #ffefd6; + --bs-border-color: #f3d7aa; + --bs-secondary-color: #7a6750; + --bs-primary-bg-subtle: #ffecd0; + --bs-primary-text-emphasis: #92400e; + --torrent-progress-complete: #16a34a; + --pytorrent-page-bg: linear-gradient(180deg, #fffaf2 0%, #fff0d8 100%); + --pytorrent-shell-shadow: 0 16px 38px rgba(146, 64, 14, 0.13); +} + +[data-bs-theme="dark"] { + color-scheme: dark; + --bs-primary: #f59e0b; + --bs-primary-rgb: 245,158,11; + --bs-link-color: #f59e0b; + --bs-link-color-rgb: 245,158,11; + --bs-link-hover-color: #fde68a; + --bs-link-hover-color-rgb: 245,158,11; + --bs-body-bg: #140d05; + --bs-body-bg-rgb: 20,13,5; + --bs-body-color: #f5e9d6; + --bs-secondary-bg: #211607; + --bs-secondary-bg-rgb: 33,22,7; + --bs-tertiary-bg: #2b1d0b; + --bs-border-color: #4c3515; + --bs-secondary-color: #c3a780; + --bs-primary-bg-subtle: #3a2609; + --bs-primary-text-emphasis: #fde68a; + --torrent-progress-complete: #22c55e; + --pytorrent-page-bg: radial-gradient(circle at top left, rgba(245, 158, 11, 0.16), transparent 34%), #140d05; + --pytorrent-shell-shadow: 0 18px 55px rgba(30, 15, 0, 0.58); +} + +.btn-primary { + --bs-btn-bg: var(--bs-primary); + --bs-btn-border-color: var(--bs-primary); + --bs-btn-hover-bg: var(--bs-primary-text-emphasis); + --bs-btn-hover-border-color: var(--bs-primary-text-emphasis); +} +.btn-outline-primary { + --bs-btn-color: var(--bs-primary); + --bs-btn-border-color: rgba(var(--bs-primary-rgb), 0.72); + --bs-btn-hover-bg: var(--bs-primary); + --bs-btn-hover-border-color: var(--bs-primary); +} +.nav-pills { + --bs-nav-pills-link-active-bg: var(--bs-primary); +} +.progress, +.progress-stacked { + --bs-progress-bg: rgba(var(--bs-secondary-bg-rgb), 0.82); +} diff --git a/pytorrent/static/libs/pytorrent-themes/crimson/bootstrap.min.css b/pytorrent/static/libs/pytorrent-themes/crimson/bootstrap.min.css new file mode 100644 index 0000000..778ef69 --- /dev/null +++ b/pytorrent/static/libs/pytorrent-themes/crimson/bootstrap.min.css @@ -0,0 +1,66 @@ +/* High-contrast red accent theme without the previous purple feel. */ +:root, [data-bs-theme="light"] { + color-scheme: light; + --bs-primary: #be123c; + --bs-primary-rgb: 190,18,60; + --bs-link-color: #be123c; + --bs-link-color-rgb: 190,18,60; + --bs-link-hover-color: #9f1239; + --bs-link-hover-color-rgb: 190,18,60; + --bs-body-bg: #fff5f7; + --bs-body-bg-rgb: 255,245,247; + --bs-body-color: #2b1118; + --bs-secondary-bg: #ffffff; + --bs-secondary-bg-rgb: 255,255,255; + --bs-tertiary-bg: #ffe4ea; + --bs-border-color: #f5c6d1; + --bs-secondary-color: #76545d; + --bs-primary-bg-subtle: #ffe1e9; + --bs-primary-text-emphasis: #9f1239; + --torrent-progress-complete: #16a34a; + --pytorrent-page-bg: linear-gradient(180deg, #fff8fa 0%, #ffe8ee 100%); + --pytorrent-shell-shadow: 0 16px 38px rgba(159, 18, 57, 0.13); +} + +[data-bs-theme="dark"] { + color-scheme: dark; + --bs-primary: #fb7185; + --bs-primary-rgb: 251,113,133; + --bs-link-color: #fb7185; + --bs-link-color-rgb: 251,113,133; + --bs-link-hover-color: #fecdd3; + --bs-link-hover-color-rgb: 251,113,133; + --bs-body-bg: #13070a; + --bs-body-bg-rgb: 19,7,10; + --bs-body-color: #f7dce2; + --bs-secondary-bg: #211014; + --bs-secondary-bg-rgb: 33,16,20; + --bs-tertiary-bg: #2b151b; + --bs-border-color: #4b2430; + --bs-secondary-color: #c096a1; + --bs-primary-bg-subtle: #3b111b; + --bs-primary-text-emphasis: #fecdd3; + --torrent-progress-complete: #22c55e; + --pytorrent-page-bg: radial-gradient(circle at top left, rgba(251, 113, 133, 0.15), transparent 35%), #13070a; + --pytorrent-shell-shadow: 0 18px 55px rgba(28, 0, 8, 0.58); +} + +.btn-primary { + --bs-btn-bg: var(--bs-primary); + --bs-btn-border-color: var(--bs-primary); + --bs-btn-hover-bg: var(--bs-primary-text-emphasis); + --bs-btn-hover-border-color: var(--bs-primary-text-emphasis); +} +.btn-outline-primary { + --bs-btn-color: var(--bs-primary); + --bs-btn-border-color: rgba(var(--bs-primary-rgb), 0.72); + --bs-btn-hover-bg: var(--bs-primary); + --bs-btn-hover-border-color: var(--bs-primary); +} +.nav-pills { + --bs-nav-pills-link-active-bg: var(--bs-primary); +} +.progress, +.progress-stacked { + --bs-progress-bg: rgba(var(--bs-secondary-bg-rgb), 0.82); +} diff --git a/pytorrent/static/libs/pytorrent-themes/forest/bootstrap.min.css b/pytorrent/static/libs/pytorrent-themes/forest/bootstrap.min.css new file mode 100644 index 0000000..c3cafb8 --- /dev/null +++ b/pytorrent/static/libs/pytorrent-themes/forest/bootstrap.min.css @@ -0,0 +1,66 @@ +/* Green productivity theme with warm contrast. */ +:root, [data-bs-theme="light"] { + color-scheme: light; + --bs-primary: #2f7d32; + --bs-primary-rgb: 47,125,50; + --bs-link-color: #2f7d32; + --bs-link-color-rgb: 47,125,50; + --bs-link-hover-color: #256329; + --bs-link-hover-color-rgb: 47,125,50; + --bs-body-bg: #f4faf2; + --bs-body-bg-rgb: 244,250,242; + --bs-body-color: #172417; + --bs-secondary-bg: #ffffff; + --bs-secondary-bg-rgb: 255,255,255; + --bs-tertiary-bg: #e7f4e4; + --bs-border-color: #cce4c7; + --bs-secondary-color: #61735e; + --bs-primary-bg-subtle: #dcf2d7; + --bs-primary-text-emphasis: #256329; + --torrent-progress-complete: #22c55e; + --pytorrent-page-bg: linear-gradient(180deg, #fbfff9 0%, #e9f6e5 100%); + --pytorrent-shell-shadow: 0 16px 38px rgba(35, 84, 38, 0.13); +} + +[data-bs-theme="dark"] { + color-scheme: dark; + --bs-primary: #4ade80; + --bs-primary-rgb: 74,222,128; + --bs-link-color: #4ade80; + --bs-link-color-rgb: 74,222,128; + --bs-link-hover-color: #bbf7d0; + --bs-link-hover-color-rgb: 74,222,128; + --bs-body-bg: #071109; + --bs-body-bg-rgb: 7,17,9; + --bs-body-color: #d9f1dc; + --bs-secondary-bg: #0f1f12; + --bs-secondary-bg-rgb: 15,31,18; + --bs-tertiary-bg: #152b18; + --bs-border-color: #24472a; + --bs-secondary-color: #95b79a; + --bs-primary-bg-subtle: #13381a; + --bs-primary-text-emphasis: #bbf7d0; + --torrent-progress-complete: #4ade80; + --pytorrent-page-bg: radial-gradient(circle at top left, rgba(74, 222, 128, 0.14), transparent 36%), #071109; + --pytorrent-shell-shadow: 0 18px 55px rgba(0, 20, 4, 0.58); +} + +.btn-primary { + --bs-btn-bg: var(--bs-primary); + --bs-btn-border-color: var(--bs-primary); + --bs-btn-hover-bg: var(--bs-primary-text-emphasis); + --bs-btn-hover-border-color: var(--bs-primary-text-emphasis); +} +.btn-outline-primary { + --bs-btn-color: var(--bs-primary); + --bs-btn-border-color: rgba(var(--bs-primary-rgb), 0.72); + --bs-btn-hover-bg: var(--bs-primary); + --bs-btn-hover-border-color: var(--bs-primary); +} +.nav-pills { + --bs-nav-pills-link-active-bg: var(--bs-primary); +} +.progress, +.progress-stacked { + --bs-progress-bg: rgba(var(--bs-secondary-bg-rgb), 0.82); +} diff --git a/pytorrent/static/libs/pytorrent-themes/graphite/bootstrap.min.css b/pytorrent/static/libs/pytorrent-themes/graphite/bootstrap.min.css new file mode 100644 index 0000000..d819e03 --- /dev/null +++ b/pytorrent/static/libs/pytorrent-themes/graphite/bootstrap.min.css @@ -0,0 +1,66 @@ +/* Neutral grey theme for users who want low saturation. */ +:root, [data-bs-theme="light"] { + color-scheme: light; + --bs-primary: #334155; + --bs-primary-rgb: 51,65,85; + --bs-link-color: #334155; + --bs-link-color-rgb: 51,65,85; + --bs-link-hover-color: #1f2937; + --bs-link-hover-color-rgb: 51,65,85; + --bs-body-bg: #f5f6f8; + --bs-body-bg-rgb: 245,246,248; + --bs-body-color: #18202f; + --bs-secondary-bg: #ffffff; + --bs-secondary-bg-rgb: 255,255,255; + --bs-tertiary-bg: #eceff3; + --bs-border-color: #d7dce3; + --bs-secondary-color: #667085; + --bs-primary-bg-subtle: #e4e8ef; + --bs-primary-text-emphasis: #1f2937; + --torrent-progress-complete: #16a34a; + --pytorrent-page-bg: linear-gradient(180deg, #fafafa 0%, #edf0f4 100%); + --pytorrent-shell-shadow: 0 16px 38px rgba(31, 41, 55, 0.13); +} + +[data-bs-theme="dark"] { + color-scheme: dark; + --bs-primary: #94a3b8; + --bs-primary-rgb: 148,163,184; + --bs-link-color: #94a3b8; + --bs-link-color-rgb: 148,163,184; + --bs-link-hover-color: #e2e8f0; + --bs-link-hover-color-rgb: 148,163,184; + --bs-body-bg: #07090d; + --bs-body-bg-rgb: 7,9,13; + --bs-body-color: #d7dde7; + --bs-secondary-bg: #11151c; + --bs-secondary-bg-rgb: 17,21,28; + --bs-tertiary-bg: #171c25; + --bs-border-color: #2b3442; + --bs-secondary-color: #99a3b3; + --bs-primary-bg-subtle: #1d2531; + --bs-primary-text-emphasis: #e2e8f0; + --torrent-progress-complete: #22c55e; + --pytorrent-page-bg: radial-gradient(circle at top left, rgba(148, 163, 184, 0.11), transparent 34%), #07090d; + --pytorrent-shell-shadow: 0 18px 55px rgba(0, 0, 0, 0.52); +} + +.btn-primary { + --bs-btn-bg: var(--bs-primary); + --bs-btn-border-color: var(--bs-primary); + --bs-btn-hover-bg: var(--bs-primary-text-emphasis); + --bs-btn-hover-border-color: var(--bs-primary-text-emphasis); +} +.btn-outline-primary { + --bs-btn-color: var(--bs-primary); + --bs-btn-border-color: rgba(var(--bs-primary-rgb), 0.72); + --bs-btn-hover-bg: var(--bs-primary); + --bs-btn-hover-border-color: var(--bs-primary); +} +.nav-pills { + --bs-nav-pills-link-active-bg: var(--bs-primary); +} +.progress, +.progress-stacked { + --bs-progress-bg: rgba(var(--bs-secondary-bg-rgb), 0.82); +} diff --git a/pytorrent/static/libs/pytorrent-themes/nord/bootstrap.min.css b/pytorrent/static/libs/pytorrent-themes/nord/bootstrap.min.css new file mode 100644 index 0000000..a87e14e --- /dev/null +++ b/pytorrent/static/libs/pytorrent-themes/nord/bootstrap.min.css @@ -0,0 +1,66 @@ +/* Cool blue-grey theme inspired by Nordic palettes. */ +:root, [data-bs-theme="light"] { + color-scheme: light; + --bs-primary: #4c6a92; + --bs-primary-rgb: 76,106,146; + --bs-link-color: #4c6a92; + --bs-link-color-rgb: 76,106,146; + --bs-link-hover-color: #365174; + --bs-link-hover-color-rgb: 76,106,146; + --bs-body-bg: #f4f7fb; + --bs-body-bg-rgb: 244,247,251; + --bs-body-color: #182334; + --bs-secondary-bg: #ffffff; + --bs-secondary-bg-rgb: 255,255,255; + --bs-tertiary-bg: #e7edf5; + --bs-border-color: #d0d9e8; + --bs-secondary-color: #607089; + --bs-primary-bg-subtle: #dfe8f4; + --bs-primary-text-emphasis: #365174; + --torrent-progress-complete: #3aa675; + --pytorrent-page-bg: linear-gradient(180deg, #fbfdff 0%, #e9eff7 100%); + --pytorrent-shell-shadow: 0 16px 38px rgba(47, 64, 87, 0.13); +} + +[data-bs-theme="dark"] { + color-scheme: dark; + --bs-primary: #88c0d0; + --bs-primary-rgb: 136,192,208; + --bs-link-color: #88c0d0; + --bs-link-color-rgb: 136,192,208; + --bs-link-hover-color: #b6e3ee; + --bs-link-hover-color-rgb: 136,192,208; + --bs-body-bg: #0b111a; + --bs-body-bg-rgb: 11,17,26; + --bs-body-color: #d8dee9; + --bs-secondary-bg: #111927; + --bs-secondary-bg-rgb: 17,25,39; + --bs-tertiary-bg: #172233; + --bs-border-color: #2e3a4d; + --bs-secondary-color: #9aa8bc; + --bs-primary-bg-subtle: #132b3a; + --bs-primary-text-emphasis: #b6e3ee; + --torrent-progress-complete: #a3be8c; + --pytorrent-page-bg: radial-gradient(circle at top left, rgba(136, 192, 208, 0.14), transparent 35%), #0b111a; + --pytorrent-shell-shadow: 0 18px 55px rgba(3, 8, 15, 0.55); +} + +.btn-primary { + --bs-btn-bg: var(--bs-primary); + --bs-btn-border-color: var(--bs-primary); + --bs-btn-hover-bg: var(--bs-primary-text-emphasis); + --bs-btn-hover-border-color: var(--bs-primary-text-emphasis); +} +.btn-outline-primary { + --bs-btn-color: var(--bs-primary); + --bs-btn-border-color: rgba(var(--bs-primary-rgb), 0.72); + --bs-btn-hover-bg: var(--bs-primary); + --bs-btn-hover-border-color: var(--bs-primary); +} +.nav-pills { + --bs-nav-pills-link-active-bg: var(--bs-primary); +} +.progress, +.progress-stacked { + --bs-progress-bg: rgba(var(--bs-secondary-bg-rgb), 0.82); +} diff --git a/pytorrent/static/libs/pytorrent-themes/ocean/bootstrap.min.css b/pytorrent/static/libs/pytorrent-themes/ocean/bootstrap.min.css new file mode 100644 index 0000000..f36ee52 --- /dev/null +++ b/pytorrent/static/libs/pytorrent-themes/ocean/bootstrap.min.css @@ -0,0 +1,66 @@ +/* Dark teal/navy with a bright, clean light mode. */ +:root, [data-bs-theme="light"] { + color-scheme: light; + --bs-primary: #0f766e; + --bs-primary-rgb: 15,118,110; + --bs-link-color: #0f766e; + --bs-link-color-rgb: 15,118,110; + --bs-link-hover-color: #095c55; + --bs-link-hover-color-rgb: 15,118,110; + --bs-body-bg: #f2fbfa; + --bs-body-bg-rgb: 242,251,250; + --bs-body-color: #102a2a; + --bs-secondary-bg: #ffffff; + --bs-secondary-bg-rgb: 255,255,255; + --bs-tertiary-bg: #dff5f2; + --bs-border-color: #bfe4df; + --bs-secondary-color: #54706f; + --bs-primary-bg-subtle: #d6f4ef; + --bs-primary-text-emphasis: #095c55; + --torrent-progress-complete: #14b8a6; + --pytorrent-page-bg: linear-gradient(180deg, #f7fffe 0%, #e6f7f5 100%); + --pytorrent-shell-shadow: 0 16px 38px rgba(10, 78, 74, 0.13); +} + +[data-bs-theme="dark"] { + color-scheme: dark; + --bs-primary: #2dd4bf; + --bs-primary-rgb: 45,212,191; + --bs-link-color: #2dd4bf; + --bs-link-color-rgb: 45,212,191; + --bs-link-hover-color: #99f6e4; + --bs-link-hover-color-rgb: 45,212,191; + --bs-body-bg: #031316; + --bs-body-bg-rgb: 3,19,22; + --bs-body-color: #d5f4f1; + --bs-secondary-bg: #082326; + --bs-secondary-bg-rgb: 8,35,38; + --bs-tertiary-bg: #0d3034; + --bs-border-color: #17494e; + --bs-secondary-color: #8ab6b2; + --bs-primary-bg-subtle: #063a3c; + --bs-primary-text-emphasis: #99f6e4; + --torrent-progress-complete: #34d399; + --pytorrent-page-bg: radial-gradient(circle at top left, rgba(45, 212, 191, 0.16), transparent 36%), #031316; + --pytorrent-shell-shadow: 0 18px 55px rgba(0, 20, 24, 0.58); +} + +.btn-primary { + --bs-btn-bg: var(--bs-primary); + --bs-btn-border-color: var(--bs-primary); + --bs-btn-hover-bg: var(--bs-primary-text-emphasis); + --bs-btn-hover-border-color: var(--bs-primary-text-emphasis); +} +.btn-outline-primary { + --bs-btn-color: var(--bs-primary); + --bs-btn-border-color: rgba(var(--bs-primary-rgb), 0.72); + --bs-btn-hover-bg: var(--bs-primary); + --bs-btn-hover-border-color: var(--bs-primary); +} +.nav-pills { + --bs-nav-pills-link-active-bg: var(--bs-primary); +} +.progress, +.progress-stacked { + --bs-progress-bg: rgba(var(--bs-secondary-bg-rgb), 0.82); +} diff --git a/pytorrent/static/libs/pytorrent-themes/sky/bootstrap.min.css b/pytorrent/static/libs/pytorrent-themes/sky/bootstrap.min.css new file mode 100644 index 0000000..4f5a55b --- /dev/null +++ b/pytorrent/static/libs/pytorrent-themes/sky/bootstrap.min.css @@ -0,0 +1,66 @@ +/* Fresh lighter blue theme with a readable dark variant. */ +:root, [data-bs-theme="light"] { + color-scheme: light; + --bs-primary: #0284c7; + --bs-primary-rgb: 2,132,199; + --bs-link-color: #0284c7; + --bs-link-color-rgb: 2,132,199; + --bs-link-hover-color: #0369a1; + --bs-link-hover-color-rgb: 2,132,199; + --bs-body-bg: #f2f9ff; + --bs-body-bg-rgb: 242,249,255; + --bs-body-color: #102033; + --bs-secondary-bg: #ffffff; + --bs-secondary-bg-rgb: 255,255,255; + --bs-tertiary-bg: #dff1ff; + --bs-border-color: #bddff5; + --bs-secondary-color: #567185; + --bs-primary-bg-subtle: #d7efff; + --bs-primary-text-emphasis: #0369a1; + --torrent-progress-complete: #10b981; + --pytorrent-page-bg: linear-gradient(180deg, #f8fcff 0%, #e5f4ff 100%); + --pytorrent-shell-shadow: 0 16px 38px rgba(3, 105, 161, 0.13); +} + +[data-bs-theme="dark"] { + color-scheme: dark; + --bs-primary: #38bdf8; + --bs-primary-rgb: 56,189,248; + --bs-link-color: #38bdf8; + --bs-link-color-rgb: 56,189,248; + --bs-link-hover-color: #bae6fd; + --bs-link-hover-color-rgb: 56,189,248; + --bs-body-bg: #06111a; + --bs-body-bg-rgb: 6,17,26; + --bs-body-color: #d9ecf8; + --bs-secondary-bg: #0d1c29; + --bs-secondary-bg-rgb: 13,28,41; + --bs-tertiary-bg: #12283a; + --bs-border-color: #21435b; + --bs-secondary-color: #91b4ca; + --bs-primary-bg-subtle: #0b334d; + --bs-primary-text-emphasis: #bae6fd; + --torrent-progress-complete: #34d399; + --pytorrent-page-bg: radial-gradient(circle at top left, rgba(56, 189, 248, 0.15), transparent 36%), #06111a; + --pytorrent-shell-shadow: 0 18px 55px rgba(0, 13, 24, 0.58); +} + +.btn-primary { + --bs-btn-bg: var(--bs-primary); + --bs-btn-border-color: var(--bs-primary); + --bs-btn-hover-bg: var(--bs-primary-text-emphasis); + --bs-btn-hover-border-color: var(--bs-primary-text-emphasis); +} +.btn-outline-primary { + --bs-btn-color: var(--bs-primary); + --bs-btn-border-color: rgba(var(--bs-primary-rgb), 0.72); + --bs-btn-hover-bg: var(--bs-primary); + --bs-btn-hover-border-color: var(--bs-primary); +} +.nav-pills { + --bs-nav-pills-link-active-bg: var(--bs-primary); +} +.progress, +.progress-stacked { + --bs-progress-bg: rgba(var(--bs-secondary-bg-rgb), 0.82); +} diff --git a/pytorrent/static/styles.css b/pytorrent/static/styles.css index b9dc43a..9f974f1 100644 --- a/pytorrent/static/styles.css +++ b/pytorrent/static/styles.css @@ -7,6 +7,8 @@ --mobile-filterbar-height: 132px; --sidebar: calc(270px * var(--ui-scale)); --torrent-progress-complete: #198754; + --pytorrent-page-bg: var(--bs-body-bg); + --pytorrent-shell-shadow: 0 12px 45px rgba(0, 0, 0, 0.38); } [data-bs-theme="dark"] { --bs-body-bg: #05070a; @@ -20,6 +22,7 @@ --bs-primary-bg-subtle: #0d2238; --bs-primary-text-emphasis: #9ecbff; --torrent-progress-complete: #2f9e75; + --pytorrent-page-bg: var(--bs-body-bg); } html[data-app-font="adwaita-mono"] { @@ -109,7 +112,7 @@ body { min-height: 100vh; min-height: 100dvh; padding: calc(8px * var(--ui-scale)); - background: #05070a; + background: var(--pytorrent-page-bg, var(--bs-body-bg)); font-family: var(--app-font-family); } .app-shell { @@ -121,7 +124,7 @@ body { border: 1px solid var(--bs-border-color); border-radius: 12px; overflow: hidden; - box-shadow: 0 12px 45px rgba(0, 0, 0, 0.38); + box-shadow: var(--pytorrent-shell-shadow, 0 12px 45px rgba(0, 0, 0, 0.38)); } .topbar { display: flex; @@ -1524,6 +1527,10 @@ body.mobile-mode .mobile-card { padding-bottom: 0.1rem; padding-top: 0.1rem; } +.file-priority-table .file-media-info-blocked { + cursor: not-allowed; + opacity: 0.55; +} .file-priority-table .file-check, .file-priority-table #fileSelectAll { display: block; diff --git a/pytorrent/templates/index.html b/pytorrent/templates/index.html index 74cbd08..d3bbe07 100644 --- a/pytorrent/templates/index.html +++ b/pytorrent/templates/index.html @@ -6,6 +6,7 @@ pyTorrent + diff --git a/scripts/download_frontend_libs.py b/scripts/download_frontend_libs.py index 892ec1e..f61c0a2 100755 --- a/scripts/download_frontend_libs.py +++ b/scripts/download_frontend_libs.py @@ -41,18 +41,71 @@ def google_fonts_css_url() -> str: return f"https://fonts.googleapis.com/css2?{families}&display=swap" -BOOTSTRAP_THEMES = ( - "default", - "flatly", - "litera", - "lumen", - "minty", - "sketchy", - "solar", - "spacelab", - "united", - "zephyr", -) +DEVEXPRESS_BOOTSTRAP_THEMES = { + "blazing-berry": "Blazing Berry", + "office-white": "Office White", + "purple": "Purple", +} + +PYTORRENT_APP_THEMES = { + "adaptive": "pyTorrent Adaptive", + "ocean": "pyTorrent Ocean", + "graphite": "pyTorrent Graphite", + "forest": "pyTorrent Forest", + "amber": "pyTorrent Amber", + "nord": "pyTorrent Nord", + "crimson": "pyTorrent Crimson", + "sky": "pyTorrent Sky", +} + + +BOOTSTRAP_THEME_DEFINITIONS = { + "default": { + "label": "Default Bootstrap", + "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", + }, + # Bootswatch themes. + "flatly": {"label": "Bootswatch: Flatly", "provider": "bootswatch"}, + "litera": {"label": "Bootswatch: Litera", "provider": "bootswatch"}, + "lumen": {"label": "Bootswatch: Lumen", "provider": "bootswatch"}, + "minty": {"label": "Bootswatch: Minty", "provider": "bootswatch"}, + "sketchy": {"label": "Bootswatch: Sketchy", "provider": "bootswatch"}, + "spacelab": {"label": "Bootswatch: Spacelab", "provider": "bootswatch"}, + "united": {"label": "Bootswatch: United", "provider": "bootswatch"}, + "zephyr": {"label": "Bootswatch: Zephyr", "provider": "bootswatch"}, + # Complete DevExpress Bootstrap v5 dist.v5 set. + **{ + f"dx-{theme}": { + "label": f"DevExpress: {label}", + "provider": "devexpress", + "local": f"{LIBS_STATIC_DIR}/devexpress-bootstrap-themes/dist.v5/{theme}/bootstrap.min.css", + "cdn": f"https://cdn.jsdelivr.net/gh/DevExpress/bootstrap-themes@master/dist.v5/{theme}/bootstrap.min.css", + } + for theme, label in DEVEXPRESS_BOOTSTRAP_THEMES.items() + }, + # App-specific Bootstrap variable overrides. These sit on top of default Bootstrap. + **{ + f"pytorrent-{theme}": { + "label": f"Custom: {label}", + "provider": "pytorrent", + "local": f"{LIBS_STATIC_DIR}/pytorrent-themes/{theme}/bootstrap.min.css", + "cdn": f"/static/{LIBS_STATIC_DIR}/pytorrent-themes/{theme}/bootstrap.min.css", + } + for theme, label in PYTORRENT_APP_THEMES.items() + }, +} + +def _theme_definition(theme: str | None) -> dict[str, str]: + theme = theme if theme in BOOTSTRAP_THEME_DEFINITIONS else "default" + item = dict(BOOTSTRAP_THEME_DEFINITIONS[theme]) + if item.get("provider") == "bootswatch": + item["local"] = f"{LIBS_STATIC_DIR}/bootswatch/{BOOTSWATCH_VERSION}/{theme}/bootstrap.min.css" + item["cdn"] = f"https://cdn.jsdelivr.net/npm/bootswatch@{BOOTSWATCH_VERSION}/dist/{theme}/bootstrap.min.css" + return item + + +BOOTSTRAP_THEMES = tuple(BOOTSTRAP_THEME_DEFINITIONS.keys()) STATIC_ASSETS = { "bootstrap_js": { "local": f"{LIBS_STATIC_DIR}/bootstrap/{BOOTSTRAP_VERSION}/js/bootstrap.bundle.min.js", @@ -88,15 +141,8 @@ ANY_URL_RE = re.compile(r"url\((['\"]?)(?!data:)([^)'\"]+)\1\)") def bootstrap_css_asset(theme: str) -> dict[str, str]: - 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", - } + item = _theme_definition(theme) + return {"local": item["local"], "cdn": item["cdn"]} def download(url: str, dest: Path) -> None: @@ -169,7 +215,11 @@ def main() -> None: for item in items: url = item["cdn"] dest = ROOT / "pytorrent" / "static" / item["local"] - if item.get("local") == STATIC_ASSETS["font_css"]["local"]: + if url.startswith("/static/"): + if not dest.is_file() or dest.stat().st_size <= 0: + raise RuntimeError(f"Bundled app theme is missing: {dest.relative_to(ROOT)}") + print(f"OK {dest.relative_to(ROOT)}") + elif item.get("local") == STATIC_ASSETS["font_css"]["local"]: download_google_fonts_css(url, dest) elif dest.suffix == ".css": download_css_with_assets(url, dest)