"""Integration test: Recently Added Guard in a throwaway Jellyfin 12.2 container. Usage: python3 integration-test/it.py [--keep] Starts jellyfin/jellyfin:12.2 on 127.0.0.1:18096 with the plugin installed like the catalog does, creates a small library (test videos via jellyfin-ffmpeg), simulates Radarr/Sonarr upgrades and checks the dates and "Recently Added". Needs Docker and internet access (metadata from TMDb). State lives in integration-test/.run; --keep leaves the container running for inspection. """ import json import os import shutil import subprocess import sys import time import urllib.error import urllib.request import zipfile HERE = os.path.dirname(os.path.abspath(__file__)) NAME = "rag-it-jellyfin" IMAGE = "jellyfin/jellyfin:12.2" PORT = 18096 BASE = f"http://127.0.0.1:{PORT}" PLUGIN_ID = "6fdfedef-adde-4ac3-a873-62a057c95574" CLIENT = 'MediaBrowser Client="rag-it", Device="rag-it", DeviceId="rag-it", Version="1.0"' FFMPEG = "/usr/lib/jellyfin-ffmpeg/ffmpeg" token = None failures = [] def log(msg): print(f"[{time.strftime('%H:%M:%S')}] {msg}", flush=True) def check(cond, msg): log(("PASS " if cond else "FAIL ") + msg) if not cond: failures.append(msg) def sh(*args, check_rc=True): return subprocess.run(args, check=check_rc, capture_output=True, text=True) def api(method, path, body=None, raw=False, expect=(200, 204)): headers = {"Authorization": CLIENT + (f', Token="{token}"' if token else "")} data = None if body is not None: data = json.dumps(body).encode() headers["Content-Type"] = "application/json" req = urllib.request.Request(BASE + path, data=data, headers=headers, method=method) try: with urllib.request.urlopen(req, timeout=60) as resp: payload = resp.read() status = resp.status except urllib.error.HTTPError as e: payload = e.read() status = e.code if status not in expect: raise RuntimeError(f"{method} {path} -> {status}: {payload[:400]!r}") if raw: return status, payload return json.loads(payload) if payload else None def make_video(path): sh("docker", "exec", NAME, "mkdir", "-p", os.path.dirname(path)) sh("docker", "exec", NAME, FFMPEG, "-v", "error", "-f", "lavfi", "-i", "testsrc=duration=2:size=320x240:rate=10", "-c:v", "libx264", "-y", path) def rm(path): sh("docker", "exec", NAME, "rm", "-f", path) def wait_for(pred, what, timeout=180, interval=2): end = time.time() + timeout while time.time() < end: try: value = pred() if value: return value except (OSError, RuntimeError, ValueError): pass # server still starting time.sleep(interval) raise RuntimeError(f"timeout waiting for {what}") def scan_and_settle(): api("POST", "/Library/Refresh") time.sleep(3) wait_for(lambda: all(t["State"] == "Idle" for t in api("GET", "/ScheduledTasks?isHidden=false") if t["Key"] == "RefreshLibrary"), "library scan", timeout=300) time.sleep(8) # plugin debounce (2 s) + metadata refresh events def items(kind): res = api("GET", f"/Items?IncludeItemTypes={kind}&Recursive=true&Fields=DateCreated,Path,ProviderIds") return res["Items"] def find(kind, *needles): return [i for i in items(kind) if all(n in i.get("Path", "") for n in needles)] def main(): global token root = os.path.join(HERE, ".run") sh("docker", "rm", "-f", NAME, check_rc=False) shutil.rmtree(root, ignore_errors=True) for d in ("config/plugins", "cache", "media/movies", "media/tv"): os.makedirs(os.path.join(root, d), exist_ok=True) # Install like the catalog does: plugins/_/ with the DLL and meta.json. zip_path = os.path.abspath(sys.argv[1]) plugin_dir = os.path.join(root, "config/plugins", "Recently Added Guard_1.0.0.0") with zipfile.ZipFile(zip_path) as zf: zf.extractall(plugin_dir) sh("docker", "run", "-d", "--name", NAME, "-p", f"127.0.0.1:{PORT}:8096", "-v", f"{root}/config:/config", "-v", f"{root}/cache:/cache", "-v", f"{root}/media:/media", "--memory", "2g", IMAGE) wait_for(lambda: api("GET", "/System/Info/Public"), "server start") wait_for(lambda: api("GET", "/Startup/Configuration"), "startup api") api("POST", "/Startup/Configuration", {"UICulture": "de-DE", "MetadataCountryCode": "DE", "PreferredMetadataLanguage": "de"}) api("GET", "/Startup/User") api("POST", "/Startup/User", {"Name": "admin", "Password": "admin"}) api("POST", "/Startup/Complete") auth = api("POST", "/Users/AuthenticateByName", {"Username": "admin", "Pw": "admin"}) token = auth["AccessToken"] user_id = auth["User"]["Id"] log("wizard done, authenticated") plugins = api("GET", "/Plugins") plugin = next((p for p in plugins if p["Id"].replace("-", "") == PLUGIN_ID.replace("-", "")), None) check(plugin is not None and plugin["Status"] == "Active", f"plugin loaded and active: {plugin}") status = wait_for(lambda: (s := api("GET", "/RecentlyAddedGuard/Status")) and s["SeedCompleted"] and s, "seed") check(status["LedgerEntries"] == 0, f"seed on empty library: {status['LedgerEntries']} entries") _, page = api("GET", "/web/ConfigurationPage?name=RecentlyAddedGuard", raw=True) check(b'data-role="page"' in page and PLUGIN_ID.encode() in page, "config page served") pages = api("GET", "/web/ConfigurationPages") check(any(p["Name"] == "RecentlyAddedGuard" for p in pages), "config page listed") # Initial media matrix_old = "/media/movies/The Matrix (1999) {tmdb-603}/The Matrix (1999) WEBDL-720p.mkv" andor1_old = "/media/tv/Andor (2022) {tvdb-393189}/Season 01/Andor - S01E01 - Kassa WEBDL-720p.mkv" andor2 = "/media/tv/Andor (2022) {tvdb-393189}/Season 01/Andor - S01E02 - That Would Be Me WEBDL-720p.mkv" blade_old = "/media/movies/Blade Runner (1982)/Blade Runner (1982) WEBDL-720p.mkv" for p in (matrix_old, andor1_old, andor2, blade_old): make_video(p) api("POST", "/Library/VirtualFolders?name=Filme&collectionType=movies&refreshLibrary=false", {"LibraryOptions": {"PathInfos": [{"Path": "/media/movies"}], "EnableRealtimeMonitor": False}}) api("POST", "/Library/VirtualFolders?name=Serien&collectionType=tvshows&refreshLibrary=false", {"LibraryOptions": {"PathInfos": [{"Path": "/media/tv"}], "EnableRealtimeMonitor": False}}) scan_and_settle() movie = find("Movie", "The Matrix", "720p")[0] ep1 = find("Episode", "S01E01")[0] ep2 = find("Episode", "S01E02")[0] blade = find("Movie", "Blade Runner")[0] log(f"movie {movie['Id']} provider ids {movie.get('ProviderIds')} date {movie['DateCreated']}") status = api("GET", "/RecentlyAddedGuard/Status") check(status["LedgerEntries"] == 4, f"4 media registered by events: {status['LedgerEntries']}") check(status["CorrectionsSinceStart"] == 0, "no corrections for new media") # Make the existing items "old" (like an NFO date or an item added long ago): lowers the ledger. for item, date in ((movie, "2024-01-01T00:00:00.0000000Z"), (ep1, "2024-02-01T00:00:00.0000000Z"), (ep2, "2024-03-01T00:00:00.0000000Z"), (blade, "2023-05-01T00:00:00.0000000Z")): dto = api("GET", f"/Users/{user_id}/Items/{item['Id']}") dto["DateCreated"] = date api("POST", f"/Items/{item['Id']}", dto) time.sleep(6) check(api("GET", "/RecentlyAddedGuard/Status")["CorrectionsSinceStart"] == 0, "backdating is no upgrade") # A genuinely new movie make_video("/media/movies/Inception (2010) {tmdb-27205}/Inception (2010) WEBDL-1080p.mkv") # Upgrades with new file names (Radarr/Sonarr style): old file gone, new file there. rm(matrix_old) make_video("/media/movies/The Matrix (1999) {tmdb-603}/The Matrix (1999) Bluray-1080p.mkv") rm(andor1_old) make_video("/media/tv/Andor (2022) {tvdb-393189}/Season 01/Andor - S01E01 - Kassa Bluray-1080p.mkv") scan_and_settle() new_movie = find("Movie", "The Matrix", "Bluray-1080p")[0] new_ep1 = find("Episode", "Bluray-1080p")[0] inception = find("Movie", "Inception")[0] check(new_movie["Id"] != movie["Id"], "upgraded movie is a new item (new path)") check(new_movie["DateCreated"].startswith("2024-01-01"), f"upgraded movie keeps date: {new_movie['DateCreated']}") check(new_ep1["DateCreated"].startswith("2024-02-01"), f"upgraded episode keeps date: {new_ep1['DateCreated']}") check(not inception["DateCreated"].startswith("2024"), f"new movie keeps its own date: {inception['DateCreated']}") check(not find("Movie", "The Matrix", "720p"), "old movie item removed") libs = {f["Name"]: f["ItemId"] for f in api("GET", "/Library/VirtualFolders")} latest_movies = api("GET", f"/Items/Latest?userId={user_id}&parentId={libs['Filme']}&limit=10") log("latest movies: " + ", ".join(f"{i['Name']}" for i in latest_movies)) check(latest_movies and latest_movies[0]["Id"] == inception["Id"], "Inception is the newest movie") latest_tv = api("GET", f"/Items/Latest?userId={user_id}&parentId={libs['Serien']}&limit=10&groupItems=false") log("latest episodes: " + ", ".join(f"{i['Name']} {i.get('DateCreated', '')}" for i in latest_tv)) status = api("GET", "/RecentlyAddedGuard/Status") log(f"status: {json.dumps(status, ensure_ascii=False)}") check(status["CorrectionsSinceStart"] == 2, f"two corrections recorded: {status['CorrectionsSinceStart']}") # Same path, new file (new inode -> new creation time); the metadata refresh resets DateCreated. rm(andor2) time.sleep(2) make_video(andor2) api("POST", f"/Items/{ep2['Id']}/Refresh?metadataRefreshMode=Default&imageRefreshMode=Default&replaceAllMetadata=false") time.sleep(10) same = [i for i in items("Episode") if i["Id"] == ep2["Id"]] check(bool(same) and same[0]["DateCreated"].startswith("2024-03-01"), f"same-path replacement keeps date: {same[0]['DateCreated'] if same else 'missing'}") def set_config(**changes): config = api("GET", f"/Plugins/{PLUGIN_ID}/Configuration") config.update(changes) api("POST", f"/Plugins/{PLUGIN_ID}/Configuration", config) check(all(api("GET", f"/Plugins/{PLUGIN_ID}/Configuration")[k] == v for k, v in changes.items()), f"config saved: {changes}") # C: movie without id in the folder name; matched once the metadata refresh brings the TMDb id. rm(blade_old) make_video("/media/movies/Blade Runner (1982)/Blade Runner (1982) Bluray-2160p.mkv") scan_and_settle() time.sleep(10) blade_new = find("Movie", "Bluray-2160p")[0] log(f"blade runner new item provider ids {blade_new.get('ProviderIds')}") check(blade_new["DateCreated"].startswith("2023-05-01"), f"upgrade without id in folder name keeps date: {blade_new['DateCreated']}") # D: dry run detects but does not change. set_config(DryRun=True) andor2_new = "/media/tv/Andor (2022) {tvdb-393189}/Season 01/Andor - S01E02 - That Would Be Me Bluray-1080p.mkv" rm(andor2) make_video(andor2_new) scan_and_settle() ep2_new = find("Episode", "S01E02")[0] status = api("GET", "/RecentlyAddedGuard/Status") check(not ep2_new["DateCreated"].startswith("2024"), f"dry run leaves the date: {ep2_new['DateCreated']}") check(status["DryRun"] and status["DryRunDetectionsSinceStart"] >= 1, f"dry run detection counted: {status['DryRunDetectionsSinceStart']}") set_config(DryRun=False) # E: a medium returning after the grace period counts as new. set_config(GraceDays=0) inception_path = "/media/movies/Inception (2010) {tmdb-27205}/Inception (2010) WEBDL-1080p.mkv" rm(inception_path) scan_and_settle() make_video("/media/movies/Inception (2010) {tmdb-27205}/Inception (2010) Remux-2160p.mkv") scan_and_settle() inception_new = find("Movie", "Remux-2160p")[0] check(inception_new["DateCreated"] > inception["DateCreated"], f"return after grace counts as new: {inception_new['DateCreated']} > {inception['DateCreated']}") set_config(GraceDays=14) # Scheduled task tasks = api("GET", "/ScheduledTasks?isHidden=false") task = next((t for t in tasks if t["Key"] == "RecentlyAddedGuardSyncLedger"), None) check(task is not None and task["Category"] == "Recently Added Guard", "scheduled task registered") api("POST", "/RecentlyAddedGuard/Sync", expect=(204,)) wait_for(lambda: next(t for t in api("GET", "/ScheduledTasks?isHidden=false") if t["Key"] == "RecentlyAddedGuardSyncLedger").get("LastExecutionResult"), "sync task run") result = next(t for t in api("GET", "/ScheduledTasks?isHidden=false") if t["Key"] == "RecentlyAddedGuardSyncLedger")["LastExecutionResult"] check(result["Status"] == "Completed", f"sync task completed: {result['Status']}") status = api("GET", "/RecentlyAddedGuard/Status") log(f"final status: {json.dumps(status, ensure_ascii=False)}") logs = sh("docker", "logs", NAME, check_rc=False) lines = [l for l in (logs.stdout + logs.stderr).splitlines() if "RecentlyAddedGuard" in l or "Recently Added Guard" in l or "Upgrade erkannt" in l or "Ledger" in l or "ERR]" in l] print("\n".join(lines[-60:])) ledger = os.path.join(root, "config", "plugins", "Jellyfin.Plugin.RecentlyAddedGuard", "ledger.json") check(os.path.exists(ledger), f"ledger persisted at {ledger}") # Restart: ledger survives, no new seed sh("docker", "restart", NAME) wait_for(lambda: api("GET", "/System/Info/Public"), "restart") status = wait_for(lambda: api("GET", "/RecentlyAddedGuard/Status"), "status after restart") check(status["SeedCompleted"] and status["LedgerEntries"] >= 4, f"ledger reloaded after restart: {status['LedgerEntries']}") print("\nFAILURES:" if failures else "\nALL CHECKS PASSED") for f in failures: print(" - " + f) return 1 if failures else 0 if __name__ == "__main__": if len(sys.argv) < 2 or not sys.argv[1].endswith(".zip"): sys.exit(__doc__) rc = 1 try: rc = main() finally: if "--keep" not in sys.argv: subprocess.run(["docker", "rm", "-f", NAME], capture_output=True) sys.exit(rc)