diff --git a/Data/DatabaseManager.Catalog.cs b/Data/DatabaseManager.Catalog.cs index 5343fd4..17801dc 100644 --- a/Data/DatabaseManager.Catalog.cs +++ b/Data/DatabaseManager.Catalog.cs @@ -774,7 +774,11 @@ public async Task UpsertBlockedItemAsync( { const string sql = @" INSERT INTO blocked_items (aio_id, tmdb_id, anilist_id, title, media_type, blocked_at, blocked_by) - VALUES (@aio_id, @tmdb_id, @anilist_id, @title, @media_type, datetime('now'), @blocked_by)"; + SELECT @aio_id, @tmdb_id, @anilist_id, @title, @media_type, datetime('now'), @blocked_by + WHERE NOT EXISTS (SELECT 1 FROM blocked_items WHERE unblocked_at IS NULL + AND ((@aio_id IS NOT NULL AND lower(aio_id)=lower(@aio_id)) + OR (@tmdb_id IS NOT NULL AND lower(tmdb_id)=lower(@tmdb_id)) + OR (@anilist_id IS NOT NULL AND lower(anilist_id)=lower(@anilist_id))))"; await ExecuteWriteAsync(sql, cmd => { diff --git a/InfiniteDrive.csproj b/InfiniteDrive.csproj index 9d3297c..ef7ce1a 100755 --- a/InfiniteDrive.csproj +++ b/InfiniteDrive.csproj @@ -2,9 +2,9 @@ net8.0 - 0.42.12.0 - 0.42.12.0 - 0.42.12.0 + 0.42.13.0 + 0.42.13.0 + 0.42.13.0 enable InfiniteDrive InfiniteDrive diff --git a/README.md b/README.md index e3d0951..a3ab57c 100755 --- a/README.md +++ b/README.md @@ -9,7 +9,9 @@ imports titles and repairs missing files. A stream will appear. Probably. -**Current release: 0.42.12**, built and tested against **Emby 4.10.0.40**. +**Current released version: 0.42.12** + +This branch prepares 0.42.13 title block controls. It has not been deployed to production. The candidate targets **Emby 4.10.0.40**. ## How we run it diff --git a/Services/TitleBlockService.cs b/Services/TitleBlockService.cs new file mode 100644 index 0000000..73a41ea --- /dev/null +++ b/Services/TitleBlockService.cs @@ -0,0 +1,183 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using InfiniteDrive.Data; +using InfiniteDrive.Models; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Controller.Net; +using MediaBrowser.Controller.Session; +using MediaBrowser.Model.Services; + +namespace InfiniteDrive.Services; + +[Route("/InfiniteDrive/Admin/TitleBlocks", "POST", Summary = "Admin: block stable title identities; optionally archive verified managed STRMs")] +public sealed class TitleBlockRequest : IReturn +{ + public List Identities { get; set; } = new(); + public string ImdbId { get; set; } = ""; + public string Title { get; set; } = ""; + public string MediaType { get; set; } = ""; + public bool ArchiveStreams { get; set; } +} + +[Route("/InfiniteDrive/Admin/TitleBlocks", "GET", Summary = "Admin: title blocking capability; starts no work")] +public sealed class TitleBlockCapabilities : IReturn { } + +public sealed class TitleBlockService : IService, IRequiresRequest +{ + private readonly IAuthorizationContext _auth; + private readonly IApplicationPaths _paths; + private readonly ISessionManager _sessions; + public IRequest Request { get; set; } = null!; + public TitleBlockService(IAuthorizationContext auth, IApplicationPaths paths, ISessionManager sessions) + { _auth = auth; _paths = paths; _sessions = sessions; } + + public object Get(TitleBlockCapabilities request) => AdminGuard.RequireAdmin(_auth, Request) + ?? new { Available = true, MaxTitles = 25, ArchiveVerifiedStreams = true }; + + public async Task Post(TitleBlockRequest request) + { + var deny = AdminGuard.RequireAdmin(_auth, Request); + if (deny != null) return deny; + var db = Plugin.Instance.DatabaseManager; + var cfg = Plugin.Instance.Configuration; + // Clearing files waits for an idle playback lane, never stops a session. + if (request.ArchiveStreams && _sessions.Sessions.Any(s => s.NowPlayingItem != null)) + return new { Status = "playback_active", Message = "Try again when playback has finished." }; + var targets = (request.Identities ?? new List()).Distinct(StringComparer.Ordinal).ToList(); + if (targets.Count > 25 || (targets.Count == 0 && !TitleBlockPolicy.ValidImdb(request.ImdbId))) + return new { Status = "invalid_selection" }; + if (targets.Count > 0) await db.EnsureImportCoverageAsync(); + var outcomes = new List(); + foreach (var identity in targets) + { + var imdb = TitleBlockPolicy.ImdbFromIdentity(identity); + if (imdb == null) + { outcomes.Add(new { Identity = identity, Status = "identity_requires_review" }); continue; } + var coverage = await db.GetImportCoverageAsync(identity); + var aliases = new List(); + foreach (var id in coverage?.CatalogIds ?? new List()) + { var row = await db.GetImportCatalogByIdAsync(id); if (row != null) aliases.Add(row); } + if (coverage == null) + { + aliases = await db.GetImportCatalogAliasesAsync(new CatalogItem { AioId = imdb, + MediaType = identity.StartsWith("series:", StringComparison.Ordinal) ? "series" : "movie" }); + coverage = new ImportCoverage { Identity = identity, Title = aliases.FirstOrDefault()?.Title ?? imdb }; + } + if (aliases.Count == 0 || !aliases.All(x => TitleBlockPolicy.Matches(identity, x) && ImportInventory.CompatibleIdentity(aliases[0], x))) + { outcomes.Add(new { Identity = identity, Status = "identity_requires_review" }); continue; } + if (!await db.IsBlockedAsync(imdb, null, null)) + await db.UpsertBlockedItemAsync(imdb, null, null, coverage.Title, + identity.StartsWith("series:", StringComparison.Ordinal) ? "series" : "movie", "admin"); + var archived = 0; var skipped = 0; + if (request.ArchiveStreams) + { + // Files from disputed/owned aliases never qualify for cleanup. + if (coverage.Exclusion.Length != 0 || coverage.SnapshotStatus != "success" || + aliases.Any(x => x.LocalSource == "library" || x.ItemState == ItemState.Retired)) + skipped = coverage.Items.Sum(x => x.Versions.Count); + else + { + await ImportReconciliationService.MutationGate.WaitAsync(); + try + { + var liveAliases = await db.GetImportCatalogAliasesAsync(aliases[0]); + if (liveAliases.Any(x => x.LocalSource == "library" || x.ItemState == ItemState.Retired || + !TitleBlockPolicy.Matches(identity, x) || !ImportInventory.CompatibleIdentity(aliases[0], x))) + { + outcomes.Add(new { Identity = identity, Status = "blocked", Archived = 0, + Preserved = coverage.Items.Sum(x => x.Versions.Count) }); + continue; + } + var archiveRoot = Path.Combine(_paths.DataPath, "InfiniteDrive", "blocked-streams", Guid.NewGuid().ToString("N")); + foreach (var version in coverage.Items.SelectMany(x => x.Versions).DistinctBy(v => v.Path)) + { + if (_sessions.Sessions.Any(s => s.NowPlayingItem != null)) { skipped++; continue; } + var result = TitleBlockPolicy.Archive(version, + new[] { cfg.SyncPathMovies, cfg.SyncPathShows, cfg.SyncPathAnime }, archiveRoot); + if (result == "archived") archived++; else if (result != "already_absent") skipped++; + } + } + finally { ImportReconciliationService.MutationGate.Release(); } + } + } + // Keep coverage, retries, saved selections, watch state and attempt rows. + // An unblock authorizes natural repair; it never resets those records. + outcomes.Add(new { Identity = identity, Status = "blocked", Archived = archived, Preserved = skipped }); + } + if (targets.Count == 0) + { + if (request.MediaType is not ("movie" or "series") || (request.Title ?? "").Length is < 1 or > 200 || request.ArchiveStreams) + return new { Status = "invalid_selection" }; + var imdb = request.ImdbId.ToLowerInvariant(); + if (!await db.IsBlockedAsync(imdb, null, null)) + await db.UpsertBlockedItemAsync(imdb, null, null, request.Title, request.MediaType, "admin"); + outcomes.Add(new { Identity = request.MediaType + ":imdb:" + imdb, Status = "blocked", Archived = 0, Preserved = 0 }); + } + return new { Status = "complete", Outcomes = outcomes }; + } +} + +internal static class TitleBlockPolicy +{ + internal static bool ValidImdb(string value) => Regex.IsMatch(value ?? "", "^tt[0-9]{5,12}$", RegexOptions.IgnoreCase); + internal static string? ImdbFromIdentity(string identity) + { + var parts = (identity ?? "").Split(':'); + return parts.Length == 3 && parts[0] is "movie" or "series" && parts[1] == "imdb" && ValidImdb(parts[2]) + ? parts[2].ToLowerInvariant() : null; + } + internal static bool Matches(string identity, CatalogItem item) => ImportInventory.Aliases(item).Contains(identity, StringComparer.Ordinal); + internal static string Archive(ImportVersionEvidence version, IEnumerable roots, string destination) + { + try + { + if (!Path.IsPathFullyQualified(version.Path) || Path.GetExtension(version.Path) != ".strm" || version.SizeBytes is not > 0) + return "unverified"; + var file = Path.GetFullPath(version.Path); + var root = roots.Where(x => !string.IsNullOrWhiteSpace(x)).Select(Path.GetFullPath) + .FirstOrDefault(x => file.StartsWith(x.TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar, StringComparison.Ordinal)); + if (root == null) return "outside_managed_library"; + var archive = Path.GetFullPath(destination); + if (roots.Where(x => !string.IsNullOrWhiteSpace(x)).Select(Path.GetFullPath).Any(x => + archive == x || archive.StartsWith(x.TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar, StringComparison.Ordinal))) + return "archive_inside_library"; + // Refuse both leaf and parent symlinks, including the configured root. + var cursor = new FileInfo(file) as FileSystemInfo; + while (cursor != null) + { + if (cursor.LinkTarget != null) return "symlink"; + if (cursor.FullName == root) break; + cursor = Directory.GetParent(cursor.FullName); + } + if (!File.Exists(file)) return "already_absent"; + if (new FileInfo(file).Length > 65536) return "unverified"; + var bytes = File.ReadAllBytes(file); + if (bytes.Length > 65536) return "unverified"; + var hash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(Encoding.UTF8.GetString(bytes).Trim()))).ToLowerInvariant(); + if (hash != version.UrlSha256) return "changed_evidence"; + Directory.CreateDirectory(destination); + if (!OperatingSystem.IsWindows()) + File.SetUnixFileMode(destination, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + var name = Guid.NewGuid().ToString("N") + ".strm"; + // Private archive is outside watched media. Copy+verify first because + // DataPath and media may be different filesystems; retain recovery map. + var target = Path.Combine(destination, name); + File.WriteAllBytes(target, bytes); + if (!OperatingSystem.IsWindows()) File.SetUnixFileMode(target, UnixFileMode.UserRead | UnixFileMode.UserWrite); + if (!File.ReadAllBytes(target).SequenceEqual(bytes)) return "archive_verification_failed"; + File.AppendAllText(Path.Combine(destination, "restore.jsonl"), JsonSerializer.Serialize(new { OriginalPath = file, ArchivedFile = name }) + "\n"); + if (!OperatingSystem.IsWindows()) File.SetUnixFileMode(Path.Combine(destination, "restore.jsonl"), UnixFileMode.UserRead | UnixFileMode.UserWrite); + if (!File.ReadAllBytes(file).SequenceEqual(bytes)) return "changed_evidence"; + File.Delete(file); + return "archived"; + } + catch { return "preserved_on_error"; } + } +} diff --git a/Tests/TitleBlockTests.cs b/Tests/TitleBlockTests.cs new file mode 100644 index 0000000..8c9c494 --- /dev/null +++ b/Tests/TitleBlockTests.cs @@ -0,0 +1,82 @@ +using System; +using System.IO; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using System.Threading.Tasks; +using InfiniteDrive.Data; +using InfiniteDrive.Models; +using InfiniteDrive.Services; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace InfiniteDrive.Tests; +public sealed class TitleBlockTests +{ + [Theory] + [InlineData("series:imdb:tt0123338", "tt0123338")] + [InlineData("movie:imdb:tt0000001", "tt0000001")] + [InlineData("series:tmdb:12345", null)] + [InlineData("series:imdb:tt123", null)] + [InlineData("series:imdb:../../etc", null)] + public void OnlyStableExplicitImdbIdentitiesQualify(string identity, string? expected) + => Assert.Equal(expected, TitleBlockPolicy.ImdbFromIdentity(identity)); + + [Fact] public void ArchiveKeepsExactRecoveryCopyAndOnlyRemovesVerifiedStrm() + { + using var h = new Files(); var version = h.Version(100); + Assert.Equal("archived", TitleBlockPolicy.Archive(version,new[]{h.Managed},h.Archive)); + Assert.False(File.Exists(version.Path)); + Assert.Equal(h.Content, File.ReadAllText(Directory.GetFiles(h.Archive,"*.strm").Single())); + Assert.Contains(version.Path, File.ReadAllText(Path.Combine(h.Archive,"restore.jsonl"))); + Assert.True(File.Exists(Path.Combine(h.Managed,"owned.mkv"))); + if (!OperatingSystem.IsWindows()) Assert.Equal(UnixFileMode.UserRead|UnixFileMode.UserWrite, + File.GetUnixFileMode(Directory.GetFiles(h.Archive,"*.strm").Single())); + } + [Theory] + [InlineData(null, false)] + [InlineData(0L, false)] + [InlineData(100L, true)] + public void UnknownSizeOrChangedEvidenceIsNeverDeleted(long? size, bool changed) + { + using var h = new Files(); var v = h.Version(size); + if (changed) File.AppendAllText(v.Path,"changed"); + Assert.NotEqual("archived",TitleBlockPolicy.Archive(v,new[]{h.Managed},h.Archive)); + Assert.True(File.Exists(v.Path)); Assert.False(Directory.Exists(h.Archive)); + } + [Fact] public void OutsideRootsAndSymlinkParentsArePreserved() + { + using var h = new Files(); var v = h.Version(100); + Assert.Equal("outside_managed_library",TitleBlockPolicy.Archive(v,new[]{h.Root+"/other"},h.Archive)); + var link=Path.Combine(h.Root,"link"); Directory.CreateSymbolicLink(link,h.Managed); + v=v with { Path=Path.Combine(link,"episode.strm") }; + Assert.Equal("symlink",TitleBlockPolicy.Archive(v,new[]{link},h.Archive)); + Assert.True(File.Exists(v.Path)); + } + [Fact] public void FailedArchiveNeverDeletesSource() + { + using var h = new Files(); var v=h.Version(100); File.WriteAllText(h.Archive,"not a directory"); + Assert.Equal("preserved_on_error",TitleBlockPolicy.Archive(v,new[]{h.Managed},h.Archive)); + Assert.True(File.Exists(v.Path)); + } + [Fact] public async Task RepeatedAndConcurrentBlocksKeepOneActiveRowAndUnblockHistory() + { + using var h=new Files(); SqliteTestRuntime.EnsureInitialized(); var db=new DatabaseManager(h.Root,NullLogger.Instance); db.Initialise(); + await Task.WhenAll(Enumerable.Range(0,8).Select(_=>db.UpsertBlockedItemAsync("tt0123338",null,null,"60 Minutes","series","admin"))); + Assert.True(await db.IsBlockedAsync("TT0123338",null,null)); + var blocks=await db.GetBlockedItemsAsync(0, 100); Assert.Single(blocks); + await db.UnblockItemAsync(blocks[0].Id,"admin"); Assert.False(await db.IsBlockedAsync("tt0123338",null,null)); + await db.UpsertBlockedItemAsync("tt0123338",null,null,"60 Minutes","series","admin"); + Assert.Single(await db.GetBlockedItemsAsync(0, 100)); + } + private sealed class Files : IDisposable + { + public string Root=Path.Combine(Path.GetTempPath(),"title-block-"+Guid.NewGuid().ToString("N")); + public string Managed=>Path.Combine(Root,"managed"); public string Archive=>Path.Combine(Root,"archive"); + public string Content="https://example.invalid/test-fixture\n"; + public Files(){Directory.CreateDirectory(Managed);File.WriteAllText(Path.Combine(Managed,"owned.mkv"),"owned");} + public ImportVersionEvidence Version(long? size) { var path=Path.Combine(Managed,"episode.strm");File.WriteAllText(path,Content); + return new ImportVersionEvidence(path,Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(Content.Trim()))).ToLowerInvariant(),"fixture","1080p",null,size,DateTimeOffset.UtcNow); } + public void Dispose(){Directory.Delete(Root,true);} + } +} diff --git a/docs/README.md b/docs/README.md index dae782b..bfadee8 100644 --- a/docs/README.md +++ b/docs/README.md @@ -6,6 +6,7 @@ - [Configuration](configuration.md): destinations, providers, quality and recovery. - [Discover](USER_DISCOVER_UI.md): browser discovery and saved titles. - [External lists](EXTERNAL_LISTS.md): system and user catalog sources. +- [Title blocks](title-blocks.md): administrator whole-title blocking and recoverable stream cleanup. - [Import recovery](import-reconciliation.md): Observe/Repair, limits and retention. - [Troubleshooting](troubleshooting.md): diagnosis and recovery. - [Security](SECURITY.md): private configuration, STRMs and administrative access. @@ -26,4 +27,4 @@ old test plans and dated reports. These are not current configuration instructions. The implementation and current guides take precedence over historical claims. -Updated October 1, 2026 for the 0.42.12 source tree. +Updated October 1, 2026 for the 0.42.13 candidate source tree. diff --git a/docs/title-blocks.md b/docs/title-blocks.md new file mode 100644 index 0000000..fab73e9 --- /dev/null +++ b/docs/title-blocks.md @@ -0,0 +1,15 @@ +# Whole-title blocks (0.42.13 candidate) + +This candidate is not a production deployment. It builds on the released 0.42.12 maintenance recovery changes. Production on October 1 remains 0.42.11 until an explicitly approved guarded cutover. + +The administrator-only GET/POST `/InfiniteDrive/Admin/TitleBlocks` uses confirmed `movie:imdb:tt...` or `series:imdb:tt...` identities. A batch accepts at most 25 titles, revalidates catalogue aliases and refuses conflicting media/provider identities. A confirmed IMDb ID, title and media type can also create a future block before any catalogue entry exists. Plain display names are never identities. + +A title block is the native persistent `blocked_items` record. Existing publication guards serialize against it, so catalogue reimport and in-flight Marvin work cannot republish a blocked title. Inserts are idempotent while active; native unblocking retains historical rows. It does not clear saves, watch state, failures, retries, attempt allowance or acquisition state. The older Admin/BlockItems removal route also clears saves; it is not used for this feature. Native Settings restrictions can unblock the native block. Unblocking authorizes natural repair after existing backoff, not an immediate restore/reset. + +Optional cleanup archives only managed `.strm` files supported by positive exact-size and current URL-hash evidence. It refuses owned/retired aliases, disputed coverage, paths outside configured managed roots, symlinks, changed hashes, unknown sizes and an archive inside a watched root. Active playback prevents cleanup. It copies and verifies before unlinking, rechecks the source bytes, and preserves a private per-file restore map under the Emby data directory `InfiniteDrive/blocked-streams/`. It never recursively deletes folders or NFOs. Empty series metadata can remain until Emby's normal library update; a successful archive is not playback verification. + +The Vault UI can sort by whole-series eligible missing counts and review whole titles, regardless of episode filters. Missing counts are dated coverage observations, not a fresh filesystem census. Retained failed-refresh files, future/ineligible/disputed episodes are not counted missing; absent/disputed coverage is unknown. Browse reads make no provider searches. The portal uses normal Emby administrator authentication, HttpOnly SameSite cookies, bounded same-origin actions and fixed native routes; it never grants writes through a monitoring service key. Installed releases without this endpoint keep actions unavailable. + +Validation: synthetic exact-copy/recovery, size/hash/root/symlink/error preservation, stable ID validation and concurrent idempotent native block tests run in the pinned ABI build. Portal tests cover anonymous refusal, cross-origin/oversized rejection, administrator versus regular-user login, fixed-target routing and private-field exclusion. October 1 isolated Emby 4.10.0.40 native QA verified initialization, administrator capability, anonymous refusal, idempotent future blocks, blocking an unprocessed catalogue title, one verified archive with a private restore map, unknown-size/changed/owned preservation, conflicting aliases refused without a block, and unchanged coverage/backoff/attempt rows. The QA container was removed; production and frozen beta were untouched. All 240 tests and the pinned ABI publish passed. No production title is blocked merely to test the UI. + +Rollback: restore individual archived STRMs from the private restore map only after reconciling current paths, ownership and playback. Unblock through the native block list if future imports should resume. Revert the candidate DLL through a fresh guarded deployment; never restore older databases or reset import ledgers. diff --git a/plugin.json b/plugin.json index 1ad31fd..bf1e74d 100755 --- a/plugin.json +++ b/plugin.json @@ -5,7 +5,7 @@ "overview": "InfiniteDrive discovers your AIOStreams catalog, writes .strm files, and resolves debrid URLs on demand. Like the Infinite Improbability Drive: a stream will appear. Probably. No external processes needed.", "owner": "InfiniteDrive", "category": "General", - "version": "0.42.12.0", + "version": "0.42.13.0", "targetAbi": "4.10.0.40", "framework": "net8.0", "imageUrl": ""