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>
This commit is contained in:
commit
d431486095
38 files changed
+4396
No files matched your search
@@ -0,0 +1,301 @@
|
||||
"""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)
|
||||
Reference in new issue
Block a user