Files
jellyfin-plugin-recently-ad…/integration-test/it.py
T
Jonas MenzelandClaude Opus 5.5 d431486095
build / build (push) Successful in 1m10s
release / release (push) Failing after 1m36s
feat: Recently Added Guard plugin for Jellyfin 12.2
Keeps Radarr/Sonarr quality upgrades out of "Recently Added" by keeping
a ledger of the first date added per logical medium (movie or episode,
identified by provider ids and the series) and restoring it on upgrades.

- core logic without Jellyfin types (Reconciler, MediaKeys, GuardProcessor)
- thread-safe ledger with atomic, debounced persistence
- event processing via channel and background worker, seed on first start
- scheduled task "Ledger synchronisieren", status API, configuration page
- xUnit tests and a Jellyfin 12.2 integration test
- Gitea workflows: build/test, release with jprm and manifest branch

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 23:10:02 +02:00

302 lines
14 KiB
Python

"""Integration test: Recently Added Guard in a throwaway Jellyfin 12.2 container.
Usage: python3 integration-test/it.py <plugin zip> [--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/<Name>_<version>/ 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)