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>
302 lines
14 KiB
Python
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)
|