diff --git a/CHANGELOG.md b/CHANGELOG.md index 9eb8387..a7047df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/README.md b/README.md index 7a4fd5b..1c36836 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/VERSION b/VERSION index 3f01561..e095205 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.52.2 +0.52.3 diff --git a/app.py b/app.py index e80490b..bed030a 100644 --- a/app.py +++ b/app.py @@ -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) diff --git a/app_support.py b/app_support.py index bba39b6..25b16b3 100644 --- a/app_support.py +++ b/app_support.py @@ -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) diff --git a/http_handler.py b/http_handler.py index d85a5c1..af3efbf 100644 --- a/http_handler.py +++ b/http_handler.py @@ -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 diff --git a/queue_jobs.py b/queue_jobs.py index cd38bbc..4d2e6ff 100644 --- a/queue_jobs.py +++ b/queue_jobs.py @@ -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(), diff --git a/test_app.py b/test_app.py index cfbc144..57a3261 100644 --- a/test_app.py +++ b/test_app.py @@ -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):