Clean up review findings

This commit is contained in:
Codex
2026-08-09 13:57:55 +02:00
parent fa5b6e7636
commit 219ef1e059
8 changed files with 97 additions and 97 deletions
+7
View File
@@ -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.
+1 -1
View File
@@ -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
+1 -1
View File
@@ -1 +1 @@
0.52.2
0.52.3
+12 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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):