feat: Recently Added Guard plugin for Jellyfin 12.2
Keeps Radarr/Sonarr quality upgrades out of "Recently Added" by keeping a ledger of the first date added per logical medium (movie or episode, identified by provider ids and the series) and restoring it on upgrades. - core logic without Jellyfin types (Reconciler, MediaKeys, GuardProcessor) - thread-safe ledger with atomic, debounced persistence - event processing via channel and background worker, seed on first start - scheduled task "Ledger synchronisieren", status API, configuration page - xUnit tests and a Jellyfin 12.2 integration test - Gitea workflows: build/test, release with jprm and manifest branch Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
d431486095
38 files changed
+4396
No files matched your search
@@ -0,0 +1,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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
bin/
|
||||
obj/
|
||||
artifacts/
|
||||
TestResults/
|
||||
*.user
|
||||
*.suo
|
||||
.vs/
|
||||
.idea/
|
||||
.vscode/
|
||||
integration-test/.run/
|
||||
@@ -0,0 +1,20 @@
|
||||
<Project>
|
||||
<!-- Shared settings for the plugin and the test project. -->
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>disable</ImplicitUsings>
|
||||
<!-- Overwritten by the release workflow (jprm) with the version from the tag. -->
|
||||
<Version>1.0.0.0</Version>
|
||||
<Authors>menzeljonas</Authors>
|
||||
<Deterministic>true</Deterministic>
|
||||
<AnalysisLevel>latest-recommended</AnalysisLevel>
|
||||
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition=" '$(Configuration)' == 'Release' ">
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<IsPackable>false</IsPackable>
|
||||
<!-- Keeps `dotnet publish` of the solution (jprm) from publishing the tests. -->
|
||||
<IsPublishable>false</IsPublishable>
|
||||
<RootNamespace>Jellyfin.Plugin.RecentlyAddedGuard.Tests</RootNamespace>
|
||||
<!-- Test names use underscores; test classes need no XML docs. -->
|
||||
<NoWarn>$(NoWarn);CA1707;CS1591</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.9.0" />
|
||||
<PackageReference Include="xunit.v3" Version="3.2.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5" PrivateAssets="all" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.11" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Jellyfin.Plugin.RecentlyAddedGuard\Jellyfin.Plugin.RecentlyAddedGuard.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -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<LedgerStore>.Instance, TimeProvider.System, TimeSpan.FromMilliseconds(100));
|
||||
var reconciler = new Reconciler(store, () => GuardOptions.Default, TimeProvider.System, NullLogger<Reconciler>.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<LedgerStore>.Instance, TimeProvider.System, TimeSpan.FromHours(1));
|
||||
new Reconciler(store, () => GuardOptions.Default, TimeProvider.System, NullLogger<Reconciler>.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));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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<ReconcileResult> SeenAsync(MediaIdentity identity)
|
||||
=> _fx.Processor.ProcessSeenAsync(identity, CancellationToken.None);
|
||||
}
|
||||
@@ -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<bool> TrySetDateCreatedAsync(Guid itemId, DateTime dateCreated, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!ItemExists)
|
||||
{
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
|
||||
Writes.Add((itemId, dateCreated));
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wires the core classes against a temporary ledger file and a manual clock.
|
||||
/// </summary>
|
||||
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<Reconciler>.Instance);
|
||||
Writer = new RecordingDateWriter();
|
||||
Corrections = new CorrectionLog();
|
||||
Processor = new GuardProcessor(Reconciler, Writer, Corrections, () => Options, Time, NullLogger<GuardProcessor>.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;
|
||||
|
||||
/// <summary>
|
||||
/// Creates another store on the same file. The save delay is long, so tests control writes via Flush.
|
||||
/// </summary>
|
||||
public LedgerStore CreateStore(TimeSpan? saveDelay = null)
|
||||
=> new(LedgerPath, NullLogger<LedgerStore>.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);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
<Solution>
|
||||
<Project Path="Jellyfin.Plugin.RecentlyAddedGuard/Jellyfin.Plugin.RecentlyAddedGuard.csproj" />
|
||||
<Project Path="Jellyfin.Plugin.RecentlyAddedGuard.Tests/Jellyfin.Plugin.RecentlyAddedGuard.Tests.csproj" />
|
||||
</Solution>
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Status and actions for the configuration page. Jellyfin adds plugin assemblies as MVC application parts,
|
||||
/// so this controller is discovered automatically.
|
||||
/// </summary>
|
||||
[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;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="GuardController"/> class.
|
||||
/// </summary>
|
||||
/// <param name="store">The ledger.</param>
|
||||
/// <param name="corrections">The correction log.</param>
|
||||
/// <param name="taskManager">The task manager.</param>
|
||||
public GuardController(LedgerStore store, CorrectionLog corrections, ITaskManager taskManager)
|
||||
{
|
||||
_store = store;
|
||||
_corrections = corrections;
|
||||
_taskManager = taskManager;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets ledger statistics and the most recent corrections.
|
||||
/// </summary>
|
||||
/// <response code="200">The status.</response>
|
||||
/// <returns>The status.</returns>
|
||||
[HttpGet("Status")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
public ActionResult<GuardStatus> 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()
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Queues the scheduled task "Ledger synchronisieren".
|
||||
/// </summary>
|
||||
/// <response code="204">The task was queued (or is already running).</response>
|
||||
/// <returns>No content.</returns>
|
||||
[HttpPost("Sync")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
public ActionResult StartSync()
|
||||
{
|
||||
_taskManager.QueueIfNotRunning<SyncLedgerTask>();
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Jellyfin.Plugin.RecentlyAddedGuard.Core;
|
||||
|
||||
namespace Jellyfin.Plugin.RecentlyAddedGuard.Api;
|
||||
|
||||
/// <summary>
|
||||
/// Status shown on the configuration page.
|
||||
/// </summary>
|
||||
public sealed class GuardStatus
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the number of ledger entries.
|
||||
/// </summary>
|
||||
public int LedgerEntries { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of entries with at least one live item.
|
||||
/// </summary>
|
||||
public int LiveEntries { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of entries whose items are gone (within the grace period).
|
||||
/// </summary>
|
||||
public int RemovedEntries { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of corrections since the server started.
|
||||
/// </summary>
|
||||
public long CorrectionsSinceStart { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of upgrades detected in dry-run mode since the server started.
|
||||
/// </summary>
|
||||
public long DryRunDetectionsSinceStart { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the initial seed has completed.
|
||||
/// </summary>
|
||||
public bool SeedCompleted { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the time of the last seed or synchronization (UTC).
|
||||
/// </summary>
|
||||
public DateTime? LastSyncUtc { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether dry-run mode is active.
|
||||
/// </summary>
|
||||
public bool DryRun { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the most recent corrections, newest first.
|
||||
/// </summary>
|
||||
public IReadOnlyList<CorrectionRecord> RecentCorrections { get; init; } = [];
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
using Jellyfin.Plugin.RecentlyAddedGuard.Core;
|
||||
using MediaBrowser.Model.Plugins;
|
||||
|
||||
namespace Jellyfin.Plugin.RecentlyAddedGuard.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// The plugin configuration.
|
||||
/// </summary>
|
||||
public class PluginConfiguration : BasePluginConfiguration
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the number of days within which a returning medium counts as an upgrade.
|
||||
/// </summary>
|
||||
public int GraceDays { get; set; } = 14;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether movies are protected.
|
||||
/// </summary>
|
||||
public bool EnableMovies { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether episodes are protected.
|
||||
/// </summary>
|
||||
public bool EnableEpisodes { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether upgrades are only logged, not corrected.
|
||||
/// </summary>
|
||||
public bool DryRun { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the tolerance in seconds for comparing dates.
|
||||
/// </summary>
|
||||
public int ToleranceSeconds { get; set; } = 60;
|
||||
|
||||
/// <summary>
|
||||
/// Maps the configuration to the options of the core logic.
|
||||
/// </summary>
|
||||
/// <returns>The options.</returns>
|
||||
public GuardOptions ToGuardOptions() => new(GraceDays, ToleranceSeconds, DryRun);
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Recently Added Guard</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="RecentlyAddedGuardConfigPage" data-role="page" class="page type-interior pluginConfigurationPage" data-require="emby-input,emby-button,emby-checkbox">
|
||||
<style>
|
||||
#RecentlyAddedGuardConfigPage .ragStats { display: grid; grid-template-columns: repeat(auto-fit, minmax(11em, 1fr)); gap: 0.75em; margin: 1em 0; }
|
||||
#RecentlyAddedGuardConfigPage .ragStat { padding: 0.75em 1em; border-radius: 0.3em; background: rgba(128, 128, 128, 0.12); }
|
||||
#RecentlyAddedGuardConfigPage .ragStatValue { font-size: 1.6em; font-weight: 600; }
|
||||
#RecentlyAddedGuardConfigPage .ragStatLabel { opacity: 0.75; font-size: 0.9em; }
|
||||
#RecentlyAddedGuardConfigPage .ragTableWrap { overflow-x: auto; }
|
||||
#RecentlyAddedGuardConfigPage table { width: 100%; border-collapse: collapse; }
|
||||
#RecentlyAddedGuardConfigPage th, #RecentlyAddedGuardConfigPage td { text-align: left; padding: 0.45em 0.6em; border-bottom: 1px solid rgba(128, 128, 128, 0.25); white-space: nowrap; }
|
||||
#RecentlyAddedGuardConfigPage td.ragName { white-space: normal; }
|
||||
#RecentlyAddedGuardConfigPage .ragHint { opacity: 0.75; }
|
||||
</style>
|
||||
<div data-role="content">
|
||||
<div class="content-primary">
|
||||
<h2 class="sectionTitle">Recently Added Guard</h2>
|
||||
<p class="ragHint">
|
||||
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“.
|
||||
</p>
|
||||
|
||||
<form id="RecentlyAddedGuardConfigForm">
|
||||
<div class="inputContainer">
|
||||
<label class="inputLabel inputLabelUnfocused" for="RagGraceDays">Frist in Tagen</label>
|
||||
<input id="RagGraceDays" name="GraceDays" type="number" is="emby-input" min="0" max="3650" step="1" required />
|
||||
<div class="fieldDescription">
|
||||
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.
|
||||
</div>
|
||||
</div>
|
||||
<div class="inputContainer">
|
||||
<label class="inputLabel inputLabelUnfocused" for="RagToleranceSeconds">Toleranz in Sekunden</label>
|
||||
<input id="RagToleranceSeconds" name="ToleranceSeconds" type="number" is="emby-input" min="0" max="86400" step="1" required />
|
||||
<div class="fieldDescription">Kleinere Abweichungen des Datums werden ignoriert. Standard: 60.</div>
|
||||
</div>
|
||||
<div class="checkboxContainer checkboxContainer-withDescription">
|
||||
<label class="emby-checkbox-label">
|
||||
<input id="RagEnableMovies" name="EnableMovies" type="checkbox" is="emby-checkbox" />
|
||||
<span>Filme schützen</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="checkboxContainer checkboxContainer-withDescription">
|
||||
<label class="emby-checkbox-label">
|
||||
<input id="RagEnableEpisodes" name="EnableEpisodes" type="checkbox" is="emby-checkbox" />
|
||||
<span>Episoden schützen</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="checkboxContainer checkboxContainer-withDescription">
|
||||
<label class="emby-checkbox-label">
|
||||
<input id="RagDryRun" name="DryRun" type="checkbox" is="emby-checkbox" />
|
||||
<span>Testmodus (DryRun)</span>
|
||||
</label>
|
||||
<div class="fieldDescription checkboxFieldDescription">Upgrades nur protokollieren, kein Datum ändern.</div>
|
||||
</div>
|
||||
<div>
|
||||
<button is="emby-button" type="submit" class="raised button-submit block emby-button">
|
||||
<span>Speichern</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="verticalSection" style="margin-top: 2.5em;">
|
||||
<h2 class="sectionTitle">Status</h2>
|
||||
<div class="ragStats">
|
||||
<div class="ragStat"><div id="RagLedgerEntries" class="ragStatValue">–</div><div class="ragStatLabel">Ledger-Einträge</div></div>
|
||||
<div class="ragStat"><div id="RagLiveEntries" class="ragStatValue">–</div><div class="ragStatLabel">davon mit Items</div></div>
|
||||
<div class="ragStat"><div id="RagRemovedEntries" class="ragStatValue">–</div><div class="ragStatLabel">in der Frist</div></div>
|
||||
<div class="ragStat"><div id="RagCorrections" class="ragStatValue">–</div><div class="ragStatLabel">Korrekturen seit Start</div></div>
|
||||
</div>
|
||||
<p id="RagSyncInfo" class="ragHint"></p>
|
||||
<button is="emby-button" type="button" id="RagSyncButton" class="raised block emby-button">
|
||||
<span>Ledger synchronisieren</span>
|
||||
</button>
|
||||
|
||||
<h3 style="margin-top: 2em;">Letzte Korrekturen</h3>
|
||||
<div class="ragTableWrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Altes Datum</th>
|
||||
<th>Neues Datum</th>
|
||||
<th>Zeitpunkt</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="RagCorrectionRows">
|
||||
<tr><td colspan="4" class="ragHint">Lädt …</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="text/javascript">
|
||||
(function () {
|
||||
var pluginId = '6fdfedef-adde-4ac3-a873-62a057c95574';
|
||||
var page = document.querySelector('#RecentlyAddedGuardConfigPage');
|
||||
var form = page.querySelector('#RecentlyAddedGuardConfigForm');
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value == null ? '' : value)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function formatDate(value) {
|
||||
if (!value) {
|
||||
return '–';
|
||||
}
|
||||
|
||||
var date = new Date(value);
|
||||
return isNaN(date.getTime()) ? escapeHtml(value) : date.toLocaleString('de-DE');
|
||||
}
|
||||
|
||||
function loadConfiguration() {
|
||||
Dashboard.showLoadingMsg();
|
||||
ApiClient.getPluginConfiguration(pluginId).then(function (config) {
|
||||
page.querySelector('#RagGraceDays').value = config.GraceDays;
|
||||
page.querySelector('#RagToleranceSeconds').value = config.ToleranceSeconds;
|
||||
page.querySelector('#RagEnableMovies').checked = config.EnableMovies;
|
||||
page.querySelector('#RagEnableEpisodes').checked = config.EnableEpisodes;
|
||||
page.querySelector('#RagDryRun').checked = config.DryRun;
|
||||
Dashboard.hideLoadingMsg();
|
||||
});
|
||||
}
|
||||
|
||||
function renderStatus(status) {
|
||||
page.querySelector('#RagLedgerEntries').textContent = status.LedgerEntries;
|
||||
page.querySelector('#RagLiveEntries').textContent = status.LiveEntries;
|
||||
page.querySelector('#RagRemovedEntries').textContent = status.RemovedEntries;
|
||||
page.querySelector('#RagCorrections').textContent = status.DryRun
|
||||
? status.CorrectionsSinceStart + ' (+' + status.DryRunDetectionsSinceStart + ' DryRun)'
|
||||
: status.CorrectionsSinceStart;
|
||||
|
||||
var info = status.SeedCompleted
|
||||
? 'Letzte Synchronisation: ' + formatDate(status.LastSyncUtc) + '.'
|
||||
: 'Das Ledger wird gerade zum ersten Mal befüllt (Seed).';
|
||||
if (status.DryRun) {
|
||||
info += ' Testmodus aktiv: Upgrades werden nur protokolliert.';
|
||||
}
|
||||
page.querySelector('#RagSyncInfo').textContent = info;
|
||||
|
||||
var rows = (status.RecentCorrections || []).map(function (c) {
|
||||
return '<tr>'
|
||||
+ '<td class="ragName">' + escapeHtml(c.Name) + (c.DryRun ? ' <span class="ragHint">(DryRun)</span>' : '') + '</td>'
|
||||
+ '<td>' + formatDate(c.PreviousDate) + '</td>'
|
||||
+ '<td>' + formatDate(c.RestoredDate) + '</td>'
|
||||
+ '<td>' + formatDate(c.CorrectedAt) + '</td>'
|
||||
+ '</tr>';
|
||||
});
|
||||
page.querySelector('#RagCorrectionRows').innerHTML = rows.length
|
||||
? rows.join('')
|
||||
: '<tr><td colspan="4" class="ragHint">Seit dem Start wurden keine Upgrades korrigiert.</td></tr>';
|
||||
}
|
||||
|
||||
function loadStatus() {
|
||||
return ApiClient.getJSON(ApiClient.getUrl('RecentlyAddedGuard/Status')).then(renderStatus, function () {
|
||||
page.querySelector('#RagSyncInfo').textContent = 'Status konnte nicht geladen werden.';
|
||||
});
|
||||
}
|
||||
|
||||
page.addEventListener('pageshow', function () {
|
||||
loadConfiguration();
|
||||
loadStatus();
|
||||
});
|
||||
|
||||
form.addEventListener('submit', function (e) {
|
||||
e.preventDefault();
|
||||
Dashboard.showLoadingMsg();
|
||||
ApiClient.getPluginConfiguration(pluginId).then(function (config) {
|
||||
config.GraceDays = parseInt(page.querySelector('#RagGraceDays').value, 10) || 0;
|
||||
config.ToleranceSeconds = parseInt(page.querySelector('#RagToleranceSeconds').value, 10) || 0;
|
||||
config.EnableMovies = page.querySelector('#RagEnableMovies').checked;
|
||||
config.EnableEpisodes = page.querySelector('#RagEnableEpisodes').checked;
|
||||
config.DryRun = page.querySelector('#RagDryRun').checked;
|
||||
ApiClient.updatePluginConfiguration(pluginId, config).then(function (result) {
|
||||
Dashboard.processPluginConfigurationUpdateResult(result);
|
||||
loadStatus();
|
||||
});
|
||||
});
|
||||
return false;
|
||||
});
|
||||
|
||||
page.querySelector('#RagSyncButton').addEventListener('click', function () {
|
||||
var button = this;
|
||||
button.disabled = true;
|
||||
ApiClient.ajax({ type: 'POST', url: ApiClient.getUrl('RecentlyAddedGuard/Sync') }).then(function () {
|
||||
page.querySelector('#RagSyncInfo').textContent = 'Synchronisation gestartet. Fortschritt unter Geplante Aufgaben.';
|
||||
setTimeout(loadStatus, 5000);
|
||||
}, function () {
|
||||
page.querySelector('#RagSyncInfo').textContent = 'Synchronisation konnte nicht gestartet werden.';
|
||||
}).then(function () {
|
||||
button.disabled = false;
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,116 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
|
||||
namespace Jellyfin.Plugin.RecentlyAddedGuard.Core;
|
||||
|
||||
/// <summary>
|
||||
/// One restored (or, in dry-run mode, detected) upgrade.
|
||||
/// </summary>
|
||||
/// <param name="ItemId">The corrected item.</param>
|
||||
/// <param name="Name">The display name.</param>
|
||||
/// <param name="PreviousDate">The "date added" the upgrade brought along (UTC).</param>
|
||||
/// <param name="RestoredDate">The first "date added" from the ledger (UTC).</param>
|
||||
/// <param name="CorrectedAt">When the correction happened (UTC).</param>
|
||||
/// <param name="DryRun"><c>true</c> if only detected, not applied.</param>
|
||||
public sealed record CorrectionRecord(
|
||||
Guid ItemId,
|
||||
string Name,
|
||||
DateTime PreviousDate,
|
||||
DateTime RestoredDate,
|
||||
DateTime CorrectedAt,
|
||||
bool DryRun);
|
||||
|
||||
/// <summary>
|
||||
/// In-memory ring buffer of the most recent corrections, for the configuration page.
|
||||
/// </summary>
|
||||
public sealed class CorrectionLog
|
||||
{
|
||||
/// <summary>
|
||||
/// The default number of kept corrections.
|
||||
/// </summary>
|
||||
public const int DefaultCapacity = 100;
|
||||
|
||||
private readonly Lock _sync = new();
|
||||
private readonly List<CorrectionRecord> _records;
|
||||
private readonly int _capacity;
|
||||
private long _corrections;
|
||||
private long _dryRunDetections;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="CorrectionLog"/> class.
|
||||
/// </summary>
|
||||
/// <param name="capacity">The number of kept corrections.</param>
|
||||
public CorrectionLog(int capacity = DefaultCapacity)
|
||||
{
|
||||
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(capacity);
|
||||
_capacity = capacity;
|
||||
_records = new List<CorrectionRecord>(capacity);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of applied corrections since the server started.
|
||||
/// </summary>
|
||||
public long CorrectionsSinceStart => Interlocked.Read(ref _corrections);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of upgrades detected in dry-run mode since the server started.
|
||||
/// </summary>
|
||||
public long DryRunDetectionsSinceStart => Interlocked.Read(ref _dryRunDetections);
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <param name="record">The correction.</param>
|
||||
/// <returns><c>true</c> for a new correction, <c>false</c> for a repeated one.</returns>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the kept corrections, newest first.
|
||||
/// </summary>
|
||||
/// <returns>The corrections.</returns>
|
||||
public IReadOnlyList<CorrectionRecord> GetRecent()
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
return _records.AsEnumerable().Reverse().ToList();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
using System;
|
||||
|
||||
namespace Jellyfin.Plugin.RecentlyAddedGuard.Core;
|
||||
|
||||
/// <summary>
|
||||
/// The settings the core logic depends on, decoupled from the Jellyfin plugin configuration.
|
||||
/// </summary>
|
||||
/// <param name="GraceDays">Days within which a returning medium counts as an upgrade.</param>
|
||||
/// <param name="ToleranceSeconds">Date differences up to this many seconds are ignored.</param>
|
||||
/// <param name="DryRun">Only log detected upgrades, never change items.</param>
|
||||
public sealed record GuardOptions(int GraceDays, int ToleranceSeconds, bool DryRun)
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the defaults (14 days, 60 seconds, no dry run).
|
||||
/// </summary>
|
||||
public static GuardOptions Default { get; } = new(14, 60, false);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the grace period.
|
||||
/// </summary>
|
||||
public TimeSpan GracePeriod => TimeSpan.FromDays(Math.Max(0, GraceDays));
|
||||
|
||||
/// <summary>
|
||||
/// Gets the tolerance for date comparisons.
|
||||
/// </summary>
|
||||
public TimeSpan Tolerance => TimeSpan.FromSeconds(Math.Max(0, ToleranceSeconds));
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Turns reconciler decisions into actions: restores the date of upgrades (or only logs them in dry-run mode)
|
||||
/// and records corrections.
|
||||
/// </summary>
|
||||
public sealed class GuardProcessor
|
||||
{
|
||||
private readonly Reconciler _reconciler;
|
||||
private readonly IItemDateWriter _writer;
|
||||
private readonly CorrectionLog _corrections;
|
||||
private readonly Func<GuardOptions> _options;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<GuardProcessor> _logger;
|
||||
private readonly ConcurrentDictionary<Guid, DateTime> _dryRunReported = new();
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="GuardProcessor"/> class.
|
||||
/// </summary>
|
||||
/// <param name="reconciler">The reconciler.</param>
|
||||
/// <param name="writer">Writes item dates.</param>
|
||||
/// <param name="corrections">The correction log.</param>
|
||||
/// <param name="options">Provides the current options.</param>
|
||||
/// <param name="timeProvider">The clock.</param>
|
||||
/// <param name="logger">The logger.</param>
|
||||
public GuardProcessor(
|
||||
Reconciler reconciler,
|
||||
IItemDateWriter writer,
|
||||
CorrectionLog corrections,
|
||||
Func<GuardOptions> options,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<GuardProcessor> 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));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles an added or updated item.
|
||||
/// </summary>
|
||||
/// <param name="identity">The item.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>The reconciler's outcome.</returns>
|
||||
public async Task<ReconcileResult> 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles a removed item.
|
||||
/// </summary>
|
||||
/// <param name="itemId">The removed item.</param>
|
||||
/// <param name="keys">The keys the item still resolves to; may be empty.</param>
|
||||
/// <returns><c>true</c> if a ledger entry was affected.</returns>
|
||||
public bool ProcessRemoved(Guid itemId, IReadOnlyList<string> 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;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Jellyfin.Plugin.RecentlyAddedGuard.Core;
|
||||
|
||||
/// <summary>
|
||||
/// An external source for the first "date added" of a medium, e.g. the import history of Radarr or Sonarr.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Extension point for v2: implementations registered in the DI container are consulted by the
|
||||
/// ledger synchronization, and earlier dates lower the ledger via <see cref="Reconciler.ApplyExternalFirstAdded"/>.
|
||||
/// v1 ships no implementation.
|
||||
/// </remarks>
|
||||
public interface IExternalDateSource
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the name of the source for logs.
|
||||
/// </summary>
|
||||
string Name { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Looks up when the medium was first imported.
|
||||
/// </summary>
|
||||
/// <param name="identity">The medium.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>The first import date (UTC), or <c>null</c> if unknown.</returns>
|
||||
Task<DateTime?> GetFirstAddedAsync(MediaIdentity identity, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Jellyfin.Plugin.RecentlyAddedGuard.Core;
|
||||
|
||||
/// <summary>
|
||||
/// Writes the "date added" of a library item.
|
||||
/// </summary>
|
||||
public interface IItemDateWriter
|
||||
{
|
||||
/// <summary>
|
||||
/// Sets the item's "date added" and saves it.
|
||||
/// </summary>
|
||||
/// <param name="itemId">The item.</param>
|
||||
/// <param name="dateCreated">The new "date added" (UTC).</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns><c>false</c> if the item no longer exists.</returns>
|
||||
Task<bool> TrySetDateCreatedAsync(Guid itemId, DateTime dateCreated, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Maps Jellyfin items to <see cref="MediaIdentity"/> snapshots for the core logic.
|
||||
/// </summary>
|
||||
public sealed class IdentityResolver
|
||||
{
|
||||
private readonly ILibraryManager _libraryManager;
|
||||
private readonly ILogger<IdentityResolver> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="IdentityResolver"/> class.
|
||||
/// </summary>
|
||||
/// <param name="libraryManager">The library manager.</param>
|
||||
/// <param name="logger">The logger.</param>
|
||||
public IdentityResolver(ILibraryManager libraryManager, ILogger<IdentityResolver> logger)
|
||||
{
|
||||
_libraryManager = libraryManager;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether the item is a real movie or episode: no missing (virtual) episode, no extra.
|
||||
/// </summary>
|
||||
/// <param name="item">The item.</param>
|
||||
/// <returns><c>true</c> if the plugin tracks items of this kind.</returns>
|
||||
public static bool IsTracked(BaseItem item)
|
||||
=> item is Movie or Episode && !item.IsVirtualItem && item.ExtraType is null;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the identity of a tracked item.
|
||||
/// </summary>
|
||||
/// <param name="item">The item.</param>
|
||||
/// <param name="seriesLookup">Optional cached series lookup (used by the synchronization).</param>
|
||||
/// <returns>The identity (its keys may be empty if provider ids are still missing),
|
||||
/// or <c>null</c> if the item is not tracked or its kind is disabled.</returns>
|
||||
public MediaIdentity? Resolve(BaseItem item, Func<Guid, Series?>? 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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <param name="item">The removed item.</param>
|
||||
/// <returns>The keys; empty if they cannot be determined.</returns>
|
||||
public IReadOnlyList<string> 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<string> MovieKeys(Movie movie)
|
||||
=> MediaKeys.ForMovie(
|
||||
movie.GetProviderId(MetadataProvider.Tmdb),
|
||||
movie.GetProviderId(MetadataProvider.Imdb),
|
||||
movie.GetProviderId(MetadataProvider.Tvdb));
|
||||
|
||||
private static IReadOnlyList<string> 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<Guid, Series?>? 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
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace Jellyfin.Plugin.RecentlyAddedGuard.Core;
|
||||
|
||||
/// <summary>
|
||||
/// Kind of logical medium tracked by the ledger.
|
||||
/// </summary>
|
||||
public enum MediaKind
|
||||
{
|
||||
/// <summary>A movie.</summary>
|
||||
Movie,
|
||||
|
||||
/// <summary>A TV episode (or a multi-episode file).</summary>
|
||||
Episode
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Jellyfin-independent snapshot of a library item, as consumed by the <see cref="Reconciler"/>.
|
||||
/// </summary>
|
||||
/// <param name="ItemId">The Jellyfin item id.</param>
|
||||
/// <param name="Kind">The kind of medium.</param>
|
||||
/// <param name="DateCreated">The item's current "date added" (UTC).</param>
|
||||
/// <param name="Keys">All identity keys of the logical medium; empty if the item cannot be identified yet.</param>
|
||||
/// <param name="DisplayName">Human readable name for logs and the configuration page.</param>
|
||||
public sealed record MediaIdentity(
|
||||
Guid ItemId,
|
||||
MediaKind Kind,
|
||||
DateTime DateCreated,
|
||||
IReadOnlyList<string> Keys,
|
||||
string DisplayName);
|
||||
@@ -0,0 +1,88 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
|
||||
namespace Jellyfin.Plugin.RecentlyAddedGuard.Core;
|
||||
|
||||
/// <summary>
|
||||
/// Builds the alias keys that identify a logical medium independent of its file path.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Keys are compared case-insensitively by the ledger, so provider ids are only trimmed.
|
||||
/// </remarks>
|
||||
public static class MediaKeys
|
||||
{
|
||||
/// <summary>
|
||||
/// Builds the keys of a movie from its provider ids.
|
||||
/// </summary>
|
||||
/// <param name="tmdbId">The TMDb id, if known.</param>
|
||||
/// <param name="imdbId">The IMDb id, if known.</param>
|
||||
/// <param name="tvdbId">The TVDb id, if known.</param>
|
||||
/// <returns>The keys; empty if the movie has no provider id yet.</returns>
|
||||
public static IReadOnlyList<string> ForMovie(string? tmdbId, string? imdbId, string? tvdbId)
|
||||
{
|
||||
var keys = new List<string>(3);
|
||||
AddIfPresent(keys, "movie:tmdb:", tmdbId, string.Empty);
|
||||
AddIfPresent(keys, "movie:imdb:", imdbId, string.Empty);
|
||||
AddIfPresent(keys, "movie:tvdb:", tvdbId, string.Empty);
|
||||
return keys;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the episode part of an episode key, e.g. <c>s1e5</c> or <c>s1e5-6</c> for a double episode.
|
||||
/// </summary>
|
||||
/// <param name="seasonNumber">The season number (<c>ParentIndexNumber</c>).</param>
|
||||
/// <param name="episodeNumber">The episode number (<c>IndexNumber</c>).</param>
|
||||
/// <param name="episodeNumberEnd">The last episode number of a multi-episode file (<c>IndexNumberEnd</c>).</param>
|
||||
/// <returns>The episode part, or <c>null</c> if season or episode number is missing.</returns>
|
||||
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}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the keys of an episode.
|
||||
/// </summary>
|
||||
/// <param name="episodePart">The episode part from <see cref="EpisodePart"/>.</param>
|
||||
/// <param name="seriesTvdbId">The series' TVDb id, if known.</param>
|
||||
/// <param name="seriesTmdbId">The series' TMDb id, if known.</param>
|
||||
/// <param name="seriesImdbId">The series' IMDb id, if known.</param>
|
||||
/// <param name="seriesId">The Jellyfin id of the series; <see cref="Guid.Empty"/> if unknown.</param>
|
||||
/// <returns>The keys; contains the series-guid key whenever the series id is known.</returns>
|
||||
public static IReadOnlyList<string> ForEpisode(
|
||||
string episodePart,
|
||||
string? seriesTvdbId,
|
||||
string? seriesTmdbId,
|
||||
string? seriesImdbId,
|
||||
Guid seriesId)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(episodePart);
|
||||
|
||||
var suffix = ":" + episodePart;
|
||||
var keys = new List<string>(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<string> keys, string prefix, string? id, string suffix)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(id))
|
||||
{
|
||||
keys.Add(prefix + id.Trim() + suffix);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
using System;
|
||||
|
||||
namespace Jellyfin.Plugin.RecentlyAddedGuard.Core;
|
||||
|
||||
/// <summary>
|
||||
/// What the <see cref="Reconciler"/> concluded for an item.
|
||||
/// </summary>
|
||||
public enum ReconcileAction
|
||||
{
|
||||
/// <summary>The item has no identity keys (yet) and was skipped.</summary>
|
||||
Ignored,
|
||||
|
||||
/// <summary>A new medium; a ledger entry was created with the item's date.</summary>
|
||||
Registered,
|
||||
|
||||
/// <summary>A known medium whose date matches the ledger.</summary>
|
||||
Unchanged,
|
||||
|
||||
/// <summary>The item is older than the ledger (e.g. a date from an NFO); the ledger took the earlier date.</summary>
|
||||
FirstAddedLowered,
|
||||
|
||||
/// <summary>The medium returned after the grace period; it counts as new and the ledger took its date.</summary>
|
||||
ReturnedAfterGrace,
|
||||
|
||||
/// <summary>The item is newer than the ledger: an upgrade whose date has to be restored.</summary>
|
||||
UpgradeDetected
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The outcome of reconciling one item with the ledger.
|
||||
/// </summary>
|
||||
/// <param name="Action">The conclusion.</param>
|
||||
/// <param name="Identity">The item.</param>
|
||||
/// <param name="ItemDate">The item's "date added" as evaluated (UTC).</param>
|
||||
/// <param name="FirstAdded">The ledger's first "date added" after reconciling (UTC).</param>
|
||||
public sealed record ReconcileResult(ReconcileAction Action, MediaIdentity Identity, DateTime ItemDate, DateTime FirstAdded);
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Matches items against the ledger and decides whether an item is new, known or an upgrade.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Pure logic without Jellyfin types: it only updates the ledger and reports what it found.
|
||||
/// Changing items is up to the caller (see <see cref="GuardProcessor"/>).
|
||||
/// </remarks>
|
||||
public sealed class Reconciler
|
||||
{
|
||||
private readonly LedgerStore _store;
|
||||
private readonly Func<GuardOptions> _options;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<Reconciler> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="Reconciler"/> class.
|
||||
/// </summary>
|
||||
/// <param name="store">The ledger.</param>
|
||||
/// <param name="options">Provides the current options.</param>
|
||||
/// <param name="timeProvider">The clock.</param>
|
||||
/// <param name="logger">The logger.</param>
|
||||
public Reconciler(LedgerStore store, Func<GuardOptions> options, TimeProvider timeProvider, ILogger<Reconciler> 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));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles an added or updated item: registers it and detects upgrades.
|
||||
/// </summary>
|
||||
/// <param name="identity">The item.</param>
|
||||
/// <returns>The outcome; <see cref="ReconcileAction.UpgradeDetected"/> asks the caller to restore <see cref="ReconcileResult.FirstAdded"/>.</returns>
|
||||
public ReconcileResult OnItemSeen(MediaIdentity identity) => Apply(identity, detectUpgrades: true);
|
||||
|
||||
/// <summary>
|
||||
/// Registers an item during seed or synchronization. The ledger keeps the minimum date; upgrades are not reported.
|
||||
/// </summary>
|
||||
/// <param name="identity">The item.</param>
|
||||
/// <returns>The outcome.</returns>
|
||||
public ReconcileResult Register(MediaIdentity identity) => Apply(identity, detectUpgrades: false);
|
||||
|
||||
/// <summary>
|
||||
/// Handles a removed item. The entry is found by item id, or by key if the item was never registered.
|
||||
/// </summary>
|
||||
/// <param name="itemId">The removed item.</param>
|
||||
/// <param name="keys">The keys the item still resolves to; may be empty.</param>
|
||||
/// <returns><c>true</c> if a ledger entry was affected.</returns>
|
||||
public bool OnItemRemoved(Guid itemId, IReadOnlyList<string> 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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lists live item ids the ledger knows but a synchronization did not see.
|
||||
/// </summary>
|
||||
/// <param name="seenItemIds">The items seen by the synchronization.</param>
|
||||
/// <returns>Candidates for <see cref="RemoveStaleItems"/>; the caller checks whether they still exist.</returns>
|
||||
public IReadOnlyList<Guid> GetUnseenLiveItemIds(IReadOnlySet<Guid> seenItemIds)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(seenItemIds);
|
||||
|
||||
lock (_store.SyncRoot)
|
||||
{
|
||||
return _store.GetEntries()
|
||||
.SelectMany(entry => entry.LiveItemIds)
|
||||
.Where(itemId => !seenItemIds.Contains(itemId))
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes items that no longer exist from their entries and starts the grace period for emptied entries.
|
||||
/// </summary>
|
||||
/// <param name="itemIds">The items that no longer exist.</param>
|
||||
/// <returns>The number of detached items.</returns>
|
||||
public int RemoveStaleItems(IEnumerable<Guid> 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes entries removed longer than the grace period plus one day; a return after that counts as new anyway.
|
||||
/// </summary>
|
||||
/// <returns>The number of deleted entries.</returns>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <param name="keys">The keys of the medium.</param>
|
||||
/// <param name="firstAdded">The date reported by the source.</param>
|
||||
/// <returns><c>true</c> if the ledger was lowered.</returns>
|
||||
public bool ApplyExternalFirstAdded(IReadOnlyList<string> 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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
private void ReleaseFromOutdatedEntry(Guid itemId, IReadOnlyList<LedgerEntry> 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
private LedgerEntry Merge(IReadOnlyList<LedgerEntry> 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;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<RootNamespace>Jellyfin.Plugin.RecentlyAddedGuard</RootNamespace>
|
||||
<AssemblyName>Jellyfin.Plugin.RecentlyAddedGuard</AssemblyName>
|
||||
<CopyLocalLockFileAssemblies>false</CopyLocalLockFileAssemblies>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Provided by the Jellyfin server at runtime; only the plugin DLL is shipped. -->
|
||||
<PackageReference Include="Jellyfin.Controller" Version="12.2.0" ExcludeAssets="runtime" />
|
||||
<PackageReference Include="Jellyfin.Model" Version="12.2.0" ExcludeAssets="runtime" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Remove="Configuration\configPage.html" />
|
||||
<EmbeddedResource Include="Configuration\configPage.html" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="Jellyfin.Plugin.RecentlyAddedGuard.Tests" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,48 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace Jellyfin.Plugin.RecentlyAddedGuard.Ledger;
|
||||
|
||||
/// <summary>
|
||||
/// The first "date added" of one logical medium (a movie or an episode), independent of its files.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <see cref="Keys"/> and <see cref="LiveItemIds"/> are indexed by the <see cref="LedgerStore"/>;
|
||||
/// change them only through the store so the indexes stay consistent.
|
||||
/// </remarks>
|
||||
public sealed class LedgerEntry
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets all alias keys of the medium (e.g. <c>movie:tmdb:603</c>).
|
||||
/// </summary>
|
||||
#pragma warning disable CA1819 // Plain persistence model; the store owns all mutations.
|
||||
public string[] Keys { get; set; } = [];
|
||||
#pragma warning restore CA1819
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the protected first "date added" (UTC).
|
||||
/// </summary>
|
||||
public DateTime FirstAdded { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets when an item of this medium was last seen (UTC).
|
||||
/// </summary>
|
||||
public DateTime LastSeen { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets when the last live item of this medium disappeared (UTC); <c>null</c> while items exist.
|
||||
/// </summary>
|
||||
public DateTime? RemovedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the ids of the Jellyfin items currently representing this medium.
|
||||
/// </summary>
|
||||
#pragma warning disable CA2227 // Plain persistence model; the store owns all mutations.
|
||||
public HashSet<Guid> LiveItemIds { get; set; } = [];
|
||||
#pragma warning restore CA2227
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a display name for logs and the configuration page.
|
||||
/// </summary>
|
||||
public string DisplayName { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace Jellyfin.Plugin.RecentlyAddedGuard.Ledger;
|
||||
|
||||
/// <summary>
|
||||
/// Counters describing the ledger.
|
||||
/// </summary>
|
||||
/// <param name="Entries">All entries.</param>
|
||||
/// <param name="LiveEntries">Entries with at least one live item.</param>
|
||||
/// <param name="RemovedEntries">Entries whose items are gone (kept for the grace period).</param>
|
||||
public sealed record LedgerStatistics(int Entries, int LiveEntries, int RemovedEntries);
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Thread-safe, persistent ledger with indexes by alias key and by live item id.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// All state is guarded by <see cref="SyncRoot"/> (re-entrant). Writes are debounced and atomic:
|
||||
/// the JSON goes to a temporary file first, which then replaces the ledger file.
|
||||
/// </remarks>
|
||||
public sealed class LedgerStore : IDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// The file name of the ledger inside the plugin data folder.
|
||||
/// </summary>
|
||||
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<LedgerEntry> _entries = [];
|
||||
private readonly Dictionary<string, LedgerEntry> _byKey = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly Dictionary<Guid, LedgerEntry> _byItemId = [];
|
||||
private readonly ILogger<LedgerStore> _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;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="LedgerStore"/> class.
|
||||
/// </summary>
|
||||
/// <param name="filePath">The path of the ledger file.</param>
|
||||
/// <param name="logger">The logger.</param>
|
||||
/// <param name="timeProvider">The time provider; defaults to the system clock.</param>
|
||||
/// <param name="saveDelay">How long changes are collected before they are written; defaults to 5 seconds.</param>
|
||||
public LedgerStore(string filePath, ILogger<LedgerStore> 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the path of the ledger file.
|
||||
/// </summary>
|
||||
public string FilePath { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the initial seed has completed.
|
||||
/// </summary>
|
||||
public bool SeedCompleted
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
return _seedCompleted;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the time of the last completed seed or synchronization (UTC).
|
||||
/// </summary>
|
||||
public DateTime? LastSyncUtc
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
return _lastSyncUtc;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the lock guarding the ledger. It is re-entrant, so multi-step operations can wrap store calls.
|
||||
/// </summary>
|
||||
internal Lock SyncRoot => _sync;
|
||||
|
||||
/// <summary>
|
||||
/// Gets counters for the status page.
|
||||
/// </summary>
|
||||
/// <returns>The current statistics.</returns>
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public void Load()
|
||||
{
|
||||
TryDelete(TempPath);
|
||||
|
||||
LedgerDocument? document = null;
|
||||
if (File.Exists(FilePath))
|
||||
{
|
||||
try
|
||||
{
|
||||
using var stream = File.OpenRead(FilePath);
|
||||
document = JsonSerializer.Deserialize<LedgerDocument>(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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Requests a debounced save. Several changes within the save delay result in a single write.
|
||||
/// </summary>
|
||||
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.
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes pending changes immediately.
|
||||
/// </summary>
|
||||
public void Flush() => SaveIfDirty();
|
||||
|
||||
/// <inheritdoc />
|
||||
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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Finds all distinct entries that own at least one of the keys.
|
||||
/// </summary>
|
||||
/// <param name="keys">The keys to look up.</param>
|
||||
/// <returns>The matching entries, in key order.</returns>
|
||||
internal IReadOnlyList<LedgerEntry> FindByKeys(IEnumerable<string> keys)
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
List<LedgerEntry>? 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 ?? [];
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Finds the entry that currently lists the item as live.
|
||||
/// </summary>
|
||||
/// <param name="itemId">The item id.</param>
|
||||
/// <returns>The entry, or <c>null</c>.</returns>
|
||||
internal LedgerEntry? FindByItemId(Guid itemId)
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
return _byItemId.GetValueOrDefault(itemId);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a new entry without live items.
|
||||
/// </summary>
|
||||
/// <param name="keys">The keys; keys owned by other entries are skipped.</param>
|
||||
/// <param name="firstAdded">The first "date added".</param>
|
||||
/// <param name="now">The current time.</param>
|
||||
/// <param name="displayName">The display name.</param>
|
||||
/// <returns>The new entry.</returns>
|
||||
internal LedgerEntry Add(IEnumerable<string> 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds keys to an entry. Keys that already belong to another entry are skipped,
|
||||
/// so callers merge all matching entries first.
|
||||
/// </summary>
|
||||
/// <param name="entry">The entry.</param>
|
||||
/// <param name="keys">The keys.</param>
|
||||
internal void AddKeys(LedgerEntry entry, IEnumerable<string> keys)
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
List<string>? 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];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records the item as a live item of the entry, moving it away from any other entry.
|
||||
/// </summary>
|
||||
/// <param name="entry">The entry.</param>
|
||||
/// <param name="itemId">The item id.</param>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes the item from the live items of the entry.
|
||||
/// </summary>
|
||||
/// <param name="entry">The entry.</param>
|
||||
/// <param name="itemId">The item id.</param>
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes an entry and its index entries.
|
||||
/// </summary>
|
||||
/// <param name="entry">The entry.</param>
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Moves keys and live items of <paramref name="source"/> into <paramref name="target"/> and removes the source.
|
||||
/// Dates are left to the caller.
|
||||
/// </summary>
|
||||
/// <param name="target">The surviving entry.</param>
|
||||
/// <param name="source">The entry to merge into the target.</param>
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns a snapshot of the entry references for iteration outside the lock.
|
||||
/// </summary>
|
||||
/// <returns>The entries.</returns>
|
||||
internal IReadOnlyList<LedgerEntry> GetEntries()
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
return [.. _entries];
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records a completed seed or synchronization.
|
||||
/// </summary>
|
||||
/// <param name="now">The completion time.</param>
|
||||
internal void MarkSynced(DateTime now)
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
_seedCompleted = true;
|
||||
_lastSyncUtc = AsUtc(now);
|
||||
}
|
||||
|
||||
MarkDirty();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Normalizes a timestamp to UTC; unspecified values are taken as UTC.
|
||||
/// </summary>
|
||||
/// <param name="value">The value.</param>
|
||||
/// <returns>The UTC value.</returns>
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The on-disk format of the ledger.
|
||||
/// </summary>
|
||||
private sealed class LedgerDocument
|
||||
{
|
||||
public int SchemaVersion { get; set; }
|
||||
|
||||
public bool SeedCompleted { get; set; }
|
||||
|
||||
public DateTime? LastSyncUtc { get; set; }
|
||||
|
||||
public List<LedgerEntry> Entries { get; set; } = [];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Keeps quality upgrades from Radarr and Sonarr out of "Recently Added".
|
||||
/// </summary>
|
||||
public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
|
||||
{
|
||||
/// <summary>
|
||||
/// The permanent plugin id. Never change it: it identifies the plugin in the catalog and in installations.
|
||||
/// </summary>
|
||||
public const string PluginGuid = "6fdfedef-adde-4ac3-a873-62a057c95574";
|
||||
|
||||
private static readonly Guid _pluginId = Guid.Parse(PluginGuid);
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="Plugin"/> class.
|
||||
/// </summary>
|
||||
/// <param name="applicationPaths">The application paths.</param>
|
||||
/// <param name="xmlSerializer">The XML serializer.</param>
|
||||
public Plugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer)
|
||||
: base(applicationPaths, xmlSerializer)
|
||||
{
|
||||
Instance = this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the plugin instance; set once Jellyfin has created the plugin.
|
||||
/// </summary>
|
||||
public static Plugin? Instance { get; private set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string Name => "Recently Added Guard";
|
||||
|
||||
/// <inheritdoc />
|
||||
public override Guid Id => _pluginId;
|
||||
|
||||
/// <inheritdoc />
|
||||
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.";
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current configuration, or the defaults before the plugin is loaded.
|
||||
/// </summary>
|
||||
internal static PluginConfiguration CurrentConfiguration => Instance?.Configuration ?? new PluginConfiguration();
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<PluginPageInfo> GetPages()
|
||||
{
|
||||
yield return new PluginPageInfo
|
||||
{
|
||||
Name = "RecentlyAddedGuard",
|
||||
DisplayName = Name,
|
||||
EmbeddedResourcePath = string.Format(CultureInfo.InvariantCulture, "{0}.Configuration.configPage.html", GetType().Namespace)
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Registers the plugin services with Jellyfin's DI container.
|
||||
/// </summary>
|
||||
public class PluginServiceRegistrator : IPluginServiceRegistrator
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public void RegisterServices(IServiceCollection serviceCollection, IServerApplicationHost applicationHost)
|
||||
{
|
||||
// Runs before the plugin instance exists; factories are evaluated later, when the host starts.
|
||||
serviceCollection.AddSingleton<CorrectionLog>();
|
||||
serviceCollection.AddSingleton(sp => new LedgerStore(
|
||||
Path.Combine(GetDataFolderPath(sp), LedgerStore.DefaultFileName),
|
||||
sp.GetRequiredService<ILogger<LedgerStore>>()));
|
||||
serviceCollection.AddSingleton(sp => new Reconciler(
|
||||
sp.GetRequiredService<LedgerStore>(),
|
||||
GetOptions,
|
||||
TimeProvider.System,
|
||||
sp.GetRequiredService<ILogger<Reconciler>>()));
|
||||
serviceCollection.AddSingleton<IdentityResolver>();
|
||||
serviceCollection.AddSingleton<LibraryItemDateWriter>();
|
||||
serviceCollection.AddSingleton(sp => new GuardProcessor(
|
||||
sp.GetRequiredService<Reconciler>(),
|
||||
sp.GetRequiredService<LibraryItemDateWriter>(),
|
||||
sp.GetRequiredService<CorrectionLog>(),
|
||||
GetOptions,
|
||||
TimeProvider.System,
|
||||
sp.GetRequiredService<ILogger<GuardProcessor>>()));
|
||||
serviceCollection.AddSingleton<LedgerSynchronizer>();
|
||||
serviceCollection.AddHostedService<GuardHostedService>();
|
||||
}
|
||||
|
||||
private static GuardOptions GetOptions() => Plugin.CurrentConfiguration.ToGuardOptions();
|
||||
|
||||
private static string GetDataFolderPath(IServiceProvider serviceProvider)
|
||||
=> Plugin.Instance?.DataFolderPath
|
||||
?? Path.Combine(serviceProvider.GetRequiredService<IApplicationPaths>().PluginsPath, typeof(Plugin).Assembly.GetName().Name!);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Listens to library events and processes them on a background worker; runs the seed on first start.
|
||||
/// </summary>
|
||||
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<GuardHostedService> _logger;
|
||||
private readonly Channel<GuardEvent> _events = Channel.CreateUnbounded<GuardEvent>(
|
||||
new UnboundedChannelOptions { SingleReader = true, SingleWriter = false });
|
||||
|
||||
private readonly CancellationTokenSource _stopping = new();
|
||||
private Task _seed = Task.CompletedTask;
|
||||
private Task _worker = Task.CompletedTask;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="GuardHostedService"/> class.
|
||||
/// </summary>
|
||||
/// <param name="libraryManager">The library manager.</param>
|
||||
/// <param name="resolver">The identity resolver.</param>
|
||||
/// <param name="processor">The processor.</param>
|
||||
/// <param name="writer">The date writer.</param>
|
||||
/// <param name="store">The ledger.</param>
|
||||
/// <param name="synchronizer">The synchronizer.</param>
|
||||
/// <param name="logger">The logger.</param>
|
||||
public GuardHostedService(
|
||||
ILibraryManager libraryManager,
|
||||
IdentityResolver resolver,
|
||||
GuardProcessor processor,
|
||||
LibraryItemDateWriter writer,
|
||||
LedgerStore store,
|
||||
LedgerSynchronizer synchronizer,
|
||||
ILogger<GuardHostedService> logger)
|
||||
{
|
||||
_libraryManager = libraryManager;
|
||||
_resolver = resolver;
|
||||
_processor = processor;
|
||||
_writer = writer;
|
||||
_store = store;
|
||||
_synchronizer = synchronizer;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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<double>(), 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<Guid, GuardEvent>();
|
||||
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
|
||||
|
||||
/// <summary>
|
||||
/// A queued library event. <see cref="RemovedItem"/> is set for removals.
|
||||
/// </summary>
|
||||
private sealed record GuardEvent(Guid ItemId, BaseItem? RemovedItem);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Seeds and synchronizes the ledger with the library. Only registers items; never changes them.
|
||||
/// </summary>
|
||||
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<LedgerSynchronizer> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="LedgerSynchronizer"/> class.
|
||||
/// </summary>
|
||||
/// <param name="libraryManager">The library manager.</param>
|
||||
/// <param name="resolver">The identity resolver.</param>
|
||||
/// <param name="reconciler">The reconciler.</param>
|
||||
/// <param name="store">The ledger.</param>
|
||||
/// <param name="externalSources">External date sources (none in v1).</param>
|
||||
/// <param name="logger">The logger.</param>
|
||||
public LedgerSynchronizer(
|
||||
ILibraryManager libraryManager,
|
||||
IdentityResolver resolver,
|
||||
Reconciler reconciler,
|
||||
LedgerStore store,
|
||||
IEnumerable<IExternalDateSource> externalSources,
|
||||
ILogger<LedgerSynchronizer> logger)
|
||||
{
|
||||
_libraryManager = libraryManager;
|
||||
_resolver = resolver;
|
||||
_reconciler = reconciler;
|
||||
_store = store;
|
||||
_externalSources = externalSources.ToArray();
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <param name="progress">Reports progress from 0 to 100.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>A task that completes when the ledger is synchronized and saved.</returns>
|
||||
public async Task RunAsync(IProgress<double> progress, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(progress);
|
||||
|
||||
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
await RunCoreAsync(progress, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_gate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose() => _gate.Dispose();
|
||||
|
||||
private async Task RunCoreAsync(IProgress<double> 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<Guid>(itemIds.Count);
|
||||
var seriesCache = new Dictionary<Guid, Series?>();
|
||||
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<Guid, Series?> 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<bool> 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
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Writes "date added" through the library manager and remembers which items it is currently saving,
|
||||
/// so the resulting <c>ItemUpdated</c> events can be ignored.
|
||||
/// </summary>
|
||||
public sealed class LibraryItemDateWriter : IItemDateWriter
|
||||
{
|
||||
private readonly ILibraryManager _libraryManager;
|
||||
private readonly ConcurrentDictionary<Guid, byte> _inFlight = new();
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="LibraryItemDateWriter"/> class.
|
||||
/// </summary>
|
||||
/// <param name="libraryManager">The library manager.</param>
|
||||
public LibraryItemDateWriter(ILibraryManager libraryManager)
|
||||
{
|
||||
_libraryManager = libraryManager;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether the item is being saved by this writer right now.
|
||||
/// </summary>
|
||||
/// <param name="itemId">The item.</param>
|
||||
/// <returns><c>true</c> while a correction of the item is in progress.</returns>
|
||||
public bool IsWriting(Guid itemId) => _inFlight.ContainsKey(itemId);
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> 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 <dateadded>.
|
||||
await _libraryManager.UpdateItemAsync(item, item.GetParent(), ItemUpdateType.MetadataEdit, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
return true;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_inFlight.TryRemove(itemId, out _);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Scheduled task "Ledger synchronisieren": registers unknown items, updates live items and prunes the ledger.
|
||||
/// It never changes library items.
|
||||
/// </summary>
|
||||
public class SyncLedgerTask : IScheduledTask
|
||||
{
|
||||
/// <summary>
|
||||
/// The task key.
|
||||
/// </summary>
|
||||
public const string TaskKey = "RecentlyAddedGuardSyncLedger";
|
||||
|
||||
private readonly LedgerSynchronizer _synchronizer;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SyncLedgerTask"/> class.
|
||||
/// </summary>
|
||||
/// <param name="synchronizer">The synchronizer.</param>
|
||||
public SyncLedgerTask(LedgerSynchronizer synchronizer)
|
||||
{
|
||||
_synchronizer = synchronizer;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Name => "Ledger synchronisieren";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Key => TaskKey;
|
||||
|
||||
/// <inheritdoc />
|
||||
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.";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Category => "Recently Added Guard";
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken)
|
||||
=> _synchronizer.RunAsync(progress, cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<TaskTriggerInfo> GetDefaultTriggers()
|
||||
{
|
||||
yield return new TaskTriggerInfo
|
||||
{
|
||||
Type = TaskTriggerInfoType.DailyTrigger,
|
||||
TimeOfDayTicks = TimeSpan.FromHours(3).Ticks
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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 `<dateadded>` 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 `<Jellyfin-Config>/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_<Version>/`). 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=<Bibliothek>` 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.
|
||||
+21
@@ -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.
|
||||
@@ -0,0 +1,301 @@
|
||||
"""Integration test: Recently Added Guard in a throwaway Jellyfin 12.2 container.
|
||||
|
||||
Usage: python3 integration-test/it.py <plugin zip> [--keep]
|
||||
|
||||
Starts jellyfin/jellyfin:12.2 on 127.0.0.1:18096 with the plugin installed like the catalog does,
|
||||
creates a small library (test videos via jellyfin-ffmpeg), simulates Radarr/Sonarr upgrades and checks
|
||||
the dates and "Recently Added". Needs Docker and internet access (metadata from TMDb).
|
||||
State lives in integration-test/.run; --keep leaves the container running for inspection.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
import zipfile
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
NAME = "rag-it-jellyfin"
|
||||
IMAGE = "jellyfin/jellyfin:12.2"
|
||||
PORT = 18096
|
||||
BASE = f"http://127.0.0.1:{PORT}"
|
||||
PLUGIN_ID = "6fdfedef-adde-4ac3-a873-62a057c95574"
|
||||
CLIENT = 'MediaBrowser Client="rag-it", Device="rag-it", DeviceId="rag-it", Version="1.0"'
|
||||
FFMPEG = "/usr/lib/jellyfin-ffmpeg/ffmpeg"
|
||||
|
||||
token = None
|
||||
failures = []
|
||||
|
||||
|
||||
def log(msg):
|
||||
print(f"[{time.strftime('%H:%M:%S')}] {msg}", flush=True)
|
||||
|
||||
|
||||
def check(cond, msg):
|
||||
log(("PASS " if cond else "FAIL ") + msg)
|
||||
if not cond:
|
||||
failures.append(msg)
|
||||
|
||||
|
||||
def sh(*args, check_rc=True):
|
||||
return subprocess.run(args, check=check_rc, capture_output=True, text=True)
|
||||
|
||||
|
||||
def api(method, path, body=None, raw=False, expect=(200, 204)):
|
||||
headers = {"Authorization": CLIENT + (f', Token="{token}"' if token else "")}
|
||||
data = None
|
||||
if body is not None:
|
||||
data = json.dumps(body).encode()
|
||||
headers["Content-Type"] = "application/json"
|
||||
req = urllib.request.Request(BASE + path, data=data, headers=headers, method=method)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=60) as resp:
|
||||
payload = resp.read()
|
||||
status = resp.status
|
||||
except urllib.error.HTTPError as e:
|
||||
payload = e.read()
|
||||
status = e.code
|
||||
if status not in expect:
|
||||
raise RuntimeError(f"{method} {path} -> {status}: {payload[:400]!r}")
|
||||
if raw:
|
||||
return status, payload
|
||||
return json.loads(payload) if payload else None
|
||||
|
||||
|
||||
def make_video(path):
|
||||
sh("docker", "exec", NAME, "mkdir", "-p", os.path.dirname(path))
|
||||
sh("docker", "exec", NAME, FFMPEG, "-v", "error", "-f", "lavfi", "-i",
|
||||
"testsrc=duration=2:size=320x240:rate=10", "-c:v", "libx264", "-y", path)
|
||||
|
||||
|
||||
def rm(path):
|
||||
sh("docker", "exec", NAME, "rm", "-f", path)
|
||||
|
||||
|
||||
def wait_for(pred, what, timeout=180, interval=2):
|
||||
end = time.time() + timeout
|
||||
while time.time() < end:
|
||||
try:
|
||||
value = pred()
|
||||
if value:
|
||||
return value
|
||||
except (OSError, RuntimeError, ValueError):
|
||||
pass # server still starting
|
||||
time.sleep(interval)
|
||||
raise RuntimeError(f"timeout waiting for {what}")
|
||||
|
||||
|
||||
def scan_and_settle():
|
||||
api("POST", "/Library/Refresh")
|
||||
time.sleep(3)
|
||||
wait_for(lambda: all(t["State"] == "Idle" for t in api("GET", "/ScheduledTasks?isHidden=false")
|
||||
if t["Key"] == "RefreshLibrary"), "library scan", timeout=300)
|
||||
time.sleep(8) # plugin debounce (2 s) + metadata refresh events
|
||||
|
||||
|
||||
def items(kind):
|
||||
res = api("GET", f"/Items?IncludeItemTypes={kind}&Recursive=true&Fields=DateCreated,Path,ProviderIds")
|
||||
return res["Items"]
|
||||
|
||||
|
||||
def find(kind, *needles):
|
||||
return [i for i in items(kind) if all(n in i.get("Path", "") for n in needles)]
|
||||
|
||||
|
||||
def main():
|
||||
global token
|
||||
root = os.path.join(HERE, ".run")
|
||||
sh("docker", "rm", "-f", NAME, check_rc=False)
|
||||
shutil.rmtree(root, ignore_errors=True)
|
||||
for d in ("config/plugins", "cache", "media/movies", "media/tv"):
|
||||
os.makedirs(os.path.join(root, d), exist_ok=True)
|
||||
|
||||
# Install like the catalog does: plugins/<Name>_<version>/ with the DLL and meta.json.
|
||||
zip_path = os.path.abspath(sys.argv[1])
|
||||
plugin_dir = os.path.join(root, "config/plugins", "Recently Added Guard_1.0.0.0")
|
||||
with zipfile.ZipFile(zip_path) as zf:
|
||||
zf.extractall(plugin_dir)
|
||||
|
||||
sh("docker", "run", "-d", "--name", NAME, "-p", f"127.0.0.1:{PORT}:8096",
|
||||
"-v", f"{root}/config:/config", "-v", f"{root}/cache:/cache", "-v", f"{root}/media:/media",
|
||||
"--memory", "2g", IMAGE)
|
||||
wait_for(lambda: api("GET", "/System/Info/Public"), "server start")
|
||||
wait_for(lambda: api("GET", "/Startup/Configuration"), "startup api")
|
||||
|
||||
api("POST", "/Startup/Configuration", {"UICulture": "de-DE", "MetadataCountryCode": "DE", "PreferredMetadataLanguage": "de"})
|
||||
api("GET", "/Startup/User")
|
||||
api("POST", "/Startup/User", {"Name": "admin", "Password": "admin"})
|
||||
api("POST", "/Startup/Complete")
|
||||
auth = api("POST", "/Users/AuthenticateByName", {"Username": "admin", "Pw": "admin"})
|
||||
token = auth["AccessToken"]
|
||||
user_id = auth["User"]["Id"]
|
||||
log("wizard done, authenticated")
|
||||
|
||||
plugins = api("GET", "/Plugins")
|
||||
plugin = next((p for p in plugins if p["Id"].replace("-", "") == PLUGIN_ID.replace("-", "")), None)
|
||||
check(plugin is not None and plugin["Status"] == "Active", f"plugin loaded and active: {plugin}")
|
||||
|
||||
status = wait_for(lambda: (s := api("GET", "/RecentlyAddedGuard/Status")) and s["SeedCompleted"] and s, "seed")
|
||||
check(status["LedgerEntries"] == 0, f"seed on empty library: {status['LedgerEntries']} entries")
|
||||
|
||||
_, page = api("GET", "/web/ConfigurationPage?name=RecentlyAddedGuard", raw=True)
|
||||
check(b'data-role="page"' in page and PLUGIN_ID.encode() in page, "config page served")
|
||||
pages = api("GET", "/web/ConfigurationPages")
|
||||
check(any(p["Name"] == "RecentlyAddedGuard" for p in pages), "config page listed")
|
||||
|
||||
# Initial media
|
||||
matrix_old = "/media/movies/The Matrix (1999) {tmdb-603}/The Matrix (1999) WEBDL-720p.mkv"
|
||||
andor1_old = "/media/tv/Andor (2022) {tvdb-393189}/Season 01/Andor - S01E01 - Kassa WEBDL-720p.mkv"
|
||||
andor2 = "/media/tv/Andor (2022) {tvdb-393189}/Season 01/Andor - S01E02 - That Would Be Me WEBDL-720p.mkv"
|
||||
blade_old = "/media/movies/Blade Runner (1982)/Blade Runner (1982) WEBDL-720p.mkv"
|
||||
for p in (matrix_old, andor1_old, andor2, blade_old):
|
||||
make_video(p)
|
||||
|
||||
api("POST", "/Library/VirtualFolders?name=Filme&collectionType=movies&refreshLibrary=false",
|
||||
{"LibraryOptions": {"PathInfos": [{"Path": "/media/movies"}], "EnableRealtimeMonitor": False}})
|
||||
api("POST", "/Library/VirtualFolders?name=Serien&collectionType=tvshows&refreshLibrary=false",
|
||||
{"LibraryOptions": {"PathInfos": [{"Path": "/media/tv"}], "EnableRealtimeMonitor": False}})
|
||||
scan_and_settle()
|
||||
|
||||
movie = find("Movie", "The Matrix", "720p")[0]
|
||||
ep1 = find("Episode", "S01E01")[0]
|
||||
ep2 = find("Episode", "S01E02")[0]
|
||||
blade = find("Movie", "Blade Runner")[0]
|
||||
log(f"movie {movie['Id']} provider ids {movie.get('ProviderIds')} date {movie['DateCreated']}")
|
||||
status = api("GET", "/RecentlyAddedGuard/Status")
|
||||
check(status["LedgerEntries"] == 4, f"4 media registered by events: {status['LedgerEntries']}")
|
||||
check(status["CorrectionsSinceStart"] == 0, "no corrections for new media")
|
||||
|
||||
# Make the existing items "old" (like an NFO date or an item added long ago): lowers the ledger.
|
||||
for item, date in ((movie, "2024-01-01T00:00:00.0000000Z"), (ep1, "2024-02-01T00:00:00.0000000Z"),
|
||||
(ep2, "2024-03-01T00:00:00.0000000Z"),
|
||||
(blade, "2023-05-01T00:00:00.0000000Z")):
|
||||
dto = api("GET", f"/Users/{user_id}/Items/{item['Id']}")
|
||||
dto["DateCreated"] = date
|
||||
api("POST", f"/Items/{item['Id']}", dto)
|
||||
time.sleep(6)
|
||||
check(api("GET", "/RecentlyAddedGuard/Status")["CorrectionsSinceStart"] == 0, "backdating is no upgrade")
|
||||
|
||||
# A genuinely new movie
|
||||
make_video("/media/movies/Inception (2010) {tmdb-27205}/Inception (2010) WEBDL-1080p.mkv")
|
||||
# Upgrades with new file names (Radarr/Sonarr style): old file gone, new file there.
|
||||
rm(matrix_old)
|
||||
make_video("/media/movies/The Matrix (1999) {tmdb-603}/The Matrix (1999) Bluray-1080p.mkv")
|
||||
rm(andor1_old)
|
||||
make_video("/media/tv/Andor (2022) {tvdb-393189}/Season 01/Andor - S01E01 - Kassa Bluray-1080p.mkv")
|
||||
scan_and_settle()
|
||||
|
||||
new_movie = find("Movie", "The Matrix", "Bluray-1080p")[0]
|
||||
new_ep1 = find("Episode", "Bluray-1080p")[0]
|
||||
inception = find("Movie", "Inception")[0]
|
||||
check(new_movie["Id"] != movie["Id"], "upgraded movie is a new item (new path)")
|
||||
check(new_movie["DateCreated"].startswith("2024-01-01"), f"upgraded movie keeps date: {new_movie['DateCreated']}")
|
||||
check(new_ep1["DateCreated"].startswith("2024-02-01"), f"upgraded episode keeps date: {new_ep1['DateCreated']}")
|
||||
check(not inception["DateCreated"].startswith("2024"), f"new movie keeps its own date: {inception['DateCreated']}")
|
||||
check(not find("Movie", "The Matrix", "720p"), "old movie item removed")
|
||||
|
||||
libs = {f["Name"]: f["ItemId"] for f in api("GET", "/Library/VirtualFolders")}
|
||||
latest_movies = api("GET", f"/Items/Latest?userId={user_id}&parentId={libs['Filme']}&limit=10")
|
||||
log("latest movies: " + ", ".join(f"{i['Name']}" for i in latest_movies))
|
||||
check(latest_movies and latest_movies[0]["Id"] == inception["Id"], "Inception is the newest movie")
|
||||
latest_tv = api("GET", f"/Items/Latest?userId={user_id}&parentId={libs['Serien']}&limit=10&groupItems=false")
|
||||
log("latest episodes: " + ", ".join(f"{i['Name']} {i.get('DateCreated', '')}" for i in latest_tv))
|
||||
|
||||
status = api("GET", "/RecentlyAddedGuard/Status")
|
||||
log(f"status: {json.dumps(status, ensure_ascii=False)}")
|
||||
check(status["CorrectionsSinceStart"] == 2, f"two corrections recorded: {status['CorrectionsSinceStart']}")
|
||||
|
||||
# Same path, new file (new inode -> new creation time); the metadata refresh resets DateCreated.
|
||||
rm(andor2)
|
||||
time.sleep(2)
|
||||
make_video(andor2)
|
||||
api("POST", f"/Items/{ep2['Id']}/Refresh?metadataRefreshMode=Default&imageRefreshMode=Default&replaceAllMetadata=false")
|
||||
time.sleep(10)
|
||||
same = [i for i in items("Episode") if i["Id"] == ep2["Id"]]
|
||||
check(bool(same) and same[0]["DateCreated"].startswith("2024-03-01"),
|
||||
f"same-path replacement keeps date: {same[0]['DateCreated'] if same else 'missing'}")
|
||||
|
||||
def set_config(**changes):
|
||||
config = api("GET", f"/Plugins/{PLUGIN_ID}/Configuration")
|
||||
config.update(changes)
|
||||
api("POST", f"/Plugins/{PLUGIN_ID}/Configuration", config)
|
||||
check(all(api("GET", f"/Plugins/{PLUGIN_ID}/Configuration")[k] == v for k, v in changes.items()), f"config saved: {changes}")
|
||||
|
||||
# C: movie without id in the folder name; matched once the metadata refresh brings the TMDb id.
|
||||
rm(blade_old)
|
||||
make_video("/media/movies/Blade Runner (1982)/Blade Runner (1982) Bluray-2160p.mkv")
|
||||
scan_and_settle()
|
||||
time.sleep(10)
|
||||
blade_new = find("Movie", "Bluray-2160p")[0]
|
||||
log(f"blade runner new item provider ids {blade_new.get('ProviderIds')}")
|
||||
check(blade_new["DateCreated"].startswith("2023-05-01"), f"upgrade without id in folder name keeps date: {blade_new['DateCreated']}")
|
||||
|
||||
# D: dry run detects but does not change.
|
||||
set_config(DryRun=True)
|
||||
andor2_new = "/media/tv/Andor (2022) {tvdb-393189}/Season 01/Andor - S01E02 - That Would Be Me Bluray-1080p.mkv"
|
||||
rm(andor2)
|
||||
make_video(andor2_new)
|
||||
scan_and_settle()
|
||||
ep2_new = find("Episode", "S01E02")[0]
|
||||
status = api("GET", "/RecentlyAddedGuard/Status")
|
||||
check(not ep2_new["DateCreated"].startswith("2024"), f"dry run leaves the date: {ep2_new['DateCreated']}")
|
||||
check(status["DryRun"] and status["DryRunDetectionsSinceStart"] >= 1, f"dry run detection counted: {status['DryRunDetectionsSinceStart']}")
|
||||
set_config(DryRun=False)
|
||||
|
||||
# E: a medium returning after the grace period counts as new.
|
||||
set_config(GraceDays=0)
|
||||
inception_path = "/media/movies/Inception (2010) {tmdb-27205}/Inception (2010) WEBDL-1080p.mkv"
|
||||
rm(inception_path)
|
||||
scan_and_settle()
|
||||
make_video("/media/movies/Inception (2010) {tmdb-27205}/Inception (2010) Remux-2160p.mkv")
|
||||
scan_and_settle()
|
||||
inception_new = find("Movie", "Remux-2160p")[0]
|
||||
check(inception_new["DateCreated"] > inception["DateCreated"], f"return after grace counts as new: {inception_new['DateCreated']} > {inception['DateCreated']}")
|
||||
set_config(GraceDays=14)
|
||||
|
||||
# Scheduled task
|
||||
tasks = api("GET", "/ScheduledTasks?isHidden=false")
|
||||
task = next((t for t in tasks if t["Key"] == "RecentlyAddedGuardSyncLedger"), None)
|
||||
check(task is not None and task["Category"] == "Recently Added Guard", "scheduled task registered")
|
||||
api("POST", "/RecentlyAddedGuard/Sync", expect=(204,))
|
||||
wait_for(lambda: next(t for t in api("GET", "/ScheduledTasks?isHidden=false")
|
||||
if t["Key"] == "RecentlyAddedGuardSyncLedger").get("LastExecutionResult"), "sync task run")
|
||||
result = next(t for t in api("GET", "/ScheduledTasks?isHidden=false") if t["Key"] == "RecentlyAddedGuardSyncLedger")["LastExecutionResult"]
|
||||
check(result["Status"] == "Completed", f"sync task completed: {result['Status']}")
|
||||
|
||||
status = api("GET", "/RecentlyAddedGuard/Status")
|
||||
log(f"final status: {json.dumps(status, ensure_ascii=False)}")
|
||||
|
||||
logs = sh("docker", "logs", NAME, check_rc=False)
|
||||
lines = [l for l in (logs.stdout + logs.stderr).splitlines() if "RecentlyAddedGuard" in l or "Recently Added Guard" in l
|
||||
or "Upgrade erkannt" in l or "Ledger" in l or "ERR]" in l]
|
||||
print("\n".join(lines[-60:]))
|
||||
|
||||
ledger = os.path.join(root, "config", "plugins", "Jellyfin.Plugin.RecentlyAddedGuard", "ledger.json")
|
||||
check(os.path.exists(ledger), f"ledger persisted at {ledger}")
|
||||
|
||||
# Restart: ledger survives, no new seed
|
||||
sh("docker", "restart", NAME)
|
||||
wait_for(lambda: api("GET", "/System/Info/Public"), "restart")
|
||||
status = wait_for(lambda: api("GET", "/RecentlyAddedGuard/Status"), "status after restart")
|
||||
check(status["SeedCompleted"] and status["LedgerEntries"] >= 4, f"ledger reloaded after restart: {status['LedgerEntries']}")
|
||||
|
||||
print("\nFAILURES:" if failures else "\nALL CHECKS PASSED")
|
||||
for f in failures:
|
||||
print(" - " + f)
|
||||
return 1 if failures else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) < 2 or not sys.argv[1].endswith(".zip"):
|
||||
sys.exit(__doc__)
|
||||
rc = 1
|
||||
try:
|
||||
rc = main()
|
||||
finally:
|
||||
if "--keep" not in sys.argv:
|
||||
subprocess.run(["docker", "rm", "-f", NAME], capture_output=True)
|
||||
sys.exit(rc)
|
||||
Reference in new issue
Block a user