diff --git a/pytorrent/openapi/openapi.json b/pytorrent/openapi/openapi.json
index b6c1b9c..5ef9771 100644
--- a/pytorrent/openapi/openapi.json
+++ b/pytorrent/openapi/openapi.json
@@ -955,8 +955,7 @@
"properties": {
"dirs": {
"items": {
- "additionalProperties": true,
- "type": "object"
+ "$ref": "#/components/schemas/PathDirectoryEntry"
},
"type": "array"
},
@@ -2155,6 +2154,72 @@
"url",
"expires_in"
]
+ },
+ "PathDirectoryEntry": {
+ "type": "object",
+ "properties": {
+ "name": {
+ "type": "string"
+ },
+ "path": {
+ "type": "string"
+ },
+ "empty": {
+ "type": "boolean"
+ },
+ "has_torrents": {
+ "type": "boolean"
+ },
+ "can_rename": {
+ "type": "boolean"
+ }
+ },
+ "additionalProperties": true
+ },
+ "PathDirectoryCreateRequest": {
+ "type": "object",
+ "required": [
+ "parent",
+ "name"
+ ],
+ "properties": {
+ "parent": {
+ "type": "string"
+ },
+ "name": {
+ "type": "string"
+ }
+ }
+ },
+ "PathDirectoryRenameRequest": {
+ "type": "object",
+ "required": [
+ "path",
+ "new_name"
+ ],
+ "properties": {
+ "path": {
+ "type": "string"
+ },
+ "new_name": {
+ "type": "string"
+ }
+ }
+ },
+ "PathDirectoryMutationResponse": {
+ "allOf": [
+ {
+ "$ref": "#/components/schemas/ApiOk"
+ },
+ {
+ "type": "object",
+ "properties": {
+ "directory": {
+ "$ref": "#/components/schemas/PathDirectoryEntry"
+ }
+ }
+ }
+ ]
}
},
"securitySchemes": {
@@ -7948,6 +8013,92 @@
}
}
}
+ },
+ "/api/path/directories": {
+ "post": {
+ "summary": "Create an empty directory",
+ "description": "Creates a directory on the active rTorrent host for inline path-picker use. Existing torrent state is not changed.",
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PathDirectoryCreateRequest"
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "Created",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PathDirectoryMutationResponse"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ApiError"
+ }
+ }
+ }
+ }
+ },
+ "security": [
+ {
+ "sessionCookie": []
+ }
+ ]
+ }
+ },
+ "/api/path/directories/rename": {
+ "post": {
+ "summary": "Rename an empty directory",
+ "description": "Renames a directory only when it is empty and does not contain a cached torrent path.",
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PathDirectoryRenameRequest"
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "Renamed",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PathDirectoryMutationResponse"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ApiError"
+ }
+ }
+ }
+ }
+ },
+ "security": [
+ {
+ "sessionCookie": []
+ }
+ ]
+ }
}
}
}
diff --git a/pytorrent/routes/system.py b/pytorrent/routes/system.py
index ffc99c8..fbdd63e 100644
--- a/pytorrent/routes/system.py
+++ b/pytorrent/routes/system.py
@@ -1,6 +1,7 @@
from __future__ import annotations
from ._shared import *
+import posixpath
from ..services import operation_logs
from ..services.frontend_assets import static_hash
@@ -337,6 +338,29 @@ def jobs_retry(job_id: str):
+def _remote_path_contains(base: str, candidate: str) -> bool:
+ base = posixpath.normpath(str(base or "").rstrip("/") or "/")
+ candidate = posixpath.normpath(str(candidate or "").rstrip("/") or "/")
+ return candidate == base or candidate.startswith(base.rstrip("/") + "/")
+
+
+def _path_has_cached_torrents(profile_id: int, path: str) -> bool:
+ # Note: The cache check prevents renaming folders that are currently known as torrent locations.
+ if not str(path or "").strip():
+ return False
+ return any(_remote_path_contains(path, item.get("path") or "") for item in torrent_cache.snapshot(profile_id))
+
+
+def _annotate_path_directories(profile: dict, payload: dict) -> dict:
+ dirs = payload.get("dirs") or []
+ for item in dirs:
+ item_path = item.get("path") or ""
+ has_torrents = _path_has_cached_torrents(int(profile.get("id") or 0), item_path)
+ item["has_torrents"] = has_torrents
+ item["can_rename"] = bool(item.get("empty")) and not has_torrents
+ return payload
+
+
@bp.get("/path/default")
def path_default():
profile = preferences.active_profile()
@@ -356,7 +380,40 @@ def path_browse():
return jsonify({"ok": False, "error": "No profile"}), 400
base = request.args.get("path") or ""
try:
- return ok(rtorrent.browse_path(profile, base))
+ return ok(_annotate_path_directories(profile, rtorrent.browse_path(profile, base)))
+ except Exception as exc:
+ return jsonify({"ok": False, "error": str(exc)}), 400
+
+
+@bp.post("/path/directories")
+def path_directory_create():
+ profile = preferences.active_profile()
+ if not profile:
+ return jsonify({"ok": False, "error": "No profile"}), 400
+ require_profile_write(profile.get("id"))
+ data = request.get_json(silent=True) or {}
+ try:
+ # Note: This endpoint only creates an empty directory and does not alter any torrent state.
+ result = rtorrent.create_directory(profile, data.get("parent") or "", data.get("name") or "")
+ return ok({"directory": result})
+ except Exception as exc:
+ return jsonify({"ok": False, "error": str(exc)}), 400
+
+
+@bp.post("/path/directories/rename")
+def path_directory_rename():
+ profile = preferences.active_profile()
+ if not profile:
+ return jsonify({"ok": False, "error": "No profile"}), 400
+ require_profile_write(profile.get("id"))
+ data = request.get_json(silent=True) or {}
+ path = str(data.get("path") or "").strip()
+ if _path_has_cached_torrents(int(profile.get("id") or 0), path):
+ return jsonify({"ok": False, "error": "Directory contains a known torrent path"}), 400
+ try:
+ # Note: The service also verifies that the remote directory is empty before renaming.
+ result = rtorrent.rename_empty_directory(profile, path, data.get("new_name") or "")
+ return ok({"directory": result})
except Exception as exc:
return jsonify({"ok": False, "error": str(exc)}), 400
diff --git a/pytorrent/services/rtorrent/system.py b/pytorrent/services/rtorrent/system.py
index 9a04e0b..4da5c48 100644
--- a/pytorrent/services/rtorrent/system.py
+++ b/pytorrent/services/rtorrent/system.py
@@ -78,6 +78,60 @@ def browse_path(profile: dict, path: str | None = None) -> dict:
"used_percent": disk_percent,
}
+
+
+def _safe_directory_name(name: str) -> str:
+ value = str(name or "").strip()
+ if not value or value in {".", ".."} or "/" in value or "\x00" in value:
+ raise ValueError("Invalid directory name")
+ return value
+
+
+def create_directory(profile: dict, parent: str, name: str) -> dict:
+ """Create a remote directory without changing existing path-picker behavior."""
+ # Note: Directory creation is remote-side, so Add/Move sees the same filesystem as rTorrent.
+ c = client_for(profile)
+ clean_parent = _remote_clean_path(parent or default_download_path(profile))
+ clean_name = _safe_directory_name(name)
+ target = _remote_join(clean_parent, clean_name)
+ script = (
+ 'parent=$1; target=$2; '
+ 'if [ ! -d "$parent" ]; then printf "ERR\tParent directory does not exist"; exit 0; fi; '
+ 'if [ -e "$target" ] || [ -L "$target" ]; then printf "ERR\tDirectory already exists"; exit 0; fi; '
+ 'mkdir -- "$target" 2>/dev/null || { printf "ERR\tCannot create directory"; exit 0; }; '
+ 'printf "OK\t%s" "$target"'
+ )
+ output = str(_rt_execute(c, "execute.capture", "sh", "-c", script, "pytorrent-mkdir", clean_parent, target) or "").strip()
+ if not output.startswith("OK\t"):
+ raise RuntimeError(output.split("\t", 1)[1] if "\t" in output else "Cannot create directory")
+ return {"path": output.split("\t", 1)[1], "name": clean_name}
+
+
+def rename_empty_directory(profile: dict, path: str, new_name: str) -> dict:
+ """Rename an empty remote directory in place."""
+ # Note: Rename is intentionally limited to empty folders to avoid invalidating active torrent paths.
+ c = client_for(profile)
+ source = _remote_clean_path(path or "")
+ clean_name = _safe_directory_name(new_name)
+ if not source or source == "/":
+ raise ValueError("Cannot rename this directory")
+ parent = posixpath.dirname(source.rstrip("/")) or "/"
+ target = _remote_join(parent, clean_name)
+ if source == target:
+ return {"path": target, "name": clean_name, "parent": parent}
+ script = (
+ 'src=$1; dst=$2; '
+ 'if [ ! -d "$src" ]; then printf "ERR\tDirectory does not exist"; exit 0; fi; '
+ 'if [ -e "$dst" ] || [ -L "$dst" ]; then printf "ERR\tTarget directory already exists"; exit 0; fi; '
+ 'if [ -n "$(find "$src" -mindepth 1 -maxdepth 1 -print -quit 2>/dev/null)" ]; then printf "ERR\tOnly empty directories can be renamed"; exit 0; fi; '
+ 'mv -- "$src" "$dst" 2>/dev/null || { printf "ERR\tCannot rename directory"; exit 0; }; '
+ 'printf "OK\t%s" "$dst"'
+ )
+ output = str(_rt_execute(c, "execute.capture", "sh", "-c", script, "pytorrent-rename-dir", source, target) or "").strip()
+ if not output.startswith("OK\t"):
+ raise RuntimeError(output.split("\t", 1)[1] if "\t" in output else "Cannot rename directory")
+ return {"path": output.split("\t", 1)[1], "name": clean_name, "parent": parent}
+
def remote_public_ip(profile: dict, force: bool = False) -> str:
profile_id = int(profile.get("id") or 0)
now = time.monotonic()
diff --git a/pytorrent/static/js/pathPickerTools.js b/pytorrent/static/js/pathPickerTools.js
index 9136e47..9cb2fe4 100644
--- a/pytorrent/static/js/pathPickerTools.js
+++ b/pytorrent/static/js/pathPickerTools.js
@@ -1 +1 @@
-export const pathPickerToolsSource = " function copyText(text){\n text=String(text ?? '');\n if(navigator.clipboard && window.isSecureContext){\n return navigator.clipboard.writeText(text);\n }\n return new Promise((resolve,reject)=>{\n const ta=document.createElement('textarea');\n ta.value=text; ta.setAttribute('readonly','');\n ta.style.position='fixed'; ta.style.left='-9999px'; ta.style.top='0';\n document.body.appendChild(ta); ta.focus(); ta.select();\n try{ document.execCommand('copy') ? resolve() : reject(new Error('copy command failed')); }\n catch(e){ reject(e); }\n finally{ ta.remove(); }\n });\n }\n function copySelected(field){\n const t=torrents.get(selectedHash);\n if(!t) return toast('No torrent selected','warning');\n const value=String(t[field] ?? '');\n if(!value) return toast(`No ${field} to copy`,'warning');\n copyText(value).then(()=>toast(`Copied ${field}`,'success')).catch(()=>toast('Copy failed','danger'));\n }\n\n async function getDefaultDownloadPath(){ if(defaultDownloadPath) return defaultDownloadPath; try{ const j=await (await fetch('/api/path/default')).json(); if(j.ok && j.path) defaultDownloadPath=j.path; }catch(e){} return defaultDownloadPath || '/'; }\n async function applyDefaultDownloadPath(force=false){ const p=await getDefaultDownloadPath(); ['addPath','rssPath','autoEffectPath'].forEach(id=>{ const el=$(id); if(el && (force || !el.value)) el.value=p; }); return p; }\n async function openPathPicker(target){\n pathTarget=target;\n const modal=$('pathModal');\n if(!modal) return toastMessage('toast.pathPickerUnavailable','danger');\n const def=await getDefaultDownloadPath();\n const initial=def || ($(target)?.value||'/');\n // Note: The same modal is used for Move and simple path selection; only Move shows extra options.\n $('moveOptions')?.classList.toggle('d-none', target!=='move');\n if($('moveDataPhysical')) $('moveDataPhysical').checked=true;\n if($('moveRecheck')) $('moveRecheck').checked=true;\n // Note: The path picker can be opened from Add/Create modals, so it must sit above the parent modal.\n modal.classList.toggle('path-picker-stacked', document.querySelectorAll('.modal.show').length > 0);\n new bootstrap.Modal(modal).show();\n browsePath(initial);\n }\n function pathInfoHtml(j){\n // Note: Move modal shows remote-side capacity and entry counts before queuing a move.\n const meta=[];\n if(j.free_h) meta.push(` Free ${esc(j.free_h)}`);\n if(j.used_percent!==undefined) meta.push(`${esc(j.used_percent)}% used`);\n if(j.dir_count!==undefined) meta.push(`${esc(j.dir_count)} dirs`);\n if(j.file_count!==undefined) meta.push(`${esc(j.file_count)} files`);\n return meta.length ? `
${meta.join('')}
` : '';\n }\n async function browsePath(path){\n const list=$('pathList');\n const current=$('pathCurrent');\n if(!list || !current) return;\n list.innerHTML=' Loading...';\n try{\n const res=await fetch(`/api/path/browse?path=${encodeURIComponent(path||'/')}`);\n const j=await res.json();\n if(!j.ok) throw new Error(j.error);\n current.value=j.path;\n lastPathParent=j.parent;\n const rows=j.dirs.map(d=>`${esc(d.name)}
`).join('')||'No directories.
';\n list.innerHTML=pathInfoHtml(j)+rows;\n }catch(e){\n list.innerHTML=`${esc(e.message)}
`;\n }\n }\n $('pathList')?.addEventListener('click',e=>{const r=e.target.closest('.path-row'); if(r) browsePath(r.dataset.path);});\n $('pathGoBtn')?.addEventListener('click',()=>browsePath($('pathCurrent')?.value));\n $('pathUpBtn')?.addEventListener('click',()=>browsePath(lastPathParent));\n $('pathReloadBtn')?.addEventListener('click',()=>browsePath($('pathCurrent')?.value));\n $('pathSelectBtn')?.addEventListener('click',async()=>{\n const p=($('pathCurrent')?.value||'').trim();\n if(!p) return toastMessage('toast.pathEmpty','warning');\n if(pathTarget==='move'){\n const hashes=selectedHashes();\n if(!hashes.length) return toastMessage('toast.noTorrentsSelected','warning');\n const j=await post('/api/torrents/move',{hashes,path:p,move_data:!!($('moveDataPhysical')?.checked),recheck:!!($('moveRecheck')?.checked)});\n markQueuedJobs(j,hashes,'move');\n const parts=Number(j.bulk_parts||1);\n toastMessage('toast.moveQueued','success',{parts,physical:$('moveDataPhysical')?.checked});\n } else if($(pathTarget)) {\n $(pathTarget).value=p;\n }\n bootstrap.Modal.getInstance($('pathModal'))?.hide();\n });\n document.querySelectorAll('.browse-path').forEach(b=>b.addEventListener('click',()=>openPathPicker(b.dataset.target)));\n\n";
+export const pathPickerToolsSource = " function copyText(text){\n text=String(text ?? '');\n if(navigator.clipboard && window.isSecureContext){\n return navigator.clipboard.writeText(text);\n }\n return new Promise((resolve,reject)=>{\n const ta=document.createElement('textarea');\n ta.value=text; ta.setAttribute('readonly','');\n ta.style.position='fixed'; ta.style.left='-9999px'; ta.style.top='0';\n document.body.appendChild(ta); ta.focus(); ta.select();\n try{ document.execCommand('copy') ? resolve() : reject(new Error('copy command failed')); }\n catch(e){ reject(e); }\n finally{ ta.remove(); }\n });\n }\n function copySelected(field){\n const t=torrents.get(selectedHash);\n if(!t) return toast('No torrent selected','warning');\n const value=String(t[field] ?? '');\n if(!value) return toast(`No ${field} to copy`,'warning');\n copyText(value).then(()=>toast(`Copied ${field}`,'success')).catch(()=>toast('Copy failed','danger'));\n }\n\n async function getDefaultDownloadPath(){ if(defaultDownloadPath) return defaultDownloadPath; try{ const j=await (await fetch('/api/path/default')).json(); if(j.ok && j.path) defaultDownloadPath=j.path; }catch(e){} return defaultDownloadPath || '/'; }\n async function applyDefaultDownloadPath(force=false){ const p=await getDefaultDownloadPath(); ['addPath','rssPath','autoEffectPath'].forEach(id=>{ const el=$(id); if(el && (force || !el.value)) el.value=p; }); return p; }\n async function openPathPicker(target){\n pathTarget=target;\n const modal=$('pathModal');\n if(!modal) return toastMessage('toast.pathPickerUnavailable','danger');\n const def=await getDefaultDownloadPath();\n const initial=def || ($(target)?.value||'/');\n // Note: The same modal is used for Move and simple path selection; only Move shows extra options.\n $('moveOptions')?.classList.toggle('d-none', target!=='move');\n if($('moveDataPhysical')) $('moveDataPhysical').checked=true;\n if($('moveRecheck')) $('moveRecheck').checked=true;\n resetInlineDirectoryCreate();\n // Note: The path picker can be opened from Add/Create modals, so it must sit above the parent modal.\n modal.classList.toggle('path-picker-stacked', document.querySelectorAll('.modal.show').length > 0);\n new bootstrap.Modal(modal).show();\n browsePath(initial);\n }\n function pathInfoHtml(j){\n // Note: Move modal shows remote-side capacity and entry counts before queuing a move.\n const meta=[];\n if(j.free_h) meta.push(` Free ${esc(j.free_h)}`);\n if(j.used_percent!==undefined) meta.push(`${esc(j.used_percent)}% used`);\n if(j.dir_count!==undefined) meta.push(`${esc(j.dir_count)} dirs`);\n if(j.file_count!==undefined) meta.push(`${esc(j.file_count)} files`);\n return meta.length ? `${meta.join('')}
` : '';\n }\n function resetInlineDirectoryCreate(){\n const input=$('pathCreateName');\n if(input) input.value='';\n }\n function pathDirectoryRow(d){\n const disabled=!d.can_rename;\n const reason=d.has_torrents?'Folder contains a known torrent path':(!d.empty?'Only empty folders can be renamed':'Rename folder');\n return ``;\n }\n async function browsePath(path){\n const list=$('pathList');\n const current=$('pathCurrent');\n if(!list || !current) return;\n list.innerHTML=' Loading...';\n try{\n const res=await fetch(`/api/path/browse?path=${encodeURIComponent(path||'/')}`);\n const j=await res.json();\n if(!j.ok) throw new Error(j.error);\n current.value=j.path;\n lastPathParent=j.parent;\n const rows=j.dirs.map(pathDirectoryRow).join('')||'No directories.
';\n list.innerHTML=pathInfoHtml(j)+rows;\n }catch(e){\n list.innerHTML=`${esc(e.message)}
`;\n }\n }\n function inlineDirectoryName(value){\n value=String(value||'').trim();\n if(!value || value==='.' || value==='..' || value.includes('/')) throw new Error('Enter a valid folder name');\n return value;\n }\n async function createInlineDirectory(){\n try{\n const parent=($('pathCurrent')?.value||'').trim();\n const name=inlineDirectoryName($('pathCreateName')?.value||'');\n // Note: Inline create avoids an extra modal and leaves the current Add/Move context untouched.\n await post('/api/path/directories',{parent,name});\n resetInlineDirectoryCreate();\n toast('Directory created','success');\n await browsePath(parent);\n }catch(e){ toast(e.message,'danger'); }\n }\n function startInlineRename(row){\n if(!row || row.dataset.canRename!=='1') return;\n const oldName=row.dataset.name||'';\n row.classList.add('is-renaming');\n row.innerHTML=``;\n const input=row.querySelector('.path-rename-input');\n input?.focus();\n input?.select();\n }\n async function submitInlineRename(row){\n try{\n const input=row?.querySelector('.path-rename-input');\n const newName=inlineDirectoryName(input?.value||'');\n const path=row?.dataset.path||'';\n if(newName===(row?.dataset.name||'')) return browsePath($('pathCurrent')?.value);\n // Note: Rename is limited by the backend to empty folders with no cached torrent path inside.\n await post('/api/path/directories/rename',{path,new_name:newName});\n toast('Directory renamed','success');\n await browsePath($('pathCurrent')?.value);\n }catch(e){ toast(e.message,'danger'); }\n }\n $('pathList')?.addEventListener('click',e=>{\n const rename=e.target.closest('.path-rename-btn');\n if(rename){ e.preventDefault(); e.stopPropagation(); startInlineRename(rename.closest('.path-row')); return; }\n const cancel=e.target.closest('.path-rename-cancel');\n if(cancel){ e.preventDefault(); browsePath($('pathCurrent')?.value); return; }\n const open=e.target.closest('.path-row-open');\n if(open){ const r=open.closest('.path-row'); if(r) browsePath(r.dataset.path); }\n });\n $('pathList')?.addEventListener('submit',e=>{ const form=e.target.closest('.path-rename-form'); if(form){ e.preventDefault(); submitInlineRename(form.closest('.path-row')); } });\n $('pathCreateBtn')?.addEventListener('click',createInlineDirectory);\n $('pathCreateName')?.addEventListener('keydown',e=>{ if(e.key==='Enter'){ e.preventDefault(); createInlineDirectory(); } });\n $('pathGoBtn')?.addEventListener('click',()=>browsePath($('pathCurrent')?.value));\n $('pathUpBtn')?.addEventListener('click',()=>browsePath(lastPathParent));\n $('pathReloadBtn')?.addEventListener('click',()=>browsePath($('pathCurrent')?.value));\n $('pathSelectBtn')?.addEventListener('click',async()=>{\n const p=($('pathCurrent')?.value||'').trim();\n if(!p) return toastMessage('toast.pathEmpty','warning');\n if(pathTarget==='move'){\n const hashes=selectedHashes();\n if(!hashes.length) return toastMessage('toast.noTorrentsSelected','warning');\n const j=await post('/api/torrents/move',{hashes,path:p,move_data:!!($('moveDataPhysical')?.checked),recheck:!!($('moveRecheck')?.checked)});\n markQueuedJobs(j,hashes,'move');\n const parts=Number(j.bulk_parts||1);\n toastMessage('toast.moveQueued','success',{parts,physical:$('moveDataPhysical')?.checked});\n } else if($(pathTarget)) {\n $(pathTarget).value=p;\n }\n bootstrap.Modal.getInstance($('pathModal'))?.hide();\n });\n document.querySelectorAll('.browse-path').forEach(b=>b.addEventListener('click',()=>openPathPicker(b.dataset.target)));\n";
diff --git a/pytorrent/static/js/torrentRowRenderer.js b/pytorrent/static/js/torrentRowRenderer.js
index a2ccf31..2a0bd72 100644
--- a/pytorrent/static/js/torrentRowRenderer.js
+++ b/pytorrent/static/js/torrentRowRenderer.js
@@ -1 +1 @@
-export const torrentRowRendererSource = " function statusMeta(t){\n const op=activeOperationFor(t);\n if(op) return {cls:'text-bg-info operation-status-badge', icon:actionIcon(op.action), color:'text-info', label:op.label};\n const status=String(t.status||'').toLowerCase();\n if(t.paused || status==='paused') return {cls:'text-bg-warning', icon:'fa-pause', color:'text-warning'};\n if(status==='checking' || Number(t.hashing||0)>0) return {cls:'text-bg-info', icon:'fa-rotate', color:'text-info'};\n if(status==='post-check' || t.post_check) return {cls:'text-bg-dark', icon:'fa-clipboard-check', color:'text-secondary', label:'Post-check'};\n if(status==='seeding') return {cls:'text-bg-success', icon:'fa-seedling', color:'text-success'};\n if(status==='downloading') return {cls:'text-bg-primary', icon:'fa-download', color:'text-primary'};\n if(status==='stopped') return {cls:'text-bg-secondary', icon:'fa-stop', color:'text-secondary'};\n return t.state ? {cls:'text-bg-success', icon:'fa-play', color:'text-success'} : {cls:'text-bg-secondary', icon:'fa-circle', color:'text-secondary'};\n }\n function statusBadge(t){ const m=statusMeta(t); return `${esc(m.label || t.status)}`; }\n function torrentErrorLog(t){\n // Note: The name-column status icon is useful only when the torrent has an error log to show in the tooltip.\n const status=String(t.status||'').trim().toLowerCase();\n const msg=String(t.message||'').trim();\n if(status==='error') return msg || 'Torrent reported an error.';\n if(!msg) return null;\n const patterns=['error','failed','failure','timeout','timed out','tracker','could not','cannot','refused','unreachable','denied'];\n return patterns.some(p=>msg.toLowerCase().includes(p)) ? msg : null;\n }\n function torrentNameStatusIcon(t){\n // Note: Non-error torrents keep the name cell clean; the Status column still shows their normal state.\n const errorLog=torrentErrorLog(t);\n return errorLog ? ` ` : '';\n }\n function boolCell(value){ return Number(value||0) ? 'yes' : 'no'; }\n function renderRow(t){\n const labels=labelNames(t.label).map(l=>` ${esc(l)}`).join(' ');\n const errorLog=torrentErrorLog(t);\n const op=activeOperationFor(t);\n const classes=[selected.has(t.hash)?'selected':'', t.paused?'torrent-paused':'', op?'torrent-operating':'', errorLog?'torrent-warning':''].filter(Boolean).join(' ');\n const title=[t.name,errorLog,op?op.label:''].filter(Boolean).join('\\n');\n return ``+\n ` | `+\n `${torrentNameStatusIcon(t)}${esc(t.name)} | `+\n `${statusBadge(t)} | `+\n `${esc(t.size_h)} | `+\n `${progress(t)} | `+\n `${esc(t.down_rate_h)} | `+\n `${esc(t.up_rate_h)} | `+\n `${esc(t.eta_h||\"-\")} | `+\n `${esc(t.seeds)} | `+\n `${esc(t.peers)} | `+\n `${esc(t.ratio)} | `+\n `${esc(t.path)} | `+\n `${labels||'-'} | `+\n `${esc(t.ratio_group||'')} | `+\n `${esc(t.down_total_h||'-')} | `+\n `${esc(t.to_download_h||'-')} | `+\n `${esc(t.up_total_h||'-')} | `+\n `${esc(formatDateTime(t.created))} | `+\n `${esc(formatDateTime(t.last_activity))} | `+\n `${esc(t.priority ?? '-')} | `+\n `${boolCell(t.state)} | `+\n `${boolCell(t.active)} | `+\n `${boolCell(t.complete)} | `+\n `${esc(t.hashing ?? 0)} | `+\n `${compactCell(t.message||'', 80)} | `+\n `${esc(t.hash||'')} | `+\n `
`;\n }\n\n\n\n\n";
+export const torrentRowRendererSource = " function statusMeta(t){\n const op=activeOperationFor(t);\n if(op) return {cls:'text-bg-info operation-status-badge', icon:actionIcon(op.action), color:'text-info', label:op.label};\n const status=String(t.status||'').toLowerCase();\n if(t.paused || status==='paused') return {cls:'text-bg-warning', icon:'fa-pause', color:'text-warning'};\n if(status==='checking' || Number(t.hashing||0)>0) return {cls:'text-bg-info', icon:'fa-rotate', color:'text-info'};\n if(status==='post-check' || t.post_check) return {cls:'text-bg-dark', icon:'fa-clipboard-check', color:'text-secondary', label:'Post-check'};\n if(status==='seeding') return {cls:'text-bg-success', icon:'fa-seedling', color:'text-success'};\n if(status==='downloading') return {cls:'text-bg-primary', icon:'fa-download', color:'text-primary'};\n if(status==='stopped') return {cls:'text-bg-secondary', icon:'fa-stop', color:'text-secondary'};\n return t.state ? {cls:'text-bg-success', icon:'fa-play', color:'text-success'} : {cls:'text-bg-secondary', icon:'fa-circle', color:'text-secondary'};\n }\n function statusBadge(t){ const m=statusMeta(t); return `${esc(m.label || t.status)}`; }\n function torrentErrorLog(t){\n // Note: The name-column status icon is useful only when the torrent has an error log to show in the tooltip.\n const status=String(t.status||'').trim().toLowerCase();\n const msg=String(t.message||'').trim();\n if(status==='error') return msg || 'Torrent reported an error.';\n if(!msg) return null;\n const patterns=['error','failed','failure','timeout','timed out','tracker','could not','cannot','refused','unreachable','denied'];\n return patterns.some(p=>msg.toLowerCase().includes(p)) ? msg : null;\n }\n function torrentNameStatusIcon(t){\n // Note: Non-error torrents keep the name cell clean; the Status column still shows their normal state.\n const errorLog=torrentErrorLog(t);\n return errorLog ? ` ` : '';\n }\n function boolCell(value){ return Number(value||0) ? 'yes' : 'no'; }\n function renderRow(t, rowOptions={}){\n const labels=labelNames(t.label).map(l=>` ${esc(l)}`).join(' ');\n const errorLog=torrentErrorLog(t);\n const op=activeOperationFor(t);\n const rowStyle=rowOptions.style ? ` style=\"${esc(rowOptions.style)}\"` : '';\n const extraClass=rowOptions.className ? String(rowOptions.className) : '';\n const classes=[selected.has(t.hash)?'selected':'', t.paused?'torrent-paused':'', op?'torrent-operating':'', errorLog?'torrent-warning':'', extraClass].filter(Boolean).join(' ');\n const title=[t.name,errorLog,op?op.label:''].filter(Boolean).join('\\n');\n return ``+\n ` | `+\n `${torrentNameStatusIcon(t)}${esc(t.name)} | `+\n `${statusBadge(t)} | `+\n `${esc(t.size_h)} | `+\n `${progress(t)} | `+\n `${esc(t.down_rate_h)} | `+\n `${esc(t.up_rate_h)} | `+\n `${esc(t.eta_h||\"-\")} | `+\n `${esc(t.seeds)} | `+\n `${esc(t.peers)} | `+\n `${esc(t.ratio)} | `+\n `${esc(t.path)} | `+\n `${labels||'-'} | `+\n `${esc(t.ratio_group||'')} | `+\n `${esc(t.down_total_h||'-')} | `+\n `${esc(t.to_download_h||'-')} | `+\n `${esc(t.up_total_h||'-')} | `+\n `${esc(formatDateTime(t.created))} | `+\n `${esc(formatDateTime(t.last_activity))} | `+\n `${esc(t.priority ?? '-')} | `+\n `${boolCell(t.state)} | `+\n `${boolCell(t.active)} | `+\n `${boolCell(t.complete)} | `+\n `${esc(t.hashing ?? 0)} | `+\n `${compactCell(t.message||'', 80)} | `+\n `${esc(t.hash||'')} | `+\n `
`;\n }\n\n\n\n\n";
diff --git a/pytorrent/static/js/torrentTableRenderer.js b/pytorrent/static/js/torrentTableRenderer.js
index 6516730..6646fcb 100644
--- a/pytorrent/static/js/torrentTableRenderer.js
+++ b/pytorrent/static/js/torrentTableRenderer.js
@@ -1 +1 @@
-export const torrentTableRendererSource = " function renderMobile(){\n const list=$('mobileList');\n if(!list) return;\n const src=mobileVisibleRows();\n const rows=src.slice(0,250);\n renderMobileFilters(src);\n list.innerHTML=rows.map(t=>{\n const errorLog=torrentErrorLog(t);\n const op=activeOperationFor(t);\n const classes=[selected.has(t.hash)?'selected':'', op?'torrent-operating':'', errorLog?'torrent-warning':''].filter(Boolean).join(' ');\n const lines=mobileInfoLines(t);\n // Note: Mobile details use a separate corner button so user-configurable action buttons keep their current order.\n return `${lines.primary?`
${lines.primary}
`:''}${lines.secondary?`
${lines.secondary}
`:''}${mobileColumns.path?`
${esc(t.path)}
`:''}
${mobileColumns.progress?`
${progress(t)}
`:''}
`;\n }).join('') || (hasTorrentSnapshot ? `No torrents.
` : loadingMarkup('Loading torrents...'));\n }\n function renderTable(){ updateBulkBar(); syncActiveFilterSelection(); renderCounts(); renderLabelFilters(); if(typeof renderHealthDashboard==='function') renderHealthDashboard(); if(typeof renderSmartViewsManager==='function') renderSmartViewsManager(); updateSortHeaders(); buildVisibleRows(); renderMobile(); const body=$('torrentBody'); if(!visibleRows.length){ body.innerHTML=hasTorrentSnapshot?`| No torrents for this filter. |
`:loadingTableRow('Loading torrents...'); return; } const wrap=$('tableWrap'); const rowHeight=torrentRowHeight(); const start=Math.max(0,Math.floor((wrap?.scrollTop||0)/rowHeight)-OVERSCAN); const count=Math.ceil((wrap?.clientHeight||500)/rowHeight)+OVERSCAN*2; const end=Math.min(visibleRows.length,start+count); const sig=`${renderVersion}:${start}:${end}:${visibleRows.length}:${sortState.key}:${sortState.dir}:${selected.size}:${activeFilter}:${activeTrackerFilter}:${compactTorrentListEnabled?1:0}:${$('searchBox')?.value||''}:${[...selected].slice(0,30).join(',')}`; if(sig===lastRenderSignature) return; lastRenderSignature=sig; const top=start*rowHeight,bottom=Math.max(0,(visibleRows.length-end)*rowHeight); body.innerHTML=(top?` |
`:'')+visibleRows.slice(start,end).map(renderRow).join('')+(bottom?` |
`:''); applyColumnVisibility(); }\n function scheduleRender(force=false){ if(force){lastRenderSignature='';renderVersion++;} if(renderPending)return; renderPending=true; requestAnimationFrame(()=>{renderPending=false;renderTable();}); }\n function patchRows(msg){ if(msg.summary) torrentSummary=msg.summary; (msg.removed||[]).forEach(h=>{torrents.delete(h);selected.delete(h);activeOperations.delete(h);if(selectedHash===h)selectedHash=null;}); (msg.added||[]).forEach(t=>torrents.set(t.hash,t)); (msg.updated||[]).forEach(p=>torrents.set(p.hash,{...(torrents.get(p.hash)||{}),...p})); if(msg.speed_status) applyLiveSpeedStats(msg.speed_status); else updateBrowserSpeedTitle(); scheduleRender(true); if(selectedHash&&torrents.has(selectedHash)&&activeTab()==='general') renderGeneral(); }\n function applyLiveTorrentStats(msg){ (msg.updated||[]).forEach(p=>{ if(torrents.has(p.hash)) torrents.set(p.hash,{...(torrents.get(p.hash)||{}),...p}); }); if(msg.speed_status) applyLiveSpeedStats(msg.speed_status); else updateBrowserSpeedTitle(); scheduleRender(true); if(selectedHash&&torrents.has(selectedHash)&&activeTab()==='general') renderGeneral(); }\n function selectedHashes(){ return [...selected]; }\n function updateBulkBar(){\n const bar=$(\"bulkBar\");\n if(!bar) return;\n // Note: The desktop bulk toolbar is hidden in mobile mode; mobile has its own compact actions in the filter bar.\n const isMobileMode = document.body.classList.contains('mobile-mode');\n const show = selected.size > 1 && !isMobileMode;\n bar.classList.toggle(\"d-none\", !show);\n bar.setAttribute('aria-hidden', show ? 'false' : 'true');\n const c=$(\"bulkSelectedCount\");\n if(c) c.textContent=selected.size;\n }\n function setSelectionRange(hash, keepExisting=false){ const current=visibleRows.findIndex(t=>t.hash===hash); const last=visibleRows.findIndex(t=>t.hash===lastSelectedHash); if(current<0 || last<0){ selected.add(hash); lastSelectedHash=hash; return; } if(!keepExisting) selected.clear(); const a=Math.min(current,last), b=Math.max(current,last); visibleRows.slice(a,b+1).forEach(t=>selected.add(t.hash)); selectedHash=hash; }\n";
+export const torrentTableRendererSource = " function renderMobile(){\n const list=$('mobileList');\n if(!list) return;\n const src=mobileVisibleRows();\n const rows=src.slice(0,250);\n renderMobileFilters(src);\n list.innerHTML=rows.map(t=>{\n const errorLog=torrentErrorLog(t);\n const op=activeOperationFor(t);\n const classes=[selected.has(t.hash)?'selected':'', op?'torrent-operating':'', errorLog?'torrent-warning':''].filter(Boolean).join(' ');\n const lines=mobileInfoLines(t);\n // Note: Mobile details use a separate corner button so user-configurable action buttons keep their current order.\n return `${lines.primary?`
${lines.primary}
`:''}${lines.secondary?`
${lines.secondary}
`:''}${mobileColumns.path?`
${esc(t.path)}
`:''}
${mobileColumns.progress?`
${progress(t)}
`:''}
`;\n }).join('') || (hasTorrentSnapshot ? `No torrents.
` : loadingMarkup('Loading torrents...'));\n }\n\n\n function torrentHeaderHeight(){\n const header=document.querySelector('.torrent-table thead');\n return Math.ceil(header?.getBoundingClientRect?.().height || 0);\n }\n\n function clearVirtualBody(body){\n body.classList.remove('torrent-virtual-body');\n body.style.height='';\n }\n\n function renderTable(){\n updateBulkBar();\n syncActiveFilterSelection();\n renderCounts();\n renderLabelFilters();\n if(typeof renderHealthDashboard==='function') renderHealthDashboard();\n if(typeof renderSmartViewsManager==='function') renderSmartViewsManager();\n updateSortHeaders();\n buildVisibleRows();\n renderMobile();\n\n const body=$('torrentBody');\n if(!body) return;\n\n if(!visibleRows.length){\n clearVirtualBody(body);\n body.innerHTML=hasTorrentSnapshot?`| No torrents for this filter. |
`:loadingTableRow('Loading torrents...');\n applyColumnVisibility();\n return;\n }\n\n const wrap=$('tableWrap');\n const rowHeight=torrentRowHeight();\n const headerHeight=torrentHeaderHeight();\n const scrollTop=Math.max(0, (wrap?.scrollTop || 0) - headerHeight);\n const viewportHeight=Math.max(rowHeight, (wrap?.clientHeight || 500) - headerHeight);\n const maxStart=Math.max(0, visibleRows.length - 1);\n const start=Math.min(maxStart, Math.max(0, Math.floor(scrollTop / rowHeight) - OVERSCAN));\n const visibleCount=Math.ceil(viewportHeight / rowHeight) + OVERSCAN * 2 + 1;\n const end=Math.min(visibleRows.length, start + visibleCount);\n const totalHeight=visibleRows.length * rowHeight;\n\n const sig=`${renderVersion}:${start}:${end}:${totalHeight}:${rowHeight}:${sortState.key}:${sortState.dir}:${selected.size}:${activeFilter}:${activeTrackerFilter}:${compactTorrentListEnabled?1:0}:${$('searchBox')?.value||''}:${[...selected].slice(0,30).join(',')}`;\n if(sig===lastRenderSignature) return;\n\n lastRenderSignature=sig;\n body.classList.add('torrent-virtual-body');\n body.style.height=`${totalHeight}px`;\n // Note: Rows are absolutely positioned inside one fixed-height body, so the browser keeps a stable native scroll range even at 50k torrents.\n body.innerHTML=visibleRows.slice(start,end).map((torrent,index)=>renderRow(torrent,{className:'torrent-virtual-row',style:`transform:translateY(${(start+index)*rowHeight}px)`})).join('');\n applyColumnVisibility();\n }\n\n function scheduleRender(force=false){ if(force){lastRenderSignature='';renderVersion++;} if(renderPending)return; renderPending=true; requestAnimationFrame(()=>{renderPending=false;renderTable();}); }\n function patchRows(msg){ if(msg.summary) torrentSummary=msg.summary; (msg.removed||[]).forEach(h=>{torrents.delete(h);selected.delete(h);activeOperations.delete(h);if(selectedHash===h)selectedHash=null;}); (msg.added||[]).forEach(t=>torrents.set(t.hash,t)); (msg.updated||[]).forEach(p=>torrents.set(p.hash,{...(torrents.get(p.hash)||{}),...p})); if(msg.speed_status) applyLiveSpeedStats(msg.speed_status); else updateBrowserSpeedTitle(); scheduleRender(true); if(selectedHash&&torrents.has(selectedHash)&&activeTab()==='general') renderGeneral(); }\n function applyLiveTorrentStats(msg){ (msg.updated||[]).forEach(p=>{ if(torrents.has(p.hash)) torrents.set(p.hash,{...(torrents.get(p.hash)||{}),...p}); }); if(msg.speed_status) applyLiveSpeedStats(msg.speed_status); else updateBrowserSpeedTitle(); scheduleRender(true); if(selectedHash&&torrents.has(selectedHash)&&activeTab()==='general') renderGeneral(); }\n function selectedHashes(){ return [...selected]; }\n function updateBulkBar(){\n const bar=$(\"bulkBar\");\n if(!bar) return;\n // Note: The desktop bulk toolbar is hidden in mobile mode; mobile has its own compact actions in the filter bar.\n const isMobileMode = document.body.classList.contains('mobile-mode');\n const show = selected.size > 1 && !isMobileMode;\n bar.classList.toggle(\"d-none\", !show);\n bar.setAttribute('aria-hidden', show ? 'false' : 'true');\n const c=$(\"bulkSelectedCount\");\n if(c) c.textContent=selected.size;\n }\n function setSelectionRange(hash, keepExisting=false){ const current=visibleRows.findIndex(t=>t.hash===hash); const last=visibleRows.findIndex(t=>t.hash===lastSelectedHash); if(current<0 || last<0){ selected.add(hash); lastSelectedHash=hash; return; } if(!keepExisting) selected.clear(); const a=Math.min(current,last), b=Math.max(current,last); visibleRows.slice(a,b+1).forEach(t=>selected.add(t.hash)); selectedHash=hash; }\n";
diff --git a/pytorrent/static/styles.css b/pytorrent/static/styles.css
index 54ce52a..f241d86 100644
--- a/pytorrent/static/styles.css
+++ b/pytorrent/static/styles.css
@@ -449,10 +449,12 @@ body {
.table-wrap {
contain: content;
overflow: auto;
+ overflow-anchor: none;
position: relative;
}
.torrent-table {
--torrent-row-height: 32px;
+ overflow-anchor: none;
font-size: var(--torrent-list-font-size, 13px);
margin: 0;
white-space: nowrap;
@@ -478,6 +480,24 @@ body {
cursor: default;
height: var(--torrent-row-height);
}
+
+/* Virtualized torrent table: a single fixed-height body keeps the native scroll range stable for very large lists. */
+.torrent-table tbody.torrent-virtual-body {
+ display: block;
+ position: relative;
+}
+.torrent-table tbody.torrent-virtual-body tr.torrent-virtual-row {
+ display: table;
+ left: 0;
+ position: absolute;
+ right: 0;
+ table-layout: fixed;
+ top: 0;
+ width: 100%;
+}
+.torrent-table tbody.torrent-virtual-body tr.torrent-virtual-row > td {
+ height: var(--torrent-row-height);
+}
.torrent-table > :not(caption) > * > * {
padding-bottom: 0.22rem;
padding-top: 0.22rem;
@@ -570,10 +590,6 @@ body.resizing-columns {
user-select: none;
}
-.virtual-spacer td {
- padding: 0 !important;
- border: 0 !important;
-}
.empty {
height: 120px;
text-align: center;
@@ -1042,25 +1058,71 @@ body.resizing-details {
.torrent-action + .torrent-action {
margin-left: 0.08rem !important;
}
+.path-inline-create {
+ align-items: center;
+ display: grid;
+ gap: 0.5rem;
+ grid-template-columns: minmax(0, 1fr) auto;
+}
+
.path-list {
- height: 360px;
- overflow: auto;
+ background: rgba(var(--bs-secondary-bg-rgb), 0.35);
border: 1px solid var(--bs-border-color);
border-radius: 0.6rem;
- background: rgba(var(--bs-secondary-bg-rgb), 0.35);
+ height: 360px;
+ overflow: auto;
}
+
.path-row {
- display: flex;
align-items: center;
+ border-bottom: 1px solid var(--bs-border-color);
+ display: flex;
gap: 0.5rem;
padding: 0.42rem 0.6rem;
- border-bottom: 1px solid var(--bs-border-color);
- cursor: pointer;
}
+
.path-row:hover {
background: var(--bs-primary-bg-subtle);
color: var(--bs-primary-text-emphasis);
}
+
+.path-row-open {
+ align-items: center;
+ background: transparent;
+ border: 0;
+ color: inherit;
+ cursor: pointer;
+ display: flex;
+ flex: 1 1 auto;
+ gap: 0.5rem;
+ min-width: 0;
+ padding: 0;
+ text-align: left;
+}
+
+.path-row-open i,
+.path-rename-form i {
+ color: var(--bs-warning);
+ flex: 0 0 auto;
+}
+
+.path-row-open span {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.path-rename-btn {
+ flex: 0 0 auto;
+}
+
+.path-rename-form {
+ align-items: center;
+ display: grid;
+ flex: 1 1 auto;
+ gap: 0.45rem;
+ grid-template-columns: auto minmax(0, 1fr) auto auto;
+}
.chips {
display: flex;
gap: 0.35rem;
@@ -1290,12 +1352,6 @@ body.mobile-mode .main-grid {
.column-card i {
opacity: 0.72;
}
-.path-row::before {
- content: "\f07b";
- font-family: "Font Awesome 6 Free";
- font-weight: 900;
- color: var(--bs-warning);
-}
body.mobile-mode .mobile-card {
display: block;
}
diff --git a/pytorrent/templates/index.html b/pytorrent/templates/index.html
index 00820e0..d533b9d 100644
--- a/pytorrent/templates/index.html
+++ b/pytorrent/templates/index.html
@@ -228,6 +228,10 @@
+
+
+
+