Merge pull request 'Folder management' (#26) from folder_management into master
Reviewed-on: #26
This commit was merged in pull request #26.
This commit is contained in:
@@ -955,8 +955,7 @@
|
|||||||
"properties": {
|
"properties": {
|
||||||
"dirs": {
|
"dirs": {
|
||||||
"items": {
|
"items": {
|
||||||
"additionalProperties": true,
|
"$ref": "#/components/schemas/PathDirectoryEntry"
|
||||||
"type": "object"
|
|
||||||
},
|
},
|
||||||
"type": "array"
|
"type": "array"
|
||||||
},
|
},
|
||||||
@@ -2155,6 +2154,72 @@
|
|||||||
"url",
|
"url",
|
||||||
"expires_in"
|
"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": {
|
"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": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from ._shared import *
|
from ._shared import *
|
||||||
|
import posixpath
|
||||||
from ..services import operation_logs
|
from ..services import operation_logs
|
||||||
from ..services.frontend_assets import static_hash
|
from ..services.frontend_assets import static_hash
|
||||||
|
|
||||||
@@ -337,6 +338,32 @@ 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)
|
||||||
|
is_empty = bool(item.get("empty"))
|
||||||
|
item["has_torrents"] = has_torrents
|
||||||
|
item["can_rename"] = is_empty and not has_torrents
|
||||||
|
# Note: The picker exposes a short reason so disabled rename buttons explain the safety rule.
|
||||||
|
item["rename_reason"] = "Rename folder" if item["can_rename"] else ("Folder contains a known torrent path" if has_torrents else "Only empty folders can be renamed")
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/path/default")
|
@bp.get("/path/default")
|
||||||
def path_default():
|
def path_default():
|
||||||
profile = preferences.active_profile()
|
profile = preferences.active_profile()
|
||||||
@@ -356,7 +383,40 @@ def path_browse():
|
|||||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||||
base = request.args.get("path") or ""
|
base = request.args.get("path") or ""
|
||||||
try:
|
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:
|
except Exception as exc:
|
||||||
return jsonify({"ok": False, "error": str(exc)}), 400
|
return jsonify({"ok": False, "error": str(exc)}), 400
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,10 @@ def browse_path(profile: dict, path: str | None = None) -> dict:
|
|||||||
'dir_count=0; file_count=0; '
|
'dir_count=0; file_count=0; '
|
||||||
'for p in "$base"/* "$base"/.[!.]* "$base"/..?*; do '
|
'for p in "$base"/* "$base"/.[!.]* "$base"/..?*; do '
|
||||||
'[ -e "$p" ] || continue; '
|
'[ -e "$p" ] || continue; '
|
||||||
'if [ -d "$p" ]; then dir_count=$((dir_count+1)); name=${p##*/}; printf "D\\t%s\\t%s\\n" "$name" "$p"; '
|
'if [ -d "$p" ]; then '
|
||||||
|
'dir_count=$((dir_count+1)); name=${p##*/}; empty=1; '
|
||||||
|
'if find "$p" -mindepth 1 -maxdepth 1 -print -quit 2>/dev/null | grep -q .; then empty=0; fi; '
|
||||||
|
'printf "D\\t%s\\t%s\\t%s\\n" "$name" "$p" "$empty"; '
|
||||||
'elif [ -f "$p" ]; then file_count=$((file_count+1)); fi; '
|
'elif [ -f "$p" ]; then file_count=$((file_count+1)); fi; '
|
||||||
'done; '
|
'done; '
|
||||||
'printf "M\\t%s\\t%s\\n" "$dir_count" "$file_count"; '
|
'printf "M\\t%s\\t%s\\n" "$dir_count" "$file_count"; '
|
||||||
@@ -37,9 +40,12 @@ def browse_path(profile: dict, path: str | None = None) -> dict:
|
|||||||
continue
|
continue
|
||||||
marker, rest = line.split("\t", 1)
|
marker, rest = line.split("\t", 1)
|
||||||
if marker == "D" and "\t" in rest:
|
if marker == "D" and "\t" in rest:
|
||||||
name, full_path = rest.split("\t", 1)
|
parts = rest.split("\t", 2)
|
||||||
|
name, full_path = parts[0], parts[1]
|
||||||
|
is_empty = len(parts) > 2 and parts[2] == "1"
|
||||||
if name not in {".", ".."}:
|
if name not in {".", ".."}:
|
||||||
dirs.append({"name": name, "path": full_path})
|
# Note: Empty status is returned with every directory so the path picker can enable safe inline rename.
|
||||||
|
dirs.append({"name": name, "path": full_path, "empty": is_empty})
|
||||||
elif marker == "M" and "\t" in rest:
|
elif marker == "M" and "\t" in rest:
|
||||||
first, second = rest.split("\t", 1)
|
first, second = rest.split("\t", 1)
|
||||||
try:
|
try:
|
||||||
@@ -78,6 +84,60 @@ def browse_path(profile: dict, path: str | None = None) -> dict:
|
|||||||
"used_percent": disk_percent,
|
"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:
|
def remote_public_ip(profile: dict, force: bool = False) -> str:
|
||||||
profile_id = int(profile.get("id") or 0)
|
profile_id = int(profile.get("id") or 0)
|
||||||
now = time.monotonic()
|
now = time.monotonic()
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+97
-16
@@ -449,10 +449,12 @@ body {
|
|||||||
.table-wrap {
|
.table-wrap {
|
||||||
contain: content;
|
contain: content;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
|
overflow-anchor: none;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
.torrent-table {
|
.torrent-table {
|
||||||
--torrent-row-height: 32px;
|
--torrent-row-height: 32px;
|
||||||
|
overflow-anchor: none;
|
||||||
font-size: var(--torrent-list-font-size, 13px);
|
font-size: var(--torrent-list-font-size, 13px);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
@@ -478,6 +480,24 @@ body {
|
|||||||
cursor: default;
|
cursor: default;
|
||||||
height: var(--torrent-row-height);
|
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) > * > * {
|
.torrent-table > :not(caption) > * > * {
|
||||||
padding-bottom: 0.22rem;
|
padding-bottom: 0.22rem;
|
||||||
padding-top: 0.22rem;
|
padding-top: 0.22rem;
|
||||||
@@ -570,10 +590,6 @@ body.resizing-columns {
|
|||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.virtual-spacer td {
|
|
||||||
padding: 0 !important;
|
|
||||||
border: 0 !important;
|
|
||||||
}
|
|
||||||
.empty {
|
.empty {
|
||||||
height: 120px;
|
height: 120px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@@ -1042,25 +1058,71 @@ body.resizing-details {
|
|||||||
.torrent-action + .torrent-action {
|
.torrent-action + .torrent-action {
|
||||||
margin-left: 0.08rem !important;
|
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 {
|
.path-list {
|
||||||
height: 360px;
|
background: rgba(var(--bs-secondary-bg-rgb), 0.35);
|
||||||
overflow: auto;
|
|
||||||
border: 1px solid var(--bs-border-color);
|
border: 1px solid var(--bs-border-color);
|
||||||
border-radius: 0.6rem;
|
border-radius: 0.6rem;
|
||||||
background: rgba(var(--bs-secondary-bg-rgb), 0.35);
|
height: 360px;
|
||||||
|
overflow: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.path-row {
|
.path-row {
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
border-bottom: 1px solid var(--bs-border-color);
|
||||||
|
display: flex;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
padding: 0.42rem 0.6rem;
|
padding: 0.42rem 0.6rem;
|
||||||
border-bottom: 1px solid var(--bs-border-color);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.path-row:hover {
|
.path-row:hover {
|
||||||
background: var(--bs-primary-bg-subtle);
|
background: var(--bs-primary-bg-subtle);
|
||||||
color: var(--bs-primary-text-emphasis);
|
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 {
|
.chips {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.35rem;
|
gap: 0.35rem;
|
||||||
@@ -1290,12 +1352,6 @@ body.mobile-mode .main-grid {
|
|||||||
.column-card i {
|
.column-card i {
|
||||||
opacity: 0.72;
|
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 {
|
body.mobile-mode .mobile-card {
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
@@ -3013,6 +3069,31 @@ body.mobile-mode .mobile-filter-bar {
|
|||||||
outline: 0;
|
outline: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.jobs-toolbar,
|
||||||
|
.jobs-toolbar-actions,
|
||||||
|
.jobs-toolbar-toggle {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jobs-toolbar {
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jobs-toolbar-actions,
|
||||||
|
.jobs-toolbar-toggle {
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jobs-show-details {
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.jobs-table {
|
.jobs-table {
|
||||||
min-width: 1080px;
|
min-width: 1080px;
|
||||||
white-space: normal;
|
white-space: normal;
|
||||||
|
|||||||
@@ -190,12 +190,20 @@
|
|||||||
<button class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
<button class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<div class="jobs-toolbar d-flex gap-2 mb-2 flex-wrap align-items-center">
|
<div class="jobs-toolbar">
|
||||||
|
<div class="jobs-toolbar-actions">
|
||||||
<button id="refreshJobsBtn" class="btn btn-sm btn-outline-primary" type="button"><i class="fa-solid fa-rotate"></i> Refresh</button>
|
<button id="refreshJobsBtn" class="btn btn-sm btn-outline-primary" type="button"><i class="fa-solid fa-rotate"></i> Refresh</button>
|
||||||
<button id="clearJobsBtn" class="btn btn-sm btn-outline-danger" type="button"><i class="fa-solid fa-trash"></i> Clear finished</button>
|
<button id="clearJobsBtn" class="btn btn-sm btn-outline-danger" type="button"><i class="fa-solid fa-trash"></i> Clear finished</button>
|
||||||
<button id="emergencyClearJobsBtn" class="btn btn-sm btn-danger" type="button"><i class="fa-solid fa-triangle-exclamation"></i> Emergency clean all</button>
|
<button id="emergencyClearJobsBtn" class="btn btn-sm btn-danger" type="button"><i class="fa-solid fa-triangle-exclamation"></i> Emergency clean all</button>
|
||||||
<span class="text-muted small">Pending, running, done, failed, retry and cancel history.</span>
|
<span class="text-muted small">Pending, running, done, failed, retry and cancel history.</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="jobs-toolbar-toggle">
|
||||||
|
<label class="form-check form-switch jobs-show-details">
|
||||||
|
<input id="jobsShowDetails" class="form-check-input" type="checkbox">
|
||||||
|
<span class="form-check-label">Show details</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div id="jobsTable" class="table-responsive"><span class="spinner-border spinner-border-sm"></span> Loading jobs...</div>
|
<div id="jobsTable" class="table-responsive"><span class="spinner-border spinner-border-sm"></span> Loading jobs...</div>
|
||||||
<div id="jobsPager" class="pager-row mt-2"></div>
|
<div id="jobsPager" class="pager-row mt-2"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -228,6 +236,10 @@
|
|||||||
<label class="form-check-label" for="moveRecheck">Recheck after move</label>
|
<label class="form-check-label" for="moveRecheck">Recheck after move</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="path-inline-create mb-3">
|
||||||
|
<input id="pathCreateName" class="form-control form-control-sm" autocomplete="off" placeholder="New folder name">
|
||||||
|
<button id="pathCreateBtn" class="btn btn-sm btn-outline-primary" type="button"><i class="fa-solid fa-folder-plus"></i> Create</button>
|
||||||
|
</div>
|
||||||
<div id="pathList" class="path-list"><div class="p-3 text-muted">No path loaded.</div></div>
|
<div id="pathList" class="path-list"><div class="p-3 text-muted">No path loaded.</div></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
|
|||||||
+64
-7
@@ -27,6 +27,8 @@ TRACKERS = [
|
|||||||
"https://tracker.example.dev/announce",
|
"https://tracker.example.dev/announce",
|
||||||
]
|
]
|
||||||
CLIENTS = ["qBittorrent/4.6", "Transmission/4.0", "libtorrent/2.0", "Deluge/2.1", "rtorrent/0.9"]
|
CLIENTS = ["qBittorrent/4.6", "Transmission/4.0", "libtorrent/2.0", "Deluge/2.1", "rtorrent/0.9"]
|
||||||
|
LARGE_TORRENT_DETAIL_THRESHOLD = 50_000
|
||||||
|
LARGE_TORRENT_TICK_BATCH = 5_000
|
||||||
|
|
||||||
|
|
||||||
def xmlrpc_safe(value: Any) -> Any:
|
def xmlrpc_safe(value: Any) -> Any:
|
||||||
@@ -54,6 +56,10 @@ class MockRtorrentState:
|
|||||||
def __init__(self, count: int, seed: int, state_file: Path | None = None, persist: bool = False, disk_total_gb: int = 4096, disk_used_percent: float = 68.0):
|
def __init__(self, count: int, seed: int, state_file: Path | None = None, persist: bool = False, disk_total_gb: int = 4096, disk_used_percent: float = 68.0):
|
||||||
self.lock = threading.RLock()
|
self.lock = threading.RLock()
|
||||||
self.started_at = time.time()
|
self.started_at = time.time()
|
||||||
|
self.seed = seed
|
||||||
|
self.large_mode = count >= LARGE_TORRENT_DETAIL_THRESHOLD
|
||||||
|
self.last_tick_at = 0.0
|
||||||
|
self.tick_cursor = 0
|
||||||
self.state_file = state_file
|
self.state_file = state_file
|
||||||
self.persist = persist
|
self.persist = persist
|
||||||
self.disk_total_bytes = max(1, int(disk_total_gb)) * 1024 * 1024 * 1024
|
self.disk_total_bytes = max(1, int(disk_total_gb)) * 1024 * 1024 * 1024
|
||||||
@@ -96,6 +102,7 @@ class MockRtorrentState:
|
|||||||
down_rate = 0 if complete or not active else rng.randint(50_000, 8_000_000)
|
down_rate = 0 if complete or not active else rng.randint(50_000, 8_000_000)
|
||||||
up_rate = 0 if not active else rng.randint(5_000, 2_000_000)
|
up_rate = 0 if not active else rng.randint(5_000, 2_000_000)
|
||||||
torrent = {
|
torrent = {
|
||||||
|
"mock_index": index,
|
||||||
"hash": torrent_hash,
|
"hash": torrent_hash,
|
||||||
"name": f"Mock Torrent {index + 1:05d} - {label}",
|
"name": f"Mock Torrent {index + 1:05d} - {label}",
|
||||||
"state": state,
|
"state": state,
|
||||||
@@ -122,8 +129,8 @@ class MockRtorrentState:
|
|||||||
"last_activity": now - rng.randint(0, 7 * 86400),
|
"last_activity": now - rng.randint(0, 7 * 86400),
|
||||||
"completed_at": now - rng.randint(0, 180 * 86400) if complete else 0,
|
"completed_at": now - rng.randint(0, 180 * 86400) if complete else 0,
|
||||||
"trackers": rng.sample(TRACKERS, k=rng.randint(1, len(TRACKERS))),
|
"trackers": rng.sample(TRACKERS, k=rng.randint(1, len(TRACKERS))),
|
||||||
"files": self.make_files(index, size, completed, rng),
|
"files": [] if self.large_mode else self.make_files(index, size, completed, rng),
|
||||||
"peers_list": self.make_peers(rng),
|
"peers_list": [] if self.large_mode else self.make_peers(rng),
|
||||||
}
|
}
|
||||||
self.torrents.append(torrent)
|
self.torrents.append(torrent)
|
||||||
self.reindex()
|
self.reindex()
|
||||||
@@ -171,6 +178,34 @@ class MockRtorrentState:
|
|||||||
])
|
])
|
||||||
return rows
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def detail_rng(self, torrent: dict[str, Any] | None, kind: str) -> random.Random:
|
||||||
|
"""Create deterministic detail RNGs without storing large nested lists."""
|
||||||
|
index = int((torrent or {}).get("mock_index") or 0)
|
||||||
|
return random.Random(f"{self.seed}:{kind}:{index}")
|
||||||
|
|
||||||
|
def file_rows(self, torrent: dict[str, Any] | None) -> list[dict[str, Any]]:
|
||||||
|
"""Return stored files or lazily generated files for high-volume mocks."""
|
||||||
|
if not torrent:
|
||||||
|
return []
|
||||||
|
files = torrent.get("files") or []
|
||||||
|
if files:
|
||||||
|
return files
|
||||||
|
if not self.large_mode:
|
||||||
|
return []
|
||||||
|
return self.make_files(int(torrent.get("mock_index") or 0), int(torrent.get("size") or 1), int(torrent.get("completed") or 0), self.detail_rng(torrent, "files"))
|
||||||
|
|
||||||
|
def peer_rows(self, torrent: dict[str, Any] | None) -> list[list[Any]]:
|
||||||
|
"""Return stored peers or lazily generated peers for high-volume mocks."""
|
||||||
|
if not torrent:
|
||||||
|
return []
|
||||||
|
peers = torrent.get("peers_list") or []
|
||||||
|
if peers:
|
||||||
|
return peers
|
||||||
|
if not self.large_mode:
|
||||||
|
return []
|
||||||
|
return self.make_peers(self.detail_rng(torrent, "peers"))
|
||||||
|
|
||||||
def reindex(self) -> None:
|
def reindex(self) -> None:
|
||||||
self.by_hash = {str(t["hash"]): t for t in self.torrents}
|
self.by_hash = {str(t["hash"]): t for t in self.torrents}
|
||||||
|
|
||||||
@@ -178,7 +213,14 @@ class MockRtorrentState:
|
|||||||
"""Load optional persisted mock state for repeatable development sessions."""
|
"""Load optional persisted mock state for repeatable development sessions."""
|
||||||
data = json.loads(self.state_file.read_text(encoding="utf-8"))
|
data = json.loads(self.state_file.read_text(encoding="utf-8"))
|
||||||
self.config.update(data.get("config") or {})
|
self.config.update(data.get("config") or {})
|
||||||
|
self.seed = int(data.get("seed") or self.seed)
|
||||||
self.torrents = list(data.get("torrents") or [])
|
self.torrents = list(data.get("torrents") or [])
|
||||||
|
self.large_mode = len(self.torrents) >= LARGE_TORRENT_DETAIL_THRESHOLD
|
||||||
|
for index, torrent in enumerate(self.torrents):
|
||||||
|
torrent.setdefault("mock_index", index)
|
||||||
|
if self.large_mode:
|
||||||
|
torrent.setdefault("files", [])
|
||||||
|
torrent.setdefault("peers_list", [])
|
||||||
self.reindex()
|
self.reindex()
|
||||||
|
|
||||||
def save(self) -> None:
|
def save(self) -> None:
|
||||||
@@ -187,13 +229,27 @@ class MockRtorrentState:
|
|||||||
return
|
return
|
||||||
self.state_file.parent.mkdir(parents=True, exist_ok=True)
|
self.state_file.parent.mkdir(parents=True, exist_ok=True)
|
||||||
tmp = self.state_file.with_suffix(".tmp")
|
tmp = self.state_file.with_suffix(".tmp")
|
||||||
tmp.write_text(json.dumps({"updated_at": human_now(), "config": self.config, "torrents": self.torrents}), encoding="utf-8")
|
tmp.write_text(json.dumps({"updated_at": human_now(), "seed": self.seed, "config": self.config, "torrents": self.torrents}), encoding="utf-8")
|
||||||
tmp.replace(self.state_file)
|
tmp.replace(self.state_file)
|
||||||
|
|
||||||
def tick(self) -> None:
|
def tick(self) -> None:
|
||||||
"""Advance speeds, totals and progress on each RPC request."""
|
"""Advance a bounded number of torrents so large mock sets stay responsive."""
|
||||||
now = int(time.time())
|
now = int(time.time())
|
||||||
for index, torrent in enumerate(self.torrents):
|
if now == int(self.last_tick_at):
|
||||||
|
return
|
||||||
|
self.last_tick_at = float(now)
|
||||||
|
total = len(self.torrents)
|
||||||
|
if not total:
|
||||||
|
return
|
||||||
|
if total <= LARGE_TORRENT_DETAIL_THRESHOLD:
|
||||||
|
indices = range(total)
|
||||||
|
else:
|
||||||
|
batch = min(LARGE_TORRENT_TICK_BATCH, total)
|
||||||
|
start = self.tick_cursor % total
|
||||||
|
self.tick_cursor = (start + batch) % total
|
||||||
|
indices = [(start + offset) % total for offset in range(batch)]
|
||||||
|
for index in indices:
|
||||||
|
torrent = self.torrents[index]
|
||||||
if not torrent.get("state") or not torrent.get("is_active"):
|
if not torrent.get("state") or not torrent.get("is_active"):
|
||||||
torrent["down_rate"] = 0
|
torrent["down_rate"] = 0
|
||||||
torrent["up_rate"] = 0
|
torrent["up_rate"] = 0
|
||||||
@@ -247,11 +303,11 @@ class MockRtorrentState:
|
|||||||
return [[self.torrent_row_value(t, f) for f in fields] for t in self.torrents]
|
return [[self.torrent_row_value(t, f) for f in fields] for t in self.torrents]
|
||||||
if method == "p.multicall":
|
if method == "p.multicall":
|
||||||
torrent = self.by_hash.get(str(args[0]))
|
torrent = self.by_hash.get(str(args[0]))
|
||||||
return torrent.get("peers_list", []) if torrent else []
|
return self.peer_rows(torrent) if torrent else []
|
||||||
if method == "f.multicall":
|
if method == "f.multicall":
|
||||||
torrent = self.by_hash.get(str(args[0]))
|
torrent = self.by_hash.get(str(args[0]))
|
||||||
fields = args[2:]
|
fields = args[2:]
|
||||||
return [self.file_row(file, fields) for file in (torrent or {}).get("files", [])]
|
return [self.file_row(file, fields) for file in self.file_rows(torrent)]
|
||||||
if method == "t.multicall":
|
if method == "t.multicall":
|
||||||
torrent = self.by_hash.get(str(args[0]) or str(args[1] if len(args) > 1 else ""))
|
torrent = self.by_hash.get(str(args[0]) or str(args[1] if len(args) > 1 else ""))
|
||||||
return [[tracker, 1, 120 + i, 30 + i, 5000 + i] for i, tracker in enumerate((torrent or {}).get("trackers", []))]
|
return [[tracker, 1, 120 + i, 30 + i, 5000 + i] for i, tracker in enumerate((torrent or {}).get("trackers", []))]
|
||||||
@@ -458,6 +514,7 @@ class ScgiXmlRpcHandler(socketserver.BaseRequestHandler):
|
|||||||
class ThreadingScgiServer(socketserver.ThreadingTCPServer):
|
class ThreadingScgiServer(socketserver.ThreadingTCPServer):
|
||||||
allow_reuse_address = True
|
allow_reuse_address = True
|
||||||
daemon_threads = True
|
daemon_threads = True
|
||||||
|
request_queue_size = 128
|
||||||
|
|
||||||
|
|
||||||
def fallback_db_path() -> Path:
|
def fallback_db_path() -> Path:
|
||||||
|
|||||||
Reference in New Issue
Block a user