commit d4314860954a9a44dd3626270d041ac8994bb270 Author: Jonas Menzel Date: Wed Oct 21 23:10:02 2026 +0200 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 diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..89a03e3 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,55 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_style = space +indent_size = 4 +insert_final_newline = true +trim_trailing_whitespace = true + +[*.{yml,yaml,json,xml,csproj,props,slnx}] +indent_size = 2 + +[*.md] +trim_trailing_whitespace = false + +[*.cs] +# Layout +csharp_style_namespace_declarations = file_scoped:warning +csharp_using_directive_placement = outside_namespace:warning +dotnet_sort_system_directives_first = true +csharp_new_line_before_open_brace = all +csharp_prefer_braces = true:warning +csharp_style_expression_bodied_methods = when_on_single_line:silent +csharp_style_expression_bodied_properties = true:silent + +# var only where the type is apparent +csharp_style_var_for_built_in_types = false:silent +csharp_style_var_when_type_is_apparent = true:silent +csharp_style_var_elsewhere = true:silent + +# Naming: private fields are _camelCase, constants PascalCase +dotnet_naming_rule.private_fields_underscore.symbols = private_fields +dotnet_naming_rule.private_fields_underscore.style = underscore_camel +dotnet_naming_rule.private_fields_underscore.severity = warning +dotnet_naming_symbols.private_fields.applicable_kinds = field +dotnet_naming_symbols.private_fields.applicable_accessibilities = private +dotnet_naming_style.underscore_camel.required_prefix = _ +dotnet_naming_style.underscore_camel.capitalization = camel_case + +dotnet_naming_rule.constants_pascal.symbols = constants +dotnet_naming_rule.constants_pascal.style = pascal +dotnet_naming_rule.constants_pascal.severity = warning +dotnet_naming_symbols.constants.applicable_kinds = field +dotnet_naming_symbols.constants.required_modifiers = const +dotnet_naming_style.pascal.capitalization = pascal_case + +# Code style enforced in the build +dotnet_diagnostic.IDE0005.severity = warning +dotnet_diagnostic.IDE0161.severity = warning +dotnet_diagnostic.IDE0011.severity = warning + +# Logging is event-driven and low-volume (like in Jellyfin itself); LoggerMessage delegates would only add noise. +dotnet_diagnostic.CA1848.severity = none +dotnet_diagnostic.CA1873.severity = none diff --git a/.gitea/workflows/build.yaml b/.gitea/workflows/build.yaml new file mode 100644 index 0000000..dceaec1 --- /dev/null +++ b/.gitea/workflows/build.yaml @@ -0,0 +1,47 @@ +# Build and test on every push (except the CI-maintained `manifest` branch) and on pull requests. +# Runner: Gitea act_runner with Docker. The job runs in the .NET 10 SDK image, which has no Node.js, +# so JavaScript actions such as actions/checkout cannot run; the checkout uses plain git. +name: build + +on: + push: + branches-ignore: [manifest] + pull_request: + +permissions: + contents: read + +jobs: + build: + runs-on: ubuntu-latest + container: + image: mcr.microsoft.com/dotnet/sdk:10.0 + timeout-minutes: 20 + defaults: + run: + shell: bash + env: + DOTNET_CLI_TELEMETRY_OPTOUT: "1" + DOTNET_NOLOGO: "1" + steps: + - name: Checkout + env: + TOKEN: ${{ secrets.GITEA_TOKEN }} + run: | + git config --global --add safe.directory '*' + git init -q . + git remote add origin "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY.git" + auth="Authorization: basic $(printf 'x-access-token:%s' "$TOKEN" | base64 -w0)" + git -c http.extraHeader="$auth" fetch -q --depth 1 origin "$GITHUB_SHA" + git checkout -q FETCH_HEAD + git log -1 --oneline + + - name: Restore + run: dotnet restore + + # Directory.Build.props treats warnings as errors in Release. + - name: Build + run: dotnet build -c Release --no-restore + + - name: Test + run: dotnet test -c Release --no-build diff --git a/.gitea/workflows/release.yaml b/.gitea/workflows/release.yaml new file mode 100644 index 0000000..6fcbef9 --- /dev/null +++ b/.gitea/workflows/release.yaml @@ -0,0 +1,195 @@ +# Release on tag vA.B.C.D (Jellyfin plugins use four-part versions): +# 1. test, 2. build the plugin ZIP with jprm (DLL + meta.json only), 3. publish a Gitea release +# with the ZIP, 4. add the version to manifest.json on the `manifest` branch (the URL Jellyfin reads), +# 5. check that manifest and ZIP are reachable anonymously and the MD5 matches. +# The automatic GITEA_TOKEN does all of this; Gitea never starts workflows for pushes made with it, +# so the manifest commit triggers no further run. +name: release + +on: + push: + tags: ["v*"] + +concurrency: + group: recently-added-guard-release + cancel-in-progress: false + +# contents: write = Code (manifest branch) + Releases +permissions: + contents: write + +env: + MANIFEST_BRANCH: manifest + JPRM_VERSION: "1.1.0" + PLUGIN_GUID: 6fdfedef-adde-4ac3-a873-62a057c95574 + +jobs: + release: + runs-on: ubuntu-latest + container: + image: mcr.microsoft.com/dotnet/sdk:10.0 + timeout-minutes: 30 + defaults: + run: + shell: bash + env: + DOTNET_CLI_TELEMETRY_OPTOUT: "1" + DOTNET_NOLOGO: "1" + DEBIAN_FRONTEND: noninteractive + PYTHONUTF8: "1" + TOKEN: ${{ secrets.GITEA_TOKEN }} + steps: + # The SDK image has no Node.js, so actions/checkout cannot run; plain git instead. + - name: Checkout tag + run: | + git config --global --add safe.directory '*' + git init -q . + git remote add origin "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY.git" + git config http.extraHeader "Authorization: basic $(printf 'x-access-token:%s' "$TOKEN" | base64 -w0)" + git fetch -q --tags origin "+refs/heads/*:refs/remotes/origin/*" + git checkout -q "$GITHUB_SHA" + git log -1 --oneline + + - name: Version and changelog + id: meta + run: | + tag="$GITHUB_REF_NAME" + version="${tag#v}" + if ! [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "Tag $tag hat nicht das Format vA.B.C.D" >&2 + exit 1 + fi + + # Changelog: message of an annotated tag, otherwise the commit subjects since the previous tag. + : > changelog.md + if [ "$(git cat-file -t "refs/tags/$tag")" = tag ]; then + git tag -l --format='%(contents)' "$tag" | sed '/-----BEGIN PGP SIGNATURE-----/,$d' > changelog.md + fi + if ! grep -q '[^[:space:]]' changelog.md; then + previous=$(git describe --tags --abbrev=0 --match 'v*' "$tag^" 2>/dev/null || true) + git log --no-merges --pretty='- %s' "${previous:+$previous..}$tag" > changelog.md + fi + cat changelog.md + echo "version=$version" >> "$GITHUB_OUTPUT" + + - name: Test + run: dotnet test -c Release + + - name: Install jprm + run: | + apt-get update -qq + apt-get install -y -qq --no-install-recommends python3-venv > /dev/null + python3 -m venv /opt/jprm + /opt/jprm/bin/pip install -q "jprm==$JPRM_VERSION" + + - name: Build plugin ZIP + id: build + env: + VERSION: ${{ steps.meta.outputs.version }} + run: | + # Version and changelog go through build.yaml into meta.json (only this CI checkout changes). + /opt/jprm/bin/python - "$VERSION" <<'EOF' + import sys + import yaml + + with open("build.yaml", encoding="utf-8") as fh: + config = yaml.safe_load(fh) + with open("changelog.md", encoding="utf-8") as fh: + config["changelog"] = fh.read().strip() or "Version " + sys.argv[1] + config["version"] = sys.argv[1] + with open("build.yaml", "w", encoding="utf-8") as fh: + yaml.safe_dump(config, fh, allow_unicode=True, sort_keys=False) + EOF + + mkdir -p artifacts + zip=$(/opt/jprm/bin/jprm plugin build . --output artifacts --version "$VERSION") + + contents=$(/opt/jprm/bin/python -c 'import sys, zipfile; print(" ".join(sorted(zipfile.ZipFile(sys.argv[1]).namelist())))' "$zip") + if [ "$contents" != "Jellyfin.Plugin.RecentlyAddedGuard.dll meta.json" ]; then + echo "Unerwarteter ZIP-Inhalt: $contents" >&2 + exit 1 + fi + md5sum "$zip" + echo "zip=$(realpath "$zip")" >> "$GITHUB_OUTPUT" + echo "name=$(basename "$zip")" >> "$GITHUB_OUTPUT" + + - name: Publish Gitea release + id: release + env: + VERSION: ${{ steps.meta.outputs.version }} + ZIP: ${{ steps.build.outputs.zip }} + ASSET: ${{ steps.build.outputs.name }} + run: | + api="$GITHUB_SERVER_URL/api/v1/repos/$GITHUB_REPOSITORY" + auth=(-H "Authorization: token $TOKEN") + json() { /opt/jprm/bin/python -c "import json, sys; data = json.load(sys.stdin); $1" "${@:2}"; } + + # A re-run reuses the release of the tag and replaces the asset. + if ! release=$(curl -fsS "${auth[@]}" "$api/releases/tags/$GITHUB_REF_NAME"); then + body=$(/opt/jprm/bin/python -c 'import json, sys; print(json.dumps({"tag_name": sys.argv[1], "name": "Recently Added Guard " + sys.argv[2], "body": open("changelog.md", encoding="utf-8").read()}))' "$GITHUB_REF_NAME" "$VERSION") + release=$(curl -fsS -X POST "${auth[@]}" -H "Content-Type: application/json" -d "$body" "$api/releases") + fi + release_id=$(printf '%s' "$release" | json 'print(data["id"])') + for asset_id in $(printf '%s' "$release" | json '[print(a["id"]) for a in data["assets"] if a["name"] == sys.argv[1]]' "$ASSET"); do + curl -fsS -X DELETE "${auth[@]}" "$api/releases/$release_id/assets/$asset_id" + done + + asset=$(curl -fsS -X POST "${auth[@]}" -F "attachment=@$ZIP" "$api/releases/$release_id/assets?name=$ASSET") + url=$(printf '%s' "$asset" | json 'print(data["browser_download_url"])') + echo "Release-Asset: $url" + echo "url=$url" >> "$GITHUB_OUTPUT" + + - name: Update manifest.json + env: + VERSION: ${{ steps.meta.outputs.version }} + ZIP: ${{ steps.build.outputs.zip }} + URL: ${{ steps.release.outputs.url }} + run: | + workdir=$(mktemp -d) + if git show-ref --verify --quiet "refs/remotes/origin/$MANIFEST_BRANCH"; then + git worktree add -q --detach "$workdir" "origin/$MANIFEST_BRANCH" + else + git worktree add -q --orphan -b "$MANIFEST_BRANCH" "$workdir" + fi + + cd "$workdir" + [ -f manifest.json ] || echo '[]' > manifest.json + # Adds the version in front (sorted descending) and keeps older versions; MD5 of the ZIP as checksum. + /opt/jprm/bin/jprm repo add manifest.json "$ZIP" --plugin-url "$URL" + git add manifest.json + if git diff --cached --quiet; then + echo "manifest.json unverändert" + exit 0 + fi + git -c user.name="Gitea Actions" -c user.email="actions@noreply.git.menzel.center" \ + commit -q -m "manifest: Recently Added Guard $VERSION" + git push -q origin "HEAD:refs/heads/$MANIFEST_BRANCH" + + - name: Verify anonymous download and checksum + env: + VERSION: ${{ steps.meta.outputs.version }} + URL: ${{ steps.release.outputs.url }} + run: | + manifest_url="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/raw/branch/$MANIFEST_BRANCH/manifest.json" + # No credentials: Jellyfin fetches both anonymously. + curl -fsSL "$manifest_url" -o /tmp/manifest.json + curl -fsSL "$URL" -o /tmp/plugin.zip + /opt/jprm/bin/python - "$VERSION" "$URL" <<'EOF' + import hashlib + import json + import os + import sys + + version, url = sys.argv[1], sys.argv[2] + with open("/tmp/manifest.json", encoding="utf-8") as fh: + manifest = json.load(fh) + plugin = next(p for p in manifest if p["guid"] == os.environ["PLUGIN_GUID"]) + entry = next(v for v in plugin["versions"] if v["version"] == version) + with open("/tmp/plugin.zip", "rb") as fh: + md5 = hashlib.md5(fh.read()).hexdigest() + assert plugin["versions"][0]["version"] == version, "neue Version steht nicht vorne" + assert entry["sourceUrl"] == url, (entry["sourceUrl"], url) + assert entry["checksum"] == md5, (entry["checksum"], md5) + print(f"OK: {version} -> {url}, MD5 {md5}, targetAbi {entry['targetAbi']}") + EOF + echo "Repository-URL für Jellyfin: $manifest_url" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..55d69d7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +bin/ +obj/ +artifacts/ +TestResults/ +*.user +*.suo +.vs/ +.idea/ +.vscode/ +integration-test/.run/ diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..6814807 --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,20 @@ + + + + net10.0 + latest + enable + disable + + 1.0.0.0 + menzeljonas + true + latest-recommended + true + true + + + + true + + diff --git a/Jellyfin.Plugin.RecentlyAddedGuard.Tests/Jellyfin.Plugin.RecentlyAddedGuard.Tests.csproj b/Jellyfin.Plugin.RecentlyAddedGuard.Tests/Jellyfin.Plugin.RecentlyAddedGuard.Tests.csproj new file mode 100644 index 0000000..ae54009 --- /dev/null +++ b/Jellyfin.Plugin.RecentlyAddedGuard.Tests/Jellyfin.Plugin.RecentlyAddedGuard.Tests.csproj @@ -0,0 +1,25 @@ + + + + Exe + true + false + + false + Jellyfin.Plugin.RecentlyAddedGuard.Tests + + $(NoWarn);CA1707;CS1591 + + + + + + + + + + + + + + diff --git a/Jellyfin.Plugin.RecentlyAddedGuard.Tests/LedgerStoreTests.cs b/Jellyfin.Plugin.RecentlyAddedGuard.Tests/LedgerStoreTests.cs new file mode 100644 index 0000000..ae8313f --- /dev/null +++ b/Jellyfin.Plugin.RecentlyAddedGuard.Tests/LedgerStoreTests.cs @@ -0,0 +1,208 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using Jellyfin.Plugin.RecentlyAddedGuard.Core; +using Jellyfin.Plugin.RecentlyAddedGuard.Ledger; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace Jellyfin.Plugin.RecentlyAddedGuard.Tests; + +public sealed class LedgerStoreTests : IDisposable +{ + private readonly GuardFixture _fx = new(); + + public void Dispose() => _fx.Dispose(); + + // 13 + [Fact] + public void SaveAndLoad_RestoresIdenticalState() + { + var series = Guid.NewGuid(); + var movieItem = Guid.NewGuid(); + var episodeItems = new[] { Guid.NewGuid(), Guid.NewGuid() }; + var removedItem = Guid.NewGuid(); + _fx.Reconciler.Register(Media.Movie(movieItem, _fx.Now.AddDays(-30), tmdb: "603", imdb: "tt0133093")); + _fx.Reconciler.Register(Media.Episode(episodeItems[0], _fx.Now.AddDays(-7), series, 2, 3, seriesTvdb: "393189")); + _fx.Reconciler.Register(Media.Episode(episodeItems[1], _fx.Now.AddDays(-1), series, 2, 3, seriesTvdb: "393189")); + var removed = Media.Movie(removedItem, _fx.Now.AddDays(-90), tmdb: "604"); + _fx.Reconciler.Register(removed); + _fx.Reconciler.OnItemRemoved(removedItem, removed.Keys); + _fx.Store.MarkSynced(_fx.Now); + _fx.Store.Flush(); + + using var reloaded = _fx.CreateStore(); + reloaded.Load(); + + Assert.True(reloaded.SeedCompleted); + Assert.Equal(_fx.Now, reloaded.LastSyncUtc); + Assert.Equal(_fx.Store.GetStatistics(), reloaded.GetStatistics()); + var expected = _fx.Store.GetEntries().OrderBy(e => e.Keys[0], StringComparer.Ordinal).ToList(); + var actual = reloaded.GetEntries().OrderBy(e => e.Keys[0], StringComparer.Ordinal).ToList(); + Assert.Equal(expected.Count, actual.Count); + for (var i = 0; i < expected.Count; i++) + { + Assert.Equal(expected[i].Keys, actual[i].Keys); + Assert.Equal(expected[i].FirstAdded, actual[i].FirstAdded); + Assert.Equal(DateTimeKind.Utc, actual[i].FirstAdded.Kind); + Assert.Equal(expected[i].LastSeen, actual[i].LastSeen); + Assert.Equal(expected[i].RemovedAt, actual[i].RemovedAt); + Assert.Equal(expected[i].LiveItemIds.Order(), actual[i].LiveItemIds.Order()); + Assert.Equal(expected[i].DisplayName, actual[i].DisplayName); + } + + // Indexes are rebuilt as well. + Assert.Equal(2, Assert.Single(reloaded.FindByKeys(["ep:tvdb:393189:s2e3"])).LiveItemIds.Count); + Assert.NotNull(reloaded.FindByItemId(movieItem)); + Assert.Null(reloaded.FindByItemId(removedItem)); + Assert.NotNull(Assert.Single(reloaded.FindByKeys(["movie:tmdb:604"])).RemovedAt); + } + + [Fact] + public void Flush_ReplacesFileAtomically_WithoutLeavingTempFile() + { + _fx.Reconciler.Register(Media.Movie(Guid.NewGuid(), _fx.Now, tmdb: "603")); + _fx.Store.Flush(); + _fx.Reconciler.Register(Media.Movie(Guid.NewGuid(), _fx.Now, tmdb: "604")); + _fx.Store.Flush(); + + Assert.False(File.Exists(_fx.LedgerPath + ".tmp")); + using var document = JsonDocument.Parse(File.ReadAllBytes(_fx.LedgerPath)); + Assert.Equal(2, document.RootElement.GetProperty("Entries").GetArrayLength()); + } + + [Fact] + public void Load_IgnoresLeftoverTempFileOfInterruptedWrite() + { + _fx.Reconciler.Register(Media.Movie(Guid.NewGuid(), _fx.Now, tmdb: "603")); + _fx.Store.Flush(); + File.WriteAllText(_fx.LedgerPath + ".tmp", "{ \"Entries\": [ { \"Keys\": [\"half-writ"); + + using var reloaded = _fx.CreateStore(); + reloaded.Load(); + + Assert.Single(reloaded.GetEntries()); + Assert.False(File.Exists(_fx.LedgerPath + ".tmp")); + } + + [Fact] + public void Load_MovesUnreadableFileAside_AndStartsEmpty() + { + Directory.CreateDirectory(_fx.DirectoryPath); + File.WriteAllText(_fx.LedgerPath, "{ not json"); + + using var store = _fx.CreateStore(); + store.Load(); + + Assert.Empty(store.GetEntries()); + Assert.False(store.SeedCompleted); + Assert.False(File.Exists(_fx.LedgerPath)); + Assert.Single(Directory.GetFiles(_fx.DirectoryPath, LedgerStore.DefaultFileName + ".corrupt-*")); + } + + [Fact] + public void Load_WithoutFile_YieldsEmptyLedger() + { + using var store = _fx.CreateStore(); + store.Load(); + + Assert.Empty(store.GetEntries()); + Assert.False(store.SeedCompleted); + } + + [Fact] + public void Keys_AreMatchedCaseInsensitively() + { + _fx.Reconciler.Register(Media.Movie(Guid.NewGuid(), _fx.Now, imdb: "tt0133093")); + + Assert.Single(_fx.Store.FindByKeys(["movie:imdb:TT0133093"])); + } + + [Fact] + public async Task MarkDirty_IsDebounced_AndWritesAfterTheDelay() + { + var path = Path.Combine(_fx.DirectoryPath, "debounced.json"); + using var store = new LedgerStore(path, NullLogger.Instance, TimeProvider.System, TimeSpan.FromMilliseconds(100)); + var reconciler = new Reconciler(store, () => GuardOptions.Default, TimeProvider.System, NullLogger.Instance); + + for (var i = 0; i < 50; i++) + { + reconciler.Register(Media.Movie(Guid.NewGuid(), DateTime.UtcNow, tmdb: i.ToString(System.Globalization.CultureInfo.InvariantCulture))); + } + + Assert.False(File.Exists(path)); + var stopwatch = Stopwatch.StartNew(); + while (!File.Exists(path) && stopwatch.Elapsed < TimeSpan.FromSeconds(10)) + { + await Task.Delay(25, TestContext.Current.CancellationToken); + } + + Assert.True(File.Exists(path)); + using var document = JsonDocument.Parse(File.ReadAllBytes(path)); + Assert.Equal(50, document.RootElement.GetProperty("Entries").GetArrayLength()); + } + + [Fact] + public void ConcurrentEvents_KeepLedgerAndIndexesConsistent() + { + var itemIds = Enumerable.Range(0, 4000).Select(_ => Guid.NewGuid()).ToArray(); + + Parallel.For(0, itemIds.Length, i => + { + var tmdb = (i % 500).ToString(System.Globalization.CultureInfo.InvariantCulture); + var movie = Media.Movie(itemIds[i], _fx.Now.AddMinutes(-i), tmdb: tmdb); + _fx.Reconciler.OnItemSeen(movie); + if (i % 3 == 0) + { + _fx.Reconciler.OnItemRemoved(itemIds[i], movie.Keys); + } + }); + + var entries = _fx.Store.GetEntries(); + Assert.Equal(500, entries.Count); + var live = itemIds.Where((_, i) => i % 3 != 0).ToArray(); + Assert.Equal(live.Length, entries.Sum(e => e.LiveItemIds.Count)); + Assert.All(live, id => Assert.NotNull(_fx.Store.FindByItemId(id))); + Assert.All(entries, e => Assert.Equal(e.Keys.Length, e.Keys.Distinct(StringComparer.OrdinalIgnoreCase).Count())); + } + + [Fact] + public void Dispose_FlushesPendingChanges() + { + var path = Path.Combine(_fx.DirectoryPath, "dispose.json"); + var store = new LedgerStore(path, NullLogger.Instance, TimeProvider.System, TimeSpan.FromHours(1)); + new Reconciler(store, () => GuardOptions.Default, TimeProvider.System, NullLogger.Instance) + .Register(Media.Movie(Guid.NewGuid(), DateTime.UtcNow, tmdb: "603")); + + store.Dispose(); + + Assert.True(File.Exists(path)); + } + + [Fact] + public void Load_MergesOverlappingEntriesOfADamagedFile() + { + var item = Guid.NewGuid(); + Directory.CreateDirectory(_fx.DirectoryPath); + File.WriteAllText( + _fx.LedgerPath, + $$""" + {"SchemaVersion":1,"SeedCompleted":true,"Entries":[ + {"Keys":["movie:tmdb:603"],"FirstAdded":"2025-05-01T00:00:00Z","LastSeen":"2026-01-01T00:00:00Z","LiveItemIds":["{{item}}"],"DisplayName":"A"}, + {"Keys":["movie:tmdb:603","movie:imdb:tt0133093"],"FirstAdded":"2024-05-01T00:00:00Z","LastSeen":"2025-01-01T00:00:00Z","RemovedAt":"2025-02-01T00:00:00Z","LiveItemIds":[],"DisplayName":"B"} + ]} + """); + + using var store = _fx.CreateStore(); + store.Load(); + + var entry = Assert.Single(store.GetEntries()); + Assert.Equal(new DateTime(2024, 5, 1, 0, 0, 0, DateTimeKind.Utc), entry.FirstAdded); + Assert.Equal(2, entry.Keys.Length); + Assert.Null(entry.RemovedAt); + Assert.Same(entry, store.FindByItemId(item)); + } +} diff --git a/Jellyfin.Plugin.RecentlyAddedGuard.Tests/MediaKeysTests.cs b/Jellyfin.Plugin.RecentlyAddedGuard.Tests/MediaKeysTests.cs new file mode 100644 index 0000000..576dfec --- /dev/null +++ b/Jellyfin.Plugin.RecentlyAddedGuard.Tests/MediaKeysTests.cs @@ -0,0 +1,41 @@ +using System; +using Jellyfin.Plugin.RecentlyAddedGuard.Core; +using Xunit; + +namespace Jellyfin.Plugin.RecentlyAddedGuard.Tests; + +public sealed class MediaKeysTests +{ + [Fact] + public void ForMovie_ContainsOnlyKnownProviderIds() + { + Assert.Equal(["movie:tmdb:603", "movie:imdb:tt0133093"], MediaKeys.ForMovie(" 603 ", "tt0133093", " ")); + Assert.Empty(MediaKeys.ForMovie(null, null, null)); + } + + [Theory] + [InlineData(1, 5, null, "s1e5")] + [InlineData(1, 5, 5, "s1e5")] + [InlineData(1, 5, 6, "s1e5-6")] + [InlineData(0, 1, null, "s0e1")] + [InlineData(null, 5, null, null)] + [InlineData(1, null, null, null)] + public void EpisodePart_RequiresSeasonAndEpisode(int? season, int? episode, int? episodeEnd, string? expected) + { + Assert.Equal(expected, MediaKeys.EpisodePart(season, episode, episodeEnd)); + } + + [Fact] + public void ForEpisode_AddsSeriesGuidKeyAlways() + { + var series = Guid.Parse("0f3c9bd2-38a5-4e47-9d2b-8bd3a1a3e7c4"); + + var keys = MediaKeys.ForEpisode("s2e3", "393189", "83867", null, series); + + Assert.Equal( + ["ep:tvdb:393189:s2e3", "ep:tmdb:83867:s2e3", "ep:series-guid:0f3c9bd238a54e479d2b8bd3a1a3e7c4:s2e3"], + keys); + Assert.Equal(["ep:series-guid:0f3c9bd238a54e479d2b8bd3a1a3e7c4:s2e3"], MediaKeys.ForEpisode("s2e3", null, null, null, series)); + Assert.Empty(MediaKeys.ForEpisode("s2e3", null, null, null, Guid.Empty)); + } +} diff --git a/Jellyfin.Plugin.RecentlyAddedGuard.Tests/ReconcilerTests.cs b/Jellyfin.Plugin.RecentlyAddedGuard.Tests/ReconcilerTests.cs new file mode 100644 index 0000000..6227870 --- /dev/null +++ b/Jellyfin.Plugin.RecentlyAddedGuard.Tests/ReconcilerTests.cs @@ -0,0 +1,420 @@ +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Plugin.RecentlyAddedGuard.Core; +using Xunit; + +namespace Jellyfin.Plugin.RecentlyAddedGuard.Tests; + +public sealed class ReconcilerTests : IDisposable +{ + private static readonly Guid _oldItem = Guid.Parse("11111111-1111-1111-1111-111111111111"); + private static readonly Guid _newItem = Guid.Parse("22222222-2222-2222-2222-222222222222"); + private static readonly Guid _otherItem = Guid.Parse("33333333-3333-3333-3333-333333333333"); + private static readonly Guid _series = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); + + private readonly GuardFixture _fx = new(); + + private static DateTime OriginalDate => GuardFixture.Start.UtcDateTime.AddDays(-300); + + public void Dispose() => _fx.Dispose(); + + // 1 + [Fact] + public async Task NewMedium_IsRegistered_AndNotChanged() + { + var result = await SeenAsync(Media.Movie(_oldItem, _fx.Now, tmdb: "603")); + + Assert.Equal(ReconcileAction.Registered, result.Action); + Assert.Empty(_fx.Writer.Writes); + var entry = _fx.SingleEntry(); + Assert.Equal(_fx.Now, entry.FirstAdded); + Assert.Equal([_oldItem], entry.LiveItemIds); + Assert.Equal(["movie:tmdb:603"], entry.Keys); + } + + // 2 + [Fact] + public async Task Upgrade_WithNewPath_AfterOldItemRemoved_WithinGrace_GetsOriginalDate() + { + var old = Media.Movie(_oldItem, OriginalDate, tmdb: "603"); + await SeenAsync(old); + _fx.Time.Advance(TimeSpan.FromDays(3)); + Assert.True(_fx.Processor.ProcessRemoved(_oldItem, old.Keys)); + Assert.Equal(_fx.Now, _fx.SingleEntry().RemovedAt); + + _fx.Time.Advance(TimeSpan.FromMinutes(2)); + var result = await SeenAsync(Media.Movie(_newItem, _fx.Now, tmdb: "603")); + + Assert.Equal(ReconcileAction.UpgradeDetected, result.Action); + Assert.Equal([(_newItem, OriginalDate)], _fx.Writer.Writes); + var entry = _fx.SingleEntry(); + Assert.Null(entry.RemovedAt); + Assert.Equal([_newItem], entry.LiveItemIds); + Assert.Equal(OriginalDate, entry.FirstAdded); + + var correction = Assert.Single(_fx.Corrections.GetRecent()); + Assert.Equal(_fx.Now, correction.PreviousDate); + Assert.Equal(OriginalDate, correction.RestoredDate); + Assert.False(correction.DryRun); + Assert.Equal(1, _fx.Corrections.CorrectionsSinceStart); + } + + // 3 + [Fact] + public async Task Upgrade_BeforeOldItemIsRemoved_BothLive_GetsOriginalDate() + { + var old = Media.Movie(_oldItem, OriginalDate, tmdb: "603"); + await SeenAsync(old); + _fx.Time.Advance(TimeSpan.FromDays(10)); + + var result = await SeenAsync(Media.Movie(_newItem, _fx.Now, tmdb: "603")); + + Assert.Equal(ReconcileAction.UpgradeDetected, result.Action); + Assert.Equal([(_newItem, OriginalDate)], _fx.Writer.Writes); + Assert.Equal(2, _fx.SingleEntry().LiveItemIds.Count); + + _fx.Processor.ProcessRemoved(_oldItem, old.Keys); + var entry = _fx.SingleEntry(); + Assert.Equal([_newItem], entry.LiveItemIds); + Assert.Null(entry.RemovedAt); + } + + // 4 + [Fact] + public async Task SamePath_FileReplaced_DateJumpsOnUpdate_IsReset() + { + await SeenAsync(Media.Movie(_oldItem, OriginalDate, tmdb: "603")); + _fx.Time.Advance(TimeSpan.FromDays(1)); + + // Metadata refresh noticed the new file and set DateCreated to its creation time. + var result = await SeenAsync(Media.Movie(_oldItem, _fx.Now, tmdb: "603")); + + Assert.Equal(ReconcileAction.UpgradeDetected, result.Action); + Assert.Equal([(_oldItem, OriginalDate)], _fx.Writer.Writes); + + // The ItemUpdated caused by the correction itself is a no-op. + var echo = await SeenAsync(Media.Movie(_oldItem, OriginalDate, tmdb: "603")); + Assert.Equal(ReconcileAction.Unchanged, echo.Action); + Assert.Single(_fx.Writer.Writes); + } + + // 5 + [Fact] + public async Task ReturnAfterGracePeriod_CountsAsNew_WithoutCorrection() + { + var old = Media.Movie(_oldItem, OriginalDate, tmdb: "603"); + await SeenAsync(old); + _fx.Processor.ProcessRemoved(_oldItem, old.Keys); + _fx.Time.Advance(TimeSpan.FromDays(15)); + + var result = await SeenAsync(Media.Movie(_newItem, _fx.Now, tmdb: "603")); + + Assert.Equal(ReconcileAction.ReturnedAfterGrace, result.Action); + Assert.Empty(_fx.Writer.Writes); + var entry = _fx.SingleEntry(); + Assert.Equal(_fx.Now, entry.FirstAdded); + Assert.Null(entry.RemovedAt); + } + + [Theory] + [InlineData(13, ReconcileAction.UpgradeDetected)] + [InlineData(15, ReconcileAction.ReturnedAfterGrace)] + public async Task GracePeriod_DecidesBetweenUpgradeAndNew(int daysAway, ReconcileAction expected) + { + var old = Media.Movie(_oldItem, OriginalDate, tmdb: "603"); + await SeenAsync(old); + _fx.Processor.ProcessRemoved(_oldItem, old.Keys); + _fx.Time.Advance(TimeSpan.FromDays(daysAway)); + + var result = await SeenAsync(Media.Movie(_newItem, _fx.Now, tmdb: "603")); + + Assert.Equal(expected, result.Action); + } + + // 6 + [Fact] + public async Task OlderDateFromNfo_LowersFirstAdded() + { + await SeenAsync(Media.Movie(_oldItem, _fx.Now, tmdb: "603")); + + var result = await SeenAsync(Media.Movie(_oldItem, OriginalDate, tmdb: "603")); + + Assert.Equal(ReconcileAction.FirstAddedLowered, result.Action); + Assert.Empty(_fx.Writer.Writes); + Assert.Equal(OriginalDate, _fx.SingleEntry().FirstAdded); + + _fx.Time.Advance(TimeSpan.FromDays(1)); + await SeenAsync(Media.Movie(_newItem, _fx.Now, tmdb: "603")); + Assert.Equal([(_newItem, OriginalDate)], _fx.Writer.Writes); + } + + // 7 + [Fact] + public async Task MovieWithoutProviderIds_IsIgnoredOnAdd_AndMatchedOnUpdateWithTmdbId() + { + var old = Media.Movie(_oldItem, OriginalDate, tmdb: "603"); + await SeenAsync(old); + _fx.Processor.ProcessRemoved(_oldItem, old.Keys); + _fx.Time.Advance(TimeSpan.FromMinutes(1)); + + var added = await SeenAsync(Media.Movie(_newItem, _fx.Now)); + Assert.Equal(ReconcileAction.Ignored, added.Action); + Assert.Single(_fx.Store.GetEntries()); + Assert.Empty(_fx.Writer.Writes); + + _fx.Time.Advance(TimeSpan.FromMinutes(1)); + var updated = await SeenAsync(Media.Movie(_newItem, _fx.Now.AddMinutes(-1), tmdb: "603")); + + Assert.Equal(ReconcileAction.UpgradeDetected, updated.Action); + Assert.Equal([(_newItem, OriginalDate)], _fx.Writer.Writes); + } + + // 8 + [Fact] + public async Task Episode_MatchesViaSeriesGuid_WithoutProviderIds() + { + var old = Media.Episode(_oldItem, OriginalDate, _series, 1, 1); + Assert.Equal(["ep:series-guid:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:s1e1"], old.Keys); + await SeenAsync(old); + _fx.Processor.ProcessRemoved(_oldItem, old.Keys); + + var result = await SeenAsync(Media.Episode(_newItem, _fx.Now, _series, 1, 1)); + + Assert.Equal(ReconcileAction.UpgradeDetected, result.Action); + Assert.Equal([(_newItem, OriginalDate)], _fx.Writer.Writes); + + var otherEpisode = await SeenAsync(Media.Episode(_otherItem, _fx.Now, _series, 1, 2)); + Assert.Equal(ReconcileAction.Registered, otherEpisode.Action); + } + + // 9 + [Fact] + public async Task DoubleEpisode_IsIdentifiedByIndexNumberEnd() + { + var old = Media.Episode(_oldItem, OriginalDate, _series, 1, 1, episodeEnd: 2, seriesTvdb: "393189"); + Assert.Contains("ep:tvdb:393189:s1e1-2", old.Keys); + await SeenAsync(old); + _fx.Processor.ProcessRemoved(_oldItem, old.Keys); + + var upgrade = await SeenAsync(Media.Episode(_newItem, _fx.Now, _series, 1, 1, episodeEnd: 2, seriesTvdb: "393189")); + Assert.Equal(ReconcileAction.UpgradeDetected, upgrade.Action); + + // A single episode 1 is a different medium than the double episode 1-2. + var single = await SeenAsync(Media.Episode(_otherItem, _fx.Now, _series, 1, 1, seriesTvdb: "393189")); + Assert.Equal(ReconcileAction.Registered, single.Action); + Assert.Single(_fx.Writer.Writes); + } + + // 10 + [Fact] + public async Task AliasKeys_ImdbOnlyEntry_IsMatchedByUpgradeWithTmdbAndImdb_AndKeysAreMerged() + { + var old = Media.Movie(_oldItem, OriginalDate, imdb: "tt0133093"); + await SeenAsync(old); + _fx.Processor.ProcessRemoved(_oldItem, old.Keys); + + var result = await SeenAsync(Media.Movie(_newItem, _fx.Now, tmdb: "603", imdb: "tt0133093")); + + Assert.Equal(ReconcileAction.UpgradeDetected, result.Action); + Assert.Equal([(_newItem, OriginalDate)], _fx.Writer.Writes); + var entry = _fx.SingleEntry(); + Assert.Equal(["movie:imdb:tt0133093", "movie:tmdb:603"], entry.Keys.Order()); + Assert.Same(entry, Assert.Single(_fx.Store.FindByKeys(["movie:tmdb:603"]))); + } + + [Fact] + public async Task AliasKeys_SeparateEntriesAreMerged_WhenAnItemBridgesThem() + { + await SeenAsync(Media.Movie(_oldItem, OriginalDate, imdb: "tt0133093")); + _fx.Time.Advance(TimeSpan.FromDays(2)); + + // Added with the TMDb id from the folder name only: no overlap yet, so it looks new. + var added = await SeenAsync(Media.Movie(_newItem, _fx.Now, tmdb: "603")); + Assert.Equal(ReconcileAction.Registered, added.Action); + Assert.Equal(2, _fx.Store.GetEntries().Count); + + // The metadata refresh brings the IMDb id and bridges both entries. + var updated = await SeenAsync(Media.Movie(_newItem, _fx.Now, tmdb: "603", imdb: "tt0133093")); + + Assert.Equal(ReconcileAction.UpgradeDetected, updated.Action); + Assert.Equal([(_newItem, OriginalDate)], _fx.Writer.Writes); + var entry = _fx.SingleEntry(); + Assert.Equal(OriginalDate, entry.FirstAdded); + Assert.Equal(2, entry.LiveItemIds.Count); + Assert.Equal(2, entry.Keys.Length); + } + + [Fact] + public async Task AliasKeys_EntryPastGracePeriod_OnlyContributesKeysWhenMerged() + { + var old = Media.Movie(_oldItem, OriginalDate, imdb: "tt0133093"); + await SeenAsync(old); + _fx.Processor.ProcessRemoved(_oldItem, old.Keys); + _fx.Time.Advance(TimeSpan.FromDays(20)); + + await SeenAsync(Media.Movie(_newItem, _fx.Now, tmdb: "603")); + var updated = await SeenAsync(Media.Movie(_newItem, _fx.Now, tmdb: "603", imdb: "tt0133093")); + + Assert.Equal(ReconcileAction.Unchanged, updated.Action); + Assert.Empty(_fx.Writer.Writes); + var entry = _fx.SingleEntry(); + Assert.Equal(_fx.Now, entry.FirstAdded); + Assert.Null(entry.RemovedAt); + } + + // 11 + [Fact] + public async Task DryRun_DetectsUpgrade_ButChangesNothing() + { + _fx.Options = GuardOptions.Default with { DryRun = true }; + await SeenAsync(Media.Movie(_oldItem, OriginalDate, tmdb: "603")); + _fx.Time.Advance(TimeSpan.FromDays(1)); + + var result = await SeenAsync(Media.Movie(_newItem, _fx.Now, tmdb: "603")); + await SeenAsync(Media.Movie(_newItem, _fx.Now, tmdb: "603")); + + Assert.Equal(ReconcileAction.UpgradeDetected, result.Action); + Assert.Empty(_fx.Writer.Writes); + Assert.Equal(0, _fx.Corrections.CorrectionsSinceStart); + Assert.Equal(1, _fx.Corrections.DryRunDetectionsSinceStart); + Assert.True(Assert.Single(_fx.Corrections.GetRecent()).DryRun); + Assert.Equal(OriginalDate, _fx.SingleEntry().FirstAdded); + } + + // 12 + [Fact] + public async Task Prune_RemovesEntriesRemovedLongerThanGracePlusOneDay() + { + var gone = Media.Movie(_oldItem, OriginalDate, tmdb: "603"); + await SeenAsync(gone); + await SeenAsync(Media.Movie(_otherItem, OriginalDate, tmdb: "604")); + _fx.Processor.ProcessRemoved(_oldItem, gone.Keys); + + _fx.Time.Advance(TimeSpan.FromDays(14) + TimeSpan.FromHours(23)); + Assert.Equal(0, _fx.Reconciler.Prune()); + + _fx.Time.Advance(TimeSpan.FromHours(2)); + Assert.Equal(1, _fx.Reconciler.Prune()); + + var remaining = _fx.SingleEntry(); + Assert.Equal(["movie:tmdb:604"], remaining.Keys); + Assert.Empty(_fx.Store.FindByKeys(gone.Keys)); + } + + [Fact] + public async Task RepeatedCorrectionOfTheSameUpgrade_IsAppliedAgain_ButCountedOnce() + { + await SeenAsync(Media.Movie(_oldItem, OriginalDate, tmdb: "603")); + var upgrade = Media.Movie(_newItem, _fx.Now, tmdb: "603", name: "The Matrix (1999) {tmdb-603}"); + await SeenAsync(upgrade); + + // A metadata refresh that was already running saves the item with its new date once more. + await SeenAsync(upgrade with { DisplayName = "Matrix (1999)" }); + + Assert.Equal([(_newItem, OriginalDate), (_newItem, OriginalDate)], _fx.Writer.Writes); + Assert.Equal(1, _fx.Corrections.CorrectionsSinceStart); + Assert.Equal("Matrix (1999)", Assert.Single(_fx.Corrections.GetRecent()).Name); + } + + [Fact] + public async Task ChangeWithinTolerance_IsNoUpgrade() + { + await SeenAsync(Media.Movie(_oldItem, OriginalDate, tmdb: "603")); + + var result = await SeenAsync(Media.Movie(_newItem, OriginalDate.AddSeconds(45), tmdb: "603")); + + Assert.Equal(ReconcileAction.Unchanged, result.Action); + Assert.Empty(_fx.Writer.Writes); + } + + [Fact] + public async Task MissingDateCreated_IsTreatedAsNow() + { + var result = await SeenAsync(Media.Movie(_oldItem, DateTime.MinValue, tmdb: "603")); + + Assert.Equal(ReconcileAction.Registered, result.Action); + Assert.Equal(_fx.Now, _fx.SingleEntry().FirstAdded); + } + + [Fact] + public async Task UpgradeOfDeletedItem_IsNotRecorded() + { + await SeenAsync(Media.Movie(_oldItem, OriginalDate, tmdb: "603")); + _fx.Writer.ItemExists = false; + + var result = await SeenAsync(Media.Movie(_newItem, _fx.Now, tmdb: "603")); + + Assert.Equal(ReconcileAction.UpgradeDetected, result.Action); + Assert.Empty(_fx.Corrections.GetRecent()); + } + + [Fact] + public async Task ReidentifiedItem_MovesToEntryOfItsNewKeys() + { + await SeenAsync(Media.Movie(_oldItem, OriginalDate, tmdb: "1")); + + // "Identify" corrected a wrong match: same item, other film. + var result = await SeenAsync(Media.Movie(_oldItem, OriginalDate, tmdb: "603")); + + Assert.Equal(ReconcileAction.Registered, result.Action); + var entry = _fx.SingleEntry(); + Assert.Equal(["movie:tmdb:603"], entry.Keys); + Assert.Equal(OriginalDate, entry.FirstAdded); + } + + [Fact] + public async Task Removal_FallsBackToKeys_WhenItemWasNeverRegistered() + { + await SeenAsync(Media.Movie(_oldItem, OriginalDate, tmdb: "603")); + + Assert.True(_fx.Processor.ProcessRemoved(_otherItem, MediaKeys.ForMovie("603", null, null))); + Assert.Null(_fx.SingleEntry().RemovedAt); + Assert.False(_fx.Processor.ProcessRemoved(_otherItem, [])); + } + + [Fact] + public void Register_TakesMinimumDate_RegardlessOfOrder_AndNeverReportsUpgrades() + { + var later = _fx.Reconciler.Register(Media.Movie(_newItem, _fx.Now, tmdb: "603")); + var earlier = _fx.Reconciler.Register(Media.Movie(_oldItem, OriginalDate, tmdb: "603")); + var latest = _fx.Reconciler.Register(Media.Movie(_otherItem, _fx.Now.AddDays(1), tmdb: "603")); + + Assert.Equal(ReconcileAction.Registered, later.Action); + Assert.Equal(ReconcileAction.FirstAddedLowered, earlier.Action); + Assert.Equal(ReconcileAction.Unchanged, latest.Action); + var entry = _fx.SingleEntry(); + Assert.Equal(OriginalDate, entry.FirstAdded); + Assert.Equal(3, entry.LiveItemIds.Count); + } + + [Fact] + public void Sync_DetachesItemsThatNoLongerExist() + { + _fx.Reconciler.Register(Media.Movie(_oldItem, OriginalDate, tmdb: "603")); + _fx.Reconciler.Register(Media.Movie(_newItem, OriginalDate, tmdb: "604")); + + var unseen = _fx.Reconciler.GetUnseenLiveItemIds(new[] { _oldItem }.ToHashSet()); + Assert.Equal([_newItem], unseen); + + Assert.Equal(1, _fx.Reconciler.RemoveStaleItems(unseen)); + var removed = Assert.Single(_fx.Store.FindByKeys(["movie:tmdb:604"])); + Assert.Empty(removed.LiveItemIds); + Assert.Equal(_fx.Now, removed.RemovedAt); + } + + [Fact] + public void ExternalDate_OnlyLowersFirstAdded() + { + var movie = Media.Movie(_oldItem, _fx.Now, tmdb: "603"); + _fx.Reconciler.Register(movie); + + Assert.False(_fx.Reconciler.ApplyExternalFirstAdded(movie.Keys, _fx.Now.AddDays(1))); + Assert.True(_fx.Reconciler.ApplyExternalFirstAdded(movie.Keys, OriginalDate)); + Assert.Equal(OriginalDate, _fx.SingleEntry().FirstAdded); + } + + private Task SeenAsync(MediaIdentity identity) + => _fx.Processor.ProcessSeenAsync(identity, CancellationToken.None); +} diff --git a/Jellyfin.Plugin.RecentlyAddedGuard.Tests/TestSupport.cs b/Jellyfin.Plugin.RecentlyAddedGuard.Tests/TestSupport.cs new file mode 100644 index 0000000..6ba7954 --- /dev/null +++ b/Jellyfin.Plugin.RecentlyAddedGuard.Tests/TestSupport.cs @@ -0,0 +1,136 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Plugin.RecentlyAddedGuard.Core; +using Jellyfin.Plugin.RecentlyAddedGuard.Ledger; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace Jellyfin.Plugin.RecentlyAddedGuard.Tests; + +internal sealed class ManualTimeProvider : TimeProvider +{ + public ManualTimeProvider(DateTimeOffset start) + { + Now = start; + } + + public DateTimeOffset Now { get; set; } + + public override DateTimeOffset GetUtcNow() => Now; + + public void Advance(TimeSpan by) => Now += by; +} + +internal sealed class RecordingDateWriter : IItemDateWriter +{ + public List<(Guid ItemId, DateTime Date)> Writes { get; } = []; + + public bool ItemExists { get; set; } = true; + + public Task TrySetDateCreatedAsync(Guid itemId, DateTime dateCreated, CancellationToken cancellationToken) + { + if (!ItemExists) + { + return Task.FromResult(false); + } + + Writes.Add((itemId, dateCreated)); + return Task.FromResult(true); + } +} + +/// +/// Wires the core classes against a temporary ledger file and a manual clock. +/// +internal sealed class GuardFixture : IDisposable +{ + public static readonly DateTimeOffset Start = new(2026, 3, 1, 12, 0, 0, TimeSpan.Zero); + + public GuardFixture(GuardOptions? options = null) + { + DirectoryPath = Path.Combine(Path.GetTempPath(), "rag-tests", Guid.NewGuid().ToString("N")); + Time = new ManualTimeProvider(Start); + Options = options ?? GuardOptions.Default; + Store = CreateStore(); + Reconciler = new Reconciler(Store, () => Options, Time, NullLogger.Instance); + Writer = new RecordingDateWriter(); + Corrections = new CorrectionLog(); + Processor = new GuardProcessor(Reconciler, Writer, Corrections, () => Options, Time, NullLogger.Instance); + } + + public string DirectoryPath { get; } + + public string LedgerPath => Path.Combine(DirectoryPath, LedgerStore.DefaultFileName); + + public ManualTimeProvider Time { get; } + + public GuardOptions Options { get; set; } + + public LedgerStore Store { get; } + + public Reconciler Reconciler { get; } + + public RecordingDateWriter Writer { get; } + + public CorrectionLog Corrections { get; } + + public GuardProcessor Processor { get; } + + public DateTime Now => Time.GetUtcNow().UtcDateTime; + + /// + /// Creates another store on the same file. The save delay is long, so tests control writes via Flush. + /// + public LedgerStore CreateStore(TimeSpan? saveDelay = null) + => new(LedgerPath, NullLogger.Instance, Time, saveDelay ?? TimeSpan.FromHours(1)); + + public LedgerEntry SingleEntry() + { + var entries = Store.GetEntries(); + Assert.Single(entries); + return entries[0]; + } + + public void Dispose() + { + Store.Dispose(); + try + { + Directory.Delete(DirectoryPath, recursive: true); + } + catch (DirectoryNotFoundException) + { + } + } +} + +internal static class Media +{ + public static MediaIdentity Movie( + Guid itemId, + DateTime dateCreated, + string? tmdb = null, + string? imdb = null, + string? tvdb = null, + string name = "The Matrix (1999)") + => new(itemId, MediaKind.Movie, dateCreated, MediaKeys.ForMovie(tmdb, imdb, tvdb), name); + + public static MediaIdentity Episode( + Guid itemId, + DateTime dateCreated, + Guid seriesId, + int season, + int episode, + int? episodeEnd = null, + string? seriesTvdb = null, + string name = "Andor S01E01") + => new( + itemId, + MediaKind.Episode, + dateCreated, + MediaKeys.ForEpisode(MediaKeys.EpisodePart(season, episode, episodeEnd)!, seriesTvdb, null, null, seriesId), + name); +} diff --git a/Jellyfin.Plugin.RecentlyAddedGuard.slnx b/Jellyfin.Plugin.RecentlyAddedGuard.slnx new file mode 100644 index 0000000..5487e92 --- /dev/null +++ b/Jellyfin.Plugin.RecentlyAddedGuard.slnx @@ -0,0 +1,4 @@ + + + + diff --git a/Jellyfin.Plugin.RecentlyAddedGuard/Api/GuardController.cs b/Jellyfin.Plugin.RecentlyAddedGuard/Api/GuardController.cs new file mode 100644 index 0000000..8e14912 --- /dev/null +++ b/Jellyfin.Plugin.RecentlyAddedGuard/Api/GuardController.cs @@ -0,0 +1,76 @@ +using System.Net.Mime; +using Jellyfin.Plugin.RecentlyAddedGuard.Core; +using Jellyfin.Plugin.RecentlyAddedGuard.Ledger; +using Jellyfin.Plugin.RecentlyAddedGuard.Tasks; +using MediaBrowser.Common.Api; +using MediaBrowser.Model.Tasks; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace Jellyfin.Plugin.RecentlyAddedGuard.Api; + +/// +/// Status and actions for the configuration page. Jellyfin adds plugin assemblies as MVC application parts, +/// so this controller is discovered automatically. +/// +[ApiController] +[Route("RecentlyAddedGuard")] +[Authorize(Policy = Policies.RequiresElevation)] +[Produces(MediaTypeNames.Application.Json)] +public class GuardController : ControllerBase +{ + private readonly LedgerStore _store; + private readonly CorrectionLog _corrections; + private readonly ITaskManager _taskManager; + + /// + /// Initializes a new instance of the class. + /// + /// The ledger. + /// The correction log. + /// The task manager. + public GuardController(LedgerStore store, CorrectionLog corrections, ITaskManager taskManager) + { + _store = store; + _corrections = corrections; + _taskManager = taskManager; + } + + /// + /// Gets ledger statistics and the most recent corrections. + /// + /// The status. + /// The status. + [HttpGet("Status")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult GetStatus() + { + var statistics = _store.GetStatistics(); + return new GuardStatus + { + LedgerEntries = statistics.Entries, + LiveEntries = statistics.LiveEntries, + RemovedEntries = statistics.RemovedEntries, + CorrectionsSinceStart = _corrections.CorrectionsSinceStart, + DryRunDetectionsSinceStart = _corrections.DryRunDetectionsSinceStart, + SeedCompleted = _store.SeedCompleted, + LastSyncUtc = _store.LastSyncUtc, + DryRun = Plugin.CurrentConfiguration.DryRun, + RecentCorrections = _corrections.GetRecent() + }; + } + + /// + /// Queues the scheduled task "Ledger synchronisieren". + /// + /// The task was queued (or is already running). + /// No content. + [HttpPost("Sync")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult StartSync() + { + _taskManager.QueueIfNotRunning(); + return NoContent(); + } +} diff --git a/Jellyfin.Plugin.RecentlyAddedGuard/Api/GuardStatus.cs b/Jellyfin.Plugin.RecentlyAddedGuard/Api/GuardStatus.cs new file mode 100644 index 0000000..41a7f71 --- /dev/null +++ b/Jellyfin.Plugin.RecentlyAddedGuard/Api/GuardStatus.cs @@ -0,0 +1,56 @@ +using System; +using System.Collections.Generic; +using Jellyfin.Plugin.RecentlyAddedGuard.Core; + +namespace Jellyfin.Plugin.RecentlyAddedGuard.Api; + +/// +/// Status shown on the configuration page. +/// +public sealed class GuardStatus +{ + /// + /// Gets the number of ledger entries. + /// + public int LedgerEntries { get; init; } + + /// + /// Gets the number of entries with at least one live item. + /// + public int LiveEntries { get; init; } + + /// + /// Gets the number of entries whose items are gone (within the grace period). + /// + public int RemovedEntries { get; init; } + + /// + /// Gets the number of corrections since the server started. + /// + public long CorrectionsSinceStart { get; init; } + + /// + /// Gets the number of upgrades detected in dry-run mode since the server started. + /// + public long DryRunDetectionsSinceStart { get; init; } + + /// + /// Gets a value indicating whether the initial seed has completed. + /// + public bool SeedCompleted { get; init; } + + /// + /// Gets the time of the last seed or synchronization (UTC). + /// + public DateTime? LastSyncUtc { get; init; } + + /// + /// Gets a value indicating whether dry-run mode is active. + /// + public bool DryRun { get; init; } + + /// + /// Gets the most recent corrections, newest first. + /// + public IReadOnlyList RecentCorrections { get; init; } = []; +} diff --git a/Jellyfin.Plugin.RecentlyAddedGuard/Configuration/PluginConfiguration.cs b/Jellyfin.Plugin.RecentlyAddedGuard/Configuration/PluginConfiguration.cs new file mode 100644 index 0000000..e75ebc3 --- /dev/null +++ b/Jellyfin.Plugin.RecentlyAddedGuard/Configuration/PluginConfiguration.cs @@ -0,0 +1,41 @@ +using Jellyfin.Plugin.RecentlyAddedGuard.Core; +using MediaBrowser.Model.Plugins; + +namespace Jellyfin.Plugin.RecentlyAddedGuard.Configuration; + +/// +/// The plugin configuration. +/// +public class PluginConfiguration : BasePluginConfiguration +{ + /// + /// Gets or sets the number of days within which a returning medium counts as an upgrade. + /// + public int GraceDays { get; set; } = 14; + + /// + /// Gets or sets a value indicating whether movies are protected. + /// + public bool EnableMovies { get; set; } = true; + + /// + /// Gets or sets a value indicating whether episodes are protected. + /// + public bool EnableEpisodes { get; set; } = true; + + /// + /// Gets or sets a value indicating whether upgrades are only logged, not corrected. + /// + public bool DryRun { get; set; } + + /// + /// Gets or sets the tolerance in seconds for comparing dates. + /// + public int ToleranceSeconds { get; set; } = 60; + + /// + /// Maps the configuration to the options of the core logic. + /// + /// The options. + public GuardOptions ToGuardOptions() => new(GraceDays, ToleranceSeconds, DryRun); +} diff --git a/Jellyfin.Plugin.RecentlyAddedGuard/Configuration/configPage.html b/Jellyfin.Plugin.RecentlyAddedGuard/Configuration/configPage.html new file mode 100644 index 0000000..25b0ea5 --- /dev/null +++ b/Jellyfin.Plugin.RecentlyAddedGuard/Configuration/configPage.html @@ -0,0 +1,210 @@ + + + + + Recently Added Guard + + +
+ +
+
+

Recently Added Guard

+

+ Bewahrt das erste Hinzufügedatum jedes Films und jeder Episode. Qualitäts-Upgrades aus Radarr und Sonarr + behalten so ihr ursprüngliches Datum und erscheinen nicht erneut unter „Zuletzt hinzugefügt“. +

+ +
+
+ + +
+ Taucht ein gelöschter Film oder eine gelöschte Episode innerhalb dieser Frist wieder auf, gilt das als Upgrade und + das alte Datum wird wiederhergestellt. Nach Ablauf gilt das Medium als neu. Standard: 14. +
+
+
+ + +
Kleinere Abweichungen des Datums werden ignoriert. Standard: 60.
+
+
+ +
+
+ +
+
+ +
Upgrades nur protokollieren, kein Datum ändern.
+
+
+ +
+
+ +
+

Status

+
+
–
Ledger-Einträge
+
–
davon mit Items
+
–
in der Frist
+
–
Korrekturen seit Start
+
+

+ + +

Letzte Korrekturen

+
+ + + + + + + + + + + + +
NameAltes DatumNeues DatumZeitpunkt
Lädt …
+
+
+
+
+ + +
+ + diff --git a/Jellyfin.Plugin.RecentlyAddedGuard/Core/CorrectionLog.cs b/Jellyfin.Plugin.RecentlyAddedGuard/Core/CorrectionLog.cs new file mode 100644 index 0000000..f8d937b --- /dev/null +++ b/Jellyfin.Plugin.RecentlyAddedGuard/Core/CorrectionLog.cs @@ -0,0 +1,116 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; + +namespace Jellyfin.Plugin.RecentlyAddedGuard.Core; + +/// +/// One restored (or, in dry-run mode, detected) upgrade. +/// +/// The corrected item. +/// The display name. +/// The "date added" the upgrade brought along (UTC). +/// The first "date added" from the ledger (UTC). +/// When the correction happened (UTC). +/// true if only detected, not applied. +public sealed record CorrectionRecord( + Guid ItemId, + string Name, + DateTime PreviousDate, + DateTime RestoredDate, + DateTime CorrectedAt, + bool DryRun); + +/// +/// In-memory ring buffer of the most recent corrections, for the configuration page. +/// +public sealed class CorrectionLog +{ + /// + /// The default number of kept corrections. + /// + public const int DefaultCapacity = 100; + + private readonly Lock _sync = new(); + private readonly List _records; + private readonly int _capacity; + private long _corrections; + private long _dryRunDetections; + + /// + /// Initializes a new instance of the class. + /// + /// The number of kept corrections. + public CorrectionLog(int capacity = DefaultCapacity) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(capacity); + _capacity = capacity; + _records = new List(capacity); + } + + /// + /// Gets the number of applied corrections since the server started. + /// + public long CorrectionsSinceStart => Interlocked.Read(ref _corrections); + + /// + /// Gets the number of upgrades detected in dry-run mode since the server started. + /// + public long DryRunDetectionsSinceStart => Interlocked.Read(ref _dryRunDetections); + + /// + /// Records a correction, dropping the oldest one when full. Restoring the same date of the same item again + /// (Jellyfin saved the item with its new date once more, e.g. from a metadata refresh that was already running) + /// replaces the earlier record and is not counted twice. + /// + /// The correction. + /// true for a new correction, false for a repeated one. + public bool Add(CorrectionRecord record) + { + ArgumentNullException.ThrowIfNull(record); + + lock (_sync) + { + var existing = _records.FindIndex(r => r.ItemId == record.ItemId + && r.RestoredDate == record.RestoredDate + && r.DryRun == record.DryRun); + if (existing >= 0) + { + _records.RemoveAt(existing); + _records.Add(record); + return false; + } + + if (_records.Count == _capacity) + { + _records.RemoveAt(0); + } + + _records.Add(record); + } + + if (record.DryRun) + { + Interlocked.Increment(ref _dryRunDetections); + } + else + { + Interlocked.Increment(ref _corrections); + } + + return true; + } + + /// + /// Returns the kept corrections, newest first. + /// + /// The corrections. + public IReadOnlyList GetRecent() + { + lock (_sync) + { + return _records.AsEnumerable().Reverse().ToList(); + } + } +} diff --git a/Jellyfin.Plugin.RecentlyAddedGuard/Core/GuardOptions.cs b/Jellyfin.Plugin.RecentlyAddedGuard/Core/GuardOptions.cs new file mode 100644 index 0000000..7ed0909 --- /dev/null +++ b/Jellyfin.Plugin.RecentlyAddedGuard/Core/GuardOptions.cs @@ -0,0 +1,27 @@ +using System; + +namespace Jellyfin.Plugin.RecentlyAddedGuard.Core; + +/// +/// The settings the core logic depends on, decoupled from the Jellyfin plugin configuration. +/// +/// Days within which a returning medium counts as an upgrade. +/// Date differences up to this many seconds are ignored. +/// Only log detected upgrades, never change items. +public sealed record GuardOptions(int GraceDays, int ToleranceSeconds, bool DryRun) +{ + /// + /// Gets the defaults (14 days, 60 seconds, no dry run). + /// + public static GuardOptions Default { get; } = new(14, 60, false); + + /// + /// Gets the grace period. + /// + public TimeSpan GracePeriod => TimeSpan.FromDays(Math.Max(0, GraceDays)); + + /// + /// Gets the tolerance for date comparisons. + /// + public TimeSpan Tolerance => TimeSpan.FromSeconds(Math.Max(0, ToleranceSeconds)); +} diff --git a/Jellyfin.Plugin.RecentlyAddedGuard/Core/GuardProcessor.cs b/Jellyfin.Plugin.RecentlyAddedGuard/Core/GuardProcessor.cs new file mode 100644 index 0000000..c2e8b18 --- /dev/null +++ b/Jellyfin.Plugin.RecentlyAddedGuard/Core/GuardProcessor.cs @@ -0,0 +1,137 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Plugin.RecentlyAddedGuard.Core; + +/// +/// Turns reconciler decisions into actions: restores the date of upgrades (or only logs them in dry-run mode) +/// and records corrections. +/// +public sealed class GuardProcessor +{ + private readonly Reconciler _reconciler; + private readonly IItemDateWriter _writer; + private readonly CorrectionLog _corrections; + private readonly Func _options; + private readonly TimeProvider _timeProvider; + private readonly ILogger _logger; + private readonly ConcurrentDictionary _dryRunReported = new(); + + /// + /// Initializes a new instance of the class. + /// + /// The reconciler. + /// Writes item dates. + /// The correction log. + /// Provides the current options. + /// The clock. + /// The logger. + public GuardProcessor( + Reconciler reconciler, + IItemDateWriter writer, + CorrectionLog corrections, + Func options, + TimeProvider timeProvider, + ILogger logger) + { + _reconciler = reconciler ?? throw new ArgumentNullException(nameof(reconciler)); + _writer = writer ?? throw new ArgumentNullException(nameof(writer)); + _corrections = corrections ?? throw new ArgumentNullException(nameof(corrections)); + _options = options ?? throw new ArgumentNullException(nameof(options)); + _timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + /// Handles an added or updated item. + /// + /// The item. + /// The cancellation token. + /// The reconciler's outcome. + public async Task ProcessSeenAsync(MediaIdentity identity, CancellationToken cancellationToken) + { + var result = _reconciler.OnItemSeen(identity); + switch (result.Action) + { + case ReconcileAction.UpgradeDetected: + await RestoreDateAsync(result, cancellationToken).ConfigureAwait(false); + break; + case ReconcileAction.ReturnedAfterGrace: + _logger.LogInformation( + "{Name} ist nach Ablauf der Frist zurückgekehrt und gilt als neu (DateAdded {Date:u})", + identity.DisplayName, + result.ItemDate); + break; + case ReconcileAction.Registered: + _logger.LogDebug("Neu registriert: {Name} (DateAdded {Date:u})", identity.DisplayName, result.ItemDate); + break; + case ReconcileAction.FirstAddedLowered: + _logger.LogDebug("Erstes Hinzufügedatum von {Name} auf {Date:u} vorverlegt", identity.DisplayName, result.ItemDate); + break; + default: + break; + } + + return result; + } + + /// + /// Handles a removed item. + /// + /// The removed item. + /// The keys the item still resolves to; may be empty. + /// true if a ledger entry was affected. + public bool ProcessRemoved(Guid itemId, IReadOnlyList keys) => _reconciler.OnItemRemoved(itemId, keys); + + private async Task RestoreDateAsync(ReconcileResult result, CancellationToken cancellationToken) + { + var identity = result.Identity; + if (_options().DryRun) + { + // Every metadata refresh of the item reports the upgrade again; log it once per date. + if (_dryRunReported.TryGetValue(identity.ItemId, out var reported) && reported == result.ItemDate) + { + return; + } + + _dryRunReported[identity.ItemId] = result.ItemDate; + _logger.LogInformation( + "[DryRun] Upgrade erkannt: {Name} – DateAdded {New:u} → {Original:u} (nicht geändert)", + identity.DisplayName, + result.ItemDate, + result.FirstAdded); + _corrections.Add(new CorrectionRecord(identity.ItemId, identity.DisplayName, result.ItemDate, result.FirstAdded, UtcNow(), DryRun: true)); + return; + } + + if (!await _writer.TrySetDateCreatedAsync(identity.ItemId, result.FirstAdded, cancellationToken).ConfigureAwait(false)) + { + _logger.LogDebug("Item {ItemId} existiert nicht mehr, keine Korrektur", identity.ItemId); + return; + } + + _dryRunReported.TryRemove(identity.ItemId, out _); + var record = new CorrectionRecord(identity.ItemId, identity.DisplayName, result.ItemDate, result.FirstAdded, UtcNow(), DryRun: false); + if (_corrections.Add(record)) + { + _logger.LogInformation( + "Upgrade erkannt: {Name} – DateAdded {New:u} → {Original:u}", + identity.DisplayName, + result.ItemDate, + result.FirstAdded); + } + else + { + _logger.LogDebug( + "DateAdded von {Name} wurde von Jellyfin erneut überschrieben und wieder auf {Original:u} gesetzt", + identity.DisplayName, + result.FirstAdded); + } + } + + private DateTime UtcNow() => _timeProvider.GetUtcNow().UtcDateTime; +} diff --git a/Jellyfin.Plugin.RecentlyAddedGuard/Core/IExternalDateSource.cs b/Jellyfin.Plugin.RecentlyAddedGuard/Core/IExternalDateSource.cs new file mode 100644 index 0000000..27f0e41 --- /dev/null +++ b/Jellyfin.Plugin.RecentlyAddedGuard/Core/IExternalDateSource.cs @@ -0,0 +1,29 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace Jellyfin.Plugin.RecentlyAddedGuard.Core; + +/// +/// An external source for the first "date added" of a medium, e.g. the import history of Radarr or Sonarr. +/// +/// +/// Extension point for v2: implementations registered in the DI container are consulted by the +/// ledger synchronization, and earlier dates lower the ledger via . +/// v1 ships no implementation. +/// +public interface IExternalDateSource +{ + /// + /// Gets the name of the source for logs. + /// + string Name { get; } + + /// + /// Looks up when the medium was first imported. + /// + /// The medium. + /// The cancellation token. + /// The first import date (UTC), or null if unknown. + Task GetFirstAddedAsync(MediaIdentity identity, CancellationToken cancellationToken); +} diff --git a/Jellyfin.Plugin.RecentlyAddedGuard/Core/IItemDateWriter.cs b/Jellyfin.Plugin.RecentlyAddedGuard/Core/IItemDateWriter.cs new file mode 100644 index 0000000..1cdcf38 --- /dev/null +++ b/Jellyfin.Plugin.RecentlyAddedGuard/Core/IItemDateWriter.cs @@ -0,0 +1,20 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace Jellyfin.Plugin.RecentlyAddedGuard.Core; + +/// +/// Writes the "date added" of a library item. +/// +public interface IItemDateWriter +{ + /// + /// Sets the item's "date added" and saves it. + /// + /// The item. + /// The new "date added" (UTC). + /// The cancellation token. + /// false if the item no longer exists. + Task TrySetDateCreatedAsync(Guid itemId, DateTime dateCreated, CancellationToken cancellationToken); +} diff --git a/Jellyfin.Plugin.RecentlyAddedGuard/Core/IdentityResolver.cs b/Jellyfin.Plugin.RecentlyAddedGuard/Core/IdentityResolver.cs new file mode 100644 index 0000000..dbedeb0 --- /dev/null +++ b/Jellyfin.Plugin.RecentlyAddedGuard/Core/IdentityResolver.cs @@ -0,0 +1,178 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Movies; +using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Controller.Library; +using MediaBrowser.Model.Entities; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Plugin.RecentlyAddedGuard.Core; + +/// +/// Maps Jellyfin items to snapshots for the core logic. +/// +public sealed class IdentityResolver +{ + private readonly ILibraryManager _libraryManager; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The library manager. + /// The logger. + public IdentityResolver(ILibraryManager libraryManager, ILogger logger) + { + _libraryManager = libraryManager; + _logger = logger; + } + + /// + /// Checks whether the item is a real movie or episode: no missing (virtual) episode, no extra. + /// + /// The item. + /// true if the plugin tracks items of this kind. + public static bool IsTracked(BaseItem item) + => item is Movie or Episode && !item.IsVirtualItem && item.ExtraType is null; + + /// + /// Resolves the identity of a tracked item. + /// + /// The item. + /// Optional cached series lookup (used by the synchronization). + /// The identity (its keys may be empty if provider ids are still missing), + /// or null if the item is not tracked or its kind is disabled. + public MediaIdentity? Resolve(BaseItem item, Func? seriesLookup = null) + { + ArgumentNullException.ThrowIfNull(item); + + if (!IsTracked(item)) + { + return null; + } + + var configuration = Plugin.CurrentConfiguration; + switch (item) + { + case Movie movie when configuration.EnableMovies: + return new MediaIdentity(movie.Id, MediaKind.Movie, movie.DateCreated, MovieKeys(movie), MovieName(movie)); + case Episode episode when configuration.EnableEpisodes: + var seriesId = FindSeriesId(episode); + var series = FindSeries(seriesId, seriesLookup); + return new MediaIdentity( + episode.Id, + MediaKind.Episode, + episode.DateCreated, + EpisodeKeys(episode, seriesId, series), + EpisodeName(episode, series)); + default: + return null; + } + } + + /// + /// Resolves the keys of a removed item. The provider ids are still attached to the object, + /// but the series may already be gone, so every lookup is defensive. + /// + /// The removed item. + /// The keys; empty if they cannot be determined. + public IReadOnlyList ResolveKeysForRemoval(BaseItem item) + { + ArgumentNullException.ThrowIfNull(item); + + switch (item) + { + case Movie movie: + return MovieKeys(movie); + case Episode episode: + var seriesId = FindSeriesId(episode); + return EpisodeKeys(episode, seriesId, FindSeries(seriesId, null)); + default: + return []; + } + } + + private static IReadOnlyList MovieKeys(Movie movie) + => MediaKeys.ForMovie( + movie.GetProviderId(MetadataProvider.Tmdb), + movie.GetProviderId(MetadataProvider.Imdb), + movie.GetProviderId(MetadataProvider.Tvdb)); + + private static IReadOnlyList EpisodeKeys(Episode episode, Guid seriesId, Series? series) + { + var part = MediaKeys.EpisodePart(episode.ParentIndexNumber, episode.IndexNumber, episode.IndexNumberEnd); + if (part is null) + { + return []; + } + + return MediaKeys.ForEpisode( + part, + series?.GetProviderId(MetadataProvider.Tvdb), + series?.GetProviderId(MetadataProvider.Tmdb), + series?.GetProviderId(MetadataProvider.Imdb), + seriesId); + } + + private static string MovieName(Movie movie) + { + var name = string.IsNullOrWhiteSpace(movie.Name) ? movie.Id.ToString("N", CultureInfo.InvariantCulture) : movie.Name; + return movie.ProductionYear is { } year + ? string.Create(CultureInfo.InvariantCulture, $"{name} ({year})") + : name; + } + + private static string EpisodeName(Episode episode, Series? series) + { + var seriesName = episode.SeriesName ?? series?.Name ?? "?"; + var number = string.Create(CultureInfo.InvariantCulture, $"S{episode.ParentIndexNumber ?? 0:00}E{episode.IndexNumber ?? 0:00}"); + if (episode.IndexNumberEnd is { } end && end > (episode.IndexNumber ?? 0)) + { + number += string.Create(CultureInfo.InvariantCulture, $"-E{end:00}"); + } + + return string.IsNullOrWhiteSpace(episode.Name) + ? $"{seriesName} {number}" + : $"{seriesName} {number} – {episode.Name}"; + } + +#pragma warning disable CA1031 // Lookups on half-deleted items may fail in many ways; a missing key is acceptable. + private Guid FindSeriesId(Episode episode) + { + if (episode.SeriesId != Guid.Empty) + { + return episode.SeriesId; + } + + try + { + return episode.FindSeriesId(); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Serie von Episode {ItemId} nicht ermittelbar", episode.Id); + return Guid.Empty; + } + } + + private Series? FindSeries(Guid seriesId, Func? seriesLookup) + { + if (seriesId == Guid.Empty) + { + return null; + } + + try + { + return seriesLookup is not null ? seriesLookup(seriesId) : _libraryManager.GetItemById(seriesId) as Series; + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Serie {SeriesId} nicht auffindbar", seriesId); + return null; + } + } +#pragma warning restore CA1031 +} diff --git a/Jellyfin.Plugin.RecentlyAddedGuard/Core/MediaIdentity.cs b/Jellyfin.Plugin.RecentlyAddedGuard/Core/MediaIdentity.cs new file mode 100644 index 0000000..6afb70e --- /dev/null +++ b/Jellyfin.Plugin.RecentlyAddedGuard/Core/MediaIdentity.cs @@ -0,0 +1,31 @@ +using System; +using System.Collections.Generic; + +namespace Jellyfin.Plugin.RecentlyAddedGuard.Core; + +/// +/// Kind of logical medium tracked by the ledger. +/// +public enum MediaKind +{ + /// A movie. + Movie, + + /// A TV episode (or a multi-episode file). + Episode +} + +/// +/// Jellyfin-independent snapshot of a library item, as consumed by the . +/// +/// The Jellyfin item id. +/// The kind of medium. +/// The item's current "date added" (UTC). +/// All identity keys of the logical medium; empty if the item cannot be identified yet. +/// Human readable name for logs and the configuration page. +public sealed record MediaIdentity( + Guid ItemId, + MediaKind Kind, + DateTime DateCreated, + IReadOnlyList Keys, + string DisplayName); diff --git a/Jellyfin.Plugin.RecentlyAddedGuard/Core/MediaKeys.cs b/Jellyfin.Plugin.RecentlyAddedGuard/Core/MediaKeys.cs new file mode 100644 index 0000000..dccd1fa --- /dev/null +++ b/Jellyfin.Plugin.RecentlyAddedGuard/Core/MediaKeys.cs @@ -0,0 +1,88 @@ +using System; +using System.Collections.Generic; +using System.Globalization; + +namespace Jellyfin.Plugin.RecentlyAddedGuard.Core; + +/// +/// Builds the alias keys that identify a logical medium independent of its file path. +/// +/// +/// Keys are compared case-insensitively by the ledger, so provider ids are only trimmed. +/// +public static class MediaKeys +{ + /// + /// Builds the keys of a movie from its provider ids. + /// + /// The TMDb id, if known. + /// The IMDb id, if known. + /// The TVDb id, if known. + /// The keys; empty if the movie has no provider id yet. + public static IReadOnlyList ForMovie(string? tmdbId, string? imdbId, string? tvdbId) + { + var keys = new List(3); + AddIfPresent(keys, "movie:tmdb:", tmdbId, string.Empty); + AddIfPresent(keys, "movie:imdb:", imdbId, string.Empty); + AddIfPresent(keys, "movie:tvdb:", tvdbId, string.Empty); + return keys; + } + + /// + /// Builds the episode part of an episode key, e.g. s1e5 or s1e5-6 for a double episode. + /// + /// The season number (ParentIndexNumber). + /// The episode number (IndexNumber). + /// The last episode number of a multi-episode file (IndexNumberEnd). + /// The episode part, or null if season or episode number is missing. + public static string? EpisodePart(int? seasonNumber, int? episodeNumber, int? episodeNumberEnd) + { + if (seasonNumber is not { } season || episodeNumber is not { } episode) + { + return null; + } + + return episodeNumberEnd is { } end && end > episode + ? string.Create(CultureInfo.InvariantCulture, $"s{season}e{episode}-{end}") + : string.Create(CultureInfo.InvariantCulture, $"s{season}e{episode}"); + } + + /// + /// Builds the keys of an episode. + /// + /// The episode part from . + /// The series' TVDb id, if known. + /// The series' TMDb id, if known. + /// The series' IMDb id, if known. + /// The Jellyfin id of the series; if unknown. + /// The keys; contains the series-guid key whenever the series id is known. + public static IReadOnlyList ForEpisode( + string episodePart, + string? seriesTvdbId, + string? seriesTmdbId, + string? seriesImdbId, + Guid seriesId) + { + ArgumentException.ThrowIfNullOrWhiteSpace(episodePart); + + var suffix = ":" + episodePart; + var keys = new List(4); + AddIfPresent(keys, "ep:tvdb:", seriesTvdbId, suffix); + AddIfPresent(keys, "ep:tmdb:", seriesTmdbId, suffix); + AddIfPresent(keys, "ep:imdb:", seriesImdbId, suffix); + if (seriesId != Guid.Empty) + { + keys.Add("ep:series-guid:" + seriesId.ToString("N", CultureInfo.InvariantCulture) + suffix); + } + + return keys; + } + + private static void AddIfPresent(List keys, string prefix, string? id, string suffix) + { + if (!string.IsNullOrWhiteSpace(id)) + { + keys.Add(prefix + id.Trim() + suffix); + } + } +} diff --git a/Jellyfin.Plugin.RecentlyAddedGuard/Core/ReconcileResult.cs b/Jellyfin.Plugin.RecentlyAddedGuard/Core/ReconcileResult.cs new file mode 100644 index 0000000..bb02050 --- /dev/null +++ b/Jellyfin.Plugin.RecentlyAddedGuard/Core/ReconcileResult.cs @@ -0,0 +1,36 @@ +using System; + +namespace Jellyfin.Plugin.RecentlyAddedGuard.Core; + +/// +/// What the concluded for an item. +/// +public enum ReconcileAction +{ + /// The item has no identity keys (yet) and was skipped. + Ignored, + + /// A new medium; a ledger entry was created with the item's date. + Registered, + + /// A known medium whose date matches the ledger. + Unchanged, + + /// The item is older than the ledger (e.g. a date from an NFO); the ledger took the earlier date. + FirstAddedLowered, + + /// The medium returned after the grace period; it counts as new and the ledger took its date. + ReturnedAfterGrace, + + /// The item is newer than the ledger: an upgrade whose date has to be restored. + UpgradeDetected +} + +/// +/// The outcome of reconciling one item with the ledger. +/// +/// The conclusion. +/// The item. +/// The item's "date added" as evaluated (UTC). +/// The ledger's first "date added" after reconciling (UTC). +public sealed record ReconcileResult(ReconcileAction Action, MediaIdentity Identity, DateTime ItemDate, DateTime FirstAdded); diff --git a/Jellyfin.Plugin.RecentlyAddedGuard/Core/Reconciler.cs b/Jellyfin.Plugin.RecentlyAddedGuard/Core/Reconciler.cs new file mode 100644 index 0000000..83e231a --- /dev/null +++ b/Jellyfin.Plugin.RecentlyAddedGuard/Core/Reconciler.cs @@ -0,0 +1,329 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Jellyfin.Plugin.RecentlyAddedGuard.Ledger; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Plugin.RecentlyAddedGuard.Core; + +/// +/// Matches items against the ledger and decides whether an item is new, known or an upgrade. +/// +/// +/// Pure logic without Jellyfin types: it only updates the ledger and reports what it found. +/// Changing items is up to the caller (see ). +/// +public sealed class Reconciler +{ + private readonly LedgerStore _store; + private readonly Func _options; + private readonly TimeProvider _timeProvider; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The ledger. + /// Provides the current options. + /// The clock. + /// The logger. + public Reconciler(LedgerStore store, Func options, TimeProvider timeProvider, ILogger logger) + { + _store = store ?? throw new ArgumentNullException(nameof(store)); + _options = options ?? throw new ArgumentNullException(nameof(options)); + _timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + /// Handles an added or updated item: registers it and detects upgrades. + /// + /// The item. + /// The outcome; asks the caller to restore . + public ReconcileResult OnItemSeen(MediaIdentity identity) => Apply(identity, detectUpgrades: true); + + /// + /// Registers an item during seed or synchronization. The ledger keeps the minimum date; upgrades are not reported. + /// + /// The item. + /// The outcome. + public ReconcileResult Register(MediaIdentity identity) => Apply(identity, detectUpgrades: false); + + /// + /// Handles a removed item. The entry is found by item id, or by key if the item was never registered. + /// + /// The removed item. + /// The keys the item still resolves to; may be empty. + /// true if a ledger entry was affected. + public bool OnItemRemoved(Guid itemId, IReadOnlyList keys) + { + ArgumentNullException.ThrowIfNull(keys); + + var now = UtcNow(); + lock (_store.SyncRoot) + { + var entry = _store.FindByItemId(itemId); + if (entry is not null) + { + _store.DetachItem(entry, itemId); + } + else + { + var matches = keys.Count > 0 ? _store.FindByKeys(keys) : []; + if (matches.Count == 0) + { + return false; + } + + entry = matches[0]; + } + + if (entry.LiveItemIds.Count == 0 && entry.RemovedAt is null) + { + entry.RemovedAt = now; + _logger.LogDebug("Letztes Item von {Name} entfernt, Frist läuft ab {RemovedAt:u}", entry.DisplayName, now); + } + + _store.MarkDirty(); + return true; + } + } + + /// + /// Lists live item ids the ledger knows but a synchronization did not see. + /// + /// The items seen by the synchronization. + /// Candidates for ; the caller checks whether they still exist. + public IReadOnlyList GetUnseenLiveItemIds(IReadOnlySet seenItemIds) + { + ArgumentNullException.ThrowIfNull(seenItemIds); + + lock (_store.SyncRoot) + { + return _store.GetEntries() + .SelectMany(entry => entry.LiveItemIds) + .Where(itemId => !seenItemIds.Contains(itemId)) + .ToList(); + } + } + + /// + /// Removes items that no longer exist from their entries and starts the grace period for emptied entries. + /// + /// The items that no longer exist. + /// The number of detached items. + public int RemoveStaleItems(IEnumerable itemIds) + { + ArgumentNullException.ThrowIfNull(itemIds); + + var now = UtcNow(); + var detached = 0; + lock (_store.SyncRoot) + { + foreach (var itemId in itemIds) + { + var entry = _store.FindByItemId(itemId); + if (entry is null) + { + continue; + } + + _store.DetachItem(entry, itemId); + detached++; + if (entry.LiveItemIds.Count == 0 && entry.RemovedAt is null) + { + entry.RemovedAt = now; + } + } + + if (detached > 0) + { + _store.MarkDirty(); + } + } + + return detached; + } + + /// + /// Deletes entries removed longer than the grace period plus one day; a return after that counts as new anyway. + /// + /// The number of deleted entries. + public int Prune() + { + var now = UtcNow(); + var retention = _options().GracePeriod + TimeSpan.FromDays(1); + var pruned = 0; + var changed = false; + lock (_store.SyncRoot) + { + foreach (var entry in _store.GetEntries()) + { + if (entry.LiveItemIds.Count > 0) + { + continue; + } + + if (entry.RemovedAt is not { } removedAt) + { + // Lost its last item without a removal event; start the grace period now. + entry.RemovedAt = now; + changed = true; + } + else if (now - removedAt > retention) + { + _store.Remove(entry); + pruned++; + } + } + + if (pruned > 0 || changed) + { + _store.MarkDirty(); + } + } + + return pruned; + } + + /// + /// Lowers the first "date added" of a known medium from an external source (e.g. the import history + /// of Radarr/Sonarr). Never raises it and never changes items. + /// + /// The keys of the medium. + /// The date reported by the source. + /// true if the ledger was lowered. + public bool ApplyExternalFirstAdded(IReadOnlyList keys, DateTime firstAdded) + { + ArgumentNullException.ThrowIfNull(keys); + + var date = LedgerStore.AsUtc(firstAdded); + lock (_store.SyncRoot) + { + var entry = _store.FindByKeys(keys).MinBy(candidate => candidate.FirstAdded); + if (entry is null || date >= entry.FirstAdded) + { + return false; + } + + entry.FirstAdded = date; + _store.MarkDirty(); + return true; + } + } + + private ReconcileResult Apply(MediaIdentity identity, bool detectUpgrades) + { + ArgumentNullException.ThrowIfNull(identity); + + var now = UtcNow(); + var itemDate = identity.DateCreated == DateTime.MinValue ? now : LedgerStore.AsUtc(identity.DateCreated); + if (identity.Keys.Count == 0) + { + return new ReconcileResult(ReconcileAction.Ignored, identity, itemDate, itemDate); + } + + var options = _options(); + lock (_store.SyncRoot) + { + var candidates = _store.FindByKeys(identity.Keys); + ReleaseFromOutdatedEntry(identity.ItemId, candidates); + + var entry = candidates.Count switch + { + 0 => null, + 1 => candidates[0], + _ => Merge(candidates, now, options) + }; + + if (entry is null) + { + entry = _store.Add(identity.Keys, itemDate, now, identity.DisplayName); + _store.AttachItem(entry, identity.ItemId); + _store.MarkDirty(); + return new ReconcileResult(ReconcileAction.Registered, identity, itemDate, entry.FirstAdded); + } + + var wasLive = entry.LiveItemIds.Contains(identity.ItemId); + _store.AddKeys(entry, identity.Keys); + _store.AttachItem(entry, identity.ItemId); + entry.LastSeen = now; + entry.DisplayName = identity.DisplayName; + _store.MarkDirty(); + + if (entry.RemovedAt is { } removedAt && !wasLive && now - removedAt > options.GracePeriod) + { + // Deleted on purpose and fetched again later: a genuinely new addition. + entry.RemovedAt = null; + entry.FirstAdded = itemDate; + return new ReconcileResult(ReconcileAction.ReturnedAfterGrace, identity, itemDate, itemDate); + } + + entry.RemovedAt = null; + + // Seed and sync take the exact minimum, so the order of items does not matter. + var tolerance = detectUpgrades ? options.Tolerance : TimeSpan.Zero; + if (entry.FirstAdded - itemDate > tolerance) + { + entry.FirstAdded = itemDate; + return new ReconcileResult(ReconcileAction.FirstAddedLowered, identity, itemDate, itemDate); + } + + if (detectUpgrades && itemDate - entry.FirstAdded > tolerance) + { + return new ReconcileResult(ReconcileAction.UpgradeDetected, identity, itemDate, entry.FirstAdded); + } + + return new ReconcileResult(ReconcileAction.Unchanged, identity, itemDate, entry.FirstAdded); + } + } + + /// + /// Detaches the item from an entry its keys no longer match (e.g. after "Identify"). + /// The orphaned entry is dropped if the item was its only one, as the medium lives on under the new keys. + /// + private void ReleaseFromOutdatedEntry(Guid itemId, IReadOnlyList candidates) + { + var previous = _store.FindByItemId(itemId); + if (previous is null || candidates.Contains(previous)) + { + return; + } + + _store.DetachItem(previous, itemId); + if (previous.LiveItemIds.Count == 0) + { + _store.Remove(previous); + } + + _logger.LogDebug("Item {ItemId} passt nicht mehr zu {Name} und wurde neu zugeordnet", itemId, previous.DisplayName); + } + + /// + /// Merges several entries that turned out to be the same medium. Entries past their grace period + /// only contribute keys, so a medium that returned as new does not inherit an old date. + /// + private LedgerEntry Merge(IReadOnlyList candidates, DateTime now, GuardOptions options) + { + var active = candidates.Where(entry => !IsExpired(entry, now, options)).ToList(); + var pool = active.Count > 0 ? active : candidates; + var target = pool.MinBy(entry => entry.FirstAdded)!; + var latestRemoval = pool.Max(entry => entry.RemovedAt); + + foreach (var other in candidates) + { + _store.Absorb(target, other); + } + + target.RemovedAt = target.LiveItemIds.Count > 0 ? null : latestRemoval; + _logger.LogDebug("{Count} Ledger-Einträge zu {Name} zusammengeführt", candidates.Count, target.DisplayName); + return target; + } + + private static bool IsExpired(LedgerEntry entry, DateTime now, GuardOptions options) + => entry.LiveItemIds.Count == 0 + && entry.RemovedAt is { } removedAt + && now - removedAt > options.GracePeriod; + + private DateTime UtcNow() => _timeProvider.GetUtcNow().UtcDateTime; +} diff --git a/Jellyfin.Plugin.RecentlyAddedGuard/Jellyfin.Plugin.RecentlyAddedGuard.csproj b/Jellyfin.Plugin.RecentlyAddedGuard/Jellyfin.Plugin.RecentlyAddedGuard.csproj new file mode 100644 index 0000000..76cf7bd --- /dev/null +++ b/Jellyfin.Plugin.RecentlyAddedGuard/Jellyfin.Plugin.RecentlyAddedGuard.csproj @@ -0,0 +1,24 @@ + + + + Jellyfin.Plugin.RecentlyAddedGuard + Jellyfin.Plugin.RecentlyAddedGuard + false + + + + + + + + + + + + + + + + + + diff --git a/Jellyfin.Plugin.RecentlyAddedGuard/Ledger/LedgerEntry.cs b/Jellyfin.Plugin.RecentlyAddedGuard/Ledger/LedgerEntry.cs new file mode 100644 index 0000000..cafc009 --- /dev/null +++ b/Jellyfin.Plugin.RecentlyAddedGuard/Ledger/LedgerEntry.cs @@ -0,0 +1,48 @@ +using System; +using System.Collections.Generic; + +namespace Jellyfin.Plugin.RecentlyAddedGuard.Ledger; + +/// +/// The first "date added" of one logical medium (a movie or an episode), independent of its files. +/// +/// +/// and are indexed by the ; +/// change them only through the store so the indexes stay consistent. +/// +public sealed class LedgerEntry +{ + /// + /// Gets or sets all alias keys of the medium (e.g. movie:tmdb:603). + /// +#pragma warning disable CA1819 // Plain persistence model; the store owns all mutations. + public string[] Keys { get; set; } = []; +#pragma warning restore CA1819 + + /// + /// Gets or sets the protected first "date added" (UTC). + /// + public DateTime FirstAdded { get; set; } + + /// + /// Gets or sets when an item of this medium was last seen (UTC). + /// + public DateTime LastSeen { get; set; } + + /// + /// Gets or sets when the last live item of this medium disappeared (UTC); null while items exist. + /// + public DateTime? RemovedAt { get; set; } + + /// + /// Gets or sets the ids of the Jellyfin items currently representing this medium. + /// +#pragma warning disable CA2227 // Plain persistence model; the store owns all mutations. + public HashSet LiveItemIds { get; set; } = []; +#pragma warning restore CA2227 + + /// + /// Gets or sets a display name for logs and the configuration page. + /// + public string DisplayName { get; set; } = string.Empty; +} diff --git a/Jellyfin.Plugin.RecentlyAddedGuard/Ledger/LedgerStatistics.cs b/Jellyfin.Plugin.RecentlyAddedGuard/Ledger/LedgerStatistics.cs new file mode 100644 index 0000000..bc52e22 --- /dev/null +++ b/Jellyfin.Plugin.RecentlyAddedGuard/Ledger/LedgerStatistics.cs @@ -0,0 +1,9 @@ +namespace Jellyfin.Plugin.RecentlyAddedGuard.Ledger; + +/// +/// Counters describing the ledger. +/// +/// All entries. +/// Entries with at least one live item. +/// Entries whose items are gone (kept for the grace period). +public sealed record LedgerStatistics(int Entries, int LiveEntries, int RemovedEntries); diff --git a/Jellyfin.Plugin.RecentlyAddedGuard/Ledger/LedgerStore.cs b/Jellyfin.Plugin.RecentlyAddedGuard/Ledger/LedgerStore.cs new file mode 100644 index 0000000..ab3357e --- /dev/null +++ b/Jellyfin.Plugin.RecentlyAddedGuard/Ledger/LedgerStore.cs @@ -0,0 +1,641 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Text.Json; +using System.Threading; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Plugin.RecentlyAddedGuard.Ledger; + +/// +/// Thread-safe, persistent ledger with indexes by alias key and by live item id. +/// +/// +/// All state is guarded by (re-entrant). Writes are debounced and atomic: +/// the JSON goes to a temporary file first, which then replaces the ledger file. +/// +public sealed class LedgerStore : IDisposable +{ + /// + /// The file name of the ledger inside the plugin data folder. + /// + public const string DefaultFileName = "ledger.json"; + + private const int SchemaVersion = 1; + + private static readonly TimeSpan _defaultSaveDelay = TimeSpan.FromSeconds(5); + private static readonly JsonSerializerOptions _jsonOptions = new() { WriteIndented = false }; + + private readonly Lock _sync = new(); + private readonly SemaphoreSlim _writeGate = new(1, 1); + private readonly List _entries = []; + private readonly Dictionary _byKey = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary _byItemId = []; + private readonly ILogger _logger; + private readonly TimeProvider _timeProvider; + private readonly TimeSpan _saveDelay; + private readonly ITimer _saveTimer; + private bool _seedCompleted; + private DateTime? _lastSyncUtc; + private bool _dirty; + private bool _saveScheduled; + private bool _disposed; + + /// + /// Initializes a new instance of the class. + /// + /// The path of the ledger file. + /// The logger. + /// The time provider; defaults to the system clock. + /// How long changes are collected before they are written; defaults to 5 seconds. + public LedgerStore(string filePath, ILogger logger, TimeProvider? timeProvider = null, TimeSpan? saveDelay = null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(filePath); + ArgumentNullException.ThrowIfNull(logger); + + FilePath = filePath; + _logger = logger; + _timeProvider = timeProvider ?? TimeProvider.System; + _saveDelay = saveDelay ?? _defaultSaveDelay; + _saveTimer = _timeProvider.CreateTimer(_ => OnSaveTimer(), null, Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan); + } + + /// + /// Gets the path of the ledger file. + /// + public string FilePath { get; } + + /// + /// Gets a value indicating whether the initial seed has completed. + /// + public bool SeedCompleted + { + get + { + lock (_sync) + { + return _seedCompleted; + } + } + } + + /// + /// Gets the time of the last completed seed or synchronization (UTC). + /// + public DateTime? LastSyncUtc + { + get + { + lock (_sync) + { + return _lastSyncUtc; + } + } + } + + /// + /// Gets the lock guarding the ledger. It is re-entrant, so multi-step operations can wrap store calls. + /// + internal Lock SyncRoot => _sync; + + /// + /// Gets counters for the status page. + /// + /// The current statistics. + public LedgerStatistics GetStatistics() + { + lock (_sync) + { + var live = 0; + var removed = 0; + foreach (var entry in _entries) + { + if (entry.LiveItemIds.Count > 0) + { + live++; + } + else if (entry.RemovedAt is not null) + { + removed++; + } + } + + return new LedgerStatistics(_entries.Count, live, removed); + } + } + + /// + /// Loads the ledger from disk, replacing the in-memory state. A missing file yields an empty ledger; + /// an unreadable file is moved aside so the seed runs again. + /// + public void Load() + { + TryDelete(TempPath); + + LedgerDocument? document = null; + if (File.Exists(FilePath)) + { + try + { + using var stream = File.OpenRead(FilePath); + document = JsonSerializer.Deserialize(stream, _jsonOptions) + ?? throw new JsonException("The ledger file is empty."); + } + catch (Exception ex) when (ex is JsonException or IOException or UnauthorizedAccessException or NotSupportedException) + { + var backupPath = FilePath + ".corrupt-" + + _timeProvider.GetUtcNow().ToString("yyyyMMddHHmmss", CultureInfo.InvariantCulture); + TryMove(FilePath, backupPath); + _logger.LogError(ex, "Ledger {Path} ist nicht lesbar und wurde nach {Backup} verschoben, der Seed läuft erneut", FilePath, backupPath); + document = null; + } + } + + int count; + lock (_sync) + { + _entries.Clear(); + _byKey.Clear(); + _byItemId.Clear(); + _seedCompleted = document?.SeedCompleted ?? false; + _lastSyncUtc = document?.LastSyncUtc is { } lastSync ? AsUtc(lastSync) : null; + _dirty = false; + + foreach (var loaded in document?.Entries ?? []) + { + Restore(loaded); + } + + count = _entries.Count; + } + + _logger.LogInformation("Ledger geladen: {Count} Einträge, Seed abgeschlossen: {SeedCompleted}", count, document?.SeedCompleted ?? false); + } + + /// + /// Requests a debounced save. Several changes within the save delay result in a single write. + /// + public void MarkDirty() + { + lock (_sync) + { + _dirty = true; + if (_saveScheduled || _disposed) + { + return; + } + + _saveScheduled = true; + } + + try + { + _saveTimer.Change(_saveDelay, Timeout.InfiniteTimeSpan); + } + catch (ObjectDisposedException) + { + // Disposed concurrently; the final flush happens in Dispose. + } + } + + /// + /// Writes pending changes immediately. + /// + public void Flush() => SaveIfDirty(); + + /// + public void Dispose() + { + lock (_sync) + { + if (_disposed) + { + return; + } + } + + try + { + Flush(); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + _logger.LogError(ex, "Ledger konnte beim Beenden nicht gespeichert werden: {Path}", FilePath); + } + + lock (_sync) + { + _disposed = true; + } + + _saveTimer.Dispose(); + _writeGate.Dispose(); + } + + /// + /// Finds all distinct entries that own at least one of the keys. + /// + /// The keys to look up. + /// The matching entries, in key order. + internal IReadOnlyList FindByKeys(IEnumerable keys) + { + lock (_sync) + { + List? result = null; + foreach (var key in keys) + { + if (_byKey.TryGetValue(key, out var entry) && (result is null || !result.Contains(entry))) + { + (result ??= []).Add(entry); + } + } + + return result ?? []; + } + } + + /// + /// Finds the entry that currently lists the item as live. + /// + /// The item id. + /// The entry, or null. + internal LedgerEntry? FindByItemId(Guid itemId) + { + lock (_sync) + { + return _byItemId.GetValueOrDefault(itemId); + } + } + + /// + /// Adds a new entry without live items. + /// + /// The keys; keys owned by other entries are skipped. + /// The first "date added". + /// The current time. + /// The display name. + /// The new entry. + internal LedgerEntry Add(IEnumerable keys, DateTime firstAdded, DateTime now, string displayName) + { + var entry = new LedgerEntry + { + FirstAdded = AsUtc(firstAdded), + LastSeen = AsUtc(now), + DisplayName = displayName + }; + + lock (_sync) + { + _entries.Add(entry); + AddKeys(entry, keys); + } + + return entry; + } + + /// + /// Adds keys to an entry. Keys that already belong to another entry are skipped, + /// so callers merge all matching entries first. + /// + /// The entry. + /// The keys. + internal void AddKeys(LedgerEntry entry, IEnumerable keys) + { + lock (_sync) + { + List? added = null; + foreach (var key in keys) + { + if (string.IsNullOrWhiteSpace(key) || _byKey.ContainsKey(key)) + { + continue; + } + + _byKey[key] = entry; + (added ??= []).Add(key); + } + + if (added is not null) + { + entry.Keys = [.. entry.Keys, .. added]; + } + } + } + + /// + /// Records the item as a live item of the entry, moving it away from any other entry. + /// + /// The entry. + /// The item id. + internal void AttachItem(LedgerEntry entry, Guid itemId) + { + lock (_sync) + { + if (_byItemId.TryGetValue(itemId, out var owner) && !ReferenceEquals(owner, entry)) + { + owner.LiveItemIds.Remove(itemId); + } + + entry.LiveItemIds.Add(itemId); + _byItemId[itemId] = entry; + } + } + + /// + /// Removes the item from the live items of the entry. + /// + /// The entry. + /// The item id. + internal void DetachItem(LedgerEntry entry, Guid itemId) + { + lock (_sync) + { + entry.LiveItemIds.Remove(itemId); + if (_byItemId.TryGetValue(itemId, out var owner) && ReferenceEquals(owner, entry)) + { + _byItemId.Remove(itemId); + } + } + } + + /// + /// Removes an entry and its index entries. + /// + /// The entry. + internal void Remove(LedgerEntry entry) + { + lock (_sync) + { + if (!_entries.Remove(entry)) + { + return; + } + + foreach (var key in entry.Keys) + { + if (_byKey.TryGetValue(key, out var owner) && ReferenceEquals(owner, entry)) + { + _byKey.Remove(key); + } + } + + foreach (var itemId in entry.LiveItemIds) + { + if (_byItemId.TryGetValue(itemId, out var owner) && ReferenceEquals(owner, entry)) + { + _byItemId.Remove(itemId); + } + } + } + } + + /// + /// Moves keys and live items of into and removes the source. + /// Dates are left to the caller. + /// + /// The surviving entry. + /// The entry to merge into the target. + internal void Absorb(LedgerEntry target, LedgerEntry source) + { + if (ReferenceEquals(target, source)) + { + return; + } + + lock (_sync) + { + var keys = source.Keys; + var itemIds = source.LiveItemIds.ToArray(); + Remove(source); + AddKeys(target, keys); + foreach (var itemId in itemIds) + { + AttachItem(target, itemId); + } + + if (source.LastSeen > target.LastSeen) + { + target.LastSeen = source.LastSeen; + } + } + } + + /// + /// Returns a snapshot of the entry references for iteration outside the lock. + /// + /// The entries. + internal IReadOnlyList GetEntries() + { + lock (_sync) + { + return [.. _entries]; + } + } + + /// + /// Records a completed seed or synchronization. + /// + /// The completion time. + internal void MarkSynced(DateTime now) + { + lock (_sync) + { + _seedCompleted = true; + _lastSyncUtc = AsUtc(now); + } + + MarkDirty(); + } + + /// + /// Normalizes a timestamp to UTC; unspecified values are taken as UTC. + /// + /// The value. + /// The UTC value. + internal static DateTime AsUtc(DateTime value) => value.Kind switch + { + DateTimeKind.Utc => value, + DateTimeKind.Local => value.ToUniversalTime(), + _ => DateTime.SpecifyKind(value, DateTimeKind.Utc) + }; + + private string TempPath => FilePath + ".tmp"; + + private void Restore(LedgerEntry loaded) + { + var keys = (loaded.Keys ?? []) + .Where(key => !string.IsNullOrWhiteSpace(key)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + if (keys.Length == 0) + { + return; + } + + var itemIds = loaded.LiveItemIds ?? []; + var firstAdded = AsUtc(loaded.FirstAdded); + var lastSeen = AsUtc(loaded.LastSeen); + DateTime? removedAt = loaded.RemovedAt is { } removed ? AsUtc(removed) : null; + + // Overlapping entries can only come from an older or damaged file: merge them, keeping the earliest date. + var existing = keys.Select(key => _byKey.GetValueOrDefault(key)).FirstOrDefault(entry => entry is not null); + if (existing is not null) + { + AddKeys(existing, keys); + foreach (var itemId in itemIds) + { + AttachItem(existing, itemId); + } + + if (firstAdded < existing.FirstAdded) + { + existing.FirstAdded = firstAdded; + } + + if (lastSeen > existing.LastSeen) + { + existing.LastSeen = lastSeen; + } + + existing.RemovedAt = existing.LiveItemIds.Count > 0 ? null : existing.RemovedAt ?? removedAt; + return; + } + + var entry = new LedgerEntry + { + FirstAdded = firstAdded, + LastSeen = lastSeen, + RemovedAt = itemIds.Count > 0 ? null : removedAt, + DisplayName = loaded.DisplayName ?? string.Empty + }; + _entries.Add(entry); + AddKeys(entry, keys); + foreach (var itemId in itemIds) + { + AttachItem(entry, itemId); + } + } + +#pragma warning disable CA1031 // A timer callback must never throw; failures are logged and retried. + private void OnSaveTimer() + { + try + { + SaveIfDirty(); + } + catch (ObjectDisposedException) + { + // Shutting down. + } + catch (Exception ex) + { + _logger.LogError(ex, "Ledger konnte nicht gespeichert werden: {Path}", FilePath); + MarkDirty(); + } + } +#pragma warning restore CA1031 + + private void SaveIfDirty() + { + _writeGate.Wait(); + try + { + byte[] payload; + lock (_sync) + { + _saveScheduled = false; + if (!_dirty) + { + return; + } + + payload = JsonSerializer.SerializeToUtf8Bytes( + new LedgerDocument + { + SchemaVersion = SchemaVersion, + SeedCompleted = _seedCompleted, + LastSyncUtc = _lastSyncUtc, + Entries = _entries + }, + _jsonOptions); + _dirty = false; + } + + try + { + WriteAtomically(payload); + } + catch + { + lock (_sync) + { + _dirty = true; + } + + throw; + } + } + finally + { + _writeGate.Release(); + } + } + + private void WriteAtomically(byte[] payload) + { + var directory = Path.GetDirectoryName(FilePath); + if (!string.IsNullOrEmpty(directory)) + { + Directory.CreateDirectory(directory); + } + + using (var stream = new FileStream(TempPath, FileMode.Create, FileAccess.Write, FileShare.None)) + { + stream.Write(payload); + stream.Flush(flushToDisk: true); + } + + File.Move(TempPath, FilePath, overwrite: true); + _logger.LogDebug("Ledger gespeichert: {Path} ({Bytes} Bytes)", FilePath, payload.Length); + } + + private void TryMove(string source, string destination) + { + try + { + File.Move(source, destination, overwrite: true); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + _logger.LogWarning(ex, "Datei {Source} konnte nicht verschoben werden", source); + } + } + + private void TryDelete(string path) + { + try + { + File.Delete(path); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + _logger.LogDebug(ex, "Temporäre Datei {Path} konnte nicht gelöscht werden", path); + } + } + + /// + /// The on-disk format of the ledger. + /// + private sealed class LedgerDocument + { + public int SchemaVersion { get; set; } + + public bool SeedCompleted { get; set; } + + public DateTime? LastSyncUtc { get; set; } + + public List Entries { get; set; } = []; + } +} diff --git a/Jellyfin.Plugin.RecentlyAddedGuard/Plugin.cs b/Jellyfin.Plugin.RecentlyAddedGuard/Plugin.cs new file mode 100644 index 0000000..ac9fb4d --- /dev/null +++ b/Jellyfin.Plugin.RecentlyAddedGuard/Plugin.cs @@ -0,0 +1,65 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using Jellyfin.Plugin.RecentlyAddedGuard.Configuration; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Common.Plugins; +using MediaBrowser.Model.Plugins; +using MediaBrowser.Model.Serialization; + +namespace Jellyfin.Plugin.RecentlyAddedGuard; + +/// +/// Keeps quality upgrades from Radarr and Sonarr out of "Recently Added". +/// +public class Plugin : BasePlugin, IHasWebPages +{ + /// + /// The permanent plugin id. Never change it: it identifies the plugin in the catalog and in installations. + /// + public const string PluginGuid = "6fdfedef-adde-4ac3-a873-62a057c95574"; + + private static readonly Guid _pluginId = Guid.Parse(PluginGuid); + + /// + /// Initializes a new instance of the class. + /// + /// The application paths. + /// The XML serializer. + public Plugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer) + : base(applicationPaths, xmlSerializer) + { + Instance = this; + } + + /// + /// Gets the plugin instance; set once Jellyfin has created the plugin. + /// + public static Plugin? Instance { get; private set; } + + /// + public override string Name => "Recently Added Guard"; + + /// + public override Guid Id => _pluginId; + + /// + public override string Description + => "Hält Qualitäts-Upgrades von Radarr und Sonarr aus „Zuletzt hinzugefügt“ fern, indem es das erste Hinzufügedatum pro Film und Episode bewahrt."; + + /// + /// Gets the current configuration, or the defaults before the plugin is loaded. + /// + internal static PluginConfiguration CurrentConfiguration => Instance?.Configuration ?? new PluginConfiguration(); + + /// + public IEnumerable GetPages() + { + yield return new PluginPageInfo + { + Name = "RecentlyAddedGuard", + DisplayName = Name, + EmbeddedResourcePath = string.Format(CultureInfo.InvariantCulture, "{0}.Configuration.configPage.html", GetType().Namespace) + }; + } +} diff --git a/Jellyfin.Plugin.RecentlyAddedGuard/PluginServiceRegistrator.cs b/Jellyfin.Plugin.RecentlyAddedGuard/PluginServiceRegistrator.cs new file mode 100644 index 0000000..1ac3bf1 --- /dev/null +++ b/Jellyfin.Plugin.RecentlyAddedGuard/PluginServiceRegistrator.cs @@ -0,0 +1,50 @@ +using System; +using System.IO; +using Jellyfin.Plugin.RecentlyAddedGuard.Core; +using Jellyfin.Plugin.RecentlyAddedGuard.Ledger; +using Jellyfin.Plugin.RecentlyAddedGuard.Services; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Controller; +using MediaBrowser.Controller.Plugins; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Plugin.RecentlyAddedGuard; + +/// +/// Registers the plugin services with Jellyfin's DI container. +/// +public class PluginServiceRegistrator : IPluginServiceRegistrator +{ + /// + public void RegisterServices(IServiceCollection serviceCollection, IServerApplicationHost applicationHost) + { + // Runs before the plugin instance exists; factories are evaluated later, when the host starts. + serviceCollection.AddSingleton(); + serviceCollection.AddSingleton(sp => new LedgerStore( + Path.Combine(GetDataFolderPath(sp), LedgerStore.DefaultFileName), + sp.GetRequiredService>())); + serviceCollection.AddSingleton(sp => new Reconciler( + sp.GetRequiredService(), + GetOptions, + TimeProvider.System, + sp.GetRequiredService>())); + serviceCollection.AddSingleton(); + serviceCollection.AddSingleton(); + serviceCollection.AddSingleton(sp => new GuardProcessor( + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + GetOptions, + TimeProvider.System, + sp.GetRequiredService>())); + serviceCollection.AddSingleton(); + serviceCollection.AddHostedService(); + } + + private static GuardOptions GetOptions() => Plugin.CurrentConfiguration.ToGuardOptions(); + + private static string GetDataFolderPath(IServiceProvider serviceProvider) + => Plugin.Instance?.DataFolderPath + ?? Path.Combine(serviceProvider.GetRequiredService().PluginsPath, typeof(Plugin).Assembly.GetName().Name!); +} diff --git a/Jellyfin.Plugin.RecentlyAddedGuard/Services/GuardHostedService.cs b/Jellyfin.Plugin.RecentlyAddedGuard/Services/GuardHostedService.cs new file mode 100644 index 0000000..32a53ea --- /dev/null +++ b/Jellyfin.Plugin.RecentlyAddedGuard/Services/GuardHostedService.cs @@ -0,0 +1,228 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading; +using System.Threading.Channels; +using System.Threading.Tasks; +using Jellyfin.Plugin.RecentlyAddedGuard.Core; +using Jellyfin.Plugin.RecentlyAddedGuard.Ledger; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Plugin.RecentlyAddedGuard.Services; + +/// +/// Listens to library events and processes them on a background worker; runs the seed on first start. +/// +public sealed class GuardHostedService : IHostedService, IDisposable +{ + private static readonly TimeSpan _debounceDelay = TimeSpan.FromSeconds(2); + + private readonly ILibraryManager _libraryManager; + private readonly IdentityResolver _resolver; + private readonly GuardProcessor _processor; + private readonly LibraryItemDateWriter _writer; + private readonly LedgerStore _store; + private readonly LedgerSynchronizer _synchronizer; + private readonly ILogger _logger; + private readonly Channel _events = Channel.CreateUnbounded( + new UnboundedChannelOptions { SingleReader = true, SingleWriter = false }); + + private readonly CancellationTokenSource _stopping = new(); + private Task _seed = Task.CompletedTask; + private Task _worker = Task.CompletedTask; + + /// + /// Initializes a new instance of the class. + /// + /// The library manager. + /// The identity resolver. + /// The processor. + /// The date writer. + /// The ledger. + /// The synchronizer. + /// The logger. + public GuardHostedService( + ILibraryManager libraryManager, + IdentityResolver resolver, + GuardProcessor processor, + LibraryItemDateWriter writer, + LedgerStore store, + LedgerSynchronizer synchronizer, + ILogger logger) + { + _libraryManager = libraryManager; + _resolver = resolver; + _processor = processor; + _writer = writer; + _store = store; + _synchronizer = synchronizer; + _logger = logger; + } + + /// + public Task StartAsync(CancellationToken cancellationToken) + { + _store.Load(); + + _libraryManager.ItemAdded += OnItemAddedOrUpdated; + _libraryManager.ItemUpdated += OnItemAddedOrUpdated; + _libraryManager.ItemRemoved += OnItemRemoved; + + if (!_store.SeedCompleted) + { + _seed = Task.Run(() => RunSeedAsync(_stopping.Token), CancellationToken.None); + } + + _worker = Task.Run(() => RunWorkerAsync(_stopping.Token), CancellationToken.None); + return Task.CompletedTask; + } + + /// + public async Task StopAsync(CancellationToken cancellationToken) + { + _libraryManager.ItemAdded -= OnItemAddedOrUpdated; + _libraryManager.ItemUpdated -= OnItemAddedOrUpdated; + _libraryManager.ItemRemoved -= OnItemRemoved; + _events.Writer.TryComplete(); + + // An unfinished seed starts over on the next start; pending events are still processed. + if (!_seed.IsCompleted) + { + await _stopping.CancelAsync().ConfigureAwait(false); + } + + try + { + await Task.WhenAll(_seed, _worker).WaitAsync(cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + await _stopping.CancelAsync().ConfigureAwait(false); + } + + try + { + _store.Flush(); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + _logger.LogError(ex, "Ledger konnte beim Beenden nicht gespeichert werden"); + } + } + + /// + public void Dispose() => _stopping.Dispose(); + + private void OnItemAddedOrUpdated(object? sender, ItemChangeEventArgs e) + { + var item = e.Item; + if (item is null || !IdentityResolver.IsTracked(item) || _writer.IsWriting(item.Id)) + { + return; + } + + _events.Writer.TryWrite(new GuardEvent(item.Id, null)); + } + + private void OnItemRemoved(object? sender, ItemChangeEventArgs e) + { + var item = e.Item; + if (item is null || !IdentityResolver.IsTracked(item)) + { + return; + } + + // Keep the object: its provider ids are needed later, the database row is already gone. + _events.Writer.TryWrite(new GuardEvent(item.Id, item)); + } + +#pragma warning disable CA1031 // Background loops log failures and keep running. + private async Task RunSeedAsync(CancellationToken cancellationToken) + { + try + { + _logger.LogInformation("Ledger wurde noch nicht befüllt, Seed läuft im Hintergrund"); + await _synchronizer.RunAsync(new Progress(), cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + _logger.LogInformation("Ledger-Seed abgebrochen, er läuft beim nächsten Start erneut"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Ledger-Seed fehlgeschlagen; der Scheduled Task \"Ledger synchronisieren\" holt ihn nach"); + } + } + + private async Task RunWorkerAsync(CancellationToken cancellationToken) + { + try + { + // Events raised during the seed wait in the channel, so they are matched against a complete ledger. + await _seed.ConfigureAwait(false); + + var reader = _events.Reader; + var pending = new OrderedDictionary(); + while (await reader.WaitToReadAsync(cancellationToken).ConfigureAwait(false)) + { + // A scan raises several events per item within moments; collect the burst, keep the latest per item. + await Task.Delay(_debounceDelay, cancellationToken).ConfigureAwait(false); + while (reader.TryRead(out var guardEvent)) + { + pending[guardEvent.ItemId] = guardEvent; + } + + foreach (var guardEvent in pending.Values) + { + await ProcessAsync(guardEvent, cancellationToken).ConfigureAwait(false); + } + + pending.Clear(); + } + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + // Shutting down. + } + catch (Exception ex) + { + _logger.LogError(ex, "Event-Verarbeitung von Recently Added Guard wurde unerwartet beendet"); + } + } + + private async Task ProcessAsync(GuardEvent guardEvent, CancellationToken cancellationToken) + { + try + { + if (guardEvent.RemovedItem is { } removed) + { + _processor.ProcessRemoved(guardEvent.ItemId, _resolver.ResolveKeysForRemoval(removed)); + return; + } + + var item = _libraryManager.GetItemById(guardEvent.ItemId); + var identity = item is null ? null : _resolver.Resolve(item); + if (identity is not null) + { + await _processor.ProcessSeenAsync(identity, cancellationToken).ConfigureAwait(false); + } + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + _logger.LogError(ex, "Item {ItemId} konnte nicht verarbeitet werden", guardEvent.ItemId); + } + } +#pragma warning restore CA1031 + + /// + /// A queued library event. is set for removals. + /// + private sealed record GuardEvent(Guid ItemId, BaseItem? RemovedItem); +} diff --git a/Jellyfin.Plugin.RecentlyAddedGuard/Services/LedgerSynchronizer.cs b/Jellyfin.Plugin.RecentlyAddedGuard/Services/LedgerSynchronizer.cs new file mode 100644 index 0000000..4dd6204 --- /dev/null +++ b/Jellyfin.Plugin.RecentlyAddedGuard/Services/LedgerSynchronizer.cs @@ -0,0 +1,209 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Data.Enums; +using Jellyfin.Plugin.RecentlyAddedGuard.Core; +using Jellyfin.Plugin.RecentlyAddedGuard.Ledger; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Controller.Library; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Plugin.RecentlyAddedGuard.Services; + +/// +/// Seeds and synchronizes the ledger with the library. Only registers items; never changes them. +/// +public sealed class LedgerSynchronizer : IDisposable +{ + private const int BatchSize = 500; + + private static readonly BaseItemKind[] _trackedKinds = [BaseItemKind.Movie, BaseItemKind.Episode]; + + private readonly SemaphoreSlim _gate = new(1, 1); + private readonly ILibraryManager _libraryManager; + private readonly IdentityResolver _resolver; + private readonly Reconciler _reconciler; + private readonly LedgerStore _store; + private readonly IExternalDateSource[] _externalSources; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The library manager. + /// The identity resolver. + /// The reconciler. + /// The ledger. + /// External date sources (none in v1). + /// The logger. + public LedgerSynchronizer( + ILibraryManager libraryManager, + IdentityResolver resolver, + Reconciler reconciler, + LedgerStore store, + IEnumerable externalSources, + ILogger logger) + { + _libraryManager = libraryManager; + _resolver = resolver; + _reconciler = reconciler; + _store = store; + _externalSources = externalSources.ToArray(); + _logger = logger; + } + + /// + /// Registers all movies and episodes, detaches items that no longer exist and prunes old entries. + /// The first run on an empty ledger is the seed. Concurrent calls wait for each other. + /// + /// Reports progress from 0 to 100. + /// The cancellation token. + /// A task that completes when the ledger is synchronized and saved. + public async Task RunAsync(IProgress progress, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(progress); + + await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + await RunCoreAsync(progress, cancellationToken).ConfigureAwait(false); + } + finally + { + _gate.Release(); + } + } + + /// + public void Dispose() => _gate.Dispose(); + + private async Task RunCoreAsync(IProgress progress, CancellationToken cancellationToken) + { + var isSeed = !_store.SeedCompleted; + var stopwatch = Stopwatch.StartNew(); + progress.Report(0); + + // Ids first, then the items in batches: keeps memory flat for libraries with tens of thousands of episodes. + var itemIds = _libraryManager.GetItemIds(new InternalItemsQuery + { + IncludeItemTypes = _trackedKinds, + Recursive = true, + IsVirtualItem = false + }); + + var seen = new HashSet(itemIds.Count); + var seriesCache = new Dictionary(); + int registered = 0, lowered = 0, ignored = 0, external = 0; + + for (var offset = 0; offset < itemIds.Count; offset += BatchSize) + { + cancellationToken.ThrowIfCancellationRequested(); + + var batch = itemIds.Skip(offset).Take(BatchSize).ToArray(); + var items = _libraryManager.GetItemList(new InternalItemsQuery + { + ItemIds = batch, + IncludeItemTypes = _trackedKinds, + Recursive = true, + IsVirtualItem = false + }); + + foreach (var item in items) + { + seen.Add(item.Id); + var identity = _resolver.Resolve(item, seriesId => LookupSeries(seriesId, seriesCache)); + if (identity is null || identity.Keys.Count == 0) + { + ignored++; + continue; + } + + switch (_reconciler.Register(identity).Action) + { + case ReconcileAction.Registered: + registered++; + break; + case ReconcileAction.FirstAddedLowered: + lowered++; + break; + default: + break; + } + + if (_externalSources.Length > 0 && await ApplyExternalSourcesAsync(identity, cancellationToken).ConfigureAwait(false)) + { + external++; + } + } + + progress.Report(90.0 * Math.Min(offset + BatchSize, itemIds.Count) / itemIds.Count); + } + + // Live items the query did not return may be alternate versions or added meanwhile; only drop the ones that are gone. + var stale = _reconciler.GetUnseenLiveItemIds(seen) + .Where(itemId => _libraryManager.GetItemById(itemId) is null) + .ToList(); + var detached = _reconciler.RemoveStaleItems(stale); + var pruned = _reconciler.Prune(); + + _store.MarkSynced(DateTime.UtcNow); + _store.Flush(); + progress.Report(100); + + var statistics = _store.GetStatistics(); + _logger.LogInformation( + "{Kind} abgeschlossen in {Elapsed:g}: {Items} Items geprüft, {Registered} neu registriert, {Lowered} Datum vorverlegt, " + + "{External} aus externen Quellen, {Ignored} ohne Kennung übersprungen, {Detached} entfernte Items ausgetragen, {Pruned} Einträge bereinigt. " + + "Ledger: {Entries} Einträge ({Live} mit Items, {Removed} in der Frist)", + isSeed ? "Ledger-Seed" : "Ledger-Synchronisation", + stopwatch.Elapsed, + itemIds.Count, + registered, + lowered, + external, + ignored, + detached, + pruned, + statistics.Entries, + statistics.LiveEntries, + statistics.RemovedEntries); + } + + private Series? LookupSeries(Guid seriesId, Dictionary cache) + { + if (!cache.TryGetValue(seriesId, out var series)) + { + series = _libraryManager.GetItemById(seriesId) as Series; + cache[seriesId] = series; + } + + return series; + } + +#pragma warning disable CA1031 // A failing external source must not abort the synchronization. + private async Task ApplyExternalSourcesAsync(MediaIdentity identity, CancellationToken cancellationToken) + { + var lowered = false; + foreach (var source in _externalSources) + { + try + { + if (await source.GetFirstAddedAsync(identity, cancellationToken).ConfigureAwait(false) is { } firstAdded) + { + lowered |= _reconciler.ApplyExternalFirstAdded(identity.Keys, firstAdded); + } + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _logger.LogWarning(ex, "Externe Datumsquelle {Source} ist für {Name} fehlgeschlagen", source.Name, identity.DisplayName); + } + } + + return lowered; + } +#pragma warning restore CA1031 +} diff --git a/Jellyfin.Plugin.RecentlyAddedGuard/Services/LibraryItemDateWriter.cs b/Jellyfin.Plugin.RecentlyAddedGuard/Services/LibraryItemDateWriter.cs new file mode 100644 index 0000000..913117c --- /dev/null +++ b/Jellyfin.Plugin.RecentlyAddedGuard/Services/LibraryItemDateWriter.cs @@ -0,0 +1,59 @@ +using System; +using System.Collections.Concurrent; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Plugin.RecentlyAddedGuard.Core; +using MediaBrowser.Controller.Library; + +namespace Jellyfin.Plugin.RecentlyAddedGuard.Services; + +/// +/// Writes "date added" through the library manager and remembers which items it is currently saving, +/// so the resulting ItemUpdated events can be ignored. +/// +public sealed class LibraryItemDateWriter : IItemDateWriter +{ + private readonly ILibraryManager _libraryManager; + private readonly ConcurrentDictionary _inFlight = new(); + + /// + /// Initializes a new instance of the class. + /// + /// The library manager. + public LibraryItemDateWriter(ILibraryManager libraryManager) + { + _libraryManager = libraryManager; + } + + /// + /// Checks whether the item is being saved by this writer right now. + /// + /// The item. + /// true while a correction of the item is in progress. + public bool IsWriting(Guid itemId) => _inFlight.ContainsKey(itemId); + + /// + public async Task TrySetDateCreatedAsync(Guid itemId, DateTime dateCreated, CancellationToken cancellationToken) + { + var item = _libraryManager.GetItemById(itemId); + if (item is null) + { + return false; + } + + _inFlight.TryAdd(itemId, 0); + try + { + item.DateCreated = dateCreated; + + // MetadataEdit also lets the NFO saver (if enabled) write the restored . + await _libraryManager.UpdateItemAsync(item, item.GetParent(), ItemUpdateType.MetadataEdit, cancellationToken) + .ConfigureAwait(false); + return true; + } + finally + { + _inFlight.TryRemove(itemId, out _); + } + } +} diff --git a/Jellyfin.Plugin.RecentlyAddedGuard/Tasks/SyncLedgerTask.cs b/Jellyfin.Plugin.RecentlyAddedGuard/Tasks/SyncLedgerTask.cs new file mode 100644 index 0000000..5300e46 --- /dev/null +++ b/Jellyfin.Plugin.RecentlyAddedGuard/Tasks/SyncLedgerTask.cs @@ -0,0 +1,59 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Plugin.RecentlyAddedGuard.Services; +using MediaBrowser.Model.Tasks; + +namespace Jellyfin.Plugin.RecentlyAddedGuard.Tasks; + +/// +/// Scheduled task "Ledger synchronisieren": registers unknown items, updates live items and prunes the ledger. +/// It never changes library items. +/// +public class SyncLedgerTask : IScheduledTask +{ + /// + /// The task key. + /// + public const string TaskKey = "RecentlyAddedGuardSyncLedger"; + + private readonly LedgerSynchronizer _synchronizer; + + /// + /// Initializes a new instance of the class. + /// + /// The synchronizer. + public SyncLedgerTask(LedgerSynchronizer synchronizer) + { + _synchronizer = synchronizer; + } + + /// + public string Name => "Ledger synchronisieren"; + + /// + public string Key => TaskKey; + + /// + public string Description + => "Gleicht das Ledger von Recently Added Guard mit der Bibliothek ab: registriert unbekannte Filme und Episoden, " + + "trägt gelöschte Items aus und bereinigt abgelaufene Einträge. Ändert keine Items."; + + /// + public string Category => "Recently Added Guard"; + + /// + public Task ExecuteAsync(IProgress progress, CancellationToken cancellationToken) + => _synchronizer.RunAsync(progress, cancellationToken); + + /// + public IEnumerable GetDefaultTriggers() + { + yield return new TaskTriggerInfo + { + Type = TaskTriggerInfoType.DailyTrigger, + TimeOfDayTicks = TimeSpan.FromHours(3).Ticks + }; + } +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..d7c32e0 --- /dev/null +++ b/README.md @@ -0,0 +1,147 @@ +# Recently Added Guard + +Jellyfin-Plugin (Jellyfin **12.2**, .NET 10), das Qualitäts-Upgrades von Radarr und Sonarr aus **„Zuletzt hinzugefügt“** fernhält. Dort erscheinen nur noch Filme und Episoden, die wirklich neu sind. + +## Warum + +Radarr und Sonarr ersetzen Filme und Episoden automatisch durch bessere Versionen. Die neue Datei bekommt meist einen **neuen Namen**, weil Qualität und Release-Infos darin stehen. Jellyfin identifiziert Items über den absoluten Pfad. Für einen neuen Pfad legt es deshalb ein neues Item an, dessen „Hinzugefügt am“ (`DateCreated`) **jetzt** ist. Das Upgrade landet unter „Zuletzt hinzugefügt“, als wäre es neu. + +Auch am **selben Pfad** kann das Datum springen. Ändert sich die Datei, setzt Jellyfin `DateCreated` beim nächsten Metadaten-Refresh auf die Erstellungszeit der Datei. + +## Funktionsweise + +Das Plugin führt ein **Ledger** mit dem ersten Hinzufügedatum pro **logischem Medium**, also pro Film oder Episode und nicht pro Datei. + +- **Identität über Alias-Keys** statt über Pfade: + - Film: `movie:tmdb:{id}`, `movie:imdb:{id}`, `movie:tvdb:{id}` + - Episode: `ep:tvdb|tmdb|imdb:{Serien-ID}:s{Staffel}e{Episode}` (Doppelfolgen: `s1e1-2`) und immer zusätzlich `ep:series-guid:{Jellyfin-Serien-ID}:…`. Damit greift der Schutz auch bei Serien ohne Provider-IDs. + - Ein Eintrag sammelt alle Keys. Ein Treffer über **irgendeinen** Key genügt, neue Keys werden ergänzt. +- **Neues Medium:** Das Plugin legt einen Eintrag an und ändert nichts am Item. +- **Upgrade:** Ein Item ist jünger als der Ledger-Eintrag (Toleranz 60 s), und das Medium ist entweder noch vorhanden oder wurde vor weniger als **14 Tagen** gelöscht. Dann setzt das Plugin das „Hinzugefügt am“ des Items auf das gespeicherte Datum zurück. Bei aktiviertem NFO-Speichern schreibt Jellyfin dabei auch `` neu. +- **Rückkehr nach Ablauf der Frist:** Taucht ein Medium erst später als 14 Tage nach dem Löschen wieder auf, gilt es als **neu**. +- **Älteres Datum** (z. B. aus einer NFO oder manuell vorverlegt): Der Ledger übernimmt immer das Minimum. +- Das Plugin wertet `ItemAdded`, `ItemUpdated` und `ItemRemoved` im Hintergrund aus, entprellt und ohne Jellyfin zu blockieren. Überschreibt eine noch laufende Metadaten-Aktualisierung das Datum erneut, setzt das Plugin es wieder zurück. +- **Seed:** Beim ersten Start registriert das Plugin alle vorhandenen Filme und Episoden, ohne etwas zu ändern. +- **Scheduled Task „Ledger synchronisieren“** (Kategorie „Recently Added Guard“, täglich um 03:00): Er registriert unbekannte Items, trägt gelöschte aus und entfernt Einträge, die länger als Frist + 1 Tag gelöscht sind. Auch er ändert keine Items. + +Das Ledger liegt unter `/plugins/Jellyfin.Plugin.RecentlyAddedGuard/ledger.json`, im Docker-Container also unter `/config/plugins/…`. Nimm die Datei in dein Backup auf. Löschst du sie und startest Jellyfin neu, läuft der Seed erneut. + +## Installation + +Jellyfin lädt `manifest.json` und das Plugin-ZIP **ohne Anmeldung**. Das Repository ist deshalb öffentlich. + +1. Dashboard → **Plugins** → **Repositories** → **+** +2. Name: `Recently Added Guard`, URL: + ``` + https://git.menzel.center/menzeljonas/jellyfin-plugin-recently-added-guard/raw/branch/manifest/manifest.json + ``` +3. **Katalog** → **Recently Added Guard** → **Installieren** +4. Jellyfin **neu starten**. Danach läuft der Seed im Hintergrund. Im Log erscheint `Ledger-Seed abgeschlossen …`. + +Updates erscheinen anschließend wie bei offiziellen Plugins im Katalog bzw. unter „Meine Plugins“. + +> **Wichtig:** Deinstalliere **„DateAdded Advanced“** (oder ähnliche Plugins), damit nicht zwei Plugins `DateCreated` verändern. + +## Konfiguration + +Dashboard → Plugins → Recently Added Guard: + +| Einstellung | Standard | Bedeutung | +|---|---|---| +| Frist in Tagen (`GraceDays`) | 14 | Taucht ein gelöschtes Medium innerhalb dieser Frist wieder auf, gilt es als Upgrade und bekommt das alte Datum. Danach gilt es als neu. Radarr/Sonarr ersetzen Dateien meist innerhalb von Sekunden; die Frist deckt auch längere Lücken ab, etwa wenn der Download des Upgrades erst später fertig wird. | +| Toleranz in Sekunden (`ToleranceSeconds`) | 60 | Kleinere Abweichungen des Datums werden ignoriert. | +| Filme schützen (`EnableMovies`) | an | | +| Episoden schützen (`EnableEpisodes`) | an | | +| Testmodus (`DryRun`) | aus | Upgrades nur protokollieren, kein Datum ändern. | + +Die Seite zeigt außerdem die Ledger-Statistik, die Korrekturen seit dem Start, eine Tabelle der letzten 100 Korrekturen (Name, altes Datum, neues Datum, Zeitpunkt) und einen Button **„Ledger synchronisieren“**. + +Die Daten kommen aus `GET /RecentlyAddedGuard/Status`. `POST /RecentlyAddedGuard/Sync` startet den Scheduled Task. Beide Endpunkte erfordern Administratorrechte. + +Korrekturen erscheinen im Jellyfin-Log auf Level *Information*: + +``` +Upgrade erkannt: Matrix (1999) – DateAdded 2026-10-21 21:02:15Z → 2024-01-01 00:00:00Z +``` + +## Grenzen + +- **Bereits falsch datierte Items werden nicht korrigiert.** Das Plugin schützt nur vor zukünftigen Upgrades. Geplant für v2 ist ein Abgleich mit der Import-Historie von Radarr/Sonarr. Die Schnittstelle `IExternalDateSource` ist dafür vorbereitet. +- **Filme ohne TMDb-ID im Ordnernamen** haben beim Hinzufügen noch keine Provider-ID. Sie werden erst nach dem Metadatenabruf erkannt und können bis dahin kurz unter „Zuletzt hinzugefügt“ auftauchen. Abhilfe: In Radarr als Ordnerformat z. B. `{Movie CleanTitle} ({Release Year}) {tmdb-{TmdbId}}` verwenden. Jellyfin liest die ID dann direkt aus dem Pfad. Episoden sind über die Serie sofort zuordenbar. +- **„Neues Item“-Benachrichtigungen** anderer Plugins (z. B. Webhook) feuern weiterhin, weil Jellyfin das Item tatsächlich neu anlegt. +- **Manuelles Ändern von „Hinzugefügt am“** im Metadaten-Editor: Vorverlegen senkt das gespeicherte Datum. Ein Nachverlegen setzt das Plugin wieder zurück. +- **Mehrere Dateien desselben Mediums** (alternative Versionen, z. B. eine 4K-Kopie in einer eigenen Bibliothek) teilen sich absichtlich einen Eintrag. Eine später hinzugefügte zweite Version erscheint deshalb nicht als neu. +- Ändert sich die **Staffel- oder Episodennummer** einer Datei, gilt sie als anderes Medium. + +## Entwicklung + +Voraussetzung: .NET SDK 10 oder Docker. + +```bash +dotnet build -c Release # Warnungen sind im Release-Build Fehler +dotnet test + +# ohne lokales SDK +docker run --rm -v "$PWD":/src -w /src mcr.microsoft.com/dotnet/sdk:10.0 dotnet test -c Release +``` + +Aufbau: + +| Pfad | Inhalt | +|---|---| +| `Core/` | Kernlogik ohne Jellyfin-Typen: `Reconciler` (Abgleich), `MediaKeys`, `GuardProcessor` (Korrektur/DryRun), `CorrectionLog`. Dazu `IdentityResolver` (Jellyfin-Item → `MediaIdentity`) | +| `Ledger/` | `LedgerStore`: Persistenz, Indizes nach Key und Item-ID, thread-safe, atomares und entprelltes Speichern | +| `Services/` | `GuardHostedService` (Events → Channel → Worker, Seed), `LedgerSynchronizer`, `LibraryItemDateWriter` | +| `Tasks/` | Scheduled Task „Ledger synchronisieren“ | +| `Api/` | `GuardController` (`/RecentlyAddedGuard/Status`, `/RecentlyAddedGuard/Sync`) | +| `Configuration/` | Konfiguration und eingebettete Konfigurationsseite | + +Kompiliert wird gegen die NuGet-Pakete `Jellyfin.Controller`/`Jellyfin.Model` **12.2.0** (`ExcludeAssets="runtime"`), das ZIP enthält nur die Plugin-DLL. `targetAbi` ist `12.2.0.0`. + +### Integrationstest mit Jellyfin 12.2 + +`integration-test/it.py` startet eine Wegwerf-Instanz von `jellyfin/jellyfin:12.2` auf `127.0.0.1:18096`. Das Plugin installiert es so, wie es der Katalog tut (`plugins/Recently Added Guard_/`). Danach erzeugt das Skript per jellyfin-ffmpeg eine Mini-Bibliothek und prüft: + +1. Upgrade mit neuem Dateinamen (Film mit `{tmdb-…}`, Episode): Das alte Datum bleibt erhalten. Ein echt neuer Film steht oben in `GET /Items/Latest`. +2. Datei am selben Pfad ersetzt: Das Datum wird zurückgesetzt. +3. Film ohne ID im Ordnernamen: Die Korrektur erfolgt nach dem Metadatenabruf. +4. DryRun ändert nichts, eine Rückkehr nach Ablauf der Frist gilt als neu. +5. Status-API, Konfigurationsseite, Scheduled Task, Persistenz über einen Neustart. + +```bash +# Plugin-ZIP bauen (wie die CI) und testen +docker run --rm -v "$PWD":/src:ro -v "$PWD/artifacts":/out mcr.microsoft.com/dotnet/sdk:10.0 bash -c ' + cp -r /src /work && cd /work && rm -rf */bin */obj + apt-get update -qq && apt-get install -y -qq python3-venv >/dev/null + python3 -m venv /opt/jprm && /opt/jprm/bin/pip install -q jprm==1.1.0 + /opt/jprm/bin/jprm plugin build . --output /out --version 1.0.0.0' +python3 integration-test/it.py artifacts/recently-added-guard_1.0.0.0.zip +``` + +Von Hand geht es so: + +1. Jellyfin 12.2 per Docker starten (`docker run -p 8096:8096 -v ./config:/config -v ./media:/media jellyfin/jellyfin:12.2`). Das ZIP nach `config/plugins/Recently Added Guard_1.0.0.0/` entpacken. +2. Eine Filmbibliothek mit `media/movies/The Matrix (1999) {tmdb-603}/The Matrix (1999) WEBDL-720p.mkv` anlegen und scannen. +3. Im Metadaten-Editor „Hinzugefügt am“ auf ein altes Datum setzen. Danach einen anderen Film hinzufügen. +4. Die Datei in `… Bluray-1080p.mkv` umbenennen bzw. ersetzen und die Bibliothek scannen. +5. Mit `GET /Items/Latest?userId=…&parentId=` prüfen: Matrix steht nicht vorne, sein `DateCreated` ist das alte Datum. Im Log steht `Upgrade erkannt: …`. + +## Release und CI + +| Workflow | Auslöser | Wirkung | +|---|---|---| +| `.gitea/workflows/build.yaml` | Push (außer Branch `manifest`), Pull Request | `dotnet restore`, `build -c Release` (Warnungen = Fehler), `test` im Container `mcr.microsoft.com/dotnet/sdk:10.0` | +| `.gitea/workflows/release.yaml` | Tag `v*` | Tests → ZIP mit jprm → Gitea-Release mit ZIP → `manifest.json` auf Branch `manifest` → Prüfung von anonymem Abruf und MD5 | + +So veröffentlichst du ein Release: + +```bash +git tag -a v1.0.1.0 -m "Kurzer Changelog für Jellyfin" +git push origin v1.0.1.0 +``` + +- Die Version muss vierteilig sein (`vA.B.C.D`). Die CI schreibt sie in Assembly und `meta.json`. +- Der Changelog kommt aus der Nachricht des annotierten Tags. Bei einem leichtgewichtigen Tag bilden die Commit-Betreffs seit dem letzten Tag den Changelog. +- Die CI nutzt nur das automatische `GITEA_TOKEN` (`permissions: contents: write`), ein eigenes Secret ist nicht nötig. Commits mit diesem Token starten in Gitea keine weiteren Workflows. +- `manifest.json` liegt ausschließlich auf dem Branch `manifest` und wird nur von der CI gepflegt. Die neue Version kommt nach vorne, alte Versionen bleiben erhalten (`checksum` = MD5 des ZIP, `targetAbi` = `12.2.0.0`). +- Die Plugin-GUID `6fdfedef-adde-4ac3-a873-62a057c95574` darf sich nie ändern. diff --git a/build.yaml b/build.yaml new file mode 100644 index 0000000..1a81c88 --- /dev/null +++ b/build.yaml @@ -0,0 +1,21 @@ +--- +# Metadata for jprm (Jellyfin Plugin Repository Manager). The release workflow sets +# `version` and `changelog` from the git tag before building; do not change `guid`. +name: "Recently Added Guard" +guid: "6fdfedef-adde-4ac3-a873-62a057c95574" +version: "1.0.0.0" +targetAbi: "12.2.0.0" +framework: "net10.0" +owner: "menzeljonas" +category: "General" +overview: "Hält Qualitäts-Upgrades von Radarr und Sonarr aus „Zuletzt hinzugefügt“ fern." +description: > + Radarr und Sonarr ersetzen Filme und Episoden bei Qualitäts-Upgrades durch neue Dateien. + Jellyfin legt dafür neue Items an, die als „neu hinzugefügt“ erscheinen. Das Plugin führt + ein Ledger mit dem ersten Hinzufügedatum jedes Films und jeder Episode (über TMDb-, IMDb- + und TVDb-IDs sowie die Serie) und setzt das Datum von Upgrades auf diesen Wert zurück. + So erscheinen unter „Zuletzt hinzugefügt“ nur wirklich neue Medien. +artifacts: + - "Jellyfin.Plugin.RecentlyAddedGuard.dll" +changelog: > + Erste Version. diff --git a/integration-test/it.py b/integration-test/it.py new file mode 100644 index 0000000..cfeaf2b --- /dev/null +++ b/integration-test/it.py @@ -0,0 +1,301 @@ +"""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)