Clean up review findings
This commit is contained in:
@@ -1,5 +1,12 @@
|
||||
# Changelog
|
||||
|
||||
## 0.52.3 - 2026-08-09
|
||||
|
||||
- Closed SQLite connections after each database operation to avoid long-running file descriptor leaks.
|
||||
- Removed obsolete legacy-project state migration code for clean Kaizoku deployments.
|
||||
- Made failed queue-job reuse provider-aware so retries do not cross Anikoto, AniNeko, and AnimePahe selections.
|
||||
- Added `openssl` to runtime dependency reporting and accepted comma-separated remote path roots.
|
||||
|
||||
## 0.52.2 - 2026-08-09
|
||||
|
||||
- Updated Docker runtime packages to match the current Kaizoku provider downloader and include `openssl` explicitly.
|
||||
|
||||
@@ -55,7 +55,7 @@ Useful environment variables:
|
||||
- `KAIZOKU_JOB_STDOUT=true` to mirror job logs to container or terminal output.
|
||||
- `KAIZOKU_STATE_ROOT=/path/to/state` to move SQLite state, config, thumbnails, sessions, and staging.
|
||||
- `KAIZOKU_DOWNLOAD_DIR=/downloads` for the default library output path.
|
||||
- `KAIZOKU_REMOTE_PATH_ROOTS=/downloads,/media/anime` to limit remote filesystem browsing.
|
||||
- `KAIZOKU_REMOTE_PATH_ROOTS=/downloads,/media/anime` to limit remote filesystem browsing; comma-separated and platform path separators are accepted.
|
||||
- `KAIZOKU_MODE=sub`, `KAIZOKU_QUALITY=best`, and `KAIZOKU_DEBUG=1` for runtime defaults and diagnostics.
|
||||
|
||||
## Docker
|
||||
|
||||
@@ -14,6 +14,7 @@ import urllib.error
|
||||
import urllib.request
|
||||
import xml.etree.ElementTree as ET
|
||||
from binascii import Error as BinasciiError
|
||||
from contextlib import contextmanager
|
||||
from datetime import datetime, timezone
|
||||
from http.server import ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
@@ -53,8 +54,8 @@ from app_support import (
|
||||
client_address_is_local,
|
||||
clear_remote_access_sessions,
|
||||
debug_log,
|
||||
ensure_project_app_root,
|
||||
load_json,
|
||||
migrate_legacy_state_storage,
|
||||
normalize_season,
|
||||
normalize_config,
|
||||
normalize_episode_offset,
|
||||
@@ -1473,10 +1474,18 @@ class WatchlistStore:
|
||||
"media_type": "tv",
|
||||
}
|
||||
|
||||
@contextmanager
|
||||
def _connect(self):
|
||||
conn = sqlite3.connect(STATE_DB_PATH, timeout=30)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
try:
|
||||
yield conn
|
||||
conn.commit()
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def _table_columns_conn(self, conn, table_name):
|
||||
return [str(row["name"]) for row in conn.execute(f"PRAGMA table_info({table_name})").fetchall()]
|
||||
@@ -3158,7 +3167,7 @@ def runtime_state():
|
||||
|
||||
|
||||
def build_runtime(start_workers=None):
|
||||
migrate_legacy_state_storage()
|
||||
ensure_project_app_root()
|
||||
worker_state = background_workers_enabled() if start_workers is None else bool(start_workers) and background_workers_enabled()
|
||||
config = normalize_config(load_json(CONFIG_PATH, DEFAULT_CONFIG))
|
||||
write_json(CONFIG_PATH, config)
|
||||
|
||||
+2
-85
@@ -358,14 +358,6 @@ def ensure_project_app_root():
|
||||
return PROJECT_APP_ROOT
|
||||
|
||||
|
||||
def legacy_state_root():
|
||||
return Path(os.environ.get("XDG_STATE_HOME", Path.home() / ".local" / "state")) / APP_NAME
|
||||
|
||||
|
||||
def legacy_config_root():
|
||||
return Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / APP_NAME
|
||||
|
||||
|
||||
def project_state_path(*parts):
|
||||
return PROJECT_APP_ROOT.joinpath(*parts)
|
||||
|
||||
@@ -374,53 +366,6 @@ def project_state_dir(*parts):
|
||||
return PROJECT_APP_ROOT.joinpath(*parts)
|
||||
|
||||
|
||||
def migrate_legacy_file(project_path, legacy_path, label):
|
||||
if project_path.exists() or not legacy_path.exists():
|
||||
return project_path
|
||||
project_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
shutil.move(str(legacy_path), str(project_path))
|
||||
debug_log("state.migrated", label=label, from_path=legacy_path, to_path=project_path)
|
||||
except OSError as exc:
|
||||
debug_log("state.migrate_failed", label=label, from_path=legacy_path, to_path=project_path, error=exc)
|
||||
return project_path
|
||||
|
||||
|
||||
def migrate_legacy_dir(project_dir, legacy_dir, label):
|
||||
if not legacy_dir.exists():
|
||||
return project_dir
|
||||
project_dir.mkdir(parents=True, exist_ok=True)
|
||||
moved = 0
|
||||
for child in legacy_dir.iterdir():
|
||||
target = project_dir / child.name
|
||||
if target.exists():
|
||||
if child.is_dir() and target.is_dir():
|
||||
migrate_legacy_dir(target, child, f"{label}/{child.name}")
|
||||
continue
|
||||
try:
|
||||
shutil.move(str(child), str(target))
|
||||
moved += 1
|
||||
except OSError as exc:
|
||||
debug_log("state.migrate_failed", label=label, from_path=child, to_path=target, error=exc)
|
||||
try:
|
||||
legacy_dir.rmdir()
|
||||
except OSError:
|
||||
pass
|
||||
if moved:
|
||||
debug_log("state.migrated", label=label, from_path=legacy_dir, to_path=project_dir, entries=moved)
|
||||
return project_dir
|
||||
|
||||
|
||||
LEGACY_STATE_ROOT = legacy_state_root()
|
||||
LEGACY_CONFIG_ROOT = legacy_config_root()
|
||||
LEGACY_CONFIG_PATH = LEGACY_CONFIG_ROOT / "config.json"
|
||||
LEGACY_QUEUE_PATH = LEGACY_STATE_ROOT / "queue.json"
|
||||
LEGACY_QUEUE_DB_PATH = LEGACY_STATE_ROOT / "queue.sqlite3"
|
||||
LEGACY_STAGING_ROOT = LEGACY_STATE_ROOT / "staging"
|
||||
LEGACY_WATCHLIST_JSON_PATH = LEGACY_STATE_ROOT / "watchlist.json"
|
||||
LEGACY_THUMBNAIL_ROOT = LEGACY_STATE_ROOT / "thumbnails"
|
||||
LEGACY_ANIDB_TITLES_PATH = LEGACY_STATE_ROOT / "anidb-anime-titles.xml.gz"
|
||||
|
||||
CONFIG_PATH = project_state_path("config.json")
|
||||
QUEUE_PATH = project_state_path("queue.json")
|
||||
STATE_DB_PATH = project_state_path("state.sqlite3")
|
||||
@@ -439,17 +384,6 @@ ANIDB_TITLES_CACHE = {
|
||||
}
|
||||
|
||||
|
||||
def migrate_legacy_state_storage():
|
||||
ensure_project_app_root()
|
||||
migrate_legacy_file(CONFIG_PATH, LEGACY_CONFIG_PATH, "config.json")
|
||||
migrate_legacy_file(QUEUE_PATH, LEGACY_QUEUE_PATH, "queue.json")
|
||||
migrate_legacy_file(STATE_DB_PATH, PROJECT_APP_ROOT / "queue.sqlite3", "queue.sqlite3")
|
||||
migrate_legacy_file(STATE_DB_PATH, LEGACY_QUEUE_DB_PATH, "queue.sqlite3")
|
||||
migrate_legacy_file(WATCHLIST_JSON_PATH, LEGACY_WATCHLIST_JSON_PATH, "watchlist.json")
|
||||
migrate_legacy_file(ANIDB_TITLES_PATH, LEGACY_ANIDB_TITLES_PATH, "anidb-anime-titles.xml.gz")
|
||||
migrate_legacy_dir(STAGING_ROOT, LEGACY_STAGING_ROOT, "staging")
|
||||
migrate_legacy_dir(THUMBNAIL_ROOT, LEGACY_THUMBNAIL_ROOT, "thumbnails")
|
||||
|
||||
def load_json(path, fallback):
|
||||
try:
|
||||
with path.open("r", encoding="utf-8") as handle:
|
||||
@@ -580,7 +514,7 @@ def _env_remote_path_roots():
|
||||
if not raw:
|
||||
return []
|
||||
roots = []
|
||||
for part in raw.split(os.pathsep):
|
||||
for part in re.split(rf"[{re.escape(os.pathsep)},]", raw):
|
||||
text = str(part or "").strip()
|
||||
if not text:
|
||||
continue
|
||||
@@ -660,20 +594,7 @@ def thumbnail_file_path(name):
|
||||
filename = Path(str(name or "")).name
|
||||
if not filename:
|
||||
return None
|
||||
project_path = THUMBNAIL_ROOT / filename
|
||||
if project_path.exists():
|
||||
return project_path
|
||||
legacy_path = LEGACY_THUMBNAIL_ROOT / filename
|
||||
if legacy_path.exists():
|
||||
THUMBNAIL_ROOT.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
shutil.move(str(legacy_path), str(project_path))
|
||||
debug_log("thumbnail.cache.migrated", from_path=legacy_path, to_path=project_path)
|
||||
return project_path
|
||||
except OSError as exc:
|
||||
debug_log("thumbnail.cache.migrate_failed", from_path=legacy_path, to_path=project_path, error=exc)
|
||||
return legacy_path
|
||||
return project_path
|
||||
return THUMBNAIL_ROOT / filename
|
||||
|
||||
|
||||
def _discord_file_part(boundary, field_name, attachment_name, content_type, payload):
|
||||
@@ -1017,10 +938,6 @@ def cli_executable_exists(command):
|
||||
return bool(shutil.which(text) or (Path(text).exists() and os.access(text, os.X_OK)))
|
||||
|
||||
|
||||
def cli_executable_exists_any(*commands):
|
||||
return any(cli_executable_exists(command) for command in commands)
|
||||
|
||||
|
||||
def printable_command(command):
|
||||
return " ".join(sh_quote(part) for part in command)
|
||||
|
||||
|
||||
+1
-4
@@ -6,7 +6,6 @@ import json
|
||||
import mimetypes
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import traceback
|
||||
from base64 import b64decode
|
||||
from binascii import Error as BinasciiError
|
||||
@@ -34,8 +33,6 @@ from app_support import (
|
||||
debug_log,
|
||||
env_value,
|
||||
load_project_text_file,
|
||||
cli_executable_exists_any,
|
||||
cli_executable_exists,
|
||||
normalize_config,
|
||||
path_is_within_roots,
|
||||
remote_access_allowed,
|
||||
@@ -94,7 +91,7 @@ def build_handler_class(context):
|
||||
|
||||
|
||||
def dependency_status():
|
||||
checks = ["node", "python3", "ffmpeg"]
|
||||
checks = ["node", "python3", "ffmpeg", "openssl"]
|
||||
result = {name: bool(shutil.which(name)) for name in checks}
|
||||
result["provider-bridge"] = provider_bridge.BRIDGE.exists()
|
||||
return result
|
||||
|
||||
+29
-3
@@ -11,6 +11,7 @@ import subprocess
|
||||
import threading
|
||||
import time
|
||||
import re
|
||||
from contextlib import contextmanager
|
||||
|
||||
from app_support import (
|
||||
DOWNLOAD_METHOD_CHOICES,
|
||||
@@ -85,10 +86,18 @@ class WatchlistRefreshJobs:
|
||||
if start_worker:
|
||||
self.ensure_worker()
|
||||
|
||||
@contextmanager
|
||||
def _connect(self):
|
||||
conn = sqlite3.connect(STATE_DB_PATH, timeout=30)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
try:
|
||||
yield conn
|
||||
conn.commit()
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def ensure_worker(self):
|
||||
with self.lock:
|
||||
@@ -553,10 +562,18 @@ class JellyfinSyncJobs:
|
||||
if start_worker:
|
||||
self.ensure_worker()
|
||||
|
||||
@contextmanager
|
||||
def _connect(self):
|
||||
conn = sqlite3.connect(STATE_DB_PATH, timeout=30)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
try:
|
||||
yield conn
|
||||
conn.commit()
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def ensure_worker(self):
|
||||
with self.lock:
|
||||
@@ -932,10 +949,18 @@ class DownloadQueue:
|
||||
if start_worker:
|
||||
self.ensure_worker()
|
||||
|
||||
@contextmanager
|
||||
def _connect(self):
|
||||
conn = sqlite3.connect(STATE_DB_PATH, timeout=30)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
try:
|
||||
yield conn
|
||||
conn.commit()
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def ensure_worker(self):
|
||||
with self.lock:
|
||||
@@ -1222,6 +1247,7 @@ class DownloadQueue:
|
||||
str(job.get("season") or "").strip(),
|
||||
str(job.get("episode_offset") or "").strip(),
|
||||
str(job.get("query") or "").strip(),
|
||||
str(job.get("provider") or "").strip().lower(),
|
||||
job.get("result_index"),
|
||||
str(job.get("mode") or "").strip().lower(),
|
||||
str(job.get("quality") or "").strip().lower(),
|
||||
|
||||
+44
@@ -1097,6 +1097,40 @@ class DownloadQueueWorkerFailureTests(unittest.TestCase):
|
||||
self.assertEqual([command[0] for command in commands], ["python3"])
|
||||
self.assertIn("Download completed.", stored["log"])
|
||||
|
||||
def test_failed_job_reuse_keeps_providers_separate(self):
|
||||
queue = APP.DownloadQueue(
|
||||
lambda: {"mode": "sub", "quality": "best", "download_dir": "/tmp/example"},
|
||||
start_worker=False,
|
||||
)
|
||||
with queue._connect() as conn:
|
||||
conn.execute("DELETE FROM jobs")
|
||||
first = queue.add(
|
||||
{
|
||||
"show_id": "queue-show",
|
||||
"provider": "anikoto",
|
||||
"query": "Queue Show",
|
||||
"title": "Queue Show",
|
||||
"season": "1",
|
||||
"episodes": "1",
|
||||
}
|
||||
)
|
||||
first["status"] = "failed"
|
||||
queue._save_job_locked(first)
|
||||
|
||||
second = queue.add(
|
||||
{
|
||||
"show_id": "queue-show",
|
||||
"provider": "anineko",
|
||||
"query": "Queue Show",
|
||||
"title": "Queue Show",
|
||||
"season": "1",
|
||||
"episodes": "1",
|
||||
}
|
||||
)
|
||||
|
||||
self.assertNotEqual(first["id"], second["id"])
|
||||
self.assertEqual(second["provider"], "anineko")
|
||||
|
||||
|
||||
class WatchlistRefreshAllTests(unittest.TestCase):
|
||||
def test_refresh_all_iterates_only_watching_and_planned_show_ids(self):
|
||||
@@ -2678,6 +2712,14 @@ class ConfigSnapshotTests(unittest.TestCase):
|
||||
|
||||
self.assertEqual(config["download_methods"], ["kaizoku"])
|
||||
|
||||
def test_remote_path_roots_accept_comma_and_platform_separators(self):
|
||||
with tempfile.TemporaryDirectory() as first_root, tempfile.TemporaryDirectory() as second_root, tempfile.TemporaryDirectory() as third_root:
|
||||
raw = f"{first_root},{second_root}{os.pathsep}{third_root}"
|
||||
with mock.patch.dict(os.environ, {"KAIZOKU_REMOTE_PATH_ROOTS": raw}, clear=False):
|
||||
roots = APP.app_support._env_remote_path_roots()
|
||||
|
||||
self.assertEqual(roots, [Path(first_root).resolve(), Path(second_root).resolve(), Path(third_root).resolve()])
|
||||
|
||||
def test_normalize_config_filters_discord_webhook_events(self):
|
||||
config = APP.normalize_config(
|
||||
{
|
||||
@@ -3301,6 +3343,7 @@ class HandlerRouteTests(unittest.TestCase):
|
||||
self.assertIn("node", handler.json_payload)
|
||||
self.assertIn("python3", handler.json_payload)
|
||||
self.assertIn("ffmpeg", handler.json_payload)
|
||||
self.assertIn("openssl", handler.json_payload)
|
||||
self.assertIn("provider-bridge", handler.json_payload)
|
||||
|
||||
def test_dependency_status_checks_provider_runtime(self):
|
||||
@@ -3315,6 +3358,7 @@ class HandlerRouteTests(unittest.TestCase):
|
||||
self.assertTrue(status["node"])
|
||||
self.assertTrue(status["python3"])
|
||||
self.assertTrue(status["ffmpeg"])
|
||||
self.assertTrue(status["openssl"])
|
||||
self.assertTrue(status["provider-bridge"])
|
||||
|
||||
def test_clear_failed_route_removes_failed_jobs(self):
|
||||
|
||||
Reference in New Issue
Block a user