mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-10-04 22:05:45 +01:00
Patch: Various fixes (#660)
Various fixes from the last couple days: - Add manual approval option for book/audiobook requests (#651) - Add flagged HTTP headers - Add filesystem fallback - copy + delete when hardlink/move fails across filesystems (#647) - Dependency updates - Tweak frontend test config (simplified tsconfig for tests) - Fix overlapping sort scoring in release modal - duplicate scoring keys caused incorrect release ordering (#654 ) - Fix stale search session after download - search state was not refreshed when returning from a download (#659) - Fix multi-format release filtering - releases with multiple formats were incorrectly excluded by the format filter (#658) - Fix config persistence when action button is used - clicking "Test connection" reset unsaved settings (#657) - Fix request grid text positioning in admin request policy panel - Fix rTorrent path discovery (#653) - Fix `/login` API check (#650)
This commit is contained in:
@@ -3,6 +3,7 @@ flask-cors
|
||||
flask-socketio
|
||||
python-socketio
|
||||
requests[socks]
|
||||
defusedxml
|
||||
beautifulsoup4
|
||||
tqdm
|
||||
dnspython
|
||||
|
||||
@@ -708,6 +708,7 @@ def register_request_routes(
|
||||
queue_release=queue_release,
|
||||
release_data=data.get("release_data"),
|
||||
admin_note=data.get("admin_note"),
|
||||
manual_approval=data.get("manual_approval", False),
|
||||
)
|
||||
except RequestServiceError as exc:
|
||||
return _error_response(str(exc), exc.status_code, code=exc.code)
|
||||
|
||||
@@ -413,6 +413,7 @@ def fulfil_request(
|
||||
queue_release: Callable[..., tuple[bool, str | None]],
|
||||
release_data: Any = None,
|
||||
admin_note: Any = None,
|
||||
manual_approval: Any = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Fulfil a pending request and queue the release under requesting-user identity."""
|
||||
request_row = ensure_request_access(
|
||||
@@ -434,10 +435,30 @@ def fulfil_request(
|
||||
raise RequestServiceError("admin_note must be a string", status_code=400)
|
||||
normalized_admin_note = admin_note.strip() or None
|
||||
|
||||
if not isinstance(manual_approval, bool):
|
||||
raise RequestServiceError("manual_approval must be a boolean", status_code=400)
|
||||
|
||||
selected_release_data = release_data if release_data is not None else request_row.get("release_data")
|
||||
if selected_release_data is not None and not isinstance(selected_release_data, dict):
|
||||
raise RequestServiceError("release_data must be an object", status_code=400)
|
||||
|
||||
if selected_release_data is None and manual_approval:
|
||||
try:
|
||||
return user_db.update_request(
|
||||
request_id,
|
||||
expected_current_status="pending",
|
||||
status="fulfilled",
|
||||
release_data=None,
|
||||
delivery_state="complete",
|
||||
delivery_updated_at=_now_timestamp(),
|
||||
last_failure_reason=None,
|
||||
admin_note=normalized_admin_note,
|
||||
reviewed_by=admin_user_id,
|
||||
reviewed_at=_now_timestamp(),
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise RequestServiceError(str(exc), status_code=409, code="stale_transition") from exc
|
||||
|
||||
if request_row["request_level"] == "book" and selected_release_data is None:
|
||||
raise RequestServiceError(
|
||||
"release_data is required to fulfil book-level requests",
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
"""Shared utility functions for the Shelfmark."""
|
||||
|
||||
import base64
|
||||
import importlib
|
||||
import os
|
||||
import re
|
||||
from threading import Lock
|
||||
from types import ModuleType
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from urllib.parse import urlparse
|
||||
@@ -54,6 +57,28 @@ def normalize_http_url(
|
||||
return normalized
|
||||
|
||||
|
||||
_xmlrpc_patch_lock = Lock()
|
||||
_xmlrpc_patch_applied = False
|
||||
|
||||
|
||||
def get_hardened_xmlrpc_client() -> ModuleType:
|
||||
"""Return ``xmlrpc.client`` after best-effort defusedxml monkey patching."""
|
||||
global _xmlrpc_patch_applied
|
||||
if not _xmlrpc_patch_applied:
|
||||
with _xmlrpc_patch_lock:
|
||||
if not _xmlrpc_patch_applied:
|
||||
try:
|
||||
from defusedxml.xmlrpc import monkey_patch
|
||||
|
||||
monkey_patch()
|
||||
_xmlrpc_patch_applied = True
|
||||
except Exception:
|
||||
# Keep runtime behavior unchanged if defusedxml is unavailable.
|
||||
_xmlrpc_patch_applied = False
|
||||
|
||||
return importlib.import_module("xmlrpc.client")
|
||||
|
||||
|
||||
def normalize_base_path(value: Optional[str]) -> str:
|
||||
"""Normalize a URL base path for reverse proxy subpath deployments."""
|
||||
if not isinstance(value, str):
|
||||
|
||||
@@ -10,7 +10,7 @@ from urllib.parse import urlparse
|
||||
|
||||
from shelfmark.core.config import config
|
||||
from shelfmark.core.logger import setup_logger
|
||||
from shelfmark.core.utils import normalize_http_url
|
||||
from shelfmark.core.utils import normalize_http_url, get_hardened_xmlrpc_client
|
||||
from shelfmark.download.network import get_ssl_verify
|
||||
from shelfmark.download.clients import (
|
||||
DownloadClient,
|
||||
@@ -26,17 +26,17 @@ logger = setup_logger(__name__)
|
||||
|
||||
def _create_rtorrent_server_proxy(url: str) -> Any:
|
||||
"""Create an XML-RPC ServerProxy honoring certificate validation mode."""
|
||||
from xmlrpc.client import SafeTransport, ServerProxy
|
||||
xmlrpc_client = get_hardened_xmlrpc_client()
|
||||
|
||||
verify = get_ssl_verify(url)
|
||||
if url.startswith("https://") and not verify:
|
||||
ssl_context = ssl.create_default_context()
|
||||
ssl_context.check_hostname = False
|
||||
ssl_context.verify_mode = ssl.CERT_NONE
|
||||
transport = SafeTransport(context=ssl_context)
|
||||
return ServerProxy(url, transport=transport)
|
||||
transport = xmlrpc_client.SafeTransport(context=ssl_context)
|
||||
return xmlrpc_client.ServerProxy(url, transport=transport)
|
||||
|
||||
return ServerProxy(url)
|
||||
return xmlrpc_client.ServerProxy(url)
|
||||
|
||||
|
||||
@register_client("torrent")
|
||||
@@ -120,7 +120,7 @@ class RTorrentClient(DownloadClient):
|
||||
download_dir = self._download_dir or self._get_download_dir()
|
||||
if download_dir:
|
||||
logger.debug(f"Setting rTorrent download directory: {download_dir}")
|
||||
commands.append(f"d.directory_base.set={download_dir}")
|
||||
commands.append(f"d.directory.set={download_dir}")
|
||||
|
||||
if torrent_info.torrent_data:
|
||||
logger.debug(f"Adding torrent data directly to rTorrent for: {name} with commands: {commands} with data size: {len(torrent_info.torrent_data)}")
|
||||
@@ -156,10 +156,9 @@ class RTorrentClient(DownloadClient):
|
||||
try:
|
||||
# rtorrent is somehow case sensitive and requires uppercase hashes for look
|
||||
download_id = download_id.upper()
|
||||
torrent_list = self._rpc.d.multicall.filtered(
|
||||
all_torrents = self._rpc.d.multicall2(
|
||||
"",
|
||||
"",
|
||||
"default",
|
||||
f"equal={{d.hash=,cat={download_id}}}",
|
||||
"d.hash=",
|
||||
"d.state=",
|
||||
"d.completed_bytes=",
|
||||
@@ -169,6 +168,7 @@ class RTorrentClient(DownloadClient):
|
||||
"d.custom1=",
|
||||
"d.complete=",
|
||||
)
|
||||
torrent_list = [t for t in all_torrents if t and t[0] == download_id]
|
||||
logger.debug(f"Fetched torrent status from rTorrent for: {download_id} - {torrent_list}")
|
||||
if not torrent_list:
|
||||
logger.warning(f"Torrent not found in rTorrent: {download_id}")
|
||||
@@ -327,12 +327,13 @@ class RTorrentClient(DownloadClient):
|
||||
try:
|
||||
# rTorrent is case sensitive for hashes; use uppercase as in get_status()
|
||||
download_hash = download_id.upper()
|
||||
details = self._rpc.d.multicall.filtered(
|
||||
all_torrents = self._rpc.d.multicall2(
|
||||
"",
|
||||
"default",
|
||||
f"equal={{d.hash=,cat={download_hash}}}",
|
||||
"",
|
||||
"d.hash=",
|
||||
"d.base_path=",
|
||||
)
|
||||
details = [t[1:] for t in all_torrents if t and t[0] == download_hash]
|
||||
if not details:
|
||||
return None
|
||||
path = details[0][0]
|
||||
|
||||
@@ -12,7 +12,7 @@ from shelfmark.core.settings_registry import (
|
||||
SelectField,
|
||||
TagListField,
|
||||
)
|
||||
from shelfmark.core.utils import normalize_http_url
|
||||
from shelfmark.core.utils import normalize_http_url, get_hardened_xmlrpc_client
|
||||
from shelfmark.download.network import get_ssl_verify
|
||||
|
||||
|
||||
@@ -254,7 +254,6 @@ def _test_rtorrent_connection(current_values: Optional[Dict[str, Any]] = None) -
|
||||
from shelfmark.core.config import config
|
||||
import ssl
|
||||
from urllib.parse import urlparse
|
||||
from xmlrpc.client import SafeTransport, ServerProxy
|
||||
|
||||
current_values = current_values or {}
|
||||
|
||||
@@ -270,6 +269,8 @@ def _test_rtorrent_connection(current_values: Optional[Dict[str, Any]] = None) -
|
||||
return {"success": False, "message": "rTorrent URL is invalid"}
|
||||
|
||||
try:
|
||||
xmlrpc_client = get_hardened_xmlrpc_client()
|
||||
|
||||
# Add HTTP auth to URL if credentials provided
|
||||
if username and password:
|
||||
parsed = urlparse(url)
|
||||
@@ -281,9 +282,12 @@ def _test_rtorrent_connection(current_values: Optional[Dict[str, Any]] = None) -
|
||||
ssl_context = ssl.create_default_context()
|
||||
ssl_context.check_hostname = False
|
||||
ssl_context.verify_mode = ssl.CERT_NONE
|
||||
rpc = ServerProxy(rpc_url, transport=SafeTransport(context=ssl_context))
|
||||
rpc = xmlrpc_client.ServerProxy(
|
||||
rpc_url,
|
||||
transport=xmlrpc_client.SafeTransport(context=ssl_context),
|
||||
)
|
||||
else:
|
||||
rpc = ServerProxy(rpc_url)
|
||||
rpc = xmlrpc_client.ServerProxy(rpc_url)
|
||||
|
||||
version = rpc.system.client_version()
|
||||
return {"success": True, "message": f"Connected to rTorrent {version}"}
|
||||
|
||||
@@ -200,6 +200,28 @@ def _perform_nfs_fallback(source: Path, dest: Path, is_move: bool) -> None:
|
||||
raise
|
||||
|
||||
|
||||
def _is_enoent_error(error: Exception) -> bool:
|
||||
return isinstance(error, FileNotFoundError) or (
|
||||
isinstance(error, OSError) and error.errno == errno.ENOENT
|
||||
)
|
||||
|
||||
|
||||
def _can_use_partial_copy_after_enoent(
|
||||
temp_path: Optional[Path],
|
||||
expected_size: int,
|
||||
action: str,
|
||||
) -> bool:
|
||||
"""Recover when copy2 writes bytes but fails while copying source metadata."""
|
||||
if not temp_path or not run_blocking_io(temp_path.exists):
|
||||
return False
|
||||
|
||||
try:
|
||||
_verify_transfer_size(temp_path, expected_size, action)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _claim_destination(path: Path) -> bool:
|
||||
"""Atomically claim a destination path by creating a placeholder file.
|
||||
|
||||
@@ -359,6 +381,16 @@ def atomic_move(source_path: Path, dest_path: Path, max_attempts: int = 100) ->
|
||||
copy_error,
|
||||
)
|
||||
_perform_nfs_fallback(source_path, temp_path, is_move=False)
|
||||
elif _is_enoent_error(copy_error) and _can_use_partial_copy_after_enoent(
|
||||
temp_path,
|
||||
expected_size,
|
||||
"move",
|
||||
):
|
||||
logger.warning(
|
||||
"Source vanished during move-copy metadata step; preserving copied data: %s -> %s",
|
||||
source_path,
|
||||
temp_path,
|
||||
)
|
||||
else:
|
||||
raise
|
||||
|
||||
@@ -525,6 +557,16 @@ def atomic_copy(source_path: Path, dest_path: Path, max_attempts: int = 100) ->
|
||||
fallback_error,
|
||||
)
|
||||
raise e from fallback_error
|
||||
elif _is_enoent_error(e) and _can_use_partial_copy_after_enoent(
|
||||
temp_path,
|
||||
expected_size,
|
||||
"copy",
|
||||
):
|
||||
logger.warning(
|
||||
"Source vanished during copy2 metadata step; preserving copied data: %s -> %s",
|
||||
source_path,
|
||||
temp_path,
|
||||
)
|
||||
else:
|
||||
raise
|
||||
|
||||
|
||||
@@ -590,6 +590,20 @@ def proxy_auth_middleware():
|
||||
logger.error(f"Proxy auth middleware error: {e}")
|
||||
return jsonify({"error": "Authentication error"}), 500
|
||||
|
||||
@app.after_request
|
||||
def set_security_headers(response: Response) -> Response:
|
||||
"""Add baseline security headers to every response."""
|
||||
response.headers.setdefault("X-Content-Type-Options", "nosniff")
|
||||
response.headers.setdefault("X-Frame-Options", "DENY")
|
||||
response.headers.setdefault(
|
||||
"Content-Security-Policy",
|
||||
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; connect-src 'self' ws: wss:; frame-ancestors 'none'",
|
||||
)
|
||||
response.headers.setdefault("Permissions-Policy", "camera=(), microphone=(), geolocation=()")
|
||||
response.headers.setdefault("Cross-Origin-Embedder-Policy", "credentialless")
|
||||
return response
|
||||
|
||||
|
||||
def login_required(f):
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
|
||||
@@ -8,7 +8,9 @@ isn't available via Prowlarr's JSON search endpoint.
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Optional
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
from defusedxml import ElementTree as DefusedElementTree
|
||||
from defusedxml.common import DefusedXmlException
|
||||
|
||||
|
||||
def _local_name(tag: str) -> str:
|
||||
@@ -67,8 +69,8 @@ def parse_torznab_xml(xml_text: str) -> List[Dict[str, Any]]:
|
||||
return []
|
||||
|
||||
try:
|
||||
root = ET.fromstring(xml_text)
|
||||
except ET.ParseError:
|
||||
root = DefusedElementTree.fromstring(xml_text)
|
||||
except (DefusedElementTree.ParseError, DefusedXmlException):
|
||||
return []
|
||||
|
||||
items = root.findall(".//item")
|
||||
@@ -168,4 +170,3 @@ def parse_torznab_xml(xml_text: str) -> List[Dict[str, Any]]:
|
||||
})
|
||||
|
||||
return results
|
||||
|
||||
|
||||
Generated
+75
-530
@@ -10,7 +10,7 @@
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.30.2",
|
||||
"react-router-dom": "^6.30.3",
|
||||
"socket.io-client": "^4.7.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -69,6 +69,7 @@
|
||||
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.27.1",
|
||||
"@babel/generator": "^7.28.5",
|
||||
@@ -711,24 +712,6 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@isaacs/cliui": {
|
||||
"version": "8.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
|
||||
"integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"string-width": "^5.1.2",
|
||||
"string-width-cjs": "npm:string-width@^4.2.0",
|
||||
"strip-ansi": "^7.0.1",
|
||||
"strip-ansi-cjs": "npm:strip-ansi@^6.0.1",
|
||||
"wrap-ansi": "^8.1.0",
|
||||
"wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/gen-mapping": {
|
||||
"version": "0.3.13",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||
@@ -817,21 +800,10 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/@pkgjs/parseargs": {
|
||||
"version": "0.11.0",
|
||||
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
|
||||
"integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@remix-run/router": {
|
||||
"version": "1.23.1",
|
||||
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.1.tgz",
|
||||
"integrity": "sha512-vDbaOzF7yT2Qs4vO6XV1MHcJv+3dgR1sT+l3B8xxOVhUC336prMvqrvsLL/9Dnw2xr6Qhz4J0dmS0llNAbnUmQ==",
|
||||
"version": "1.23.2",
|
||||
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz",
|
||||
"integrity": "sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
@@ -1216,6 +1188,7 @@
|
||||
"integrity": "sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~7.16.0"
|
||||
}
|
||||
@@ -1233,6 +1206,7 @@
|
||||
"integrity": "sha512-RFA/bURkcKzx/X9oumPG9Vp3D3JUgus/d0b67KB0t5S/raciymilkOa66olh78MUI92QLbEJevO7rvqU/kjwKA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/prop-types": "*",
|
||||
"csstype": "^3.0.2"
|
||||
@@ -1269,32 +1243,6 @@
|
||||
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-regex": {
|
||||
"version": "6.2.2",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
|
||||
"integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-regex?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-styles": {
|
||||
"version": "6.2.3",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
|
||||
"integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/any-promise": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",
|
||||
@@ -1361,13 +1309,6 @@
|
||||
"postcss": "^8.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/balanced-match": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/baseline-browser-mapping": {
|
||||
"version": "2.8.25",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.25.tgz",
|
||||
@@ -1391,16 +1332,6 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
|
||||
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/braces": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
|
||||
@@ -1434,6 +1365,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.8.19",
|
||||
"caniuse-lite": "^1.0.30001751",
|
||||
@@ -1517,26 +1449,6 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/color-convert": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-name": "~1.1.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/color-name": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/commander": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz",
|
||||
@@ -1554,21 +1466,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"path-key": "^3.1.0",
|
||||
"shebang-command": "^2.0.0",
|
||||
"which": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/cssesc": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
|
||||
@@ -1621,13 +1518,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/eastasianwidth": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
|
||||
"integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/electron-to-chromium": {
|
||||
"version": "1.5.249",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.249.tgz",
|
||||
@@ -1635,13 +1525,6 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/emoji-regex": {
|
||||
"version": "9.2.2",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
|
||||
"integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/engine.io-client": {
|
||||
"version": "6.6.3",
|
||||
"resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.3.tgz",
|
||||
@@ -1783,23 +1666,6 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/foreground-child": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
|
||||
"integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"cross-spawn": "^7.0.6",
|
||||
"signal-exit": "^4.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/fraction.js": {
|
||||
"version": "4.3.7",
|
||||
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz",
|
||||
@@ -1849,27 +1715,6 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/glob": {
|
||||
"version": "10.4.5",
|
||||
"resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz",
|
||||
"integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"foreground-child": "^3.1.0",
|
||||
"jackspeak": "^3.1.2",
|
||||
"minimatch": "^9.0.4",
|
||||
"minipass": "^7.1.2",
|
||||
"package-json-from-dist": "^1.0.0",
|
||||
"path-scurry": "^1.11.1"
|
||||
},
|
||||
"bin": {
|
||||
"glob": "dist/esm/bin.mjs"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/glob-parent": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
|
||||
@@ -1935,16 +1780,6 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/is-fullwidth-code-point": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/is-glob": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
|
||||
@@ -1968,35 +1803,13 @@
|
||||
"node": ">=0.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/isexe": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
||||
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/jackspeak": {
|
||||
"version": "3.4.3",
|
||||
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz",
|
||||
"integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"@isaacs/cliui": "^8.0.2"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@pkgjs/parseargs": "^0.11.0"
|
||||
}
|
||||
},
|
||||
"node_modules/jiti": {
|
||||
"version": "1.21.7",
|
||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
|
||||
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"jiti": "bin/jiti.js"
|
||||
}
|
||||
@@ -2099,32 +1912,6 @@
|
||||
"node": ">=8.6"
|
||||
}
|
||||
},
|
||||
"node_modules/minimatch": {
|
||||
"version": "9.0.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
|
||||
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16 || 14 >=14.17"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/minipass": {
|
||||
"version": "7.1.2",
|
||||
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
|
||||
"integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=16 || 14 >=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
@@ -2209,23 +1996,6 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/package-json-from-dist": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
|
||||
"integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0"
|
||||
},
|
||||
"node_modules/path-key": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
||||
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/path-parse": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
|
||||
@@ -2233,30 +2003,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/path-scurry": {
|
||||
"version": "1.11.1",
|
||||
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz",
|
||||
"integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"lru-cache": "^10.2.0",
|
||||
"minipass": "^5.0.0 || ^6.0.2 || ^7.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16 || 14 >=14.18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/path-scurry/node_modules/lru-cache": {
|
||||
"version": "10.4.3",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
|
||||
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
@@ -2317,6 +2063,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"picocolors": "^1.1.1",
|
||||
@@ -2486,6 +2233,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
||||
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0"
|
||||
},
|
||||
@@ -2498,6 +2246,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
||||
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0",
|
||||
"scheduler": "^0.23.2"
|
||||
@@ -2517,12 +2266,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-router": {
|
||||
"version": "6.30.2",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.2.tgz",
|
||||
"integrity": "sha512-H2Bm38Zu1bm8KUE5NVWRMzuIyAV8p/JrOaBJAwVmp37AXG72+CZJlEBw6pdn9i5TBgLMhNDgijS4ZlblpHyWTA==",
|
||||
"version": "6.30.3",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.3.tgz",
|
||||
"integrity": "sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@remix-run/router": "1.23.1"
|
||||
"@remix-run/router": "1.23.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
@@ -2532,13 +2281,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-router-dom": {
|
||||
"version": "6.30.2",
|
||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.2.tgz",
|
||||
"integrity": "sha512-l2OwHn3UUnEVUqc6/1VMmR1cvZryZ3j3NzapC2eUXO1dB0sYp5mvwdjiXhpUbRb21eFow3qSxpP8Yv6oAU824Q==",
|
||||
"version": "6.30.3",
|
||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.3.tgz",
|
||||
"integrity": "sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@remix-run/router": "1.23.1",
|
||||
"react-router": "6.30.2"
|
||||
"@remix-run/router": "1.23.2",
|
||||
"react-router": "6.30.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
@@ -2688,42 +2437,6 @@
|
||||
"semver": "bin/semver.js"
|
||||
}
|
||||
},
|
||||
"node_modules/shebang-command": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"shebang-regex": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/shebang-regex": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
|
||||
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/signal-exit": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
|
||||
"integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/socket.io-client": {
|
||||
"version": "4.8.1",
|
||||
"resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.1.tgz",
|
||||
@@ -2796,123 +2509,19 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/string-width": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
|
||||
"integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"eastasianwidth": "^0.2.0",
|
||||
"emoji-regex": "^9.2.2",
|
||||
"strip-ansi": "^7.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/string-width-cjs": {
|
||||
"name": "string-width",
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^8.0.0",
|
||||
"is-fullwidth-code-point": "^3.0.0",
|
||||
"strip-ansi": "^6.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/string-width-cjs/node_modules/ansi-regex": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/string-width-cjs/node_modules/emoji-regex": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/string-width-cjs/node_modules/strip-ansi": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-ansi": {
|
||||
"version": "7.1.2",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz",
|
||||
"integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^6.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-ansi-cjs": {
|
||||
"name": "strip-ansi",
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-ansi-cjs/node_modules/ansi-regex": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/sucrase": {
|
||||
"version": "3.35.0",
|
||||
"resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz",
|
||||
"integrity": "sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==",
|
||||
"version": "3.35.1",
|
||||
"resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz",
|
||||
"integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/gen-mapping": "^0.3.2",
|
||||
"commander": "^4.0.0",
|
||||
"glob": "^10.3.10",
|
||||
"lines-and-columns": "^1.1.6",
|
||||
"mz": "^2.7.0",
|
||||
"pirates": "^4.0.1",
|
||||
"tinyglobby": "^0.2.11",
|
||||
"ts-interface-checker": "^0.1.9"
|
||||
},
|
||||
"bin": {
|
||||
@@ -2997,6 +2606,55 @@
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/tinyglobby": {
|
||||
"version": "0.2.15",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
||||
"integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fdir": "^6.5.0",
|
||||
"picomatch": "^4.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/SuperchupuDev"
|
||||
}
|
||||
},
|
||||
"node_modules/tinyglobby/node_modules/fdir": {
|
||||
"version": "6.5.0",
|
||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"picomatch": "^3 || ^4"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"picomatch": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/tinyglobby/node_modules/picomatch": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/to-regex-range": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
||||
@@ -3082,6 +2740,7 @@
|
||||
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.21.3",
|
||||
"postcss": "^8.4.43",
|
||||
@@ -3136,120 +2795,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/which": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"isexe": "^2.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"node-which": "bin/node-which"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/wrap-ansi": {
|
||||
"version": "8.1.0",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz",
|
||||
"integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^6.1.0",
|
||||
"string-width": "^5.0.1",
|
||||
"strip-ansi": "^7.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/wrap-ansi-cjs": {
|
||||
"name": "wrap-ansi",
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
|
||||
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.0.0",
|
||||
"string-width": "^4.1.0",
|
||||
"strip-ansi": "^6.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/wrap-ansi-cjs/node_modules/ansi-regex": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/wrap-ansi-cjs/node_modules/ansi-styles": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-convert": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/wrap-ansi-cjs/node_modules/emoji-regex": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/wrap-ansi-cjs/node_modules/string-width": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^8.0.0",
|
||||
"is-fullwidth-code-point": "^3.0.0",
|
||||
"strip-ansi": "^6.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/wrap-ansi-cjs/node_modules/strip-ansi": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.17.1",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz",
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.30.2",
|
||||
"react-router-dom": "^6.30.3",
|
||||
"socket.io-client": "^4.7.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1039,12 +1039,25 @@ function App() {
|
||||
record: RequestRecord,
|
||||
options?: {
|
||||
browseOnly?: boolean;
|
||||
manualApproval?: boolean;
|
||||
}
|
||||
) => {
|
||||
if (!requestRoleIsAdmin) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (options?.manualApproval) {
|
||||
try {
|
||||
await fulfilSidebarRequest(requestId, undefined, undefined, true);
|
||||
await refreshActivitySnapshot();
|
||||
showToast('Request approved', 'success');
|
||||
await fetchStatus();
|
||||
} catch (error) {
|
||||
showToast(getErrorMessage(error, 'Failed to approve request'), 'error');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const shouldBrowse = Boolean(options?.browseOnly) || record.request_level === 'book';
|
||||
|
||||
if (!shouldBrowse && record.request_level === 'release') {
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
releaseLanguageMatchesFilter,
|
||||
buildLanguageNormalizer,
|
||||
} from '../utils/languageFilters';
|
||||
import { getReleaseFormats } from '../utils/releaseFormats';
|
||||
|
||||
// Module-level cache for release search results
|
||||
// Key format: `${provider}:${provider_id}:${source}:${contentType}`
|
||||
@@ -266,9 +267,7 @@ function getTitleMatchScore(title: string, titleCandidate: string): number {
|
||||
|
||||
if (normalizedTitle.startsWith(titleCandidate)) {
|
||||
score += 6000;
|
||||
}
|
||||
|
||||
if (normalizedTitle.includes(titleCandidate)) {
|
||||
} else if (normalizedTitle.includes(titleCandidate)) {
|
||||
score += 3000;
|
||||
}
|
||||
|
||||
@@ -1247,16 +1246,16 @@ export const ReleaseModal = ({
|
||||
const availableFormats = useMemo(() => {
|
||||
const releases = releasesBySource[activeTab]?.releases || [];
|
||||
const formats = new Set<string>();
|
||||
const effectiveLower = effectiveFormats.map((f) => f.toLowerCase());
|
||||
const effectiveLower = new Set(effectiveFormats.map((f) => f.toLowerCase()));
|
||||
|
||||
releases.forEach((r) => {
|
||||
if (r.format) {
|
||||
const fmt = r.format.toLowerCase();
|
||||
const releaseFormats = getReleaseFormats(r);
|
||||
releaseFormats.forEach((fmt) => {
|
||||
// Only include formats that are in the supported list
|
||||
if (effectiveLower.includes(fmt)) {
|
||||
if (effectiveLower.has(fmt)) {
|
||||
formats.add(fmt);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
return Array.from(formats).sort();
|
||||
}, [releasesBySource, activeTab, effectiveFormats]);
|
||||
@@ -1390,22 +1389,23 @@ export const ReleaseModal = ({
|
||||
// Filter and sort releases based on settings and user selection
|
||||
const filteredReleases = useMemo(() => {
|
||||
const releases = releasesBySource[activeTab]?.releases || [];
|
||||
const effectiveLower = effectiveFormats.map((f) => f.toLowerCase());
|
||||
const effectiveLower = new Set(effectiveFormats.map((f) => f.toLowerCase()));
|
||||
const supportsIndexerFilter = columnConfig.supported_filters?.includes('indexer');
|
||||
const selectedFormat = formatFilter.toLowerCase();
|
||||
|
||||
// First, filter
|
||||
let filtered = releases.filter((r) => {
|
||||
// Format filtering
|
||||
const fmt = r.format?.toLowerCase();
|
||||
const releaseFormats = getReleaseFormats(r);
|
||||
|
||||
if (formatFilter) {
|
||||
// User selected a specific format - must match exactly
|
||||
if (!fmt || fmt !== formatFilter.toLowerCase()) return false;
|
||||
} else if (fmt) {
|
||||
// No specific filter - show only supported formats
|
||||
if (!effectiveLower.includes(fmt)) return false;
|
||||
// User selected a specific format - match if any format on the release matches
|
||||
if (!releaseFormats.includes(selectedFormat)) return false;
|
||||
} else if (releaseFormats.length > 0) {
|
||||
// No specific filter - show only releases that include at least one supported format
|
||||
if (!releaseFormats.some((fmt) => effectiveLower.has(fmt))) return false;
|
||||
}
|
||||
// Releases with no format pass through when no filter is set (show all)
|
||||
// Releases with no format info pass through when no filter is set (show all)
|
||||
|
||||
// Language filtering - use r.language when provided by enriched indexers
|
||||
// Releases with no language (null/undefined) always pass
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
|
||||
interface RequestApproveOptions {
|
||||
browseOnly?: boolean;
|
||||
manualApproval?: boolean;
|
||||
}
|
||||
|
||||
type RequestApproveHandler = (
|
||||
@@ -431,6 +432,7 @@ export const ActivityCard = ({
|
||||
? 'Browse Releases To Retry'
|
||||
: 'Browse Releases To Approve'
|
||||
: 'Approve Attached File';
|
||||
const canMarkAsApprovedWithoutRelease = requiresBrowseBeforeApprove && !hasAttachedRelease;
|
||||
|
||||
const provider = toOptionalText(bookData.provider)?.toLowerCase();
|
||||
const providerId = toOptionalText(bookData.provider_id);
|
||||
@@ -467,6 +469,19 @@ export const ActivityCard = ({
|
||||
}
|
||||
};
|
||||
|
||||
const handleReviewManualApproval = async () => {
|
||||
if (!reviewRecord || !reviewApproveHandler || isReviewSubmitting) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsReviewSubmitting(true);
|
||||
try {
|
||||
await reviewApproveHandler(reviewRecord.id, reviewRecord, { manualApproval: true });
|
||||
} finally {
|
||||
setIsReviewSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const canShowInlineReview = Boolean(isRequestDetailsOpen && reviewRecord && reviewApproveHandler);
|
||||
const rejectConfirmHandler = onRequestRejectConfirm || onRequestReject;
|
||||
const canShowInlineReject = Boolean(
|
||||
@@ -704,6 +719,16 @@ export const ActivityCard = ({
|
||||
>
|
||||
{isReviewSubmitting ? 'Working...' : approveLabel}
|
||||
</button>
|
||||
{canMarkAsApprovedWithoutRelease && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleReviewManualApproval}
|
||||
disabled={isReviewSubmitting}
|
||||
className="px-2.5 py-1.5 rounded-md text-xs border border-[var(--border-muted)] hover:bg-[var(--hover-surface)] transition-colors disabled:opacity-50"
|
||||
>
|
||||
{isReviewSubmitting ? 'Working...' : 'Manually Mark as Approved'}
|
||||
</button>
|
||||
)}
|
||||
{canBrowseAlternatives && hasAttachedRelease && (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -30,6 +30,7 @@ interface ActivitySidebarProps {
|
||||
record: RequestRecord,
|
||||
options?: {
|
||||
browseOnly?: boolean;
|
||||
manualApproval?: boolean;
|
||||
}
|
||||
) => Promise<void> | void;
|
||||
onRequestReject?: (requestId: number, adminNote?: string) => Promise<void> | void;
|
||||
|
||||
@@ -260,9 +260,9 @@ export const RequestPolicyGrid = ({
|
||||
|
||||
if (!isConfigurable) {
|
||||
return (
|
||||
<div key={key} className="flex items-center min-h-[36px]">
|
||||
<div key={key} className="flex items-center justify-center min-h-[36px]">
|
||||
{mobileLabel}
|
||||
<span className="text-sm opacity-50">
|
||||
<span className="text-xs opacity-40">
|
||||
{REQUEST_POLICY_MODE_LABELS[effectiveMode]}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -154,21 +154,24 @@ export function useAuth(options: UseAuthOptions = {}): UseAuthReturn {
|
||||
window.location.href = logout_url;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// Reload auth settings after session clear so /login has current auth-mode UI config.
|
||||
applyAuthResponse(await checkAuth());
|
||||
} catch (error) {
|
||||
console.error('Post-logout auth refresh failed:', error);
|
||||
setIsAuthenticated(false);
|
||||
setIsAdmin(false);
|
||||
setUsername(null);
|
||||
setDisplayName(null);
|
||||
}
|
||||
refreshSocketSession();
|
||||
setIsAuthenticated(false);
|
||||
setIsAdmin(false);
|
||||
setUsername(null);
|
||||
setDisplayName(null);
|
||||
setOidcButtonLabel(null);
|
||||
setHideLocalAuth(false);
|
||||
setOidcAutoRedirect(false);
|
||||
onLogoutSuccess?.();
|
||||
navigate('/login', { replace: true });
|
||||
} catch (error) {
|
||||
console.error('Logout failed:', error);
|
||||
showToast?.('Logout failed', 'error');
|
||||
}
|
||||
}, [navigate, onLogoutSuccess, refreshSocketSession, showToast]);
|
||||
}, [navigate, onLogoutSuccess, refreshSocketSession, showToast, applyAuthResponse]);
|
||||
|
||||
return {
|
||||
isAuthenticated,
|
||||
|
||||
@@ -32,7 +32,8 @@ export interface UseRequestsReturn {
|
||||
fulfilRequest: (
|
||||
id: number,
|
||||
releaseData?: Record<string, unknown>,
|
||||
adminNote?: string
|
||||
adminNote?: string,
|
||||
manualApproval?: boolean
|
||||
) => Promise<void>;
|
||||
rejectRequest: (id: number, adminNote?: string) => Promise<void>;
|
||||
}
|
||||
@@ -217,7 +218,12 @@ export const useRequests = ({
|
||||
}, []);
|
||||
|
||||
const fulfilRequest = useCallback(
|
||||
async (id: number, releaseData?: Record<string, unknown>, adminNote?: string) => {
|
||||
async (
|
||||
id: number,
|
||||
releaseData?: Record<string, unknown>,
|
||||
adminNote?: string,
|
||||
manualApproval?: boolean
|
||||
) => {
|
||||
if (!isAdmin) {
|
||||
throw new Error('Admin access required');
|
||||
}
|
||||
@@ -232,6 +238,7 @@ export const useRequests = ({
|
||||
const updated = await fulfilAdminRequest(id, {
|
||||
release_data: releaseData,
|
||||
admin_note: adminNote,
|
||||
manual_approval: manualApproval,
|
||||
});
|
||||
setRequests((prev) => upsertRequestRecord(prev, updated));
|
||||
setError(null);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useCallback, useRef } from 'react';
|
||||
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Book, AppConfig, AdvancedFilterState, ContentType } from '../types';
|
||||
import { searchBooks, searchMetadata, AuthenticationError } from '../services/api';
|
||||
@@ -78,6 +78,24 @@ export function useSearch(options: UseSearchOptions): UseSearchReturn {
|
||||
sort: string;
|
||||
fieldValues: SearchFieldValues;
|
||||
} | null>(null);
|
||||
const previousContentTypeRef = useRef<ContentType>(contentType);
|
||||
|
||||
// Switching media type starts a new search session.
|
||||
useEffect(() => {
|
||||
if (previousContentTypeRef.current === contentType) {
|
||||
return;
|
||||
}
|
||||
|
||||
previousContentTypeRef.current = contentType;
|
||||
setBooks([]);
|
||||
setLastSearchQuery('');
|
||||
setSearchFieldValues({});
|
||||
setHasMore(false);
|
||||
setTotalFound(0);
|
||||
setCurrentPage(1);
|
||||
lastSearchParamsRef.current = null;
|
||||
onSearchReset?.();
|
||||
}, [contentType, onSearchReset]);
|
||||
|
||||
const updateAdvancedFilters = useCallback((updates: Partial<AdvancedFilterState>) => {
|
||||
setAdvancedFilters(prev => ({ ...prev, ...updates }));
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { useState, useCallback, useEffect, useRef } from 'react';
|
||||
import { getSettings, updateSettings, executeSettingsAction } from '../services/api';
|
||||
import {
|
||||
SettingsTab,
|
||||
@@ -12,12 +12,22 @@ import {
|
||||
setThemePreference,
|
||||
THEME_FIELD,
|
||||
} from '../utils/themePreference';
|
||||
import {
|
||||
cloneSettingsValues,
|
||||
mergeFetchedSettingsWithDirtyValues,
|
||||
type SettingsValues,
|
||||
} from '../utils/settingsValues';
|
||||
|
||||
type ValueBearingField = Exclude<
|
||||
SettingsField,
|
||||
{ type: 'ActionButton' } | { type: 'HeadingField' } | { type: 'CustomComponentField' }
|
||||
>;
|
||||
|
||||
interface FetchSettingsOptions {
|
||||
silent?: boolean;
|
||||
preserveDirtyValues?: boolean;
|
||||
}
|
||||
|
||||
// Extract value from a field based on its type
|
||||
function getFieldValue(field: SettingsField): unknown {
|
||||
// These field types have no value property
|
||||
@@ -88,11 +98,22 @@ export function useSettings(): UseSettingsReturn {
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedTab, setSelectedTab] = useState<string | null>(null);
|
||||
const [values, setValues] = useState<Record<string, Record<string, unknown>>>({});
|
||||
const [originalValues, setOriginalValues] = useState<Record<string, Record<string, unknown>>>({});
|
||||
const [values, setValues] = useState<SettingsValues>({});
|
||||
const [originalValues, setOriginalValues] = useState<SettingsValues>({});
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const valuesRef = useRef<SettingsValues>({});
|
||||
const originalValuesRef = useRef<SettingsValues>({});
|
||||
|
||||
const fetchSettings = useCallback(async (silent = false) => {
|
||||
useEffect(() => {
|
||||
valuesRef.current = values;
|
||||
}, [values]);
|
||||
|
||||
useEffect(() => {
|
||||
originalValuesRef.current = originalValues;
|
||||
}, [originalValues]);
|
||||
|
||||
const fetchSettings = useCallback(async (options: FetchSettingsOptions = {}) => {
|
||||
const { silent = false, preserveDirtyValues = false } = options;
|
||||
if (!silent) {
|
||||
setIsLoading(true);
|
||||
}
|
||||
@@ -114,7 +135,7 @@ export function useSettings(): UseSettingsReturn {
|
||||
setGroups(response.groups || []);
|
||||
|
||||
// Initialize values from fetched data
|
||||
const initialValues: Record<string, Record<string, unknown>> = {};
|
||||
const initialValues: SettingsValues = {};
|
||||
tabsWithTheme.forEach((tab) => {
|
||||
initialValues[tab.name] = {};
|
||||
getValueBearingFields(tab.fields).forEach((field) => {
|
||||
@@ -127,8 +148,12 @@ export function useSettings(): UseSettingsReturn {
|
||||
});
|
||||
});
|
||||
|
||||
setValues(initialValues);
|
||||
setOriginalValues(JSON.parse(JSON.stringify(initialValues)));
|
||||
const nextValues = preserveDirtyValues
|
||||
? mergeFetchedSettingsWithDirtyValues(initialValues, valuesRef.current, originalValuesRef.current)
|
||||
: initialValues;
|
||||
|
||||
setValues(nextValues);
|
||||
setOriginalValues(cloneSettingsValues(initialValues));
|
||||
|
||||
// Select first tab by default if none selected
|
||||
if (tabsWithTheme.length > 0) {
|
||||
@@ -231,7 +256,7 @@ export function useSettings(): UseSettingsReturn {
|
||||
if (result.success) {
|
||||
// Refetch all settings silently to pick up any backend-triggered changes
|
||||
// (e.g., enabling a metadata provider auto-updates METADATA_PROVIDER)
|
||||
await fetchSettings(true);
|
||||
await fetchSettings({ silent: true });
|
||||
}
|
||||
|
||||
return result;
|
||||
@@ -259,7 +284,7 @@ export function useSettings(): UseSettingsReturn {
|
||||
// Re-fetch settings after successful action to pick up updated options
|
||||
// (e.g., BookLore "Test Connection" refreshes library/path lists)
|
||||
if (result.success) {
|
||||
fetchSettings(true);
|
||||
await fetchSettings({ silent: true, preserveDirtyValues: true });
|
||||
}
|
||||
|
||||
return result;
|
||||
@@ -287,6 +312,6 @@ export function useSettings(): UseSettingsReturn {
|
||||
saveTab,
|
||||
executeAction,
|
||||
isSaving,
|
||||
refetch: fetchSettings,
|
||||
refetch: () => fetchSettings(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ export interface RequestListParams {
|
||||
export interface FulfilAdminRequestBody {
|
||||
release_data?: Record<string, unknown>;
|
||||
admin_note?: string;
|
||||
manual_approval?: boolean;
|
||||
}
|
||||
|
||||
export interface RejectAdminRequestBody {
|
||||
@@ -45,10 +46,17 @@ export const buildAdminRequestActionUrl = (
|
||||
export const buildFulfilAdminRequestBody = (
|
||||
body: FulfilAdminRequestBody = {}
|
||||
): FulfilAdminRequestBody => {
|
||||
return {
|
||||
release_data: body.release_data,
|
||||
admin_note: body.admin_note,
|
||||
};
|
||||
const payload: FulfilAdminRequestBody = {};
|
||||
if (body.release_data !== undefined) {
|
||||
payload.release_data = body.release_data;
|
||||
}
|
||||
if (body.admin_note !== undefined) {
|
||||
payload.admin_note = body.admin_note;
|
||||
}
|
||||
if (body.manual_approval !== undefined) {
|
||||
payload.manual_approval = body.manual_approval;
|
||||
}
|
||||
return payload;
|
||||
};
|
||||
|
||||
export const buildRejectAdminRequestBody = (
|
||||
|
||||
@@ -36,6 +36,18 @@ describe('admin request API client functions', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('builds manual-approval fulfil payload without release data', () => {
|
||||
const body = buildFulfilAdminRequestBody({
|
||||
manual_approval: true,
|
||||
admin_note: 'Handled manually',
|
||||
});
|
||||
|
||||
assert.deepEqual(body, {
|
||||
manual_approval: true,
|
||||
admin_note: 'Handled manually',
|
||||
});
|
||||
});
|
||||
|
||||
it('builds reject endpoint URL and payload shape', () => {
|
||||
const url = buildAdminRequestActionUrl('/api/admin/requests', 51, 'reject');
|
||||
const body = buildRejectAdminRequestBody({
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import * as assert from 'node:assert/strict';
|
||||
import { describe, it } from 'node:test';
|
||||
import { getReleaseFormats } from '../utils/releaseFormats.js';
|
||||
import type { Release } from '../types';
|
||||
|
||||
function buildRelease(overrides: Partial<Release>): Release {
|
||||
return {
|
||||
source: 'prowlarr',
|
||||
source_id: 'release-1',
|
||||
title: 'Test Release',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('releaseFormats.getReleaseFormats', () => {
|
||||
it('returns primary and extra formats in order, normalized and deduplicated', () => {
|
||||
const release = buildRelease({
|
||||
format: 'AZW3',
|
||||
extra: { formats: ['MOBI', 'EPUB', 'azw3', ' mobi '] },
|
||||
});
|
||||
|
||||
assert.deepEqual(getReleaseFormats(release), ['azw3', 'mobi', 'epub']);
|
||||
});
|
||||
|
||||
it('uses extra formats when primary format is missing', () => {
|
||||
const release = buildRelease({
|
||||
extra: { formats: ['EPUB', 'MOBI'] },
|
||||
});
|
||||
|
||||
assert.deepEqual(getReleaseFormats(release), ['epub', 'mobi']);
|
||||
});
|
||||
|
||||
it('supports legacy string value in extra.formats', () => {
|
||||
const release = buildRelease({
|
||||
extra: { formats: 'PDF' },
|
||||
});
|
||||
|
||||
assert.deepEqual(getReleaseFormats(release), ['pdf']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import * as assert from 'node:assert/strict';
|
||||
import { describe, it } from 'node:test';
|
||||
import { mergeFetchedSettingsWithDirtyValues } from '../utils/settingsValues.js';
|
||||
|
||||
describe('mergeFetchedSettingsWithDirtyValues', () => {
|
||||
it('preserves unsaved dirty values while applying fresh fetched values', () => {
|
||||
const fetchedValues = {
|
||||
general: {
|
||||
apiKey: 'saved-key',
|
||||
endpoint: 'https://saved.example',
|
||||
},
|
||||
};
|
||||
|
||||
const currentValues = {
|
||||
general: {
|
||||
apiKey: 'unsaved-key',
|
||||
endpoint: 'https://saved.example',
|
||||
},
|
||||
};
|
||||
|
||||
const originalValues = {
|
||||
general: {
|
||||
apiKey: 'saved-key',
|
||||
endpoint: 'https://saved.example',
|
||||
},
|
||||
};
|
||||
|
||||
assert.deepEqual(
|
||||
mergeFetchedSettingsWithDirtyValues(fetchedValues, currentValues, originalValues),
|
||||
{
|
||||
general: {
|
||||
apiKey: 'unsaved-key',
|
||||
endpoint: 'https://saved.example',
|
||||
},
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('does not preserve values that are not dirty and ignores keys removed by backend', () => {
|
||||
const fetchedValues = {
|
||||
provider: {
|
||||
host: 'new-host',
|
||||
enabled: true,
|
||||
},
|
||||
};
|
||||
|
||||
const currentValues = {
|
||||
provider: {
|
||||
host: 'old-host',
|
||||
enabled: false,
|
||||
removedField: 'local-only',
|
||||
},
|
||||
};
|
||||
|
||||
const originalValues = {
|
||||
provider: {
|
||||
host: 'old-host',
|
||||
enabled: false,
|
||||
removedField: 'local-only',
|
||||
},
|
||||
};
|
||||
|
||||
assert.deepEqual(
|
||||
mergeFetchedSettingsWithDirtyValues(fetchedValues, currentValues, originalValues),
|
||||
{
|
||||
provider: {
|
||||
host: 'new-host',
|
||||
enabled: true,
|
||||
},
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Release } from '../types';
|
||||
|
||||
function normalizeFormatValue(value: unknown): string | null {
|
||||
if (typeof value !== 'string') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalized = value.trim().toLowerCase();
|
||||
return normalized.length > 0 ? normalized : null;
|
||||
}
|
||||
|
||||
export function getReleaseFormats(release: Release): string[] {
|
||||
const formats: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
const addFormat = (value: unknown): void => {
|
||||
const normalized = normalizeFormatValue(value);
|
||||
if (!normalized || seen.has(normalized)) {
|
||||
return;
|
||||
}
|
||||
seen.add(normalized);
|
||||
formats.push(normalized);
|
||||
};
|
||||
|
||||
addFormat(release.format);
|
||||
|
||||
const extraFormats = (release.extra as Record<string, unknown> | undefined)?.formats;
|
||||
if (Array.isArray(extraFormats)) {
|
||||
extraFormats.forEach(addFormat);
|
||||
} else {
|
||||
addFormat(extraFormats);
|
||||
}
|
||||
|
||||
return formats;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
export type SettingsValues = Record<string, Record<string, unknown>>;
|
||||
|
||||
export function cloneSettingsValues(values: SettingsValues): SettingsValues {
|
||||
return JSON.parse(JSON.stringify(values)) as SettingsValues;
|
||||
}
|
||||
|
||||
export function mergeFetchedSettingsWithDirtyValues(
|
||||
fetchedValues: SettingsValues,
|
||||
currentValues: SettingsValues,
|
||||
originalValues: SettingsValues
|
||||
): SettingsValues {
|
||||
const mergedValues: SettingsValues = {};
|
||||
|
||||
for (const [tabName, fetchedTabValues] of Object.entries(fetchedValues)) {
|
||||
mergedValues[tabName] = { ...fetchedTabValues };
|
||||
const currentTabValues = currentValues[tabName] ?? {};
|
||||
const originalTabValues = originalValues[tabName] ?? {};
|
||||
|
||||
for (const [key, currentValue] of Object.entries(currentTabValues)) {
|
||||
if (!(key in fetchedTabValues)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const originalValue = originalTabValues[key];
|
||||
if (JSON.stringify(currentValue) !== JSON.stringify(originalValue)) {
|
||||
mergedValues[tabName][key] = currentValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return mergedValues;
|
||||
}
|
||||
@@ -9,10 +9,5 @@
|
||||
"types": ["node"],
|
||||
"allowImportingTsExtensions": false
|
||||
},
|
||||
"include": [
|
||||
"src/tests/**/*.node.test.ts",
|
||||
"src/hooks/requestPolicyCore.ts",
|
||||
"src/utils/requestPolicyUi.ts",
|
||||
"src/types/index.ts"
|
||||
]
|
||||
"include": ["src"]
|
||||
}
|
||||
|
||||
@@ -202,6 +202,28 @@ class TestAtomicCopy:
|
||||
# No partial file should exist
|
||||
assert not dest.exists()
|
||||
|
||||
def test_copy_recovers_when_metadata_step_hits_enoent(self, tmp_path):
|
||||
"""Treat ENOENT from copy2 metadata as recoverable if bytes already copied."""
|
||||
import errno
|
||||
from shelfmark.download.fs import atomic_copy as _atomic_copy
|
||||
|
||||
source = tmp_path / "source.txt"
|
||||
source.write_text("content")
|
||||
dest = tmp_path / "dest.txt"
|
||||
|
||||
real_copyfile = shutil.copyfile
|
||||
|
||||
def _copy_then_enoent(src, dst):
|
||||
real_copyfile(src, dst)
|
||||
raise FileNotFoundError(errno.ENOENT, "No such file or directory", src)
|
||||
|
||||
with patch("shutil.copy2", side_effect=_copy_then_enoent):
|
||||
result = _atomic_copy(source, dest)
|
||||
|
||||
assert result == dest
|
||||
assert result.exists()
|
||||
assert result.read_text() == "content"
|
||||
|
||||
def test_max_attempts_exceeded(self, tmp_path):
|
||||
"""Raises after max collision attempts."""
|
||||
from shelfmark.download.fs import atomic_copy as _atomic_copy
|
||||
|
||||
@@ -349,6 +349,34 @@ class TestAtomicMove:
|
||||
assert mock_copy.called
|
||||
assert mock_fallback.called
|
||||
|
||||
def test_cross_filesystem_move_recovers_when_metadata_step_hits_enoent(self, tmp_path, monkeypatch):
|
||||
"""Completes EXDEV fallback move when copy2 metadata fails with ENOENT."""
|
||||
from shelfmark.download.fs import atomic_move as _atomic_move
|
||||
import errno
|
||||
|
||||
source = tmp_path / "source.txt"
|
||||
source.write_text("content")
|
||||
dest = tmp_path / "dest.txt"
|
||||
|
||||
def _raise_exdev(*_args, **_kwargs):
|
||||
raise OSError(errno.EXDEV, "Cross-device link")
|
||||
|
||||
real_copyfile = shutil.copyfile
|
||||
|
||||
def _copy_then_enoent(src, dst):
|
||||
real_copyfile(src, dst)
|
||||
raise FileNotFoundError(errno.ENOENT, "No such file or directory", src)
|
||||
|
||||
monkeypatch.setattr(os, "rename", _raise_exdev)
|
||||
|
||||
with patch("shelfmark.download.fs.shutil.copy2", side_effect=_copy_then_enoent):
|
||||
result = _atomic_move(source, dest)
|
||||
|
||||
assert result == dest
|
||||
assert not source.exists()
|
||||
assert dest.exists()
|
||||
assert dest.read_text() == "content"
|
||||
|
||||
|
||||
class TestHardlinkWithLibraryMode:
|
||||
"""Tests for hardlinking in library mode context."""
|
||||
|
||||
@@ -927,6 +927,48 @@ class TestRequestRoutes:
|
||||
assert fulfil_resp.status_code == 400
|
||||
assert "release_data is required to fulfil book-level requests" in fulfil_resp.json["error"]
|
||||
|
||||
def test_admin_fulfil_book_level_request_manual_approval(self, main_module, client):
|
||||
user = _create_user(main_module, prefix="reader")
|
||||
admin = _create_user(main_module, prefix="admin", role="admin")
|
||||
policy = _policy(default_ebook="request_book")
|
||||
|
||||
_set_session(client, user_id=user["username"], db_user_id=user["id"], is_admin=False)
|
||||
create_payload = {
|
||||
"book_data": {
|
||||
"title": "Manual Approval Book",
|
||||
"author": "Manual Admin",
|
||||
"content_type": "ebook",
|
||||
"provider": "openlibrary",
|
||||
"provider_id": "ol-manual-approval",
|
||||
},
|
||||
"context": {
|
||||
"source": "direct_download",
|
||||
"content_type": "ebook",
|
||||
"request_level": "book",
|
||||
},
|
||||
}
|
||||
|
||||
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
|
||||
with patch.object(main_module, "_load_users_request_policy_settings", return_value=policy):
|
||||
with patch("shelfmark.core.request_routes._load_users_request_policy_settings", return_value=policy):
|
||||
create_resp = client.post("/api/requests", json=create_payload)
|
||||
request_id = create_resp.json["id"]
|
||||
|
||||
_set_session(client, user_id=admin["username"], db_user_id=admin["id"], is_admin=True)
|
||||
with patch.object(main_module.backend, "queue_release", return_value=(True, None)) as mock_queue:
|
||||
fulfil_resp = client.post(
|
||||
f"/api/admin/requests/{request_id}/fulfil",
|
||||
json={"manual_approval": True, "admin_note": "Added manually"},
|
||||
)
|
||||
|
||||
assert create_resp.status_code == 201
|
||||
assert fulfil_resp.status_code == 200
|
||||
assert fulfil_resp.json["status"] == "fulfilled"
|
||||
assert fulfil_resp.json["delivery_state"] == "complete"
|
||||
assert fulfil_resp.json["release_data"] is None
|
||||
assert fulfil_resp.json["admin_note"] == "Added manually"
|
||||
mock_queue.assert_not_called()
|
||||
|
||||
def test_admin_fulfil_book_level_request_with_release_data(self, main_module, client):
|
||||
user = _create_user(main_module, prefix="reader")
|
||||
admin = _create_user(main_module, prefix="admin", role="admin")
|
||||
@@ -1576,6 +1618,39 @@ class TestAdminFulfilEdgeCases:
|
||||
assert resp.status_code == 403
|
||||
assert "Admin user identity unavailable" in resp.json["error"]
|
||||
|
||||
def test_fulfil_with_non_boolean_manual_approval_returns_400(self, main_module, client):
|
||||
user = _create_user(main_module, prefix="reader")
|
||||
admin = _create_user(main_module, prefix="admin", role="admin")
|
||||
policy = _policy(default_ebook="request_release")
|
||||
|
||||
_set_session(client, user_id=user["username"], db_user_id=user["id"], is_admin=False)
|
||||
|
||||
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
|
||||
with patch.object(main_module, "_load_users_request_policy_settings", return_value=policy):
|
||||
with patch("shelfmark.core.request_routes._load_users_request_policy_settings", return_value=policy):
|
||||
create_resp = client.post("/api/requests", json={
|
||||
"book_data": {
|
||||
"title": "Manual Flag Validation",
|
||||
"author": "QA",
|
||||
"provider": "p",
|
||||
"provider_id": "mf1",
|
||||
"content_type": "ebook",
|
||||
},
|
||||
"context": {"source": "prowlarr", "content_type": "ebook", "request_level": "release"},
|
||||
"release_data": {"source": "prowlarr", "source_id": "mf-r", "title": "MF.epub"},
|
||||
})
|
||||
request_id = create_resp.json["id"]
|
||||
|
||||
_set_session(client, user_id=admin["username"], db_user_id=admin["id"], is_admin=True)
|
||||
fulfil_resp = client.post(
|
||||
f"/api/admin/requests/{request_id}/fulfil",
|
||||
json={"manual_approval": "yes"},
|
||||
)
|
||||
|
||||
assert create_resp.status_code == 201
|
||||
assert fulfil_resp.status_code == 400
|
||||
assert "manual_approval must be a boolean" in fulfil_resp.json["error"]
|
||||
|
||||
|
||||
class TestAdminRejectEdgeCases:
|
||||
"""Edge cases for POST /api/admin/requests/<id>/reject."""
|
||||
|
||||
@@ -314,6 +314,41 @@ def test_fulfil_request_requires_release_data_for_book_level(user_db):
|
||||
)
|
||||
|
||||
|
||||
def test_fulfil_request_manual_approval_allows_book_level_without_release(user_db):
|
||||
alice = user_db.create_user(username="alice")
|
||||
admin = user_db.create_user(username="admin", role="admin")
|
||||
created = create_request(
|
||||
user_db,
|
||||
user_id=alice["id"],
|
||||
source_hint="direct_download",
|
||||
content_type="ebook",
|
||||
request_level="book",
|
||||
policy_mode="request_book",
|
||||
book_data=_book_data(),
|
||||
)
|
||||
|
||||
called = {"queue_called": False}
|
||||
|
||||
def fake_queue_release(*_args, **_kwargs):
|
||||
called["queue_called"] = True
|
||||
return True, None
|
||||
|
||||
fulfilled = fulfil_request(
|
||||
user_db,
|
||||
request_id=created["id"],
|
||||
admin_user_id=admin["id"],
|
||||
queue_release=fake_queue_release,
|
||||
manual_approval=True,
|
||||
)
|
||||
|
||||
assert fulfilled["status"] == "fulfilled"
|
||||
assert fulfilled["delivery_state"] == "complete"
|
||||
assert fulfilled["delivery_updated_at"] is not None
|
||||
assert fulfilled["release_data"] is None
|
||||
assert fulfilled["reviewed_by"] == admin["id"]
|
||||
assert called["queue_called"] is False
|
||||
|
||||
|
||||
def test_fulfil_request_rejects_oversized_release_override(user_db):
|
||||
alice = user_db.create_user(username="alice")
|
||||
admin = user_db.create_user(username="admin", role="admin")
|
||||
@@ -1242,6 +1277,30 @@ def test_fulfil_non_string_admin_note_returns_error(user_db):
|
||||
)
|
||||
|
||||
|
||||
def test_fulfil_non_boolean_manual_approval_returns_error(user_db):
|
||||
alice = user_db.create_user(username="alice")
|
||||
admin = user_db.create_user(username="admin", role="admin")
|
||||
created = create_request(
|
||||
user_db,
|
||||
user_id=alice["id"],
|
||||
source_hint="prowlarr",
|
||||
content_type="ebook",
|
||||
request_level="release",
|
||||
policy_mode="request_release",
|
||||
book_data=_book_data(),
|
||||
release_data=_release_data(),
|
||||
)
|
||||
|
||||
with pytest.raises(RequestServiceError, match="manual_approval must be a boolean"):
|
||||
fulfil_request(
|
||||
user_db,
|
||||
request_id=created["id"],
|
||||
admin_user_id=admin["id"],
|
||||
queue_release=lambda *a, **kw: (True, None),
|
||||
manual_approval="yes",
|
||||
)
|
||||
|
||||
|
||||
def test_fulfil_empty_admin_note_stored_as_none(user_db):
|
||||
alice = user_db.create_user(username="alice")
|
||||
admin = user_db.create_user(username="admin", role="admin")
|
||||
|
||||
@@ -265,7 +265,7 @@ class TestRTorrentClientAddDownload:
|
||||
args = mock_rpc.load.start.call_args[0]
|
||||
assert args[1] == "magnet:?xt=urn:btih:abc123def456"
|
||||
assert "d.custom1.set=cwabd" in args[2]
|
||||
assert "d.directory_base.set=/downloads" in args[2]
|
||||
assert "d.directory.set=/downloads" in args[2]
|
||||
|
||||
def test_add_download_torrent_file(self, monkeypatch):
|
||||
"""Test adding a torrent via raw torrent data."""
|
||||
@@ -376,9 +376,9 @@ class TestRTorrentClientGetStatus:
|
||||
)
|
||||
|
||||
mock_rpc = MagicMock()
|
||||
mock_rpc.d.multicall.filtered.return_value = [
|
||||
mock_rpc.d.multicall2.return_value = [
|
||||
[
|
||||
"abc123def456",
|
||||
"ABC123DEF456",
|
||||
2,
|
||||
524288000,
|
||||
1048576000,
|
||||
@@ -423,10 +423,11 @@ class TestRTorrentClientGetStatus:
|
||||
)
|
||||
|
||||
mock_rpc = MagicMock()
|
||||
mock_rpc.d.multicall.filtered.side_effect = [
|
||||
# First call: get_status multicall2, second call: _get_torrent_path multicall2
|
||||
mock_rpc.d.multicall2.side_effect = [
|
||||
[
|
||||
[
|
||||
"abc123def456",
|
||||
"ABC123DEF456",
|
||||
4,
|
||||
1048576000,
|
||||
1048576000,
|
||||
@@ -436,7 +437,7 @@ class TestRTorrentClientGetStatus:
|
||||
1,
|
||||
]
|
||||
],
|
||||
[["/downloads/test-torrent"]],
|
||||
[["ABC123DEF456", "/downloads/test-torrent"]],
|
||||
]
|
||||
|
||||
mock_xmlrpc = create_mock_xmlrpc_module()
|
||||
@@ -474,7 +475,7 @@ class TestRTorrentClientGetStatus:
|
||||
)
|
||||
|
||||
mock_rpc = MagicMock()
|
||||
mock_rpc.d.multicall.filtered.return_value = []
|
||||
mock_rpc.d.multicall2.return_value = []
|
||||
|
||||
mock_xmlrpc = create_mock_xmlrpc_module()
|
||||
mock_xmlrpc.ServerProxy.return_value = mock_rpc
|
||||
@@ -508,9 +509,9 @@ class TestRTorrentClientGetStatus:
|
||||
)
|
||||
|
||||
mock_rpc = MagicMock()
|
||||
mock_rpc.d.multicall.filtered.return_value = [
|
||||
mock_rpc.d.multicall2.return_value = [
|
||||
[
|
||||
"abc123def456",
|
||||
"ABC123DEF456",
|
||||
2,
|
||||
524288000,
|
||||
1048576000,
|
||||
@@ -662,7 +663,7 @@ class TestRTorrentClientGetDownloadPath:
|
||||
)
|
||||
|
||||
mock_rpc = MagicMock()
|
||||
mock_rpc.d.multicall.filtered.return_value = [["/downloads/test-file"]]
|
||||
mock_rpc.d.multicall2.return_value = [["ABC123DEF456", "/downloads/test-file"]]
|
||||
|
||||
mock_xmlrpc = create_mock_xmlrpc_module()
|
||||
mock_xmlrpc.ServerProxy.return_value = mock_rpc
|
||||
@@ -695,7 +696,7 @@ class TestRTorrentClientGetDownloadPath:
|
||||
)
|
||||
|
||||
mock_rpc = MagicMock()
|
||||
mock_rpc.d.multicall.filtered.return_value = []
|
||||
mock_rpc.d.multicall2.return_value = []
|
||||
|
||||
mock_xmlrpc = create_mock_xmlrpc_module()
|
||||
mock_xmlrpc.ServerProxy.return_value = mock_rpc
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Tests for Torznab XML parsing helpers."""
|
||||
|
||||
from shelfmark.release_sources.prowlarr.torznab import parse_torznab_xml
|
||||
|
||||
|
||||
def test_parse_torznab_xml_parses_basic_item():
|
||||
xml_text = """<?xml version="1.0"?>
|
||||
<rss>
|
||||
<channel>
|
||||
<item>
|
||||
<title>Example Release</title>
|
||||
<guid>abc-123</guid>
|
||||
<link>https://example.com/download</link>
|
||||
<size>12345</size>
|
||||
<enclosure type="application/x-bittorrent" url="https://example.com/file.torrent" />
|
||||
<prowlarrindexer id="42">Test Indexer</prowlarrindexer>
|
||||
<newznab:attr xmlns:newznab="http://www.newznab.com/DTD/2010/feeds/attributes/"
|
||||
name="seeders" value="10" />
|
||||
<newznab:attr xmlns:newznab="http://www.newznab.com/DTD/2010/feeds/attributes/"
|
||||
name="peers" value="15" />
|
||||
</item>
|
||||
</channel>
|
||||
</rss>
|
||||
"""
|
||||
results = parse_torznab_xml(xml_text)
|
||||
|
||||
assert len(results) == 1
|
||||
result = results[0]
|
||||
assert result["title"] == "Example Release"
|
||||
assert result["guid"] == "abc-123"
|
||||
assert result["indexerId"] == 42
|
||||
assert result["indexer"] == "Test Indexer"
|
||||
assert result["protocol"] == "torrent"
|
||||
assert result["seeders"] == 10
|
||||
assert result["leechers"] == 5
|
||||
|
||||
|
||||
def test_parse_torznab_xml_rejects_entity_expansion_payload():
|
||||
xml_text = """<?xml version="1.0"?>
|
||||
<!DOCTYPE foo [
|
||||
<!ENTITY xxe SYSTEM "file:///etc/passwd">
|
||||
]>
|
||||
<rss>
|
||||
<channel>
|
||||
<item>
|
||||
<title>&xxe;</title>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>
|
||||
"""
|
||||
assert parse_torznab_xml(xml_text) == []
|
||||
Reference in New Issue
Block a user