From ef2780e2bef8b2e2e465cef4bf674a46decad734 Mon Sep 17 00:00:00 2001 From: mahoshojoHCG Date: Sat, 29 Aug 2026 22:46:20 +0800 Subject: [PATCH 01/37] feat: add verified backup restore and data transfer --- .github/workflows/backup-restore.yml | 165 +++++ Containerfile | 10 +- .../ILogicalDataTransferRepository.cs | 16 + .../DataRepository/LogicalDataTransfer.cs | 105 +++ .../LogicalDataTransferPostgreSqlTests.cs | 351 ++++++++++ .../LogicalDataTransferControllerTests.cs | 148 +++++ .../External/AppJsonSerializerContext.cs | 5 + .../External/LogicalDataTransfer.cs | 13 + .../LogicalDataTransferController.cs | 221 +++++++ .../ApplicationContextModelSnapshot.cs | 1 - .../Models/ApplicationContext.cs | 4 + SecondDimensionWatcherReDive/Program.cs | 1 + .../LogicalDataTransferRepository.cs | 585 +++++++++++++++++ deployments/podman-compose.yml | 3 + deployments/sdw-backup | 605 ++++++++++++++++++ deployments/tests/backup-restore-smoke.sh | 80 +++ docs/backup-restore.md | 145 +++++ docs/container-deployment.md | 2 + docs/server-deployment.md | 2 + packaging/backup.env | 17 + packaging/nfpm.yaml | 29 + packaging/postinstall.sh | 7 +- packaging/sdw-backup.service | 16 + packaging/sdw-backup.timer | 11 + 24 files changed, 2539 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/backup-restore.yml create mode 100644 SecondDimensionWatcherReDive.Framework/DataRepository/ILogicalDataTransferRepository.cs create mode 100644 SecondDimensionWatcherReDive.Framework/DataRepository/LogicalDataTransfer.cs create mode 100644 SecondDimensionWatcherReDive.IntegrationTest/PostgreSql/LogicalDataTransferPostgreSqlTests.cs create mode 100644 SecondDimensionWatcherReDive.Test/LogicalDataTransferControllerTests.cs create mode 100644 SecondDimensionWatcherReDive/Controllers/External/LogicalDataTransfer.cs create mode 100644 SecondDimensionWatcherReDive/Controllers/LogicalDataTransferController.cs create mode 100644 SecondDimensionWatcherReDive/Repositories/LogicalDataTransferRepository.cs create mode 100755 deployments/sdw-backup create mode 100755 deployments/tests/backup-restore-smoke.sh create mode 100644 docs/backup-restore.md create mode 100644 packaging/backup.env create mode 100644 packaging/sdw-backup.service create mode 100644 packaging/sdw-backup.timer diff --git a/.github/workflows/backup-restore.yml b/.github/workflows/backup-restore.yml new file mode 100644 index 0000000..f0c664c --- /dev/null +++ b/.github/workflows/backup-restore.yml @@ -0,0 +1,165 @@ +name: Backup Restore Drill + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: backup-restore-${{ github.ref }} + cancel-in-progress: true + +jobs: + drill: + runs-on: ubuntu-latest + timeout-minutes: 15 + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: sdw_source + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U postgres -d sdw_source" + --health-interval 5s + --health-timeout 5s + --health-retries 12 + env: + PGHOST: 127.0.0.1 + PGPORT: 5432 + PGUSER: postgres + PGPASSWORD: postgres + PGDATABASE: sdw_source + ConnectionStrings__sdw: Host=127.0.0.1;Port=5432;Username=postgres;Password=postgres;Database=sdw_source + JwtSecret: backup-restore-drill-jwt-secret-with-at-least-32-bytes + ASPNETCORE_ENVIRONMENT: Production + WORK_DIR: ${{ runner.temp }}/sdw-backup-drill + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-dotnet@v6 + with: + dotnet-version: "10.0.x" + + - name: Install PostgreSQL client + run: sudo apt-get update && sudo apt-get install -y postgresql-client + + - name: Build backend + run: >- + dotnet build SecondDimensionWatcherReDive/SecondDimensionWatcherReDive.csproj + -c Release /p:Version="$(tr -d '[:space:]' < VERSION)" + + - name: Migrate and health-check source instance + shell: bash + run: | + set -Eeuo pipefail + mkdir -p "$WORK_DIR/source/keys" "$WORK_DIR/plugins/example" "$WORK_DIR/backups" + printf '{}\n' > "$WORK_DIR/source/password.json" + printf 'DisableCors: true\n' > "$WORK_DIR/source/appsettings.yml" + printf '{"name":"example","version":"1"}\n' > "$WORK_DIR/plugins/example/manifest.json" + export PasswordFile="$WORK_DIR/source/password.json" + export DataProtection__KeyRingPath="$WORK_DIR/source/keys" + export Config="$WORK_DIR/source/appsettings.yml" + dotnet run --project SecondDimensionWatcherReDive -c Release --no-build \ + --urls http://127.0.0.1:5097 >"$WORK_DIR/source-app.log" 2>&1 & + app_pid=$! + trap 'kill "$app_pid" 2>/dev/null || true' EXIT + for attempt in {1..60}; do + if curl --silent --fail http://127.0.0.1:5097/api/auth/allowRegister >/dev/null; then + break + fi + sleep 1 + done + curl --fail http://127.0.0.1:5097/api/auth/allowRegister >/dev/null + kill "$app_pid" + wait "$app_pid" || true + trap - EXIT + psql --no-psqlrc --command \ + "INSERT INTO \"Feeds\" (\"Id\", \"Url\", \"Name\", \"CreatedAt\") VALUES ('11111111-1111-1111-1111-111111111111', 'https://example.com/feed', 'drill', now())" + + - name: Create, verify, and reject corruption + shell: bash + run: | + set -Eeuo pipefail + archive=$(deployments/sdw-backup create \ + --output "$WORK_DIR/backups" \ + --config "$WORK_DIR/source/appsettings.yml" \ + --password-file "$WORK_DIR/source/password.json" \ + --key-ring "$WORK_DIR/source/keys" \ + --plugin-dir "$WORK_DIR/plugins" \ + --retention-days 7 \ + --app-version "$(tr -d '[:space:]' < VERSION)") + deployments/sdw-backup verify "$archive" + cp "$archive" "$WORK_DIR/corrupt.tar.gz" + archive_size=$(stat --format=%s "$WORK_DIR/corrupt.tar.gz") + printf 'CORRUPT' | dd of="$WORK_DIR/corrupt.tar.gz" bs=1 \ + seek=$((archive_size / 2)) conv=notrunc status=none + if deployments/sdw-backup verify "$WORK_DIR/corrupt.tar.gz"; then + echo "corrupted archive unexpectedly verified" >&2 + exit 1 + fi + printf '%s\n' "$archive" > "$WORK_DIR/archive-path" + + - name: Restore into a fresh database and health-check it + shell: bash + run: | + set -Eeuo pipefail + createdb sdw_restore + export PGDATABASE=sdw_restore + export ConnectionStrings__sdw='Host=127.0.0.1;Port=5432;Username=postgres;Password=postgres;Database=sdw_restore' + archive=$(cat "$WORK_DIR/archive-path") + mkdir -p "$WORK_DIR/restored" + psql --no-psqlrc --command \ + 'CREATE TABLE restore_guard (value integer NOT NULL); INSERT INTO restore_guard VALUES (1)' + if deployments/sdw-backup restore "$WORK_DIR/corrupt.tar.gz" \ + --confirm-replace --expected-version "$(tr -d '[:space:]' < VERSION)"; then + echo "corrupted restore unexpectedly started" >&2 + exit 1 + fi + test "$(psql --no-psqlrc --tuples-only --no-align --command \ + 'SELECT value FROM restore_guard')" = "1" + if deployments/sdw-backup restore "$archive" \ + --confirm-replace --expected-version 999.0.0; then + echo "incompatible restore unexpectedly started" >&2 + exit 1 + fi + test "$(psql --no-psqlrc --tuples-only --no-align --command \ + 'SELECT value FROM restore_guard')" = "1" + deployments/sdw-backup restore "$archive" \ + --confirm-replace \ + --expected-version "$(tr -d '[:space:]' < VERSION)" \ + --config-destination "$WORK_DIR/restored/appsettings.yml" \ + --password-destination "$WORK_DIR/restored/password.json" \ + --key-ring-destination "$WORK_DIR/restored/keys" \ + --plugin-destination "$WORK_DIR/restored/plugins" \ + --safety-directory "$WORK_DIR/backups" + test "$(psql --no-psqlrc --tuples-only --no-align --command 'SELECT count(*) FROM "Feeds"')" = "1" + cmp "$WORK_DIR/source/password.json" "$WORK_DIR/restored/password.json" + cmp "$WORK_DIR/source/appsettings.yml" "$WORK_DIR/restored/appsettings.yml" + cmp "$WORK_DIR/plugins/example/manifest.json" \ + "$WORK_DIR/restored/plugins/example/manifest.json" + export PasswordFile="$WORK_DIR/restored/password.json" + export DataProtection__KeyRingPath="$WORK_DIR/restored/keys" + export Config="$WORK_DIR/restored/appsettings.yml" + dotnet run --project SecondDimensionWatcherReDive -c Release --no-build \ + --urls http://127.0.0.1:5098 >"$WORK_DIR/restored-app.log" 2>&1 & + app_pid=$! + trap 'kill "$app_pid" 2>/dev/null || true' EXIT + for attempt in {1..60}; do + if curl --silent --fail http://127.0.0.1:5098/api/auth/allowRegister >/dev/null; then + break + fi + sleep 1 + done + curl --fail http://127.0.0.1:5098/api/auth/allowRegister >/dev/null + kill "$app_pid" + wait "$app_pid" || true + trap - EXIT diff --git a/Containerfile b/Containerfile index 72b49a9..4bc08c8 100644 --- a/Containerfile +++ b/Containerfile @@ -10,18 +10,26 @@ RUN yarn build FROM mcr.microsoft.com/dotnet/sdk:10.0 AS backend-build WORKDIR /src COPY SecondDimensionWatcherReDive.slnx . +COPY VERSION . COPY SecondDimensionWatcherReDive.Framework/ SecondDimensionWatcherReDive.Framework/ COPY SecondDimensionWatcherReDive/ SecondDimensionWatcherReDive/ COPY Plugins/ Plugins/ COPY Share/ Share/ COPY --from=frontend-build /app/dist SecondDimensionWatcherReDive/wwwroot/ RUN dotnet restore SecondDimensionWatcherReDive/SecondDimensionWatcherReDive.csproj -RUN dotnet publish SecondDimensionWatcherReDive/SecondDimensionWatcherReDive.csproj -c Release -o /app --no-restore +RUN dotnet publish SecondDimensionWatcherReDive/SecondDimensionWatcherReDive.csproj \ + -c Release -o /app --no-restore \ + /p:Version="$(tr -d '[:space:]' < VERSION)" # Stage 3: Runtime FROM mcr.microsoft.com/dotnet/aspnet:10.0 WORKDIR /app +RUN apt-get update \ + && apt-get install -y --no-install-recommends postgresql-client \ + && rm -rf /var/lib/apt/lists/* COPY --from=backend-build /app . +COPY deployments/sdw-backup /usr/local/bin/sdw-backup +COPY VERSION /usr/lib/sdw-redive/VERSION EXPOSE 8080 # Optional: read-only NFSv4 export (set Nfs:Enabled=true to activate; publish port at run time). EXPOSE 2049 diff --git a/SecondDimensionWatcherReDive.Framework/DataRepository/ILogicalDataTransferRepository.cs b/SecondDimensionWatcherReDive.Framework/DataRepository/ILogicalDataTransferRepository.cs new file mode 100644 index 0000000..2186b9d --- /dev/null +++ b/SecondDimensionWatcherReDive.Framework/DataRepository/ILogicalDataTransferRepository.cs @@ -0,0 +1,16 @@ +namespace SecondDimensionWatcherReDive.Framework.DataRepository; + +public interface ILogicalDataTransferRepository +{ + Task ExportAsync( + LogicalDataCategory categories, + Guid userId, + string applicationVersion, + CancellationToken cancellationToken); + + Task ImportAsync( + LogicalDataBundle bundle, + LogicalImportConflictStrategy conflictStrategy, + Guid userId, + CancellationToken cancellationToken); +} diff --git a/SecondDimensionWatcherReDive.Framework/DataRepository/LogicalDataTransfer.cs b/SecondDimensionWatcherReDive.Framework/DataRepository/LogicalDataTransfer.cs new file mode 100644 index 0000000..d8d4028 --- /dev/null +++ b/SecondDimensionWatcherReDive.Framework/DataRepository/LogicalDataTransfer.cs @@ -0,0 +1,105 @@ +using System.Text.Json.Serialization; + +namespace SecondDimensionWatcherReDive.Framework.DataRepository; + +[Flags] +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum LogicalDataCategory +{ + None = 0, + Feeds = 1, + AutomationPolicies = 2, + FileNameRules = 4, + MetadataCorrections = 8, + Playback = 16, + All = Feeds | AutomationPolicies | FileNameRules | MetadataCorrections | Playback +} + +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum LogicalImportConflictStrategy +{ + Skip, + Overwrite, + Fail +} + +public sealed record LogicalFeed( + Guid Id, + string Url, + string? Name, + DateTimeOffset CreatedAt); + +public sealed record LogicalAutomationPolicy( + string FeedUrl, + IReadOnlyList SubtitleGroups, + IReadOnlyList Resolutions, + IReadOnlyList Codecs, + IReadOnlyList Languages, + long? MinSizeBytes, + long? MaxSizeBytes, + IReadOnlyList ExcludedKeywords, + SubscriptionAutomationMode Mode, + DateTimeOffset CreatedAt, + DateTimeOffset UpdatedAt); + +public sealed record LogicalFileNameRule( + Guid Id, + string AnimationTmdbId, + string AnimationName, + string AnimationOriginalName, + string? AnimationPosterPath, + string Pattern, + string? Description, + DateTimeOffset CreatedAt); + +public sealed record LogicalMetadataCorrection( + Guid OperationId, + string ReleaseDownloadUrl, + string ReleaseTitle, + DateTimeOffset ReleasePublishTime, + string AnimationTmdbId, + string AnimationName, + string AnimationOriginalName, + string? AnimationPosterPath, + string Description, + int? Season, + int? Episode, + string? GroupName, + DateTimeOffset AppliedAt); + +public sealed record LogicalPlaybackProgress( + string VirtualPath, + double PositionSeconds, + double DurationSeconds, + bool IsWatched, + DateTimeOffset UpdatedAt, + DateTimeOffset? WatchedAt); + +public sealed record LogicalPlaybackPreferences( + string? SubtitleLanguage, + string? SubtitleTrackLabel, + string? AudioLanguage, + string? AudioTrackLabel, + bool AutoPlayNext, + DateTimeOffset UpdatedAt); + +public sealed record LogicalDataBundle( + int FormatVersion, + DateTimeOffset ExportedAtUtc, + string ApplicationVersion, + LogicalDataCategory Categories, + IReadOnlyList Feeds, + IReadOnlyList AutomationPolicies, + IReadOnlyList FileNameRules, + IReadOnlyList MetadataCorrections, + IReadOnlyList PlaybackProgress, + LogicalPlaybackPreferences? PlaybackPreferences); + +public sealed record LogicalImportResult( + int Added, + int Updated, + int Skipped, + int Conflicts, + IReadOnlyList Messages); + +public sealed class LogicalDataImportConflictException(string message) : InvalidOperationException(message); diff --git a/SecondDimensionWatcherReDive.IntegrationTest/PostgreSql/LogicalDataTransferPostgreSqlTests.cs b/SecondDimensionWatcherReDive.IntegrationTest/PostgreSql/LogicalDataTransferPostgreSqlTests.cs new file mode 100644 index 0000000..d39c863 --- /dev/null +++ b/SecondDimensionWatcherReDive.IntegrationTest/PostgreSql/LogicalDataTransferPostgreSqlTests.cs @@ -0,0 +1,351 @@ +using Microsoft.EntityFrameworkCore; +using SecondDimensionWatcherReDive.Framework.DataRepository; +using SecondDimensionWatcherReDive.Repositories; +using Testcontainers.PostgreSql; +using Models = SecondDimensionWatcherReDive.Models; + +namespace SecondDimensionWatcherReDive.IntegrationTest.PostgreSql; + +[TestClass] +public sealed class LogicalDataTransferPostgreSqlTests +{ + private static readonly PostgreSqlContainer Database = new PostgreSqlBuilder("postgres:17-alpine") + .WithDatabase("sdw_transfer_tests") + .WithUsername("postgres") + .WithPassword("postgres") + .Build(); + + private static DbContextOptions Options = null!; + + [ClassInitialize] + public static async Task InitializeAsync(TestContext _) + { + await Database.StartAsync(); + Options = new DbContextOptionsBuilder() + .UseNpgsql(Database.GetConnectionString()) + .Options; + } + + [ClassCleanup] + public static async Task CleanupAsync() => await Database.DisposeAsync(); + + [TestInitialize] + public async Task ResetAsync() + { + await using var context = new Models.ApplicationContext(Options); + await context.Database.EnsureDeletedAsync(); + await context.Database.MigrateAsync(); + } + + [TestMethod] + public async Task FeedsPoliciesAndRulesRoundTripWithExplicitConflictStrategies() + { + LogicalDataBundle bundle; + await using (var source = new Models.ApplicationContext(Options)) + { + var feed = new Models.Feed + { + Id = Guid.NewGuid(), + Url = "https://example.com/feed.xml", + Name = "Example", + CreatedAt = DateTimeOffset.UtcNow.AddDays(-2) + }; + var animation = new Models.Animation + { + Id = Guid.NewGuid(), + TmdbId = "tv:123", + Name = "Example Show", + OriginalName = "Example Show", + PosterPath = "/poster.jpg" + }; + source.AddRange( + feed, + animation, + new Models.SubscriptionAutomationPolicy + { + FeedId = feed.Id, + Feed = feed, + SubtitleGroups = ["Group"], + Resolutions = ["1080p"], + Codecs = ["HEVC"], + Languages = ["zh-Hans"], + ExcludedKeywords = ["batch"], + Mode = SubscriptionAutomationMode.ManualConfirm, + CreatedAt = feed.CreatedAt, + UpdatedAt = DateTimeOffset.UtcNow + }, + new Models.FileNameRegexRule + { + Id = Guid.NewGuid(), + AnimationId = animation.Id, + Pattern = @"E(?\d+)", + Description = "episode", + CreatedAt = DateTimeOffset.UtcNow + }); + await source.SaveChangesAsync(); + bundle = await new LogicalDataTransferRepository(source).ExportAsync( + LogicalDataCategory.Feeds | + LogicalDataCategory.AutomationPolicies | + LogicalDataCategory.FileNameRules, + Guid.Empty, + "1.0.0", + CancellationToken.None); + + await source.SubscriptionAutomationPolicies.ExecuteDeleteAsync(); + await source.FileNameRegexRules.ExecuteDeleteAsync(); + await source.Feeds.ExecuteDeleteAsync(); + await source.Animations.ExecuteDeleteAsync(); + } + + await using var target = new Models.ApplicationContext(Options); + var repository = new LogicalDataTransferRepository(target); + var first = await repository.ImportAsync( + bundle, + LogicalImportConflictStrategy.Skip, + Guid.Empty, + CancellationToken.None); + Assert.AreEqual(3, first.Added); + Assert.AreEqual(1, await target.Feeds.CountAsync()); + Assert.AreEqual(1, await target.SubscriptionAutomationPolicies.CountAsync()); + Assert.AreEqual(1, await target.FileNameRegexRules.CountAsync()); + + var repeated = await repository.ImportAsync( + bundle, + LogicalImportConflictStrategy.Skip, + Guid.Empty, + CancellationToken.None); + Assert.AreEqual(3, repeated.Skipped); + Assert.AreEqual(1, repeated.Conflicts); + + var changed = bundle with + { + Feeds = [bundle.Feeds[0] with { Name = "Renamed" }], + AutomationPolicies = + [bundle.AutomationPolicies[0] with { Mode = SubscriptionAutomationMode.AutoDownload }], + FileNameRules = [bundle.FileNameRules[0] with { Description = "updated" }] + }; + var overwritten = await repository.ImportAsync( + changed, + LogicalImportConflictStrategy.Overwrite, + Guid.Empty, + CancellationToken.None); + Assert.AreEqual(3, overwritten.Updated); + Assert.AreEqual("Renamed", (await target.Feeds.SingleAsync()).Name); + Assert.AreEqual(SubscriptionAutomationMode.AutoDownload, + (await target.SubscriptionAutomationPolicies.SingleAsync()).Mode); + Assert.AreEqual("updated", (await target.FileNameRegexRules.SingleAsync()).Description); + } + + [TestMethod] + public async Task MetadataAndPlaybackUseStableReleaseAndVirtualPathKeys() + { + var publishedAt = DateTimeOffset.UtcNow.AddDays(-1); + const string DownloadUrl = "https://example.com/release.torrent"; + const string VirtualPath = "/Example/Group/Example S01E02.mkv"; + LogicalDataBundle bundle; + + await using (var source = new Models.ApplicationContext(Options)) + { + var animation = Animation("tv:456", "Correct Show"); + var group = new Models.AnimationGroup { Id = Guid.NewGuid(), Name = "Correct Group" }; + var info = Release(Guid.NewGuid(), DownloadUrl, publishedAt); + info.Animation = animation; + info.Group = group; + info.Description = "corrected description"; + info.Season = 1; + info.Episode = 2; + info.MetadataStatus = MetadataReviewStatus.Reviewed; + info.MetadataReviewedAt = DateTimeOffset.UtcNow; + info.StateVersion = 1; + var mapping = Mapping(info.Id, VirtualPath); + source.AddRange(animation, group, info, mapping); + await source.SaveChangesAsync(); + + var operation = new Models.MetadataReviewOperation + { + Id = Guid.NewGuid(), + AnimationInfoId = info.Id, + AnimationInfo = info, + State = MetadataReviewOperationState.Applied, + CreatedAt = DateTimeOffset.UtcNow.AddMinutes(-2), + ExpiresAt = DateTimeOffset.UtcNow.AddDays(1), + BaseVersion = 0, + ProposedAnimationTmdbId = animation.TmdbId, + ProposedAnimationName = animation.Name, + ProposedAnimationOriginalName = animation.OriginalName, + ProposedAnimationPosterPath = animation.PosterPath, + ProposedDescription = info.Description, + ProposedSeason = info.Season, + ProposedEpisode = info.Episode, + ProposedGroupName = group.Name, + AppliedAt = DateTimeOffset.UtcNow.AddMinutes(-1), + AppliedVersion = 1, + PreviousDescription = "uncorrected", + PreviousMetadataStatus = MetadataReviewStatus.LowConfidence, + PreviousIsAiProcessed = true, + PreviousAiRetryCount = 0 + }; + info.CurrentMetadataReviewOperationId = operation.Id; + source.AddRange( + operation, + new Models.MetadataReviewOperation + { + Id = Guid.NewGuid(), + AnimationInfoId = info.Id, + AnimationInfo = info, + State = MetadataReviewOperationState.Applied, + CreatedAt = DateTimeOffset.UtcNow.AddDays(-2), + ExpiresAt = DateTimeOffset.UtcNow.AddDays(-1), + BaseVersion = 0, + AppliedAt = DateTimeOffset.UtcNow.AddDays(-1), + AppliedVersion = 0 + }, + new Models.PlaybackProgress + { + Id = Guid.NewGuid(), + UserId = Guid.Empty, + AnimationInfoId = info.Id, + VirtualPath = VirtualPath, + PositionSeconds = 600, + DurationSeconds = 1_440, + UpdatedAt = DateTimeOffset.UtcNow + }); + await source.SaveChangesAsync(); + await source.Database.ExecuteSqlInterpolatedAsync( + $""" + INSERT INTO "PlaybackPreferences" + ("UserId", "SubtitleLanguage", "AutoPlayNext", "UpdatedAt") + VALUES ({Guid.Empty}, {"zh-Hans"}, {false}, {DateTimeOffset.UtcNow}) + """); + Assert.AreEqual(1, await source.PlaybackPreferences.AsNoTracking().CountAsync()); + + bundle = await new LogicalDataTransferRepository(source).ExportAsync( + LogicalDataCategory.MetadataCorrections | LogicalDataCategory.Playback, + Guid.Empty, + "1.0.0", + CancellationToken.None); + Assert.IsNotNull(bundle.PlaybackPreferences); + Assert.AreEqual(1, bundle.MetadataCorrections.Count); + Assert.AreEqual(operation.Id, bundle.MetadataCorrections[0].OperationId); + + await source.MetadataReviewOperations.ExecuteDeleteAsync(); + await source.PlaybackProgresses.ExecuteDeleteAsync(); + await source.PlaybackPreferences.ExecuteDeleteAsync(); + await source.FileMappings.ExecuteDeleteAsync(); + await source.AnimationInfo.ExecuteDeleteAsync(); + await source.Animations.ExecuteDeleteAsync(); + await source.AnimationGroups.ExecuteDeleteAsync(); + } + + var targetInfoId = Guid.NewGuid(); + await using var target = new Models.ApplicationContext(Options); + var targetInfo = Release(targetInfoId, DownloadUrl, publishedAt); + targetInfo.Description = "uncorrected"; + target.AddRange(targetInfo, Mapping(targetInfoId, VirtualPath)); + await target.SaveChangesAsync(); + + var result = await new LogicalDataTransferRepository(target).ImportAsync( + bundle, + LogicalImportConflictStrategy.Skip, + Guid.Empty, + CancellationToken.None); + + Assert.AreEqual(0, result.Skipped, string.Join(Environment.NewLine, result.Messages)); + Assert.AreEqual(3, result.Added); + var importedInfo = await target.AnimationInfo + .Include(info => info.Animation) + .Include(info => info.Group) + .SingleAsync(); + Assert.AreEqual("corrected description", importedInfo.Description); + Assert.AreEqual("tv:456", importedInfo.Animation!.TmdbId); + Assert.AreEqual("Correct Group", importedInfo.Group!.Name); + Assert.IsNotNull(importedInfo.CurrentMetadataReviewOperationId); + var progress = await target.PlaybackProgresses.SingleAsync(); + Assert.AreEqual(targetInfoId, progress.AnimationInfoId); + Assert.AreEqual(600, progress.PositionSeconds); + var preferences = await target.PlaybackPreferences.SingleAsync(); + Assert.AreEqual(Guid.Empty, preferences.UserId); + Assert.IsFalse(preferences.AutoPlayNext); + } + + [TestMethod] + public async Task FailConflictStrategyRollsBackEarlierItems() + { + await using (var seed = new Models.ApplicationContext(Options)) + { + seed.Feeds.Add(new Models.Feed + { + Id = Guid.NewGuid(), + Url = "https://example.com/existing.xml", + Name = "Existing", + CreatedAt = DateTimeOffset.UtcNow + }); + await seed.SaveChangesAsync(); + } + + var bundle = new LogicalDataBundle( + 1, + DateTimeOffset.UtcNow, + "1.0.0", + LogicalDataCategory.Feeds, + [ + new LogicalFeed(Guid.NewGuid(), "https://example.com/new.xml", "New", DateTimeOffset.UtcNow), + new LogicalFeed(Guid.NewGuid(), "https://example.com/existing.xml", "Changed", DateTimeOffset.UtcNow) + ], + [], + [], + [], + [], + null); + + await using (var importing = new Models.ApplicationContext(Options)) + { + await Assert.ThrowsAsync(() => + new LogicalDataTransferRepository(importing).ImportAsync( + bundle, + LogicalImportConflictStrategy.Fail, + Guid.Empty, + CancellationToken.None)); + } + + await using var verification = new Models.ApplicationContext(Options); + Assert.AreEqual(1, await verification.Feeds.CountAsync()); + Assert.AreEqual("Existing", (await verification.Feeds.SingleAsync()).Name); + } + + private static Models.Animation Animation(string tmdbId, string name) => + new() + { + Id = Guid.NewGuid(), + TmdbId = tmdbId, + Name = name, + OriginalName = name, + PosterPath = "/poster.jpg" + }; + + private static Models.AnimationInfo Release( + Guid id, + string downloadUrl, + DateTimeOffset publishedAt) => + new() + { + Id = id, + Title = "[Group] Example - 02", + Description = "uncorrected", + PublishTime = publishedAt, + DownloadUrl = downloadUrl, + DownloadType = "torrent", + IsAiProcessed = true, + MetadataStatus = MetadataReviewStatus.LowConfidence + }; + + private static Models.FileMapping Mapping(Guid animationInfoId, string virtualPath) => + new() + { + Id = Guid.NewGuid(), + AnimationInfoId = animationInfoId, + VirtualPath = virtualPath, + PhysicalPath = "/media/example.mkv", + FileStore = "local" + }; +} diff --git a/SecondDimensionWatcherReDive.Test/LogicalDataTransferControllerTests.cs b/SecondDimensionWatcherReDive.Test/LogicalDataTransferControllerTests.cs new file mode 100644 index 0000000..56c5744 --- /dev/null +++ b/SecondDimensionWatcherReDive.Test/LogicalDataTransferControllerTests.cs @@ -0,0 +1,148 @@ +using System.Security.Cryptography; +using System.Text.Json; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Moq; +using SecondDimensionWatcherReDive.Controllers; +using SecondDimensionWatcherReDive.Controllers.External; +using SecondDimensionWatcherReDive.Framework.DataRepository; + +namespace SecondDimensionWatcherReDive.Test; + +[TestClass] +public sealed class LogicalDataTransferControllerTests +{ + [TestMethod] + public async Task ExportSelectsCategoriesAndReturnsChecksummedEnvelope() + { + var repository = new Mock(); + repository.Setup(item => item.ExportAsync( + LogicalDataCategory.Feeds | LogicalDataCategory.Playback, + Guid.Empty, + It.IsAny(), + It.IsAny())) + .ReturnsAsync((LogicalDataCategory categories, Guid _, string version, CancellationToken _) => + Bundle(version, categories)); + var controller = Controller(repository.Object); + + var action = await controller.ExportAsync("feeds,playback", CancellationToken.None); + + var file = (FileContentResult)action; + Assert.AreEqual("application/json", file.ContentType); + StringAssert.StartsWith(file.FileDownloadName!, "sdw-logical-export-"); + var envelope = JsonSerializer.Deserialize( + file.FileContents, + AppJsonSerializerContext.Default.LogicalDataExportEnvelope)!; + Assert.AreEqual(LogicalDataCategory.Feeds | LogicalDataCategory.Playback, + envelope.Data.Categories); + Assert.AreEqual(Digest(envelope.Data), envelope.Sha256); + } + + [TestMethod] + public async Task ImportRejectsChecksumMismatchBeforeRepositoryWrite() + { + var repository = new Mock(); + var controller = Controller(repository.Object); + var request = new LogicalDataImportRequest( + Bundle("1.0.0", LogicalDataCategory.Feeds), + new string('0', 64), + LogicalImportConflictStrategy.Skip); + + var action = await controller.ImportAsync(request, CancellationToken.None); + + Assert.IsInstanceOfType(action); + repository.Verify(item => item.ImportAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ImportRejectsUnknownConflictStrategyBeforeRepositoryWrite() + { + var repository = new Mock(); + var controller = Controller(repository.Object); + var bundle = Bundle("1.0.0", LogicalDataCategory.Feeds); + var request = new LogicalDataImportRequest( + bundle, + Digest(bundle), + (LogicalImportConflictStrategy)999); + + var action = await controller.ImportAsync(request, CancellationToken.None); + + Assert.IsInstanceOfType(action); + repository.Verify(item => item.ImportAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ImportPassesValidatedBundleAndConflictStrategy() + { + var repository = new Mock(); + repository.Setup(item => item.ExportAsync( + It.IsAny(), Guid.Empty, It.IsAny(), + It.IsAny())) + .ReturnsAsync((LogicalDataCategory categories, Guid _, string version, CancellationToken _) => + Bundle(version, categories)); + repository.Setup(item => item.ImportAsync( + It.IsAny(), + LogicalImportConflictStrategy.Overwrite, + Guid.Empty, + It.IsAny())) + .ReturnsAsync(new LogicalImportResult(1, 0, 0, 0, [])); + var controller = Controller(repository.Object); + var export = (FileContentResult)await controller.ExportAsync("feeds", CancellationToken.None); + var envelope = JsonSerializer.Deserialize( + export.FileContents, + AppJsonSerializerContext.Default.LogicalDataExportEnvelope)!; + + var action = await controller.ImportAsync( + new LogicalDataImportRequest( + envelope.Data, + envelope.Sha256, + LogicalImportConflictStrategy.Overwrite), + CancellationToken.None); + + var result = (LogicalImportResult)((OkObjectResult)action).Value!; + Assert.AreEqual(1, result.Added); + } + + private static LogicalDataTransferController Controller( + ILogicalDataTransferRepository repository) => + new(repository) + { + ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext() + } + }; + + private static LogicalDataBundle Bundle( + string version, + LogicalDataCategory categories) => + new( + 1, + DateTimeOffset.UtcNow, + version, + categories, + categories.HasFlag(LogicalDataCategory.Feeds) + ? [new LogicalFeed(Guid.NewGuid(), "https://example.com/feed", "Example", DateTimeOffset.UtcNow)] + : [], + [], + [], + [], + [], + null); + + private static string Digest(LogicalDataBundle bundle) + { + var bytes = JsonSerializer.SerializeToUtf8Bytes( + bundle, + AppJsonSerializerContext.Default.LogicalDataBundle); + return Convert.ToHexString(SHA256.HashData(bytes)); + } +} diff --git a/SecondDimensionWatcherReDive/Controllers/External/AppJsonSerializerContext.cs b/SecondDimensionWatcherReDive/Controllers/External/AppJsonSerializerContext.cs index 0f20309..b61931c 100644 --- a/SecondDimensionWatcherReDive/Controllers/External/AppJsonSerializerContext.cs +++ b/SecondDimensionWatcherReDive/Controllers/External/AppJsonSerializerContext.cs @@ -1,4 +1,5 @@ using System.Text.Json.Serialization; +using SecondDimensionWatcherReDive.Framework.DataRepository; namespace SecondDimensionWatcherReDive.Controllers.External; @@ -65,4 +66,8 @@ namespace SecondDimensionWatcherReDive.Controllers.External; [JsonSerializable(typeof(QueueMediaLibraryScanResponse))] [JsonSerializable(typeof(ApplicationSettingsResponse))] [JsonSerializable(typeof(PatchApplicationSettingsRequest))] +[JsonSerializable(typeof(LogicalDataBundle))] +[JsonSerializable(typeof(LogicalDataExportEnvelope))] +[JsonSerializable(typeof(LogicalDataImportRequest))] +[JsonSerializable(typeof(LogicalImportResult))] internal partial class AppJsonSerializerContext : JsonSerializerContext; diff --git a/SecondDimensionWatcherReDive/Controllers/External/LogicalDataTransfer.cs b/SecondDimensionWatcherReDive/Controllers/External/LogicalDataTransfer.cs new file mode 100644 index 0000000..00e8f5f --- /dev/null +++ b/SecondDimensionWatcherReDive/Controllers/External/LogicalDataTransfer.cs @@ -0,0 +1,13 @@ +using System.ComponentModel.DataAnnotations; +using SecondDimensionWatcherReDive.Framework.DataRepository; + +namespace SecondDimensionWatcherReDive.Controllers.External; + +internal sealed record LogicalDataExportEnvelope( + LogicalDataBundle Data, + string Sha256); + +internal sealed record LogicalDataImportRequest( + [property: Required] LogicalDataBundle? Data, + [property: Required] string? Sha256, + [property: Required] LogicalImportConflictStrategy? ConflictStrategy); diff --git a/SecondDimensionWatcherReDive/Controllers/LogicalDataTransferController.cs b/SecondDimensionWatcherReDive/Controllers/LogicalDataTransferController.cs new file mode 100644 index 0000000..3a6f7fc --- /dev/null +++ b/SecondDimensionWatcherReDive/Controllers/LogicalDataTransferController.cs @@ -0,0 +1,221 @@ +using System.Globalization; +using System.Reflection; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using SecondDimensionWatcherReDive.Framework.DataRepository; +using SecondDimensionWatcherReDive.Framework.Inference; + +namespace SecondDimensionWatcherReDive.Controllers; + +[ApiController] +[Route("api/data-transfer")] +[Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)] +internal sealed class LogicalDataTransferController( + ILogicalDataTransferRepository repository) : ControllerBase +{ + private const int SupportedFormatVersion = 1; + private const int MaximumItemsPerCategory = 10_000; + private static readonly Guid CurrentUserId = Guid.Empty; + private static readonly string ApplicationVersion = + typeof(LogicalDataTransferController).Assembly + .GetCustomAttribute()?.InformationalVersion + ?? typeof(LogicalDataTransferController).Assembly.GetName().Version?.ToString() + ?? "0.0.0"; + + [HttpGet("export")] + public async Task ExportAsync( + [FromQuery] string categories = "all", + CancellationToken cancellationToken = default) + { + if (!TryParseCategories(categories, out var selected)) + return BadRequest(new { error = "Unknown data category." }); + + var bundle = await repository.ExportAsync( + selected, + CurrentUserId, + ApplicationVersion, + cancellationToken); + var envelope = new External.LogicalDataExportEnvelope(bundle, Digest(bundle)); + var bytes = JsonSerializer.SerializeToUtf8Bytes( + envelope, + External.AppJsonSerializerContext.Default.LogicalDataExportEnvelope); + var timestamp = bundle.ExportedAtUtc.UtcDateTime.ToString("yyyyMMddTHHmmssZ", CultureInfo.InvariantCulture); + Response.Headers.CacheControl = "private,no-store"; + return File(bytes, "application/json", $"sdw-logical-export-{timestamp}.json"); + } + + [HttpPost("import")] + [RequestSizeLimit(10 * 1024 * 1024)] + public async Task ImportAsync( + [FromBody] External.LogicalDataImportRequest request, + CancellationToken cancellationToken) + { + if (request.Data is null || request.ConflictStrategy is null || + string.IsNullOrWhiteSpace(request.Sha256)) + return BadRequest(new { error = "Data, sha256 and conflictStrategy are required." }); + if (!Enum.IsDefined(request.ConflictStrategy.Value)) + return BadRequest(new { error = "Unknown import conflict strategy." }); + + var actualDigest = Encoding.ASCII.GetBytes(Digest(request.Data)); + var expectedDigest = Encoding.ASCII.GetBytes(request.Sha256.Trim().ToUpperInvariant()); + if (actualDigest.Length != expectedDigest.Length || + !CryptographicOperations.FixedTimeEquals(actualDigest, expectedDigest)) + return BadRequest(new { error = "Logical export checksum mismatch." }); + if (!IsCompatible(request.Data, out var error)) + return BadRequest(new { error }); + + try + { + var result = await repository.ImportAsync( + request.Data, + request.ConflictStrategy.Value, + CurrentUserId, + cancellationToken); + return Ok(result); + } + catch (LogicalDataImportConflictException exception) + { + return Conflict(new { error = exception.Message }); + } + } + + private static bool TryParseCategories(string value, out LogicalDataCategory categories) + { + categories = LogicalDataCategory.None; + foreach (var token in value.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)) + { + var category = token.ToLowerInvariant() switch + { + "all" => LogicalDataCategory.All, + "feeds" => LogicalDataCategory.Feeds, + "automation" or "automation-policies" => LogicalDataCategory.AutomationPolicies, + "rules" or "filename-rules" => LogicalDataCategory.FileNameRules, + "metadata" or "metadata-corrections" => LogicalDataCategory.MetadataCorrections, + "playback" => LogicalDataCategory.Playback, + _ => LogicalDataCategory.None + }; + if (category == LogicalDataCategory.None) + return false; + categories |= category; + } + return categories != LogicalDataCategory.None; + } + + private static bool IsCompatible(LogicalDataBundle bundle, out string error) + { + if (string.IsNullOrWhiteSpace(bundle.ApplicationVersion) || + bundle.Feeds is null || bundle.AutomationPolicies is null || + bundle.FileNameRules is null || bundle.MetadataCorrections is null || + bundle.PlaybackProgress is null) + { + error = "Logical export is incomplete."; + return false; + } + if (bundle.FormatVersion != SupportedFormatVersion) + { + error = $"Unsupported logical export format {bundle.FormatVersion}."; + return false; + } + if (bundle.Categories == LogicalDataCategory.None || + (bundle.Categories & ~LogicalDataCategory.All) != 0) + { + error = "Logical export contains unknown categories."; + return false; + } + var importedMajor = Major(bundle.ApplicationVersion); + var currentMajor = Major(ApplicationVersion); + if (importedMajor < 0 || currentMajor < 0 || importedMajor != currentMajor) + { + error = "Logical export was created by an incompatible application major version."; + return false; + } + if (bundle.Feeds.Count > MaximumItemsPerCategory || + bundle.AutomationPolicies.Count > MaximumItemsPerCategory || + bundle.FileNameRules.Count > MaximumItemsPerCategory || + bundle.MetadataCorrections.Count > MaximumItemsPerCategory || + bundle.PlaybackProgress.Count > MaximumItemsPerCategory) + { + error = $"A logical export category exceeds {MaximumItemsPerCategory} items."; + return false; + } + if ((!bundle.Categories.HasFlag(LogicalDataCategory.Feeds) && bundle.Feeds.Count > 0) || + (!bundle.Categories.HasFlag(LogicalDataCategory.AutomationPolicies) && bundle.AutomationPolicies.Count > 0) || + (!bundle.Categories.HasFlag(LogicalDataCategory.FileNameRules) && bundle.FileNameRules.Count > 0) || + (!bundle.Categories.HasFlag(LogicalDataCategory.MetadataCorrections) && bundle.MetadataCorrections.Count > 0) || + (!bundle.Categories.HasFlag(LogicalDataCategory.Playback) && + (bundle.PlaybackProgress.Count > 0 || bundle.PlaybackPreferences is not null))) + { + error = "Logical export data does not match its declared categories."; + return false; + } + if (bundle.PlaybackProgress.Any(item => + item.PositionSeconds < 0 || item.DurationSeconds < 0 || + !double.IsFinite(item.PositionSeconds) || !double.IsFinite(item.DurationSeconds))) + { + error = "Logical export contains invalid playback values."; + return false; + } + if (bundle.Feeds.Any(item => item.Id == Guid.Empty || !IsSafeHttpUrl(item.Url)) || + bundle.AutomationPolicies.Any(item => + !IsSafeHttpUrl(item.FeedUrl) || + item.SubtitleGroups is null || item.Resolutions is null || item.Codecs is null || + item.Languages is null || item.ExcludedKeywords is null || + !Enum.IsDefined(item.Mode) || item.MinSizeBytes < 0 || item.MaxSizeBytes < 0 || + (item.MinSizeBytes is not null && item.MaxSizeBytes is not null && + item.MinSizeBytes > item.MaxSizeBytes)) || + bundle.FileNameRules.Any(item => + item.Id == Guid.Empty || + string.IsNullOrWhiteSpace(item.AnimationTmdbId) || + string.IsNullOrWhiteSpace(item.AnimationName) || + string.IsNullOrWhiteSpace(item.AnimationOriginalName) || + !FileNameRegexMatcher.TryCreateRegex(item.Pattern, out _, out _)) || + bundle.FileNameRules + .GroupBy(item => item.AnimationTmdbId, StringComparer.Ordinal) + .Any(group => group.Count() > FileNameRegexMatcher.MaxRulesPerAnimation) || + bundle.MetadataCorrections.Any(item => + item.OperationId == Guid.Empty || + string.IsNullOrWhiteSpace(item.ReleaseDownloadUrl) || + string.IsNullOrWhiteSpace(item.ReleaseTitle) || + string.IsNullOrWhiteSpace(item.AnimationTmdbId) || + string.IsNullOrWhiteSpace(item.AnimationName) || + string.IsNullOrWhiteSpace(item.AnimationOriginalName) || + item.Description is null || item.Season < 0 || item.Episode < 0) || + bundle.PlaybackProgress.Any(item => + string.IsNullOrWhiteSpace(item.VirtualPath) || + !item.VirtualPath.StartsWith("/", StringComparison.Ordinal)) || + bundle.PlaybackPreferences is { } preferences && + (preferences.SubtitleLanguage?.Length > 64 || + preferences.AudioLanguage?.Length > 64 || + preferences.SubtitleTrackLabel?.Length > 128 || + preferences.AudioTrackLabel?.Length > 128)) + { + error = "Logical export contains invalid identifiers or paths."; + return false; + } + error = string.Empty; + return true; + } + + private static bool IsSafeHttpUrl(string? value) => + Uri.TryCreate(value, UriKind.Absolute, out var uri) && + (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps) && + string.IsNullOrEmpty(uri.UserInfo); + + private static int Major(string version) + { + var value = version.Split('+', 2)[0].Split('-', 2)[0]; + return Version.TryParse(value, out var parsed) ? parsed.Major : -1; + } + + private static string Digest(LogicalDataBundle bundle) + { + var bytes = JsonSerializer.SerializeToUtf8Bytes( + bundle, + External.AppJsonSerializerContext.Default.LogicalDataBundle); + return Convert.ToHexString(SHA256.HashData(bytes)); + } +} diff --git a/SecondDimensionWatcherReDive/Migrations/ApplicationContextModelSnapshot.cs b/SecondDimensionWatcherReDive/Migrations/ApplicationContextModelSnapshot.cs index 8126b9e..7364757 100644 --- a/SecondDimensionWatcherReDive/Migrations/ApplicationContextModelSnapshot.cs +++ b/SecondDimensionWatcherReDive/Migrations/ApplicationContextModelSnapshot.cs @@ -674,7 +674,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) modelBuilder.Entity("SecondDimensionWatcherReDive.Models.PlaybackPreference", b => { b.Property("UserId") - .ValueGeneratedOnAdd() .HasColumnType("uuid"); b.Property("AudioLanguage") diff --git a/SecondDimensionWatcherReDive/Models/ApplicationContext.cs b/SecondDimensionWatcherReDive/Models/ApplicationContext.cs index 59764ac..4e0ac4f 100644 --- a/SecondDimensionWatcherReDive/Models/ApplicationContext.cs +++ b/SecondDimensionWatcherReDive/Models/ApplicationContext.cs @@ -207,6 +207,10 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) modelBuilder.Entity() .HasKey(preference => preference.UserId); + modelBuilder.Entity() + .Property(preference => preference.UserId) + .ValueGeneratedNever(); + modelBuilder.Entity() .Property(preference => preference.SubtitleLanguage) .HasMaxLength(64); diff --git a/SecondDimensionWatcherReDive/Program.cs b/SecondDimensionWatcherReDive/Program.cs index 80f5f19..00c04c7 100644 --- a/SecondDimensionWatcherReDive/Program.cs +++ b/SecondDimensionWatcherReDive/Program.cs @@ -277,6 +277,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddSingleton(); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/SecondDimensionWatcherReDive/Repositories/LogicalDataTransferRepository.cs b/SecondDimensionWatcherReDive/Repositories/LogicalDataTransferRepository.cs new file mode 100644 index 0000000..d9ef62b --- /dev/null +++ b/SecondDimensionWatcherReDive/Repositories/LogicalDataTransferRepository.cs @@ -0,0 +1,585 @@ +using Microsoft.EntityFrameworkCore; +using System.Security.Cryptography; +using System.Text; +using SecondDimensionWatcherReDive.Framework.DataRepository; + +namespace SecondDimensionWatcherReDive.Repositories; + +public sealed class LogicalDataTransferRepository(Models.ApplicationContext context) + : ILogicalDataTransferRepository +{ + private const int FormatVersion = 1; + + public async Task ExportAsync( + LogicalDataCategory categories, + Guid userId, + string applicationVersion, + CancellationToken cancellationToken) + { + var feeds = categories.HasFlag(LogicalDataCategory.Feeds) + ? await context.Feeds.AsNoTracking() + .OrderBy(feed => feed.CreatedAt) + .Select(feed => new LogicalFeed(feed.Id, feed.Url, feed.Name, feed.CreatedAt)) + .ToListAsync(cancellationToken) + : []; + + var policies = categories.HasFlag(LogicalDataCategory.AutomationPolicies) + ? await context.SubscriptionAutomationPolicies.AsNoTracking() + .OrderBy(policy => policy.Feed.Url) + .Select(policy => new LogicalAutomationPolicy( + policy.Feed.Url, + policy.SubtitleGroups, + policy.Resolutions, + policy.Codecs, + policy.Languages, + policy.MinSizeBytes, + policy.MaxSizeBytes, + policy.ExcludedKeywords, + policy.Mode, + policy.CreatedAt, + policy.UpdatedAt)) + .ToListAsync(cancellationToken) + : []; + + var rules = categories.HasFlag(LogicalDataCategory.FileNameRules) + ? await (from rule in context.FileNameRegexRules.AsNoTracking() + join animation in context.Animations.AsNoTracking() + on rule.AnimationId equals animation.Id + orderby animation.TmdbId, rule.CreatedAt + select new LogicalFileNameRule( + rule.Id, + animation.TmdbId, + animation.Name, + animation.OriginalName, + animation.PosterPath, + rule.Pattern, + rule.Description, + rule.CreatedAt)) + .ToListAsync(cancellationToken) + : []; + + var corrections = categories.HasFlag(LogicalDataCategory.MetadataCorrections) + ? await context.MetadataReviewOperations.AsNoTracking() + .Where(operation => operation.State == MetadataReviewOperationState.Applied + && operation.AppliedAt != null + && operation.AnimationInfo.CurrentMetadataReviewOperationId == operation.Id) + .OrderBy(operation => operation.AppliedAt) + .Select(operation => new LogicalMetadataCorrection( + operation.Id, + operation.AnimationInfo.DownloadUrl, + operation.AnimationInfo.Title, + operation.AnimationInfo.PublishTime, + operation.ProposedAnimationTmdbId, + operation.ProposedAnimationName, + operation.ProposedAnimationOriginalName, + operation.ProposedAnimationPosterPath, + operation.ProposedDescription, + operation.ProposedSeason, + operation.ProposedEpisode, + operation.ProposedGroupName, + operation.AppliedAt!.Value)) + .ToListAsync(cancellationToken) + : []; + + var progress = categories.HasFlag(LogicalDataCategory.Playback) + ? await context.PlaybackProgresses.AsNoTracking() + .Where(item => item.UserId == userId) + .OrderBy(item => item.VirtualPath) + .Select(item => new LogicalPlaybackProgress( + item.VirtualPath, + item.PositionSeconds, + item.DurationSeconds, + item.IsWatched, + item.UpdatedAt, + item.WatchedAt)) + .ToListAsync(cancellationToken) + : []; + + LogicalPlaybackPreferences? preferences = null; + if (categories.HasFlag(LogicalDataCategory.Playback)) + { + preferences = await context.PlaybackPreferences.AsNoTracking() + .Where(item => item.UserId == userId) + .Select(item => new LogicalPlaybackPreferences( + item.SubtitleLanguage, + item.SubtitleTrackLabel, + item.AudioLanguage, + item.AudioTrackLabel, + item.AutoPlayNext, + item.UpdatedAt)) + .FirstOrDefaultAsync(cancellationToken); + } + + return new LogicalDataBundle( + FormatVersion, + DateTimeOffset.UtcNow, + applicationVersion, + categories, + feeds, + policies, + rules, + corrections, + progress, + preferences); + } + + public async Task ImportAsync( + LogicalDataBundle bundle, + LogicalImportConflictStrategy conflictStrategy, + Guid userId, + CancellationToken cancellationToken) + { + if (bundle.FormatVersion != FormatVersion) + throw new ArgumentException($"Unsupported logical data format {bundle.FormatVersion}.", nameof(bundle)); + + var statistics = new ImportStatistics(); + await using var transaction = await context.Database.BeginTransactionAsync(cancellationToken); + + var feedsByUrl = await context.Feeds + .ToDictionaryAsync(feed => feed.Url, StringComparer.Ordinal, cancellationToken); + var usedFeedIds = await context.Feeds.AsNoTracking() + .Select(feed => feed.Id) + .ToHashSetAsync(cancellationToken); + await ImportFeedsAsync(bundle, conflictStrategy, feedsByUrl, usedFeedIds, statistics, cancellationToken); + await ImportPoliciesAsync(bundle, conflictStrategy, feedsByUrl, statistics, cancellationToken); + await ImportRulesAsync(bundle, conflictStrategy, statistics, cancellationToken); + await ImportMetadataCorrectionsAsync(bundle, conflictStrategy, statistics, cancellationToken); + await ImportPlaybackAsync(bundle, conflictStrategy, userId, statistics, cancellationToken); + + await context.SaveChangesAsync(cancellationToken); + await transaction.CommitAsync(cancellationToken); + return statistics.ToResult(); + } + + private async Task ImportFeedsAsync( + LogicalDataBundle bundle, + LogicalImportConflictStrategy strategy, + IDictionary feedsByUrl, + ISet usedIds, + ImportStatistics statistics, + CancellationToken cancellationToken) + { + if (!bundle.Categories.HasFlag(LogicalDataCategory.Feeds)) + return; + + foreach (var imported in bundle.Feeds) + { + cancellationToken.ThrowIfCancellationRequested(); + if (feedsByUrl.TryGetValue(imported.Url, out var existing)) + { + if (existing.Name == imported.Name) + { + statistics.Skip(); + continue; + } + if (!HandleConflict(strategy, Identifier("feed", imported.Url), statistics)) + continue; + existing.Name = imported.Name; + statistics.Update(); + continue; + } + + var id = usedIds.Add(imported.Id) ? imported.Id : Guid.NewGuid(); + var entity = new Models.Feed + { + Id = id, + Url = imported.Url, + Name = imported.Name, + CreatedAt = imported.CreatedAt + }; + context.Feeds.Add(entity); + feedsByUrl.Add(entity.Url, entity); + statistics.Add(); + } + await Task.CompletedTask; + } + + private async Task ImportPoliciesAsync( + LogicalDataBundle bundle, + LogicalImportConflictStrategy strategy, + IReadOnlyDictionary feedsByUrl, + ImportStatistics statistics, + CancellationToken cancellationToken) + { + if (!bundle.Categories.HasFlag(LogicalDataCategory.AutomationPolicies)) + return; + + var existing = await context.SubscriptionAutomationPolicies + .ToDictionaryAsync(policy => policy.FeedId, cancellationToken); + foreach (var imported in bundle.AutomationPolicies) + { + if (!feedsByUrl.TryGetValue(imported.FeedUrl, out var feed)) + { + statistics.Skip($"policy feed is missing:{Identifier("feed", imported.FeedUrl)}"); + continue; + } + + if (existing.TryGetValue(feed.Id, out var entity)) + { + if (!HandleConflict(strategy, Identifier("policy", imported.FeedUrl), statistics)) + continue; + ApplyPolicy(imported, entity); + statistics.Update(); + continue; + } + + entity = new Models.SubscriptionAutomationPolicy { FeedId = feed.Id, Feed = feed }; + ApplyPolicy(imported, entity); + context.SubscriptionAutomationPolicies.Add(entity); + existing.Add(feed.Id, entity); + statistics.Add(); + } + } + + private async Task ImportRulesAsync( + LogicalDataBundle bundle, + LogicalImportConflictStrategy strategy, + ImportStatistics statistics, + CancellationToken cancellationToken) + { + if (!bundle.Categories.HasFlag(LogicalDataCategory.FileNameRules)) + return; + + var animations = await context.Animations + .ToDictionaryAsync(animation => animation.TmdbId, StringComparer.Ordinal, cancellationToken); + var rules = await context.FileNameRegexRules.ToListAsync(cancellationToken); + var ruleByKey = rules.ToDictionary(rule => (rule.AnimationId, rule.Pattern)); + var usedRuleIds = rules.Select(rule => rule.Id).ToHashSet(); + + foreach (var imported in bundle.FileNameRules) + { + if (!animations.TryGetValue(imported.AnimationTmdbId, out var animation)) + { + animation = new Models.Animation + { + Id = Guid.NewGuid(), + TmdbId = imported.AnimationTmdbId, + Name = imported.AnimationName, + OriginalName = imported.AnimationOriginalName, + PosterPath = imported.AnimationPosterPath + }; + context.Animations.Add(animation); + animations.Add(animation.TmdbId, animation); + } + + if (ruleByKey.TryGetValue((animation.Id, imported.Pattern), out var existing)) + { + if (existing.Description == imported.Description) + { + statistics.Skip(); + continue; + } + if (!HandleConflict(strategy, + $"filename-rule:{imported.AnimationTmdbId}:{imported.Pattern}", statistics)) + continue; + existing.Description = imported.Description; + statistics.Update(); + continue; + } + + var id = usedRuleIds.Add(imported.Id) ? imported.Id : Guid.NewGuid(); + var entity = new Models.FileNameRegexRule + { + Id = id, + AnimationId = animation.Id, + Pattern = imported.Pattern, + Description = imported.Description, + CreatedAt = imported.CreatedAt + }; + context.FileNameRegexRules.Add(entity); + ruleByKey.Add((animation.Id, entity.Pattern), entity); + statistics.Add(); + } + } + + private async Task ImportMetadataCorrectionsAsync( + LogicalDataBundle bundle, + LogicalImportConflictStrategy strategy, + ImportStatistics statistics, + CancellationToken cancellationToken) + { + if (!bundle.Categories.HasFlag(LogicalDataCategory.MetadataCorrections) || + bundle.MetadataCorrections.Count == 0) + return; + + var downloadUrls = bundle.MetadataCorrections.Select(item => item.ReleaseDownloadUrl).Distinct().ToArray(); + var candidates = await context.AnimationInfo + .Include(info => info.Animation) + .Include(info => info.Group) + .Where(info => downloadUrls.Contains(info.DownloadUrl)) + .ToListAsync(cancellationToken); + var byKey = candidates + .GroupBy(info => (info.DownloadUrl, info.Title, info.PublishTime.ToUnixTimeSeconds())) + .ToDictionary(group => group.Key, group => group.ToList()); + var operationIds = bundle.MetadataCorrections.Select(item => item.OperationId).ToArray(); + var existingOperationIds = await context.MetadataReviewOperations.AsNoTracking() + .Where(operation => operationIds.Contains(operation.Id)) + .Select(operation => operation.Id) + .ToHashSetAsync(cancellationToken); + var animations = await context.Animations + .ToDictionaryAsync(animation => animation.TmdbId, StringComparer.Ordinal, cancellationToken); + var groups = await context.AnimationGroups + .ToDictionaryAsync(group => group.Name, StringComparer.Ordinal, cancellationToken); + + foreach (var imported in bundle.MetadataCorrections) + { + if (existingOperationIds.Contains(imported.OperationId)) + { + statistics.Skip(); + continue; + } + if (!byKey.TryGetValue( + (imported.ReleaseDownloadUrl, imported.ReleaseTitle, + imported.ReleasePublishTime.ToUnixTimeSeconds()), + out var matches) || matches.Count != 1) + { + statistics.Skip($"metadata release is missing or ambiguous:{imported.ReleaseTitle}"); + continue; + } + + var info = matches[0]; + if (info.CurrentMetadataReviewOperationId is not null && + !HandleConflict(strategy, $"metadata:{imported.ReleaseTitle}", statistics)) + continue; + + if (!animations.TryGetValue(imported.AnimationTmdbId, out var animation)) + { + animation = new Models.Animation + { + Id = Guid.NewGuid(), + TmdbId = imported.AnimationTmdbId + }; + context.Animations.Add(animation); + animations.Add(animation.TmdbId, animation); + } + animation.Name = imported.AnimationName; + animation.OriginalName = imported.AnimationOriginalName; + animation.PosterPath = imported.AnimationPosterPath; + + Models.AnimationGroup? group = null; + if (!string.IsNullOrWhiteSpace(imported.GroupName) && + !groups.TryGetValue(imported.GroupName, out group)) + { + group = new Models.AnimationGroup { Id = Guid.NewGuid(), Name = imported.GroupName }; + context.AnimationGroups.Add(group); + groups.Add(group.Name, group); + } + + var nextVersion = checked(info.StateVersion + 1); + var operation = new Models.MetadataReviewOperation + { + Id = imported.OperationId, + AnimationInfoId = info.Id, + AnimationInfo = info, + State = MetadataReviewOperationState.Applied, + CreatedAt = imported.AppliedAt, + ExpiresAt = imported.AppliedAt.AddDays(1), + BaseVersion = info.StateVersion, + BaseFileStore = info.FileStore, + BaseStorePath = info.StorePath, + BaseIsDownloadFinished = info.IsDownloadFinished, + ProposedAnimationTmdbId = imported.AnimationTmdbId, + ProposedAnimationName = imported.AnimationName, + ProposedAnimationOriginalName = imported.AnimationOriginalName, + ProposedAnimationPosterPath = imported.AnimationPosterPath, + ProposedDescription = imported.Description, + ProposedSeason = imported.Season, + ProposedEpisode = imported.Episode, + ProposedGroupName = imported.GroupName, + AppliedAt = imported.AppliedAt, + AppliedVersion = nextVersion, + PreviousDescription = info.Description, + PreviousAnimationId = info.Animation?.Id, + PreviousGroupId = info.Group?.Id, + PreviousSeason = info.Season, + PreviousEpisode = info.Episode, + PreviousMetadataStatus = info.MetadataStatus, + PreviousConfidence = info.MetadataConfidence, + PreviousLastError = info.MetadataLastError, + PreviousIsAiProcessed = info.IsAiProcessed, + PreviousAiRetryCount = info.AiRetryCount, + PreviousReviewedAt = info.MetadataReviewedAt, + PreviousCurrentOperationId = info.CurrentMetadataReviewOperationId + }; + context.MetadataReviewOperations.Add(operation); + info.Animation = animation; + info.Group = group; + info.Description = imported.Description; + info.Season = imported.Season; + info.Episode = imported.Episode; + info.MetadataStatus = MetadataReviewStatus.Reviewed; + info.MetadataConfidence = 1; + info.MetadataLastError = null; + info.MetadataReviewedAt = imported.AppliedAt; + info.IsAiProcessed = true; + info.AiRetryCount = 0; + info.StateVersion = nextVersion; + info.CurrentMetadataReviewOperationId = operation.Id; + existingOperationIds.Add(operation.Id); + statistics.Add(); + } + } + + private async Task ImportPlaybackAsync( + LogicalDataBundle bundle, + LogicalImportConflictStrategy strategy, + Guid userId, + ImportStatistics statistics, + CancellationToken cancellationToken) + { + if (!bundle.Categories.HasFlag(LogicalDataCategory.Playback)) + return; + + var paths = bundle.PlaybackProgress.Select(item => item.VirtualPath).Distinct().ToArray(); + var mappings = await context.FileMappings.AsNoTracking() + .Where(mapping => paths.Contains(mapping.VirtualPath)) + .ToDictionaryAsync(mapping => mapping.VirtualPath, StringComparer.Ordinal, cancellationToken); + var existing = await context.PlaybackProgresses + .Where(item => item.UserId == userId && paths.Contains(item.VirtualPath)) + .ToDictionaryAsync(item => item.VirtualPath, StringComparer.Ordinal, cancellationToken); + + foreach (var imported in bundle.PlaybackProgress + .GroupBy(item => item.VirtualPath, StringComparer.Ordinal) + .Select(group => group.OrderByDescending(item => item.UpdatedAt).First())) + { + if (!mappings.TryGetValue(imported.VirtualPath, out var mapping)) + { + statistics.Skip($"playback path is missing:{imported.VirtualPath}"); + continue; + } + if (existing.TryGetValue(imported.VirtualPath, out var progress)) + { + if (!HandleConflict(strategy, $"playback:{imported.VirtualPath}", statistics)) + continue; + ApplyProgress(imported, mapping.AnimationInfoId, userId, progress); + statistics.Update(); + continue; + } + + progress = new Models.PlaybackProgress { Id = Guid.NewGuid() }; + ApplyProgress(imported, mapping.AnimationInfoId, userId, progress); + context.PlaybackProgresses.Add(progress); + existing.Add(progress.VirtualPath, progress); + statistics.Add(); + } + + if (bundle.PlaybackPreferences is not { } importedPreferences) + return; + var preferences = await context.PlaybackPreferences + .FirstOrDefaultAsync(item => item.UserId == userId, cancellationToken); + if (preferences is not null) + { + if (!HandleConflict(strategy, "playback-preferences", statistics)) + return; + ApplyPreferences(importedPreferences, userId, preferences); + statistics.Update(); + return; + } + + preferences = new Models.PlaybackPreference(); + ApplyPreferences(importedPreferences, userId, preferences); + context.PlaybackPreferences.Add(preferences); + statistics.Add(); + } + + private static bool HandleConflict( + LogicalImportConflictStrategy strategy, + string identifier, + ImportStatistics statistics) + { + if (strategy == LogicalImportConflictStrategy.Fail) + throw new LogicalDataImportConflictException($"Import conflict at {identifier}."); + if (strategy == LogicalImportConflictStrategy.Skip) + { + statistics.Conflict(identifier); + return false; + } + return true; + } + + private static string Identifier(string kind, string value) + { + var digest = SHA256.HashData(Encoding.UTF8.GetBytes(value)); + return $"{kind}:{Convert.ToHexString(digest.AsSpan(0, 8))}"; + } + + private static void ApplyPolicy( + LogicalAutomationPolicy source, + Models.SubscriptionAutomationPolicy target) + { + target.SubtitleGroups = source.SubtitleGroups.ToArray(); + target.Resolutions = source.Resolutions.ToArray(); + target.Codecs = source.Codecs.ToArray(); + target.Languages = source.Languages.ToArray(); + target.MinSizeBytes = source.MinSizeBytes; + target.MaxSizeBytes = source.MaxSizeBytes; + target.ExcludedKeywords = source.ExcludedKeywords.ToArray(); + target.Mode = source.Mode; + target.CreatedAt = source.CreatedAt; + target.UpdatedAt = source.UpdatedAt; + } + + private static void ApplyProgress( + LogicalPlaybackProgress source, + Guid animationInfoId, + Guid userId, + Models.PlaybackProgress target) + { + target.UserId = userId; + target.AnimationInfoId = animationInfoId; + target.VirtualPath = source.VirtualPath; + target.PositionSeconds = source.PositionSeconds; + target.DurationSeconds = source.DurationSeconds; + target.IsWatched = source.IsWatched; + target.UpdatedAt = source.UpdatedAt; + target.WatchedAt = source.WatchedAt; + } + + private static void ApplyPreferences( + LogicalPlaybackPreferences source, + Guid userId, + Models.PlaybackPreference target) + { + target.UserId = userId; + target.SubtitleLanguage = source.SubtitleLanguage; + target.SubtitleTrackLabel = source.SubtitleTrackLabel; + target.AudioLanguage = source.AudioLanguage; + target.AudioTrackLabel = source.AudioTrackLabel; + target.AutoPlayNext = source.AutoPlayNext; + target.UpdatedAt = source.UpdatedAt; + } + + private sealed class ImportStatistics + { + private readonly List _messages = []; + + public int Added { get; private set; } + public int Updated { get; private set; } + public int Skipped { get; private set; } + public int Conflicts { get; private set; } + + public void Add() => Added++; + public void Update() => Updated++; + public void Skip(string? message = null) + { + Skipped++; + Message(message); + } + + public void Conflict(string identifier) + { + Conflicts++; + Skipped++; + Message($"conflict skipped:{identifier}"); + } + + public LogicalImportResult ToResult() => + new(Added, Updated, Skipped, Conflicts, _messages); + + private void Message(string? value) + { + if (!string.IsNullOrEmpty(value) && _messages.Count < 100) + _messages.Add(value); + } + } +} diff --git a/deployments/podman-compose.yml b/deployments/podman-compose.yml index 7a4f463..47944af 100644 --- a/deployments/podman-compose.yml +++ b/deployments/podman-compose.yml @@ -65,6 +65,9 @@ services: volumes: - downloads:/downloads - appdata:/app/data + - ./backups:/app/backups + # The backup CLI archives this deployment configuration without printing it. + - ./podman-compose.yml:/app/deployment/podman-compose.yml:ro # Existing libraries can be imported in place from Settings. Replace the # host path and keep the mount read-only so originals cannot be modified. # - /path/to/anime:/media/anime:ro diff --git a/deployments/sdw-backup b/deployments/sdw-backup new file mode 100755 index 0000000..1670cad --- /dev/null +++ b/deployments/sdw-backup @@ -0,0 +1,605 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +umask 077 + +readonly BACKUP_FORMAT_VERSION=1 +backup_temp_dir="" +backup_partial_archive="" +backup_partial_checksum="" + +cleanup() { + case "${backup_temp_dir}" in + "${TMPDIR:-/tmp}"/sdw-backup.*) + [[ -d "${backup_temp_dir}" ]] && rm -rf -- "${backup_temp_dir}" + ;; + esac + if [[ "${backup_partial_archive}" == *sdw-backup-*.partial ]]; then + rm -f -- "${backup_partial_archive}" + fi + if [[ "${backup_partial_checksum}" == *sdw-backup-*.sha256.partial ]]; then + rm -f -- "${backup_partial_checksum}" + fi +} + +notify_failure() { + local status=$? + if [[ ${status} -ne 0 && -n "${SDW_BACKUP_FAILURE_WEBHOOK:-}" ]] && command -v curl >/dev/null 2>&1; then + curl --silent --show-error --fail --max-time 10 \ + --header 'Content-Type: application/json' \ + --data '{"event":"sdw_backup_failed"}' \ + "${SDW_BACKUP_FAILURE_WEBHOOK}" >/dev/null 2>&1 || true + fi + return "${status}" +} + +trap notify_failure ERR +trap cleanup EXIT + +usage() { + cat <<'EOF' +Usage: + sdw-backup create [options] + sdw-backup list [--output DIR] + sdw-backup verify ARCHIVE [--age-identity FILE] + sdw-backup restore ARCHIVE --confirm-replace [options] + +Create options: + --output DIR Local backup target + --config FILE Deployment config to include + --password-file FILE password.json to include + --key-ring DIR Data Protection key ring to include + --plugin-dir DIR Plugin directory; manifest files are included + --retention-days DAYS Delete completed backups older than DAYS + --app-version VERSION Application version written to non-secret metadata + --age-recipient RECIPIENT Encrypt the final archive with age + +Restore options: + --config-destination FILE + --password-destination FILE + --key-ring-destination DIR + --plugin-destination DIR + --expected-version VERSION + --expected-schema MIGRATION + --age-identity FILE + --safety-directory DIR Write a pre-restore database dump here + +PostgreSQL is read from PGHOST/PGPORT/PGUSER/PGPASSWORD/PGDATABASE. If those +are absent, ConnectionStrings__sdw may contain an ASP.NET semicolon connection +string. Secrets are never printed. +EOF +} + +die() { + printf 'sdw-backup: %s\n' "$1" >&2 + exit 1 +} + +require_command() { + command -v "$1" >/dev/null 2>&1 || die "required command is missing: $1" +} + +new_temp_dir() { + backup_temp_dir=$(mktemp -d "${TMPDIR:-/tmp}/sdw-backup.XXXXXX") +} + +parse_connection_string() { + if [[ -n "${PGHOST:-}" && -n "${PGUSER:-}" && -n "${PGDATABASE:-}" ]]; then + export PGHOST PGUSER PGDATABASE + export PGPORT="${PGPORT:-5432}" + return + fi + + local connection="${ConnectionStrings__sdw:-}" + [[ -n "${connection}" ]] || die "PostgreSQL connection environment is not configured" + local pair key value + local -a pairs + IFS=';' read -r -a pairs <<<"${connection}" + for pair in "${pairs[@]}"; do + key=${pair%%=*} + value=${pair#*=} + key=${key//[[:space:]]/} + case "${key,,}" in + host) PGHOST=${value} ;; + port) PGPORT=${value} ;; + username|userid) PGUSER=${value} ;; + password) PGPASSWORD=${value} ;; + database|initial\ catalog) PGDATABASE=${value} ;; + esac + done + [[ -n "${PGHOST:-}" && -n "${PGUSER:-}" && -n "${PGDATABASE:-}" ]] || + die "the ASP.NET connection string is missing Host, Username, or Database" + export PGHOST PGPORT="${PGPORT:-5432}" PGUSER PGPASSWORD="${PGPASSWORD:-}" PGDATABASE +} + +validate_output_directory() { + local directory=$1 + [[ -n "${directory}" && "${directory}" != "/" ]] || die "refusing unsafe backup output directory" + mkdir -p -- "${directory}" + chmod 0700 "${directory}" +} + +validate_file_destination() { + local destination=$1 + [[ -n "${destination}" && "${destination}" != "/" && + ! -d "${destination}" && ! -L "${destination}" ]] || + die "refusing unsafe restore file destination" +} + +validate_directory_destination() { + local destination=$1 normalized + [[ -n "${destination}" ]] || die "refusing empty restore directory destination" + [[ ! -L "${destination}" ]] || die "refusing symlink restore directory destination" + normalized=$(realpath --canonicalize-missing -- "${destination}") + case "${normalized}" in + /|/etc|/usr|/var|/var/lib|/home|/root|/app) + die "refusing unsafe restore directory destination" + ;; + esac +} + +safe_token() { + [[ "$1" =~ ^[A-Za-z0-9._:+/@-]+$ ]] +} + +database_schema_version() { + local schema + schema=$(psql --no-psqlrc --tuples-only --no-align --command \ + 'SELECT "MigrationId" FROM "__EFMigrationsHistory" ORDER BY "MigrationId" DESC LIMIT 1' \ + 2>/dev/null || true) + schema=${schema//$'\n'/} + if [[ -z "${schema}" ]]; then + printf 'uninitialized' + else + safe_token "${schema}" || die "database returned an unsafe schema version" + printf '%s' "${schema}" + fi +} + +copy_plugin_manifests() { + local plugin_dir=$1 destination=$2 + mkdir -p "${destination}" + if [[ ! -d "${plugin_dir}" ]]; then + : >"${destination}/none" + return + fi + + local source relative target count=0 + while IFS= read -r -d '' source; do + relative=${source#"${plugin_dir}"/} + target="${destination}/${relative}" + mkdir -p -- "$(dirname "${target}")" + install -m 0600 -- "${source}" "${target}" + count=$((count + 1)) + done < <(find "${plugin_dir}" -type f \( -name 'plugin.json' -o -name 'manifest.json' \) -print0) + if [[ ${count} -eq 0 ]]; then + : >"${destination}/none" + fi +} + +validate_stage_paths() { + local stage=$1 relative + if find "${stage}" -mindepth 1 ! -type f ! -type d -print -quit | grep -q .; then + die "backup source contains a link or special file" + fi + while IFS= read -r -d '' relative; do + relative=${relative#"${stage}"/} + [[ "${relative}" =~ ^[A-Za-z0-9._+@:/{}-]+$ ]] || + die "backup source contains an unsupported file name" + [[ "${relative}" != */../* && "${relative}" != ../* ]] || + die "backup source contains an unsafe path" + done < <(find "${stage}" -mindepth 1 -print0) +} + +create_backup() { + local output="${SDW_BACKUP_DIRECTORY:-/var/lib/sdw-redive/backups}" + local config="${SDW_CONFIG_PATH:-/etc/sdw-redive/appsettings.yml}" + local password_file="${SDW_PASSWORD_FILE:-/var/lib/sdw-redive/password.json}" + local key_ring="${SDW_KEY_RING_PATH:-/var/lib/sdw-redive/data-protection-keys}" + local plugin_dir="${SDW_PLUGIN_DIRECTORY:-/var/lib/sdw-redive/plugins}" + local retention_days="${SDW_BACKUP_RETENTION_DAYS:-14}" + local app_version="${SDW_APP_VERSION:-}" + local age_recipient="${SDW_BACKUP_AGE_RECIPIENT:-}" + + while [[ $# -gt 0 ]]; do + case "$1" in + --output) output=$2; shift 2 ;; + --config) config=$2; shift 2 ;; + --password-file) password_file=$2; shift 2 ;; + --key-ring) key_ring=$2; shift 2 ;; + --plugin-dir) plugin_dir=$2; shift 2 ;; + --retention-days) retention_days=$2; shift 2 ;; + --app-version) app_version=$2; shift 2 ;; + --age-recipient) age_recipient=$2; shift 2 ;; + *) die "unknown create option: $1" ;; + esac + done + + if [[ -z "${app_version}" && -f /usr/lib/sdw-redive/VERSION ]]; then + app_version=$(tr -d '[:space:]' /dev/null + local created schema stage_bytes required_bytes + created=$(date -u +%Y-%m-%dT%H:%M:%SZ) + schema=$(database_schema_version) + stage_bytes=$(du -sb "${stage}" | awk '{print $1}') + required_bytes=$((stage_bytes * 2 + 67108864)) + { + printf 'format_version=%s\n' "${BACKUP_FORMAT_VERSION}" + printf 'created_utc=%s\n' "${created}" + printf 'application_version=%s\n' "${app_version}" + printf 'schema_version=%s\n' "${schema}" + printf 'required_free_bytes=%s\n' "${required_bytes}" + printf 'media_files_included=false\n' + } >"${stage}/manifest.env" + ( + cd "${stage}" + find . -type f ! -path ./checksums.sha256 -print0 | + LC_ALL=C sort -z | + xargs -0 sha256sum >checksums.sha256 + ) + + local timestamp archive_name plain_archive final_archive + timestamp=$(date -u +%Y%m%dT%H%M%SZ) + archive_name="sdw-backup-${timestamp}-$$.tar.gz" + plain_archive="${backup_temp_dir}/${archive_name}" + tar --create --gzip --file "${plain_archive}" --directory "${stage}" . + gzip --test "${plain_archive}" + if [[ -n "${age_recipient}" ]]; then + require_command age + final_archive="${output}/${archive_name}.age" + else + final_archive="${output}/${archive_name}" + fi + [[ ! -e "${final_archive}" && ! -e "${final_archive}.sha256" ]] || + die "refusing to overwrite an existing backup" + backup_partial_archive="${final_archive}.partial" + backup_partial_checksum="${final_archive}.sha256.partial" + if [[ -n "${age_recipient}" ]]; then + age --recipient "${age_recipient}" --output "${backup_partial_archive}" "${plain_archive}" + else + cp -- "${plain_archive}" "${backup_partial_archive}" + printf 'sdw-backup: warning: archive is not encrypted; protect it as a secret\n' >&2 + fi + chmod 0600 "${backup_partial_archive}" + local archive_digest + archive_digest=$(sha256sum "${backup_partial_archive}" | awk '{print $1}') + printf '%s %s\n' "${archive_digest}" "$(basename "${final_archive}")" \ + >"${backup_partial_checksum}" + chmod 0600 "${backup_partial_checksum}" + mv -- "${backup_partial_archive}" "${final_archive}" + backup_partial_archive="" + mv -- "${backup_partial_checksum}" "${final_archive}.sha256" + backup_partial_checksum="" + + if [[ ${retention_days} -gt 0 ]]; then + find "${output}" -maxdepth 1 -type f \ + \( -name 'sdw-backup-*.tar.gz' -o -name 'sdw-backup-*.tar.gz.age' -o \ + -name 'sdw-backup-*.tar.gz.sha256' -o -name 'sdw-backup-*.tar.gz.age.sha256' \) \ + -mtime "+${retention_days}" -delete + fi + printf '%s\n' "${final_archive}" +} + +list_backups() { + local output="${SDW_BACKUP_DIRECTORY:-/var/lib/sdw-redive/backups}" + while [[ $# -gt 0 ]]; do + case "$1" in + --output) output=$2; shift 2 ;; + *) die "unknown list option: $1" ;; + esac + done + [[ -d "${output}" ]] || return 0 + TZ=UTC find "${output}" -maxdepth 1 -type f \ + \( -name 'sdw-backup-*.tar.gz' -o -name 'sdw-backup-*.tar.gz.age' \) \ + -printf '%TY-%Tm-%TdT%TH:%TM:%TSZ %s %f\n' | LC_ALL=C sort -r +} + +validate_archive_members() { + local archive=$1 + if LC_ALL=C tar --list --gzip --file "${archive}" | + grep -Eq '(^/|(^|/)\.\.(/|$))'; then + die "archive contains an unsafe path" + fi + if LC_ALL=C tar --list --gzip --file "${archive}" | + grep -Eqv '^(\./)?([A-Za-z0-9._+@:{}-]+/)*[A-Za-z0-9._+@:{}-]*/?$'; then + die "archive contains an unsupported path" + fi + if LC_ALL=C tar --list --verbose --gzip --file "${archive}" | + awk 'substr($1, 1, 1) !~ /^[-d]$/ { found=1 } END { exit(found ? 0 : 1) }'; then + die "archive contains a link or special file" + fi +} + +archive_unpacked_bytes() { + LC_ALL=C tar --list --verbose --numeric-owner --full-time --gzip --file "$1" | + awk ' + $3 !~ /^[0-9]+$/ { exit 2 } + { total += $3; if (total > 900000000000000000) exit 2 } + END { if (NR > 0) printf "%.0f\n", total; else exit 2 } + ' +} + +available_bytes() { + df -Pk "$1" | awk 'NR==2 {printf "%.0f\n", $4 * 1024}' +} + +manifest_value() { + local manifest=$1 key=$2 + awk -F= -v wanted="${key}" '$1 == wanted { sub(/^[^=]*=/, ""); print; exit }' "${manifest}" +} + +verify_outer_checksum() { + local archive=$1 sidecar="${1}.sha256" line expected expected_name actual + [[ -f "${sidecar}" ]] || return 0 + IFS= read -r line <"${sidecar}" || die "backup archive checksum is unreadable" + expected=${line:0:64} + expected_name=${line:66} + [[ "${expected}" =~ ^[0-9a-f]{64}$ && "${line:64:2}" == " " && + "${expected_name}" == "$(basename "${archive}")" ]] || + die "backup archive checksum is malformed" + actual=$(sha256sum "${archive}" | awk '{print $1}') + [[ "${actual}" == "${expected}" ]] || die "backup archive checksum verification failed" +} + +validate_checksum_manifest() { + local root=$1 manifest="${1}/checksums.sha256" line digest path + local listed=() + while IFS= read -r line; do + digest=${line:0:64} + path=${line:66} + [[ "${digest}" =~ ^[0-9a-f]{64}$ && "${line:64:2}" == " " ]] || + die "backup checksum manifest is malformed" + [[ "${path}" == ./* && "${path}" != */../* && "${path}" != ../* ]] || + die "backup checksum manifest contains an unsafe path" + [[ "${path}" =~ ^\./[A-Za-z0-9._+@:/{}-]+$ ]] || + die "backup checksum manifest contains an unsupported path" + listed+=("${path}") + done <"${manifest}" + + local actual + actual=$(find "${root}" -type f ! -path "${root}/checksums.sha256" \ + -printf './%P\n' | LC_ALL=C sort) + [[ "$(printf '%s\n' "${listed[@]}" | LC_ALL=C sort)" == "${actual}" ]] || + die "backup checksum manifest does not cover every file" +} + +prepare_archive() { + local archive=$1 identity=$2 + [[ -f "${archive}" ]] || die "backup archive does not exist" + require_command tar + require_command sha256sum + require_command pg_restore + verify_outer_checksum "${archive}" + new_temp_dir + local plain="${archive}" + if [[ "${archive}" == *.age ]]; then + [[ -n "${identity}" ]] || die "an age identity is required for this archive" + require_command age + plain="${backup_temp_dir}/backup.tar.gz" + local encrypted_bytes decrypt_free_bytes + encrypted_bytes=$(wc -c <"${archive}") + decrypt_free_bytes=$(available_bytes "${backup_temp_dir}") + [[ ${decrypt_free_bytes%.*} -gt $((encrypted_bytes * 3 + 67108864)) ]] || + die "insufficient temporary disk space to decrypt backup" + age --decrypt --identity "${identity}" --output "${plain}" "${archive}" + fi + + validate_archive_members "${plain}" + local archive_bytes unpacked_bytes free_bytes + archive_bytes=$(wc -c <"${plain}") + unpacked_bytes=$(archive_unpacked_bytes "${plain}") || die "archive has invalid size metadata" + free_bytes=$(available_bytes "${backup_temp_dir}") + [[ ${free_bytes%.*} -gt $((archive_bytes + unpacked_bytes + 67108864)) ]] || + die "insufficient temporary disk space to validate backup" + mkdir "${backup_temp_dir}/extracted" + tar --extract --gzip --no-same-owner --no-same-permissions \ + --file "${plain}" --directory "${backup_temp_dir}/extracted" + [[ -f "${backup_temp_dir}/extracted/manifest.env" && + -f "${backup_temp_dir}/extracted/checksums.sha256" && + -f "${backup_temp_dir}/extracted/database.dump" ]] || + die "backup is missing required files" + validate_checksum_manifest "${backup_temp_dir}/extracted" + ( + cd "${backup_temp_dir}/extracted" + sha256sum --check --strict checksums.sha256 >/dev/null + ) || die "backup checksum verification failed" + pg_restore --list "${backup_temp_dir}/extracted/database.dump" >/dev/null || + die "PostgreSQL dump verification failed" + local format required created application schema media_scope + format=$(manifest_value "${backup_temp_dir}/extracted/manifest.env" format_version) + [[ "${format}" == "${BACKUP_FORMAT_VERSION}" ]] || die "unsupported backup format" + required=$(manifest_value "${backup_temp_dir}/extracted/manifest.env" required_free_bytes) + created=$(manifest_value "${backup_temp_dir}/extracted/manifest.env" created_utc) + application=$(manifest_value "${backup_temp_dir}/extracted/manifest.env" application_version) + schema=$(manifest_value "${backup_temp_dir}/extracted/manifest.env" schema_version) + media_scope=$(manifest_value "${backup_temp_dir}/extracted/manifest.env" media_files_included) + [[ "${required}" =~ ^[0-9]+$ && ${#required} -le 18 ]] || + die "backup has invalid space metadata" + [[ "${created}" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$ ]] || + die "backup has invalid timestamp metadata" + safe_token "${application}" && safe_token "${schema}" || + die "backup has invalid version metadata" + [[ "${media_scope}" == false ]] || die "backup has an unsupported media scope" + free_bytes=$(available_bytes "${backup_temp_dir}") + [[ ${free_bytes%.*} -gt ${required} ]] || die "insufficient disk space for restore" +} + +verify_backup() { + [[ $# -ge 1 ]] || die "verify requires an archive" + local archive=$1 identity="${SDW_BACKUP_AGE_IDENTITY:-}" + shift + while [[ $# -gt 0 ]]; do + case "$1" in + --age-identity) identity=$2; shift 2 ;; + *) die "unknown verify option: $1" ;; + esac + done + prepare_archive "${archive}" "${identity}" + local manifest="${backup_temp_dir}/extracted/manifest.env" + printf 'verified format=%s created=%s application=%s schema=%s media_files_included=false\n' \ + "$(manifest_value "${manifest}" format_version)" \ + "$(manifest_value "${manifest}" created_utc)" \ + "$(manifest_value "${manifest}" application_version)" \ + "$(manifest_value "${manifest}" schema_version)" +} + +major_version() { + local value=${1%%+*} + value=${value%%-*} + [[ "${value}" =~ ^[0-9]+(\.[0-9]+){0,3}$ ]] || return 1 + printf '%s' "${value%%.*}" +} + +restore_backup() { + [[ $# -ge 1 ]] || die "restore requires an archive" + local archive=$1 + shift + local config_destination="${SDW_CONFIG_PATH:-/etc/sdw-redive/appsettings.yml}" + local password_destination="${SDW_PASSWORD_FILE:-/var/lib/sdw-redive/password.json}" + local key_ring_destination="${SDW_KEY_RING_PATH:-/var/lib/sdw-redive/data-protection-keys}" + local plugin_destination="${SDW_PLUGIN_DIRECTORY:-/var/lib/sdw-redive/plugins}" + local expected_version="${SDW_APP_VERSION:-}" + local expected_schema="${SDW_EXPECTED_SCHEMA_VERSION:-}" + local identity="${SDW_BACKUP_AGE_IDENTITY:-}" + local safety_directory="${SDW_BACKUP_DIRECTORY:-/var/lib/sdw-redive/backups}" + local confirmed=false allow_version_mismatch=false + + while [[ $# -gt 0 ]]; do + case "$1" in + --config-destination) config_destination=$2; shift 2 ;; + --password-destination) password_destination=$2; shift 2 ;; + --key-ring-destination) key_ring_destination=$2; shift 2 ;; + --plugin-destination) plugin_destination=$2; shift 2 ;; + --expected-version) expected_version=$2; shift 2 ;; + --expected-schema) expected_schema=$2; shift 2 ;; + --age-identity) identity=$2; shift 2 ;; + --safety-directory) safety_directory=$2; shift 2 ;; + --allow-version-mismatch) allow_version_mismatch=true; shift ;; + --confirm-replace) confirmed=true; shift ;; + *) die "unknown restore option: $1" ;; + esac + done + [[ "${confirmed}" == true ]] || die "restore requires --confirm-replace" + require_command realpath + validate_file_destination "${config_destination}" + validate_file_destination "${password_destination}" + validate_directory_destination "${key_ring_destination}" + validate_directory_destination "${plugin_destination}" + parse_connection_string + require_command psql + require_command pg_dump + prepare_archive "${archive}" "${identity}" + + local extracted="${backup_temp_dir}/extracted" + local manifest="${extracted}/manifest.env" + local backup_version backup_schema + backup_version=$(manifest_value "${manifest}" application_version) + backup_schema=$(manifest_value "${manifest}" schema_version) + if [[ "${allow_version_mismatch}" != true ]]; then + [[ -n "${expected_version}" ]] || + die "--expected-version is required unless --allow-version-mismatch is explicit" + [[ "$(major_version "${backup_version}")" == "$(major_version "${expected_version}")" ]] || + die "backup application major version is incompatible" + fi + if [[ -n "${expected_schema}" && "${backup_schema}" != "${expected_schema}" ]]; then + die "backup schema is incompatible with the requested schema" + fi + + local restored_config required_bytes destination_parent probe + restored_config=$(find "${extracted}/state/config" -maxdepth 1 -type f -print -quit) + [[ -n "${restored_config}" && -f "${extracted}/state/password.json" && + -d "${extracted}/state/data-protection-keys" ]] || + die "backup state files are incomplete" + required_bytes=$(manifest_value "${manifest}" required_free_bytes) + for destination_parent in \ + "$(dirname "${config_destination}")" \ + "$(dirname "${password_destination}")" \ + "$(dirname "${key_ring_destination}")" \ + "$(dirname "${plugin_destination}")"; do + mkdir -p -- "${destination_parent}" + [[ $(available_bytes "${destination_parent}") -gt ${required_bytes} ]] || + die "insufficient destination disk space for restore" + probe="${destination_parent}/.sdw-restore-write-test-$$" + : >"${probe}" || die "restore destination is not writable" + rm -f -- "${probe}" + done + + validate_output_directory "${safety_directory}" + local timestamp safety_dump + timestamp=$(date -u +%Y%m%dT%H%M%SZ) + safety_dump="${safety_directory}/pre-restore-${timestamp}-$$.dump" + pg_dump --format=custom --compress=6 --no-owner --no-acl --file "${safety_dump}.partial" + chmod 0600 "${safety_dump}.partial" + mv "${safety_dump}.partial" "${safety_dump}" + + pg_restore --clean --if-exists --exit-on-error --no-owner --no-acl \ + --dbname "${PGDATABASE}" "${extracted}/database.dump" + [[ "$(database_schema_version)" == "${backup_schema}" ]] || + die "restored database schema does not match the backup" + + if [[ -f "${config_destination}" ]]; then + install -m 0600 "${config_destination}" "${config_destination}.pre-restore-${timestamp}" + fi + if [[ -f "${password_destination}" ]]; then + install -m 0600 "${password_destination}" "${password_destination}.pre-restore-${timestamp}" + fi + install -m 0600 "${restored_config}" "${config_destination}.partial" + mv "${config_destination}.partial" "${config_destination}" + install -m 0600 "${extracted}/state/password.json" "${password_destination}.partial" + mv "${password_destination}.partial" "${password_destination}" + + if [[ -d "${key_ring_destination}" ]]; then + mv "${key_ring_destination}" "${key_ring_destination}.pre-restore-${timestamp}" + fi + mkdir -p "${key_ring_destination}" + chmod 0700 "${key_ring_destination}" + cp -a "${extracted}/state/data-protection-keys/." "${key_ring_destination}/" + find "${key_ring_destination}" -type f -exec chmod 0600 {} + + + if [[ -d "${extracted}/state/plugin-manifests" && + ! -f "${extracted}/state/plugin-manifests/none" ]]; then + mkdir -p "${plugin_destination}" + cp -a "${extracted}/state/plugin-manifests/." "${plugin_destination}/" + fi + printf 'restore completed; restart the application and run its health check\n' +} + +command=${1:-} +[[ -n "${command}" ]] || { usage; exit 1; } +shift +case "${command}" in + create) create_backup "$@" ;; + list) list_backups "$@" ;; + verify) verify_backup "$@" ;; + restore) restore_backup "$@" ;; + help|-h|--help) usage ;; + *) usage; die "unknown command: ${command}" ;; +esac diff --git a/deployments/tests/backup-restore-smoke.sh b/deployments/tests/backup-restore-smoke.sh new file mode 100755 index 0000000..38f4875 --- /dev/null +++ b/deployments/tests/backup-restore-smoke.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +repo_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd) +drill_root=$(mktemp -d "${TMPDIR:-/tmp}/sdw-backup-drill.XXXXXX") +container_name="sdw-backup-drill-$$" + +cleanup() { + podman stop "${container_name}" >/dev/null 2>&1 || true + case "${drill_root}" in + "${TMPDIR:-/tmp}"/sdw-backup-drill.*) rm -rf -- "${drill_root}" ;; + esac +} +trap cleanup EXIT + +mkdir -p \ + "${drill_root}/keys" \ + "${drill_root}/plugins" \ + "${drill_root}/backups" \ + "${drill_root}/restored" +install -m 0600 "${repo_root}/VERSION" "${drill_root}/appsettings.yml" +install -m 0600 "${repo_root}/VERSION" "${drill_root}/password.json" +app_version=$(tr -d '[:space:]' <"${repo_root}/VERSION") + +podman run --rm --detach --name "${container_name}" \ + --env POSTGRES_PASSWORD=postgres \ + --env POSTGRES_USER=postgres \ + --env POSTGRES_DB=sdw_source \ + --publish 127.0.0.1::5432 \ + postgres:17-alpine >/dev/null + +port=$(podman port "${container_name}" 5432/tcp | sed -E 's/.*:([0-9]+)$/\1/') +export PGHOST=127.0.0.1 PGPORT="${port}" PGUSER=postgres PGPASSWORD=postgres PGDATABASE=sdw_source +for _ in {1..30}; do + pg_isready --quiet && break + sleep 1 +done +pg_isready --quiet +psql --no-psqlrc --set=ON_ERROR_STOP=1 --command \ + "CREATE TABLE drill_items (id integer PRIMARY KEY, value text NOT NULL); INSERT INTO drill_items VALUES (1, 'round-trip');" \ + >/dev/null + +export ConnectionStrings__sdw="Host=127.0.0.1;Port=${port};User ID=postgres;Password=postgres;Database=sdw_source" +unset PGHOST PGPORT PGUSER PGPASSWORD PGDATABASE +archive=$("${repo_root}/deployments/sdw-backup" create \ + --output "${drill_root}/backups" \ + --config "${drill_root}/appsettings.yml" \ + --password-file "${drill_root}/password.json" \ + --key-ring "${drill_root}/keys" \ + --plugin-dir "${drill_root}/plugins" \ + --retention-days 7 \ + --app-version "${app_version}") +unset ConnectionStrings__sdw +export PGHOST=127.0.0.1 PGPORT="${port}" PGUSER=postgres PGPASSWORD=postgres PGDATABASE=sdw_source +"${repo_root}/deployments/sdw-backup" verify "${archive}" + +cp -- "${archive}" "${drill_root}/corrupt.tar.gz" +archive_size=$(stat --format=%s "${drill_root}/corrupt.tar.gz") +printf CORRUPT | dd of="${drill_root}/corrupt.tar.gz" bs=1 \ + seek=$((archive_size / 2)) conv=notrunc status=none +if "${repo_root}/deployments/sdw-backup" verify "${drill_root}/corrupt.tar.gz" >/dev/null 2>&1; then + printf 'corrupt archive unexpectedly verified\n' >&2 + exit 1 +fi + +createdb sdw_restore +export PGDATABASE=sdw_restore +"${repo_root}/deployments/sdw-backup" restore "${archive}" \ + --confirm-replace \ + --expected-version "${app_version}" \ + --config-destination "${drill_root}/restored/appsettings.yml" \ + --password-destination "${drill_root}/restored/password.json" \ + --key-ring-destination "${drill_root}/restored/keys" \ + --plugin-destination "${drill_root}/restored/plugins" \ + --safety-directory "${drill_root}/backups" + +test "$(psql --no-psqlrc --tuples-only --no-align --command \ + 'SELECT value FROM drill_items WHERE id = 1')" = round-trip +cmp "${drill_root}/password.json" "${drill_root}/restored/password.json" +printf 'backup restore smoke test passed\n' diff --git a/docs/backup-restore.md b/docs/backup-restore.md new file mode 100644 index 0000000..49e2952 --- /dev/null +++ b/docs/backup-restore.md @@ -0,0 +1,145 @@ +# 备份、恢复与逻辑数据迁移 + +`sdw-backup` 提供可自动化的本地备份目标;系统包和应用容器均携带该命令。备份由 PostgreSQL custom-format dump、部署配置、`password.json`、Data Protection 密钥环和插件 manifest 组成。下载媒体、导入的原始媒体、qBittorrent 数据与 Valkey 缓存不在其中。 + +## 恢复目标与范围 + +- 建议 RPO:每日一次,重要升级前额外一次。默认 systemd timer 示例为每天 03:30,并随机延迟最多 30 分钟。 +- 典型 RTO:数据库小于 10 GiB 时约 15–60 分钟;实际取决于 PostgreSQL、存储速度和重新核对媒体映射的时间。 +- `downloads` 与外部媒体目录必须使用文件系统快照、NAS 快照或独立备份工具。数据库备份不能还原媒体内容。 +- Data Protection 密钥环是恢复数据库内加密运行时凭据的必要条件,必须与数据库来自同一个恢复点。 +- Valkey 中的会话和短期状态不恢复;恢复后用户可能需要重新登录。 + +每个归档包含时间戳、应用版本、最新 EF schema 版本、最低临时空间估算和 SHA-256 文件清单;旁边的同名 `.sha256` 文件校验整个压缩或加密归档。manifest 和旁路校验文件均不包含数据库口令、JWT、上游 API key 或连接字符串。未加密归档仍包含配置和密钥文件,因此必须按秘密处理。 + +## 创建、列出和验证 + +命令读取标准 `PGHOST`、`PGPORT`、`PGUSER`、`PGPASSWORD`、`PGDATABASE`;在容器内也可直接解析已有的 `ConnectionStrings__sdw` 环境变量。 + +```bash +export PGHOST=localhost PGPORT=5432 PGUSER=sdw PGDATABASE=sdw +read -rsp 'PostgreSQL password: ' PGPASSWORD && export PGPASSWORD + +sudo -u sdw-redive --preserve-env=PGHOST,PGPORT,PGUSER,PGPASSWORD,PGDATABASE \ + sdw-backup create \ + --output /var/lib/sdw-redive/backups \ + --config /etc/sdw-redive/appsettings.yml \ + --password-file /var/lib/sdw-redive/password.json \ + --key-ring /var/lib/sdw-redive/data-protection-keys \ + --retention-days 14 + +sdw-backup list --output /var/lib/sdw-redive/backups +sdw-backup verify /var/lib/sdw-redive/backups/sdw-backup-YYYYMMDDTHHMMSSZ-PID.tar.gz +``` + +命令只输出最终归档路径或非敏感验证结果。最终文件及其 `.sha256` 以 `0600` 发布;失败的 `.partial` 不会被当作备份。保留清理只匹配目标目录中的 `sdw-backup-*.tar.gz[.age]` 及对应校验文件。 + +### age 加密 + +```bash +sdw-backup create ... --age-recipient 'age1...' +sdw-backup verify backup.tar.gz.age --age-identity /secure/backup-key.txt +``` + +私钥不写入归档、manifest 或日志。若使用 webhook,只会发送固定的 `sdw_backup_failed` 事件,不发送错误文本、路径或凭据: + +```bash +export SDW_BACKUP_FAILURE_WEBHOOK=https://monitor.example/hooks/opaque-token +``` + +本地目录是当前内置 target driver。向对象存储扩展时,应在归档完成并通过本地 `verify` 后上传不可变文件;远端上传器不得读取或重新生成 manifest,也不得把 age identity 与备份存放在同一目标。 + +## systemd 定时执行 + +系统包安装 `/etc/sdw-redive/backup.env`、`sdw-backup.service` 和 `sdw-backup.timer`,但不会在口令仍为占位符时自动启用。安装 PostgreSQL client,编辑并保护环境文件后启用: + +```bash +sudoedit /etc/sdw-redive/backup.env +sudo chown root:sdw-redive /etc/sdw-redive/backup.env +sudo chmod 0640 /etc/sdw-redive/backup.env +sudo systemctl enable --now sdw-backup.timer +sudo systemctl start sdw-backup.service +sudo journalctl -u sdw-backup.service +``` + +升级前可执行 `sudo systemctl start sdw-backup.service`,验证新归档后再升级。这样自动迁移数据库前已有明确恢复点。 + +## 容器部署 + +模板把 `./backups` 挂载到 `/app/backups`,并以只读方式挂载 Compose 配置。创建与验证: + +```bash +mkdir -p backups && chmod 0700 backups +podman-compose exec sdw-redive sdw-backup create \ + --output /app/backups \ + --config /app/deployment/podman-compose.yml \ + --password-file /app/data/password.json \ + --key-ring /app/data/data-protection-keys +podman-compose exec sdw-redive sdw-backup verify /app/backups/sdw-backup-....tar.gz +``` + +生产环境可用宿主机 systemd timer 或 cron 调用上述命令。不要把数据库口令作为命令行参数;脚本使用容器已有的连接字符串环境变量。 + +## 灾难恢复 + +恢复是替换操作。先停止所有应用副本和后台任务;仅停止应用,不要停止 PostgreSQL。 + +1. 把目标应用安装为与备份相同的 major 版本,并准备空数据库。 +2. 预先挂载足够空间;脚本在任何数据库写入前验证路径、链接、所有 SHA-256、`pg_restore --list`、格式、major 版本、可选 schema 版本、临时空间和目标目录可写性。 +3. 执行恢复。命令先在 safety directory 创建现有数据库 dump,旧配置、密码和密钥环也会重命名为 `.pre-restore-*`,可人工回退。 +4. 修正文件所有者与权限,启动应用,检查 `/api/auth/allowRegister`、登录、订阅和文件浏览。 + +```bash +sudo systemctl stop sdw-redive +sudo -u sdw-redive --preserve-env=PGHOST,PGPORT,PGUSER,PGPASSWORD,PGDATABASE \ + sdw-backup restore /var/lib/sdw-redive/backups/sdw-backup-....tar.gz \ + --confirm-replace \ + --expected-version 2.3.0 \ + --expected-schema 20260801000000_ExpectedMigration \ + --config-destination /etc/sdw-redive/appsettings.yml \ + --password-destination /var/lib/sdw-redive/password.json \ + --key-ring-destination /var/lib/sdw-redive/data-protection-keys +sudo chown root:sdw-redive /etc/sdw-redive/appsettings.yml +sudo chmod 0640 /etc/sdw-redive/appsettings.yml +sudo chown -R sdw-redive:sdw-redive /var/lib/sdw-redive/data-protection-keys \ + /var/lib/sdw-redive/password.json +sudo systemctl start sdw-redive +curl --fail http://127.0.0.1:5097/api/auth/allowRegister +``` + +容器恢复时停止应用,然后用临时容器运行 backup entrypoint。Compose 文件在容器内只读,所以先把恢复出的配置放到宿主机可写的 backups 目录,再由管理员检查并替换: + +```bash +podman-compose stop sdw-redive +podman-compose run --rm --no-deps --entrypoint sdw-backup sdw-redive \ + restore /app/backups/sdw-backup-....tar.gz \ + --confirm-replace --expected-version 2.3.0 \ + --config-destination /app/backups/restored/podman-compose.yml \ + --password-destination /app/data/password.json \ + --key-ring-destination /app/data/data-protection-keys \ + --safety-directory /app/backups +# 检查并在宿主机替换 podman-compose.yml,然后: +podman-compose up -d +curl --fail http://127.0.0.1:5097/api/auth/allowRegister +``` + +如果版本、schema 或空间检查失败,数据库不会被写入。只有在经过人工评估后才能使用 `--allow-version-mismatch`;该开关仍不会跳过格式、校验和、dump 与空间验证。 + +## 逻辑 JSON 导出与导入 + +JWT 管理员可按类别迁移非秘密业务数据:`feeds`、`automation-policies`、`filename-rules`、`metadata-corrections`、`playback`,或 `all`。 + +```bash +curl --fail -H "Authorization: Bearer $TOKEN" \ + 'https://sdw.example/api/data-transfer/export?categories=feeds,automation-policies,playback' \ + -o logical-export.json + +jq '. + {conflictStrategy:"skip"}' logical-export.json > logical-import.json +curl --fail -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \ + --data-binary @logical-import.json \ + https://sdw.example/api/data-transfer/import +``` + +导出 envelope 内的 SHA-256 在任何写入前验证。`skip` 可安全重复导入;`overwrite` 更新稳定键冲突项;`fail` 在首个冲突处返回 409,事务不会提交。订阅以 URL、规则以 TMDB id + pattern、人工修正以 release URL + title + publish time、播放进度以虚拟路径匹配。目标实例缺少对应 release 或虚拟文件时会明确计入 skipped,不会制造指向不存在媒体的记录。人工修正迁移当前元数据和审计操作,但不会覆盖物理路径;映射仍由目标实例的文件映射流程负责。 + +逻辑导出不含 JWT、登录密码、WebDAV token、Data Protection key、AI/qBittorrent 凭据、聊天内容或媒体文件。跨 major 格式、不匹配校验和、未知类别、非法数值与超大类别会在事务开始前拒绝。 diff --git a/docs/container-deployment.md b/docs/container-deployment.md index f14757e..18ec411 100644 --- a/docs/container-deployment.md +++ b/docs/container-deployment.md @@ -215,6 +215,8 @@ podman-compose down -v ## 更新 +更新前建议先执行并验证一次快照;完整的定时、加密和灾难恢复流程见 [备份、恢复与逻辑数据迁移](backup-restore.md)。数据库备份不包含 `downloads` 或外部媒体目录,这些卷需要独立快照。 + ```bash # 拉取最新镜像 podman-compose pull sdw-redive diff --git a/docs/server-deployment.md b/docs/server-deployment.md index 5814636..35fecd0 100644 --- a/docs/server-deployment.md +++ b/docs/server-deployment.md @@ -218,6 +218,8 @@ Environment=ASPNETCORE_URLS=http://0.0.0.0:8080 ## 服务管理 +正式升级或修改数据库前,建议先运行 `sdw-backup create` 并执行 `sdw-backup verify`。系统包还提供可选的每日 systemd timer;RPO/RTO、加密、恢复演练和逻辑 JSON 迁移详见 [备份、恢复与逻辑数据迁移](backup-restore.md)。媒体文件不包含在数据库备份中,必须单独保护。 + ```bash # 启动服务 sudo systemctl start sdw-redive diff --git a/packaging/backup.env b/packaging/backup.env new file mode 100644 index 0000000..4b8684b --- /dev/null +++ b/packaging/backup.env @@ -0,0 +1,17 @@ +# Protect this file as a secret (root:sdw-redive, mode 0640). +PGHOST=localhost +PGPORT=5432 +PGUSER=sdw +PGDATABASE=sdw +PGPASSWORD=CHANGE_ME + +SDW_BACKUP_DIRECTORY=/var/lib/sdw-redive/backups +SDW_BACKUP_RETENTION_DAYS=14 +SDW_CONFIG_PATH=/etc/sdw-redive/appsettings.yml +SDW_PASSWORD_FILE=/var/lib/sdw-redive/password.json +SDW_KEY_RING_PATH=/var/lib/sdw-redive/data-protection-keys +SDW_PLUGIN_DIRECTORY=/var/lib/sdw-redive/plugins + +# Optional age encryption and generic failure webhook. Neither value is logged. +# SDW_BACKUP_AGE_RECIPIENT=age1... +# SDW_BACKUP_FAILURE_WEBHOOK=https://monitor.example/hooks/... diff --git a/packaging/nfpm.yaml b/packaging/nfpm.yaml index 3dcd289..d3ce403 100644 --- a/packaging/nfpm.yaml +++ b/packaging/nfpm.yaml @@ -43,6 +43,28 @@ contents: file_info: mode: 0755 + - src: ./deployments/sdw-backup + dst: /usr/bin/sdw-backup + file_info: + mode: 0755 + + - src: ./packaging/sdw-backup.service + dst: /usr/lib/systemd/system/sdw-backup.service + + - src: ./packaging/sdw-backup.timer + dst: /usr/lib/systemd/system/sdw-backup.timer + + - src: ./packaging/backup.env + dst: /etc/sdw-redive/backup.env + type: config|noreplace + file_info: + mode: 0640 + owner: root + group: sdw-redive + + - src: ./VERSION + dst: /usr/lib/sdw-redive/VERSION + - src: ./LICENSE dst: /usr/share/doc/sdw-redive/LICENSE @@ -77,6 +99,13 @@ contents: owner: sdw-redive group: sdw-redive + - dst: /var/lib/sdw-redive/backups + type: dir + file_info: + mode: 0700 + owner: sdw-redive + group: sdw-redive + scripts: postinstall: ./packaging/postinstall.sh preremove: ./packaging/preremove.sh diff --git a/packaging/postinstall.sh b/packaging/postinstall.sh index 1530a32..10cd22a 100755 --- a/packaging/postinstall.sh +++ b/packaging/postinstall.sh @@ -19,6 +19,10 @@ if [ -f "$CONFIG" ]; then chown root:sdw-redive "$CONFIG" chmod 0640 "$CONFIG" fi +if [ -f /etc/sdw-redive/backup.env ]; then + chown root:sdw-redive /etc/sdw-redive/backup.env + chmod 0640 /etc/sdw-redive/backup.env +fi # Add sdw-redive to valkey group if it exists (for Unix socket access) if getent group valkey >/dev/null 2>&1; then @@ -43,7 +47,8 @@ chown -R sdw-redive:sdw-redive /var/lib/sdw-redive # Data Protection keys and the password hash are service-owned secrets. The # private directory also protects keys created by future application runs. install -d -m 0700 -o sdw-redive -g sdw-redive \ - /var/lib/sdw-redive/data-protection-keys + /var/lib/sdw-redive/data-protection-keys \ + /var/lib/sdw-redive/backups if [ -f /var/lib/sdw-redive/password.json ]; then chown sdw-redive:sdw-redive /var/lib/sdw-redive/password.json chmod 0600 /var/lib/sdw-redive/password.json diff --git a/packaging/sdw-backup.service b/packaging/sdw-backup.service new file mode 100644 index 0000000..cd14bd4 --- /dev/null +++ b/packaging/sdw-backup.service @@ -0,0 +1,16 @@ +[Unit] +Description=Create a verified SecondDimensionWatcher Re:Dive backup +After=network-online.target postgresql.service +Wants=network-online.target + +[Service] +Type=oneshot +User=sdw-redive +Group=sdw-redive +EnvironmentFile=/etc/sdw-redive/backup.env +ExecStart=/usr/bin/sdw-backup create +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=strict +ProtectHome=true +ReadWritePaths=/var/lib/sdw-redive/backups diff --git a/packaging/sdw-backup.timer b/packaging/sdw-backup.timer new file mode 100644 index 0000000..4af178b --- /dev/null +++ b/packaging/sdw-backup.timer @@ -0,0 +1,11 @@ +[Unit] +Description=Daily SecondDimensionWatcher Re:Dive backup + +[Timer] +OnCalendar=*-*-* 03:30:00 +Persistent=true +RandomizedDelaySec=30m +Unit=sdw-backup.service + +[Install] +WantedBy=timers.target From 30192c9d34a52924dce8692212d9af198a36bb17 Mon Sep 17 00:00:00 2001 From: mahoshojoHCG Date: Sat, 29 Aug 2026 23:20:06 +0800 Subject: [PATCH 02/37] feat: add server-side HLS transcoding --- .github/workflows/build.yml | 5 + Containerfile | 3 + ...ffmpeg-ffmpeg-npm-0.12.15-ef17f09f1b.patch | 47 - .../mock-server.mjs | 98 +- .../package.json | 3 +- .../src/i18n/locales/en/player.json | 24 +- .../src/i18n/locales/ja/player.json | 22 +- .../src/i18n/locales/zh-CN/player.json | 22 +- .../src/pages/PlayerPage.tsx | 237 ++++- .../playback/mkv/serverTranscoding.test.ts | 84 ++ .../src/playback/mkv/transcoder.ts | 397 -------- .../src/playback/serverTranscoding.ts | 121 +++ .../src/types/parcel-assets.d.ts | 10 - SecondDimensionWatcherReDive.Client/yarn.lock | 42 +- .../Helpers/FakeTranscodingService.cs | 103 ++ .../Transcoding/TranscodingApiTests.cs | 72 ++ .../WebDavWebApplicationFactory.cs | 5 + .../FfmpegProcessRunnerTests.cs | 126 +++ .../HlsTranscodingServiceTests.cs | 480 +++++++++ .../TranscodingControllerTests.cs | 178 ++++ .../TranscodingPlannerTests.cs | 145 +++ .../External/AppJsonSerializerContext.cs | 5 + .../Controllers/External/Transcoding.cs | 50 + .../Controllers/FileController.cs | 28 +- .../Controllers/TranscodingController.cs | 237 +++++ SecondDimensionWatcherReDive/Program.cs | 36 + .../Transcoding/FfmpegProcessRunner.cs | 545 ++++++++++ .../Transcoding/HlsTranscodingService.cs | 945 ++++++++++++++++++ .../Transcoding/IHlsTranscodingService.cs | 44 + .../Services/Transcoding/ScopeOwnedStream.cs | 97 ++ .../Transcoding/TranscodingMetrics.cs | 107 ++ .../Services/Transcoding/TranscodingModels.cs | 161 +++ .../Transcoding/TranscodingOptions.cs | 27 + .../Transcoding/TranscodingPlanner.cs | 126 +++ .../Utils/FileStore/PlaybackPathResolver.cs | 29 + .../appsettings.example.json | 23 + THIRD_PARTY_NOTICES.md | 31 +- deployments/podman-compose.yml | 1 + docs/container-deployment.md | 12 +- docs/server-deployment.md | 53 + packaging/appsettings.yml | 21 + packaging/nfpm.yaml | 9 + packaging/postinstall.sh | 2 + 43 files changed, 4196 insertions(+), 617 deletions(-) delete mode 100644 SecondDimensionWatcherReDive.Client/.yarn/patches/@ffmpeg-ffmpeg-npm-0.12.15-ef17f09f1b.patch create mode 100644 SecondDimensionWatcherReDive.Client/src/playback/mkv/serverTranscoding.test.ts delete mode 100644 SecondDimensionWatcherReDive.Client/src/playback/mkv/transcoder.ts create mode 100644 SecondDimensionWatcherReDive.Client/src/playback/serverTranscoding.ts create mode 100644 SecondDimensionWatcherReDive.IntegrationTest/Helpers/FakeTranscodingService.cs create mode 100644 SecondDimensionWatcherReDive.IntegrationTest/Transcoding/TranscodingApiTests.cs create mode 100644 SecondDimensionWatcherReDive.Test/FfmpegProcessRunnerTests.cs create mode 100644 SecondDimensionWatcherReDive.Test/HlsTranscodingServiceTests.cs create mode 100644 SecondDimensionWatcherReDive.Test/TranscodingControllerTests.cs create mode 100644 SecondDimensionWatcherReDive.Test/TranscodingPlannerTests.cs create mode 100644 SecondDimensionWatcherReDive/Controllers/External/Transcoding.cs create mode 100644 SecondDimensionWatcherReDive/Controllers/TranscodingController.cs create mode 100644 SecondDimensionWatcherReDive/Services/Transcoding/FfmpegProcessRunner.cs create mode 100644 SecondDimensionWatcherReDive/Services/Transcoding/HlsTranscodingService.cs create mode 100644 SecondDimensionWatcherReDive/Services/Transcoding/IHlsTranscodingService.cs create mode 100644 SecondDimensionWatcherReDive/Services/Transcoding/ScopeOwnedStream.cs create mode 100644 SecondDimensionWatcherReDive/Services/Transcoding/TranscodingMetrics.cs create mode 100644 SecondDimensionWatcherReDive/Services/Transcoding/TranscodingModels.cs create mode 100644 SecondDimensionWatcherReDive/Services/Transcoding/TranscodingOptions.cs create mode 100644 SecondDimensionWatcherReDive/Services/Transcoding/TranscodingPlanner.cs create mode 100644 SecondDimensionWatcherReDive/Utils/FileStore/PlaybackPathResolver.cs diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 98cb3e1..6b635da 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -26,6 +26,11 @@ jobs: with: dotnet-version: '10.0.x' + - name: Install FFmpeg + run: | + sudo apt-get update + sudo apt-get install -y ffmpeg + - name: Run tests run: >- dotnet test SecondDimensionWatcherReDive.slnx -c Release diff --git a/Containerfile b/Containerfile index 72b49a9..5468f19 100644 --- a/Containerfile +++ b/Containerfile @@ -21,6 +21,9 @@ RUN dotnet publish SecondDimensionWatcherReDive/SecondDimensionWatcherReDive.csp # Stage 3: Runtime FROM mcr.microsoft.com/dotnet/aspnet:10.0 WORKDIR /app +RUN apt-get update \ + && apt-get install -y --no-install-recommends ffmpeg \ + && rm -rf /var/lib/apt/lists/* COPY --from=backend-build /app . EXPOSE 8080 # Optional: read-only NFSv4 export (set Nfs:Enabled=true to activate; publish port at run time). diff --git a/SecondDimensionWatcherReDive.Client/.yarn/patches/@ffmpeg-ffmpeg-npm-0.12.15-ef17f09f1b.patch b/SecondDimensionWatcherReDive.Client/.yarn/patches/@ffmpeg-ffmpeg-npm-0.12.15-ef17f09f1b.patch deleted file mode 100644 index 94376e5..0000000 --- a/SecondDimensionWatcherReDive.Client/.yarn/patches/@ffmpeg-ffmpeg-npm-0.12.15-ef17f09f1b.patch +++ /dev/null @@ -1,47 +0,0 @@ -diff --git a/dist/esm/worker.js b/dist/esm/worker.js -index cca2a6116bab5349cfa35bc1dba0b6e696af91ee..84c1e701e92810b722a186a941db0a56a615407f 100644 ---- a/dist/esm/worker.js -+++ b/dist/esm/worker.js -@@ -6,21 +6,29 @@ import { ERROR_UNKNOWN_MESSAGE_TYPE, ERROR_NOT_LOADED, ERROR_IMPORT_FAILURE, } f - let ffmpeg; - const load = async ({ coreURL: _coreURL, wasmURL: _wasmURL, workerURL: _workerURL, }) => { - const first = !ffmpeg; -+ if (!_coreURL) -+ _coreURL = CORE_URL; -+ // Parcel emits the core as a self-registering module without native ESM -+ // exports. Expose the registered entry explicitly before importing it in -+ // this module worker; this also avoids forbidden importScripts() calls. -+ const response = await fetch(_coreURL); -+ if (!response.ok) { -+ throw ERROR_IMPORT_FAILURE; -+ } -+ const bundledCore = await response.text(); -+ const exposedCore = bundledCore.replace(/([$_A-Za-z][\w$]*)\("([$_A-Za-z0-9]+)"\);(\s*(?:\}\)\(\);?)?\s*)$/, 'self.createFFmpegCore=$1("$2").default;$3'); -+ if (exposedCore === bundledCore) { -+ throw ERROR_IMPORT_FAILURE; -+ } -+ const coreBlobURL = URL.createObjectURL(new Blob([exposedCore], { type: 'text/javascript' })); - try { -- if (!_coreURL) -- _coreURL = CORE_URL; -- // when web worker type is `classic`. -- importScripts(_coreURL); -+ await import(/* @vite-ignore */ coreBlobURL); - } -- catch { -- if (!_coreURL || _coreURL === CORE_URL) -- _coreURL = CORE_URL.replace('/umd/', '/esm/'); -- // when web worker type is `module`. -- self.createFFmpegCore = (await import( -- /* @vite-ignore */ _coreURL)).default; -- if (!self.createFFmpegCore) { -- throw ERROR_IMPORT_FAILURE; -- } -+ finally { -+ URL.revokeObjectURL(coreBlobURL); -+ } -+ if (!self.createFFmpegCore) { -+ throw ERROR_IMPORT_FAILURE; - } - const coreURL = _coreURL; - const wasmURL = _wasmURL ? _wasmURL : _coreURL.replace(/.js$/g, ".wasm"); diff --git a/SecondDimensionWatcherReDive.Client/mock-server.mjs b/SecondDimensionWatcherReDive.Client/mock-server.mjs index f5ee4d4..a43bb6b 100644 --- a/SecondDimensionWatcherReDive.Client/mock-server.mjs +++ b/SecondDimensionWatcherReDive.Client/mock-server.mjs @@ -1369,7 +1369,14 @@ async function route(method, pathname, searchParams, req, res) { } // --- All remaining endpoints require auth --- - if (!hasAuth(req) && !pathname.startsWith("/api/auth/")) { + const publicTranscodingSession = + (method === "GET" || method === "DELETE") && + pathname.startsWith("/api/transcoding/sessions/"); + if ( + !hasAuth(req) && + !pathname.startsWith("/api/auth/") && + !publicTranscodingSession + ) { return empty(res, 401); } @@ -2498,6 +2505,95 @@ async function route(method, pathname, searchParams, req, res) { // --- Files --- + if (method === "POST" && pathname === "/api/transcoding/prepare") { + const sessionId = randomUUID(); + const token = randomBytes(32).toString("hex"); + const base = `/api/transcoding/sessions/${sessionId}`; + return json(res, { + sessionId, + state: "ready", + strategy: "remux", + isPlayable: true, + cacheHit: false, + progress: 1, + speed: 8.5, + queuePosition: null, + error: null, + videoCodec: "h264", + audioCodec: "aac", + statusUrl: `${base}?token=${token}`, + cancelUrl: `${base}?token=${token}`, + playbackUrl: `${base}/media.m3u8?token=${token}`, + subtitles: [], + unsupportedSubtitleCount: 0, + }); + } + + const transcodeSessionMatch = pathname.match( + /^\/api\/transcoding\/sessions\/([^/]+)\/([^/]+)(?:\/([^/]+))?$/, + ); + if (method === "GET" && transcodeSessionMatch?.[2] === "media.m3u8") { + const sessionId = transcodeSessionMatch[1]; + const token = searchParams.get("token") ?? ""; + res.writeHead(200, { + "Content-Type": "application/vnd.apple.mpegurl", + "Cache-Control": "no-cache, no-store", + }); + return res.end( + `#EXTM3U\n#EXT-X-VERSION:3\n#EXTINF:6,\n/api/transcoding/sessions/${sessionId}/segments/segment-000000.ts?token=${token}\n#EXT-X-ENDLIST\n`, + ); + } + if ( + method === "GET" && + transcodeSessionMatch?.[2] === "segments" && + transcodeSessionMatch?.[3] + ) { + res.writeHead(200, { "Content-Type": "video/mp2t" }); + return res.end("Mock HLS segment"); + } + const transcodeStatusMatch = pathname.match( + /^\/api\/transcoding\/sessions\/([^/]+)$/, + ); + if (method === "GET" && transcodeStatusMatch) { + const sessionId = transcodeStatusMatch[1]; + const token = searchParams.get("token") ?? ""; + const base = `/api/transcoding/sessions/${sessionId}`; + return json(res, { + sessionId, + state: "ready", + strategy: "remux", + isPlayable: true, + cacheHit: true, + progress: 1, + speed: 8.5, + queuePosition: null, + error: null, + videoCodec: "h264", + audioCodec: "aac", + statusUrl: `${base}?token=${token}`, + cancelUrl: `${base}?token=${token}`, + playbackUrl: `${base}/media.m3u8?token=${token}`, + subtitles: [], + unsupportedSubtitleCount: 0, + }); + } + if (method === "DELETE" && transcodeStatusMatch) return empty(res, 204); + + if (method === "GET" && pathname === "/api/transcoding/metrics") { + return json(res, { + queuedJobs: 0, + activeJobs: 0, + completedJobs: 1, + failedJobs: 0, + canceledJobs: 0, + cacheHits: 1, + cacheBytes: 1048576, + averageFirstSegmentSeconds: 0.8, + averageTranscodeSpeed: 8.5, + failureRate: 0, + }); + } + if (method === "GET" && pathname === "/api/file/list") { const id = searchParams.get("id"); const relativeDir = searchParams.get("relativeDir") ?? ""; diff --git a/SecondDimensionWatcherReDive.Client/package.json b/SecondDimensionWatcherReDive.Client/package.json index 5f71a25..e7147e1 100644 --- a/SecondDimensionWatcherReDive.Client/package.json +++ b/SecondDimensionWatcherReDive.Client/package.json @@ -1,7 +1,5 @@ { "dependencies": { - "@ffmpeg/core": "^0.12.10", - "@ffmpeg/ffmpeg": "patch:@ffmpeg/ffmpeg@npm%3A0.12.15#~/.yarn/patches/@ffmpeg-ffmpeg-npm-0.12.15-ef17f09f1b.patch", "@radix-ui/react-dialog": "^1.1.23", "@radix-ui/react-dropdown-menu": "^2.1.24", "@radix-ui/react-progress": "^1.1.16", @@ -12,6 +10,7 @@ "artplayer-proxy-mediabunny": "^1.2.0", "clsx": "^2.1.1", "dayjs": "^1.11.23", + "hls.js": "^1.7.1", "i18next": "^26.4.0", "i18next-browser-languagedetector": "^8.2.1", "lucide-react": "^1.34.0", diff --git a/SecondDimensionWatcherReDive.Client/src/i18n/locales/en/player.json b/SecondDimensionWatcherReDive.Client/src/i18n/locales/en/player.json index 2c3c475..0225168 100644 --- a/SecondDimensionWatcherReDive.Client/src/i18n/locales/en/player.json +++ b/SecondDimensionWatcherReDive.Client/src/i18n/locales/en/player.json @@ -56,25 +56,27 @@ "mkv": { "mode": { "demuxed": "Browser MKV demux", - "transcoded": "Browser transcoded" + "serverRemuxed": "Server HLS remux", + "serverTranscoded": "Server HLS transcode" }, "stages": { "probing": "Inspecting MKV audio and video tracks", "extractingSubtitles": "Extracting and converting embedded subtitles", - "loadingTranscoder": "Loading the browser transcoder", - "downloading": "Downloading the MKV for browser conversion", - "readingTracks": "Reading MKV track information", - "convertingSubtitles": "Converting embedded subtitles to WebVTT", - "transcodingVideo": "Converting unsupported audio or video codecs", - "finalizing": "Preparing the playable video" + "serverQueued": "Waiting in the server transcoding queue (position {{position}})", + "serverProbing": "The server is inspecting media tracks", + "serverRemuxing": "Remuxing compatible tracks into HLS", + "serverTranscoding": "Transcoding unsupported tracks into HLS", + "serverFinalizing": "Streaming generated HLS segments while the server finishes" }, - "playbackPreparationFailed": "This MKV could not be demuxed or converted in the browser", + "playbackPreparationFailed": "The server could not prepare this MKV for browser playback", "subtitleExtractionFailed": "The MKV can play, but its embedded subtitles could not be extracted", - "transcodeNotice": "Browser software conversion downloads the complete file and can take a while. Keep this page open.", + "serverNotice": "Playback starts as soon as the first server segment is ready; the source file is not downloaded to this device.", + "subtitleNotice": "The video is ready while compatible embedded subtitles are prepared in the background.", + "cacheHit": "Cached segments", "codecSummary": "MKV demuxed automatically · video {{video}} · audio {{audio}}", "noAudio": "none", - "bitmapSubtitlesSkipped_one": "Skipped {{count}} subtitle track that the browser could not convert", - "bitmapSubtitlesSkipped_other": "Skipped {{count}} subtitle tracks that the browser could not convert" + "bitmapSubtitlesSkipped_one": "{{count}} bitmap subtitle track is unavailable unless server burn-in is enabled", + "bitmapSubtitlesSkipped_other": "{{count}} bitmap subtitle tracks are unavailable unless server burn-in is enabled" }, "next": { "play": "Next episode", diff --git a/SecondDimensionWatcherReDive.Client/src/i18n/locales/ja/player.json b/SecondDimensionWatcherReDive.Client/src/i18n/locales/ja/player.json index 4e78732..34d486f 100644 --- a/SecondDimensionWatcherReDive.Client/src/i18n/locales/ja/player.json +++ b/SecondDimensionWatcherReDive.Client/src/i18n/locales/ja/player.json @@ -53,24 +53,26 @@ "mkv": { "mode": { "demuxed": "ブラウザーで MKV 分離", - "transcoded": "ブラウザーで変換済み" + "serverRemuxed": "サーバー HLS リマックス", + "serverTranscoded": "サーバー HLS 変換" }, "stages": { "probing": "MKV の映像・音声トラックを確認しています", "extractingSubtitles": "内蔵字幕を抽出して変換しています", - "loadingTranscoder": "ブラウザー変換エンジンを読み込んでいます", - "downloading": "ブラウザー変換用に MKV をダウンロードしています", - "readingTracks": "MKV のトラック情報を読み取っています", - "convertingSubtitles": "内蔵字幕を WebVTT に変換しています", - "transcodingVideo": "未対応の映像・音声コーデックを変換しています", - "finalizing": "再生可能な動画を準備しています" + "serverQueued": "サーバー変換待ちです(キュー {{position}} 番)", + "serverProbing": "サーバーがメディアトラックを確認しています", + "serverRemuxing": "互換トラックを HLS にリマックスしています", + "serverTranscoding": "未対応トラックを HLS に変換しています", + "serverFinalizing": "生成済み HLS を再生しながら残りを処理しています" }, - "playbackPreparationFailed": "この MKV をブラウザーで分離または変換できませんでした", + "playbackPreparationFailed": "サーバーでこの MKV をブラウザー再生用に準備できませんでした", "subtitleExtractionFailed": "MKV は再生できますが、内蔵字幕を抽出できませんでした", - "transcodeNotice": "ブラウザーでのソフトウェア変換はファイル全体をダウンロードするため、時間がかかる場合があります。このページを開いたままにしてください。", + "serverNotice": "最初のサーバー分割ができ次第再生し、元ファイル全体は端末へダウンロードしません。", + "subtitleNotice": "動画は再生可能です。対応する内蔵字幕をバックグラウンドで準備しています。", + "cacheHit": "キャッシュ済み分割を再利用", "codecSummary": "MKV を自動分離しました · 映像 {{video}} · 音声 {{audio}}", "noAudio": "なし", - "bitmapSubtitlesSkipped": "ブラウザーで変換できない字幕トラック {{count}} 件をスキップしました" + "bitmapSubtitlesSkipped": "画像字幕 {{count}} 件は利用できません。サーバー側の焼き込み設定で有効化できます" }, "next": { "play": "次のエピソード", diff --git a/SecondDimensionWatcherReDive.Client/src/i18n/locales/zh-CN/player.json b/SecondDimensionWatcherReDive.Client/src/i18n/locales/zh-CN/player.json index 63a4d8b..417d87f 100644 --- a/SecondDimensionWatcherReDive.Client/src/i18n/locales/zh-CN/player.json +++ b/SecondDimensionWatcherReDive.Client/src/i18n/locales/zh-CN/player.json @@ -53,24 +53,26 @@ "mkv": { "mode": { "demuxed": "MKV 前端拆包", - "transcoded": "浏览器已转码" + "serverRemuxed": "服务端 HLS 无损封装", + "serverTranscoded": "服务端 HLS 转码" }, "stages": { "probing": "正在检查 MKV 音视频轨道", "extractingSubtitles": "正在拆出并转换内封字幕", - "loadingTranscoder": "正在加载浏览器转码器", - "downloading": "正在下载 MKV 以供前端转换", - "readingTracks": "正在读取 MKV 轨道信息", - "convertingSubtitles": "正在把内封字幕转换为 WebVTT", - "transcodingVideo": "正在转换浏览器不支持的音视频编码", - "finalizing": "正在生成可播放的视频" + "serverQueued": "正在等待服务端转码(队列第 {{position}} 位)", + "serverProbing": "服务端正在探测媒体轨道", + "serverRemuxing": "正在将兼容轨道无损封装为 HLS", + "serverTranscoding": "正在将不兼容轨道转为 HLS", + "serverFinalizing": "已开始播放生成的分片,服务端继续处理剩余内容" }, - "playbackPreparationFailed": "无法在浏览器中拆包或转换此 MKV", + "playbackPreparationFailed": "服务端无法为浏览器准备此 MKV", "subtitleExtractionFailed": "MKV 可以播放,但内封字幕拆出失败", - "transcodeNotice": "前端软件转码需要完整下载文件,首次处理可能较慢,请保持此页面打开。", + "serverNotice": "首个服务端分片就绪后即可播放,源文件不会完整下载到本设备。", + "subtitleNotice": "视频已可播放,兼容的内封字幕仍在后台准备。", + "cacheHit": "已复用缓存分片", "codecSummary": "MKV 已自动拆包 · 视频 {{video}} · 音频 {{audio}}", "noAudio": "无", - "bitmapSubtitlesSkipped": "已跳过 {{count}} 条浏览器无法转换的字幕轨道" + "bitmapSubtitlesSkipped": "{{count}} 条位图字幕不可用;管理员可配置服务端烧录" }, "next": { "play": "下一集", diff --git a/SecondDimensionWatcherReDive.Client/src/pages/PlayerPage.tsx b/SecondDimensionWatcherReDive.Client/src/pages/PlayerPage.tsx index a6d86cc..46edbe4 100644 --- a/SecondDimensionWatcherReDive.Client/src/pages/PlayerPage.tsx +++ b/SecondDimensionWatcherReDive.Client/src/pages/PlayerPage.tsx @@ -1,5 +1,6 @@ import Artplayer from "artplayer"; import artplayerProxyMediabunny from "artplayer-proxy-mediabunny"; +import Hls from "hls.js"; import { CaptionsFileFormat, CaptionsRenderer, @@ -38,15 +39,17 @@ import { } from "../playback/mkv/subtitles"; import { MkvPlaybackProbe, - canCopyVideoCodecToMp4, isAbortError, isMkvPath, probeMkvPlayback, } from "../playback/mkv/support"; import { - MkvTranscodeStage, - transcodeMkvForBrowser, -} from "../playback/mkv/transcoder"; + ServerTranscodingStrategy, + prepareServerTranscoding, + releaseServerTranscoding, + touchServerTranscoding, + watchServerTranscoding, +} from "../playback/serverTranscoding"; import { ExternalSubtitle, PlaybackPreferences, @@ -66,13 +69,21 @@ interface ResolvedSubtitle extends ExternalSubtitle { source: "external" | "embedded"; } -type PlaybackMode = "native" | "mkvProxy" | "transcoded"; +type PlaybackMode = "native" | "mkvProxy" | "hls"; type MkvPreparationStage = - "probing" | "extractingSubtitles" | MkvTranscodeStage; + | "probing" + | "extractingSubtitles" + | "serverQueued" + | "serverProbing" + | "serverRemuxing" + | "serverTranscoding" + | "serverFinalizing"; interface MkvPreparationStatus { stage: MkvPreparationStage; progress?: number; + queuePosition?: number; + speed?: number; } interface BrowserAudioTrack { @@ -248,6 +259,9 @@ export const PlayerPage: React.FC = () => { const [mkvStatus, setMkvStatus] = React.useState( null, ); + const [serverStrategy, setServerStrategy] = + React.useState(null); + const [serverCacheHit, setServerCacheHit] = React.useState(false); const [skippedSubtitleCount, setSkippedSubtitleCount] = React.useState(0); const [subtitleDiscoveryComplete, setSubtitleDiscoveryComplete] = React.useState(false); @@ -301,6 +315,8 @@ export const PlayerPage: React.FC = () => { setPlaybackMode("native"); setMkvProbe(null); setMkvStatus(null); + setServerStrategy(null); + setServerCacheHit(false); setSkippedSubtitleCount(0); setSubtitleDiscoveryComplete(false); setSubtitles([]); @@ -318,6 +334,9 @@ export const PlayerPage: React.FC = () => { if (!animationId || !playbackContext) return; let cancelled = false; let releasePreparedMedia: (() => void) | null = null; + let cancelServerUrl: string | null = null; + let serverKeepAliveTimer: ReturnType | null = + null; const controller = new AbortController(); setLinkLoading(true); setLinkError(null); @@ -357,7 +376,7 @@ export const PlayerPage: React.FC = () => { } catch (error) { if (isAbortError(error)) throw error; // A server/probe incompatibility should still get a chance to use - // the full-file software fallback. + // the server-side probe and streaming fallback. } if (cancelled) return; setMkvProbe(probe); @@ -414,35 +433,78 @@ export const PlayerPage: React.FC = () => { return; } - const transcoded = await transcodeMkvForBrowser( - videoLink.url, - controller.signal, - (update) => { - if (!cancelled) setMkvStatus(update); - }, + const initialSession = await prepareServerTranscoding( { - copyVideo: - probe?.videoDecodable === true && - canCopyVideoCodecToMp4(probe.videoCodec), + id: animationId, + path: playbackContext.media.path, + quality: "auto", + audioLanguage: playbackContext.preferences.audioLanguage, + audioTrackLabel: playbackContext.preferences.audioTrackLabel, + subtitleLanguage: playbackContext.preferences.subtitleLanguage, + subtitleTrackLabel: playbackContext.preferences.subtitleTrackLabel, }, + controller.signal, ); - if (cancelled) { - transcoded.release(); - return; + cancelServerUrl = initialSession.cancelUrl; + const readySession = await watchServerTranscoding( + initialSession, + controller.signal, + (session) => { + if (cancelled) return; + cancelServerUrl = session.cancelUrl; + setServerStrategy(session.strategy); + setServerCacheHit(session.cacheHit); + setSkippedSubtitleCount(session.unsupportedSubtitleCount); + if (session.subtitles.length > 0) { + setSubtitles([ + ...subtitleLinks, + ...session.subtitles.map((subtitle) => ({ + ...subtitle, + source: "embedded" as const, + })), + ]); + } + + if (session.isPlayable && session.playbackUrl) { + setPlaybackMode(session.strategy === "direct" ? "native" : "hls"); + setPlaybackUrl(session.playbackUrl); + setLinkLoading(false); + } + + if (session.state === "ready") { + setMkvStatus(null); + setSubtitleDiscoveryComplete(true); + return; + } + const stage: MkvPreparationStage = + session.state === "queued" + ? "serverQueued" + : session.state === "probing" + ? "serverProbing" + : session.strategy === "remux" + ? "serverRemuxing" + : session.isPlayable + ? "serverFinalizing" + : "serverTranscoding"; + setMkvStatus({ + stage, + progress: session.progress ?? undefined, + queuePosition: session.queuePosition ?? undefined, + speed: session.speed ?? undefined, + }); + }, + ); + if (!cancelled) { + serverKeepAliveTimer = globalThis.setInterval( + () => { + void touchServerTranscoding( + readySession.statusUrl, + controller.signal, + ).catch(() => undefined); + }, + 5 * 60 * 1000, + ); } - releasePreparedMedia = transcoded.release; - setSkippedSubtitleCount(transcoded.skippedSubtitleCount); - setPlaybackMode("transcoded"); - setPlaybackUrl(transcoded.url); - setSubtitles([ - ...subtitleLinks, - ...transcoded.subtitles.map((subtitle) => ({ - ...subtitle, - source: "embedded" as const, - })), - ]); - setSubtitleDiscoveryComplete(true); - setMkvStatus(null); } catch (error) { if (cancelled || isAbortError(error)) return; const message = i18n.t( @@ -463,6 +525,10 @@ export const PlayerPage: React.FC = () => { cancelled = true; controller.abort(); releasePreparedMedia?.(); + if (serverKeepAliveTimer !== null) { + globalThis.clearInterval(serverKeepAliveTimer); + } + if (cancelServerUrl) releaseServerTranscoding(cancelServerUrl); }; }, [ animationId, @@ -613,9 +679,39 @@ export const PlayerPage: React.FC = () => { ? "ja" : "en"; + let hls: Hls | null = null; const art = new Artplayer({ container: playerContainerRef.current, url: playbackUrl, + type: playbackMode === "hls" ? "m3u8" : undefined, + customType: + playbackMode === "hls" + ? { + m3u8: (video: HTMLVideoElement, url: string) => { + if (!Hls.isSupported()) { + video.src = url; + return; + } + hls = new Hls({ + backBufferLength: 90, + maxBufferLength: 30, + manifestLoadingMaxRetry: 6, + levelLoadingMaxRetry: 6, + fragLoadingMaxRetry: 6, + }); + hls.on(Hls.Events.ERROR, (_event, data) => { + if (!data.fatal || !hls) return; + if (data.type === Hls.ErrorTypes.NETWORK_ERROR) { + hls.startLoad(); + } else if (data.type === Hls.ErrorTypes.MEDIA_ERROR) { + hls.recoverMediaError(); + } + }); + hls.loadSource(url); + hls.attachMedia(video); + }, + } + : undefined, proxy: playbackMode === "mkvProxy" ? artplayerProxyMediabunny({ @@ -661,27 +757,40 @@ export const PlayerPage: React.FC = () => { captionsRendererRef.current = captionsRenderer; } + const applyInitialSeek = () => { + const context = contextRef.current; + if (!context || initialSeekAppliedRef.current) return; + const resumeAt = context.state?.positionSeconds ?? 0; + const duration = art.duration; + if ( + playbackMode === "hls" && + !context.state?.isWatched && + resumeAt >= 5 && + (!Number.isFinite(duration) || resumeAt >= duration - 10) + ) { + // The event playlist grows while FFmpeg works. Wait for the requested + // timestamp to appear instead of discarding the cross-device resume. + return; + } + if ( + !context.state?.isWatched && + resumeAt >= 5 && + Number.isFinite(duration) && + resumeAt < duration - 10 + ) { + art.currentTime = resumeAt; + art.notice.show = i18n.t("player:progress.resumed", { + time: new Date(resumeAt * 1000).toISOString().slice(11, 19), + }); + lastSyncedTimeRef.current = resumeAt; + } + initialSeekAppliedRef.current = true; + }; + const onLoadedMetadata = () => { const context = contextRef.current; if (!context) return; - - if (!initialSeekAppliedRef.current) { - const resumeAt = context.state?.positionSeconds ?? 0; - const duration = art.duration; - if ( - !context.state?.isWatched && - resumeAt >= 5 && - Number.isFinite(duration) && - resumeAt < duration - 10 - ) { - art.currentTime = resumeAt; - art.notice.show = i18n.t("player:progress.resumed", { - time: new Date(resumeAt * 1000).toISOString().slice(11, 19), - }); - lastSyncedTimeRef.current = resumeAt; - } - initialSeekAppliedRef.current = true; - } + applyInitialSeek(); const discoveredTracks = readAudioTracks( art.video as VideoWithAudioTracks, @@ -741,6 +850,7 @@ export const PlayerPage: React.FC = () => { const onBeforeUnload = () => persistCurrentProgressRef.current(true, true); art.on("video:loadedmetadata", onLoadedMetadata); + art.on("video:durationchange", applyInitialSeek); art.on("video:timeupdate", onTimeUpdate); art.on("video:pause", onPause); art.on("video:seeked", onSeeked); @@ -754,6 +864,7 @@ export const PlayerPage: React.FC = () => { window.removeEventListener("beforeunload", onBeforeUnload); captionsRenderer?.destroy(); captionsOverlay?.remove(); + hls?.destroy(); if (captionsRendererRef.current === captionsRenderer) { captionsRendererRef.current = null; } @@ -968,7 +1079,13 @@ export const PlayerPage: React.FC = () => { mkvStatus?.progress == null ? null : Math.round(Math.min(1, Math.max(0, mkvStatus.progress)) * 100); - const mkvStatusLabel = mkvStatus ? t(`mkv.stages.${mkvStatus.stage}`) : null; + const mkvStatusLabel = mkvStatus + ? t(`mkv.stages.${mkvStatus.stage}`, { + position: mkvStatus.queuePosition ?? 1, + }) + : null; + const mkvSpeedLabel = + mkvStatus?.speed == null ? null : `${mkvStatus.speed.toFixed(2)}×`; return ( @@ -985,6 +1102,7 @@ export const PlayerPage: React.FC = () => {

{mkvStatusLabel} {mkvProgressPercent == null ? "" : ` · ${mkvProgressPercent}%`} + {mkvSpeedLabel ? ` · ${mkvSpeedLabel}` : ""}

{mkvProgressPercent == null ? null : (
@@ -996,7 +1114,11 @@ export const PlayerPage: React.FC = () => { )} {mkvStatus?.stage === "probing" ? null : (

- {t("mkv.transcodeNotice")} + {t( + mkvStatus?.stage === "extractingSubtitles" + ? "mkv.subtitleNotice" + : "mkv.serverNotice", + )}

)}
@@ -1033,14 +1155,21 @@ export const PlayerPage: React.FC = () => { {t( playbackMode === "mkvProxy" ? "mkv.mode.demuxed" - : "mkv.mode.transcoded", + : serverStrategy === "remux" + ? "mkv.mode.serverRemuxed" + : "mkv.mode.serverTranscoded", )} ) : null} + {serverCacheHit ? ( + + {t("mkv.cacheHit")} + + ) : null}

{mkvStatusLabel - ? `${mkvStatusLabel}${mkvProgressPercent == null ? "" : ` · ${mkvProgressPercent}%`}` + ? `${mkvStatusLabel}${mkvProgressPercent == null ? "" : ` · ${mkvProgressPercent}%`}${mkvSpeedLabel ? ` · ${mkvSpeedLabel}` : ""}` : playbackMode === "mkvProxy" && mkvProbe ? t("mkv.codecSummary", { video: mkvProbe.videoCodec, diff --git a/SecondDimensionWatcherReDive.Client/src/playback/mkv/serverTranscoding.test.ts b/SecondDimensionWatcherReDive.Client/src/playback/mkv/serverTranscoding.test.ts new file mode 100644 index 0000000..c64390b --- /dev/null +++ b/SecondDimensionWatcherReDive.Client/src/playback/mkv/serverTranscoding.test.ts @@ -0,0 +1,84 @@ +import { + ServerTranscodingSession, + watchServerTranscoding, +} from "../serverTranscoding"; + +type TestCallback = () => void | Promise; +type TestFunction = (name: string, callback: TestCallback) => void; + +declare const require: (specifier: string) => unknown; + +const { rejects, strictEqual } = require("node:assert") as { + rejects: ( + promise: Promise, + check: (error: unknown) => boolean, + ) => Promise; + strictEqual: (actual: unknown, expected: unknown) => void; +}; +const { describe, it } = require("node:test") as { + describe: TestFunction; + it: TestFunction; +}; + +const createSession = ( + state: ServerTranscodingSession["state"], +): ServerTranscodingSession => ({ + sessionId: "session", + state, + strategy: state === "queued" ? null : "remux", + isPlayable: state === "ready", + cacheHit: false, + progress: null, + speed: null, + queuePosition: state === "queued" ? 1 : null, + error: state === "failed" ? "fixture failure" : null, + videoCodec: null, + audioCodec: null, + statusUrl: "/status", + cancelUrl: "/cancel", + playbackUrl: state === "ready" ? "/media.m3u8" : null, + subtitles: [], + unsupportedSubtitleCount: 0, +}); + +describe("watchServerTranscoding", () => { + it("returns an already-ready cache entry without polling", async () => { + const controller = new AbortController(); + let updates = 0; + const result = await watchServerTranscoding( + createSession("ready"), + controller.signal, + () => { + updates += 1; + }, + ); + + strictEqual(result.state, "ready"); + strictEqual(updates, 1); + }); + + it("surfaces terminal server failures", async () => { + await rejects( + watchServerTranscoding( + createSession("failed"), + new AbortController().signal, + () => undefined, + ), + (error) => error instanceof Error && error.message === "fixture failure", + ); + }); + + it("aborts queue polling when the player is closed", async () => { + const controller = new AbortController(); + const pending = watchServerTranscoding( + createSession("queued"), + controller.signal, + () => controller.abort(), + ); + + await rejects( + pending, + (error) => error instanceof DOMException && error.name === "AbortError", + ); + }); +}); diff --git a/SecondDimensionWatcherReDive.Client/src/playback/mkv/transcoder.ts b/SecondDimensionWatcherReDive.Client/src/playback/mkv/transcoder.ts deleted file mode 100644 index d1f6742..0000000 --- a/SecondDimensionWatcherReDive.Client/src/playback/mkv/transcoder.ts +++ /dev/null @@ -1,397 +0,0 @@ -import { FFFSType, FFmpeg } from "@ffmpeg/ffmpeg"; -import ffmpegCoreUrl from "url:@ffmpeg/core"; -import ffmpegWasmUrl from "url:@ffmpeg/core/wasm"; - -export type MkvTranscodeStage = - | "loadingTranscoder" - | "downloading" - | "readingTracks" - | "convertingSubtitles" - | "transcodingVideo" - | "finalizing"; - -export interface MkvTranscodeUpdate { - stage: MkvTranscodeStage; - progress?: number; -} - -export interface TranscodedMkvSubtitle { - path: string; - virtualPath: string; - language: string | null; - label: string; - format: "vtt"; - url: string; -} - -export interface TranscodedMkvResult { - url: string; - videoCodec: string; - audioCodec: string | null; - subtitles: TranscodedMkvSubtitle[]; - skippedSubtitleCount: number; - release: () => void; -} - -export interface MkvTranscodeOptions { - /** Preserve a browser-decodable video stream when only audio needs conversion. */ - copyVideo?: boolean; -} - -interface ProbeStream { - index: number; - codec_name?: string; - codec_type?: "video" | "audio" | "subtitle" | string; - disposition?: { - attached_pic?: number; - default?: number; - forced?: number; - }; - tags?: { - language?: string; - title?: string; - }; -} - -interface ProbeResult { - streams?: ProbeStream[]; -} - -interface ExtractTextSubtitlesResult { - subtitles: TranscodedMkvSubtitle[]; - skippedCount: number; -} - -const TEXT_SUBTITLE_CODECS = new Set([ - "ass", - "jacosub", - "microdvd", - "mov_text", - "mpl2", - "realtext", - "sami", - "ssa", - "subrip", - "subviewer", - "subviewer1", - "text", - "vplayer", - "webvtt", -]); - -const abortError = (): DOMException => - new DOMException("The operation was aborted", "AbortError"); - -const clampProgress = (value: number): number => - Math.min(1, Math.max(0, Number.isFinite(value) ? value : 0)); - -const uint8ArrayBuffer = (value: Uint8Array): ArrayBuffer => - value.buffer.slice( - value.byteOffset, - value.byteOffset + value.byteLength, - ) as ArrayBuffer; - -const fetchBlobWithProgress = async ( - url: string, - signal: AbortSignal, - onProgress: (progress?: number) => void, -): Promise => { - const response = await fetch(url, { signal }); - if (!response.ok) { - throw new Error(`Unable to download MKV (${response.status})`); - } - - const total = Number(response.headers.get("content-length")); - if (!response.body) { - const blob = await response.blob(); - onProgress(1); - return blob; - } - - const reader = response.body.getReader(); - let loaded = 0; - const stream = new ReadableStream({ - async pull(controller) { - if (signal.aborted) { - await reader.cancel(); - controller.error(abortError()); - return; - } - const { done, value } = await reader.read(); - if (done) { - controller.close(); - onProgress(1); - return; - } - loaded += value.byteLength; - onProgress( - Number.isFinite(total) && total > 0 ? loaded / total : undefined, - ); - controller.enqueue(value); - }, - cancel(reason) { - return reader.cancel(reason); - }, - }); - - return await new Response(stream, { - headers: { - "Content-Type": - response.headers.get("content-type") ?? "video/x-matroska", - }, - }).blob(); -}; - -const subtitleLabel = (stream: ProbeStream, ordinal: number): string => { - const title = stream.tags?.title?.trim(); - const language = stream.tags?.language?.trim(); - if (title) return title; - if (language) return `${language.toUpperCase()} · Embedded`; - return `Embedded subtitle ${ordinal}`; -}; - -const extractTextSubtitles = async ( - ffmpeg: FFmpeg, - inputPath: string, - streams: ProbeStream[], - signal: AbortSignal, -): Promise => { - const subtitles: TranscodedMkvSubtitle[] = []; - let skippedCount = 0; - - // Convert tracks independently. A malformed or nominally text subtitle must - // not prevent the audio/video fallback from producing playable media. - for (const [ordinal, stream] of streams.entries()) { - const outputPath = `/subtitle-${stream.index}.vtt`; - try { - const exitCode = await ffmpeg.exec( - [ - "-i", - inputPath, - "-map", - `0:${stream.index}`, - "-c:s", - "webvtt", - outputPath, - ], - -1, - { signal }, - ); - if (exitCode !== 0) { - skippedCount += 1; - continue; - } - - const data = await ffmpeg.readFile(outputPath, undefined, { signal }); - if (typeof data === "string") { - skippedCount += 1; - continue; - } - - const url = URL.createObjectURL( - new Blob([uint8ArrayBuffer(data)], { type: "text/vtt;charset=utf-8" }), - ); - subtitles.push({ - path: `__mkv_subtitle_${stream.index}`, - virtualPath: `mkv://subtitle/${stream.index}`, - language: stream.tags?.language ?? null, - label: subtitleLabel(stream, ordinal + 1), - format: "vtt", - url, - }); - } catch { - if (signal.aborted) throw abortError(); - skippedCount += 1; - } - } - - return { subtitles, skippedCount }; -}; - -/** - * Last-resort software conversion for codecs that WebCodecs cannot decode. - * WORKERFS avoids copying the input into the WebAssembly heap; the output is - * still materialized as a Blob because native playback needs a seekable file. - */ -export const transcodeMkvForBrowser = async ( - sourceUrl: string, - signal: AbortSignal, - onUpdate: (update: MkvTranscodeUpdate) => void, - options: MkvTranscodeOptions = {}, -): Promise => { - if (signal.aborted) throw abortError(); - - const ffmpeg = new FFmpeg(); - const createdUrls: string[] = []; - const recentLogs: string[] = []; - const logListener = ({ message }: { message: string }) => { - recentLogs.push(message); - if (recentLogs.length > 8) recentLogs.shift(); - }; - const conversionError = (message: string): Error => - new Error( - recentLogs.length > 0 ? `${message}: ${recentLogs.join(" | ")}` : message, - ); - ffmpeg.on("log", logListener); - let mounted = false; - const onAbort = () => ffmpeg.terminate(); - signal.addEventListener("abort", onAbort, { once: true }); - - try { - onUpdate({ stage: "loadingTranscoder" }); - await ffmpeg.load( - { coreURL: ffmpegCoreUrl, wasmURL: ffmpegWasmUrl }, - { signal }, - ); - - onUpdate({ stage: "downloading", progress: 0 }); - const sourceBlob = await fetchBlobWithProgress( - sourceUrl, - signal, - (progress) => onUpdate({ stage: "downloading", progress }), - ); - - await ffmpeg.createDir("/source", { signal }); - mounted = await ffmpeg.mount( - FFFSType.WORKERFS, - { blobs: [{ name: "episode.mkv", data: sourceBlob }] }, - "/source", - ); - if (!mounted) { - throw conversionError("Unable to mount the MKV in FFmpeg"); - } - const inputPath = "/source/episode.mkv"; - - onUpdate({ stage: "readingTracks" }); - const probePath = "/probe.json"; - const probeExitCode = await ffmpeg.ffprobe( - [ - "-v", - "error", - "-show_streams", - "-of", - "json", - inputPath, - "-o", - probePath, - ], - -1, - { signal }, - ); - let probeData: string | Uint8Array; - try { - probeData = await ffmpeg.readFile(probePath, "utf8", { signal }); - } catch { - throw conversionError( - `Unable to inspect MKV tracks (exit ${probeExitCode})`, - ); - } - const probe = JSON.parse(String(probeData)) as ProbeResult; - const streams = probe.streams ?? []; - const videoStream = streams.find( - (stream) => - stream.codec_type === "video" && stream.disposition?.attached_pic !== 1, - ); - if (!videoStream) throw new Error("The MKV file has no video track"); - const audioStream = streams.find((stream) => stream.codec_type === "audio"); - const subtitleStreams = streams.filter( - (stream) => stream.codec_type === "subtitle", - ); - const textSubtitleStreams = subtitleStreams.filter((stream) => - TEXT_SUBTITLE_CODECS.has(stream.codec_name?.toLowerCase() ?? ""), - ); - - onUpdate({ stage: "convertingSubtitles" }); - const subtitleExtraction = await extractTextSubtitles( - ffmpeg, - inputPath, - textSubtitleStreams, - signal, - ); - const subtitles = subtitleExtraction.subtitles; - createdUrls.push(...subtitles.map((subtitle) => subtitle.url)); - - onUpdate({ stage: "transcodingVideo", progress: 0 }); - recentLogs.length = 0; - const progressListener = ({ progress }: { progress: number }) => { - onUpdate({ - stage: "transcodingVideo", - progress: clampProgress(progress), - }); - }; - ffmpeg.on("progress", progressListener); - const outputPath = "/episode-browser.mp4"; - const transcodeExitCode = await ffmpeg.exec( - [ - "-i", - inputPath, - "-map", - `0:${videoStream.index}`, - ...(audioStream ? ["-map", `0:${audioStream.index}`] : []), - "-sn", - ...(options.copyVideo - ? ["-c:v", "copy"] - : [ - "-c:v", - "libx264", - "-preset", - "ultrafast", - "-crf", - "23", - "-pix_fmt", - "yuv420p", - ]), - ...(audioStream ? ["-c:a", "aac", "-b:a", "192k", "-ac", "2"] : []), - "-movflags", - "+faststart", - "-max_muxing_queue_size", - "1024", - outputPath, - ], - -1, - { signal }, - ); - ffmpeg.off("progress", progressListener); - if (transcodeExitCode !== 0) { - throw conversionError("Unable to convert the MKV video stream"); - } - - onUpdate({ stage: "finalizing" }); - const outputData = await ffmpeg.readFile(outputPath, undefined, { signal }); - if (typeof outputData === "string") { - throw new Error("FFmpeg returned an invalid video payload"); - } - const videoUrl = URL.createObjectURL( - new Blob([uint8ArrayBuffer(outputData)], { type: "video/mp4" }), - ); - createdUrls.push(videoUrl); - - let released = false; - return { - url: videoUrl, - videoCodec: videoStream.codec_name ?? "unknown", - audioCodec: audioStream?.codec_name ?? null, - subtitles, - skippedSubtitleCount: - subtitleStreams.length - - textSubtitleStreams.length + - subtitleExtraction.skippedCount, - release: () => { - if (released) return; - released = true; - createdUrls.forEach((url) => URL.revokeObjectURL(url)); - }, - }; - } catch (error) { - createdUrls.forEach((url) => URL.revokeObjectURL(url)); - if (signal.aborted) throw abortError(); - throw error; - } finally { - signal.removeEventListener("abort", onAbort); - ffmpeg.off("log", logListener); - if (mounted && !signal.aborted && ffmpeg.loaded) { - await ffmpeg.unmount("/source").catch(() => undefined); - } - ffmpeg.terminate(); - } -}; diff --git a/SecondDimensionWatcherReDive.Client/src/playback/serverTranscoding.ts b/SecondDimensionWatcherReDive.Client/src/playback/serverTranscoding.ts new file mode 100644 index 0000000..075c8cb --- /dev/null +++ b/SecondDimensionWatcherReDive.Client/src/playback/serverTranscoding.ts @@ -0,0 +1,121 @@ +import fetcher from "../auth/httpClient"; + +export type ServerTranscodingState = + "queued" | "probing" | "transcoding" | "ready" | "failed" | "canceled"; + +export type ServerTranscodingStrategy = "direct" | "remux" | "transcode"; + +export interface ServerTranscodingSubtitle { + path: string; + virtualPath: string; + language: string | null; + label: string; + format: "vtt"; + url: string; +} + +export interface ServerTranscodingSession { + sessionId: string; + state: ServerTranscodingState; + strategy: ServerTranscodingStrategy | null; + isPlayable: boolean; + cacheHit: boolean; + progress: number | null; + speed: number | null; + queuePosition: number | null; + error: string | null; + videoCodec: string | null; + audioCodec: string | null; + statusUrl: string; + cancelUrl: string; + playbackUrl: string | null; + subtitles: ServerTranscodingSubtitle[]; + unsupportedSubtitleCount: number; +} + +export interface PrepareServerTranscodingRequest { + id: string; + path: string; + quality?: "auto" | "720p" | "1080p"; + audioLanguage?: string | null; + audioTrackLabel?: string | null; + subtitleLanguage?: string | null; + subtitleTrackLabel?: string | null; +} + +const abortError = (): DOMException => + new DOMException("The operation was aborted", "AbortError"); + +const pollDelay = async ( + milliseconds: number, + signal: AbortSignal, +): Promise => { + if (signal.aborted) throw abortError(); + await new Promise((resolve, reject) => { + const onAbort = () => { + globalThis.clearTimeout(timeout); + reject(abortError()); + }; + const timeout = globalThis.setTimeout(() => { + signal.removeEventListener("abort", onAbort); + resolve(); + }, milliseconds); + signal.addEventListener("abort", onAbort, { once: true }); + }); +}; + +export const prepareServerTranscoding = async ( + request: PrepareServerTranscodingRequest, + signal: AbortSignal, +): Promise => + await fetcher("/api/transcoding/prepare", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(request), + signal, + }); + +export const watchServerTranscoding = async ( + initial: ServerTranscodingSession, + signal: AbortSignal, + onUpdate: (session: ServerTranscodingSession) => void, +): Promise => { + let current = initial; + let transientFailures = 0; + while (true) { + if (signal.aborted) throw abortError(); + onUpdate(current); + if (current.state === "ready") return current; + if (current.state === "failed" || current.state === "canceled") { + throw new Error(current.error || `Server transcoding ${current.state}`); + } + + await pollDelay(current.state === "queued" ? 1000 : 750, signal); + try { + const response = await fetch(current.statusUrl, { signal }); + if (!response.ok) + throw new Error(`Transcoding status ${response.status}`); + current = (await response.json()) as ServerTranscodingSession; + transientFailures = 0; + } catch (error) { + if (signal.aborted) throw abortError(); + transientFailures += 1; + if (transientFailures >= 3) throw error; + } + } +}; + +export const touchServerTranscoding = async ( + statusUrl: string, + signal: AbortSignal, +): Promise => { + const response = await fetch(statusUrl, { signal }); + if (!response.ok) throw new Error(`Transcoding status ${response.status}`); + await response.json(); +}; + +export const releaseServerTranscoding = (cancelUrl: string): void => { + void fetch(cancelUrl, { method: "DELETE", keepalive: true }).catch( + () => undefined, + ); +}; diff --git a/SecondDimensionWatcherReDive.Client/src/types/parcel-assets.d.ts b/SecondDimensionWatcherReDive.Client/src/types/parcel-assets.d.ts index 7f2ed7d..d80e98f 100644 --- a/SecondDimensionWatcherReDive.Client/src/types/parcel-assets.d.ts +++ b/SecondDimensionWatcherReDive.Client/src/types/parcel-assets.d.ts @@ -1,15 +1,5 @@ declare module "*.css"; -declare module "url:@ffmpeg/core" { - const url: string; - export default url; -} - -declare module "url:@ffmpeg/core/wasm" { - const url: string; - export default url; -} - declare module "bundle-text:*" { const source: string; export default source; diff --git a/SecondDimensionWatcherReDive.Client/yarn.lock b/SecondDimensionWatcherReDive.Client/yarn.lock index dbd57a3..c4f6f4c 100644 --- a/SecondDimensionWatcherReDive.Client/yarn.lock +++ b/SecondDimensionWatcherReDive.Client/yarn.lock @@ -330,38 +330,6 @@ __metadata: languageName: node linkType: hard -"@ffmpeg/core@npm:^0.12.10": - version: 0.12.10 - resolution: "@ffmpeg/core@npm:0.12.10" - checksum: 10/1385a695b5c3f2ceac75f59a4413c7e529e70b5eef9813d92fb929571e14124c26b8f760bfa89aefc442dc0bbb154c35bfde5a3b31b90a063a6507f7640ebef6 - languageName: node - linkType: hard - -"@ffmpeg/ffmpeg@npm:0.12.15": - version: 0.12.15 - resolution: "@ffmpeg/ffmpeg@npm:0.12.15" - dependencies: - "@ffmpeg/types": "npm:^0.12.4" - checksum: 10/8969f3e99be5ba318c6b2aa635703687d8c534d59a295042af28a395c5aa29d6338627200d4d81c339056c93b7e7782246450dfdebf9392081c3753a3d41028f - languageName: node - linkType: hard - -"@ffmpeg/ffmpeg@patch:@ffmpeg/ffmpeg@npm%3A0.12.15#~/.yarn/patches/@ffmpeg-ffmpeg-npm-0.12.15-ef17f09f1b.patch": - version: 0.12.15 - resolution: "@ffmpeg/ffmpeg@patch:@ffmpeg/ffmpeg@npm%3A0.12.15#~/.yarn/patches/@ffmpeg-ffmpeg-npm-0.12.15-ef17f09f1b.patch::version=0.12.15&hash=cbebad" - dependencies: - "@ffmpeg/types": "npm:^0.12.4" - checksum: 10/124f2a16e18f6dc7e9654f03a7ff05ec4a3f4459ca5e5507a7e5aa6801cef70076eadda43465e5e18443784fc35b4a77466ca8bad15a38e0ff9bbd237642d719 - languageName: node - linkType: hard - -"@ffmpeg/types@npm:^0.12.4": - version: 0.12.4 - resolution: "@ffmpeg/types@npm:0.12.4" - checksum: 10/8b898163e79945d2eba26f2a46f35a22d00296cb53424f16118060f28c36f694d74d48c49f6570f2974320dc089b7aa9f64c3a0fde45109ba125aa9e3c0926db - languageName: node - linkType: hard - "@floating-ui/core@npm:^1.8.0": version: 1.8.0 resolution: "@floating-ui/core@npm:1.8.0" @@ -404,8 +372,6 @@ __metadata: version: 0.0.0-use.local resolution: "@hcgstudio/sdwr-client@workspace:." dependencies: - "@ffmpeg/core": "npm:^0.12.10" - "@ffmpeg/ffmpeg": "patch:@ffmpeg/ffmpeg@npm%3A0.12.15#~/.yarn/patches/@ffmpeg-ffmpeg-npm-0.12.15-ef17f09f1b.patch" "@parcel/core": "npm:^2.16.4" "@parcel/transformer-inline-string": "npm:2.16.4" "@radix-ui/react-dialog": "npm:^1.1.23" @@ -423,6 +389,7 @@ __metadata: clsx: "npm:^2.1.1" color-convert: "npm:^3.1.3" dayjs: "npm:^1.11.23" + hls.js: "npm:^1.7.1" http-proxy-middleware: "npm:^4.2.0" i18next: "npm:^26.4.0" i18next-browser-languagedetector: "npm:^8.2.1" @@ -3692,6 +3659,13 @@ __metadata: languageName: node linkType: hard +"hls.js@npm:^1.7.1": + version: 1.7.1 + resolution: "hls.js@npm:1.7.1" + checksum: 10/5bf1c5ba1cbb82a4274f55afe62c361137bad7a60e6c7a3cfd881ca47a6883357b660c080982a7393c840bcd710c6ca97ad12094a2db72d0c0dbe1cc9e48edb1 + languageName: node + linkType: hard + "hpagent@npm:^1.2.0": version: 1.2.0 resolution: "hpagent@npm:1.2.0" diff --git a/SecondDimensionWatcherReDive.IntegrationTest/Helpers/FakeTranscodingService.cs b/SecondDimensionWatcherReDive.IntegrationTest/Helpers/FakeTranscodingService.cs new file mode 100644 index 0000000..17cb5d0 --- /dev/null +++ b/SecondDimensionWatcherReDive.IntegrationTest/Helpers/FakeTranscodingService.cs @@ -0,0 +1,103 @@ +using SecondDimensionWatcherReDive.Services.Transcoding; + +namespace SecondDimensionWatcherReDive.IntegrationTest.Helpers; + +internal sealed class FakeTranscodingService : IHlsTranscodingService +{ + public Guid SessionId { get; private set; } = Guid.NewGuid(); + public string Token { get; private set; } = "integration-transcoding-token"; + + public void Reset() + { + SessionId = Guid.NewGuid(); + Token = "integration-transcoding-token"; + } + + public Task PrepareAsync( + Guid animationInfoId, + string? relativePath, + TranscodingSelection selection, + CancellationToken cancellationToken) + => Task.FromResult(CreateStatus()); + + public Task GetStatusAsync( + Guid sessionId, + string accessToken, + CancellationToken cancellationToken) + => Task.FromResult(IsValid(sessionId, accessToken) ? CreateStatus() : null); + + public Task GetPlaylistAsync( + Guid sessionId, + string accessToken, + CancellationToken cancellationToken) + => Task.FromResult(IsValid(sessionId, accessToken) + ? "#EXTM3U\n#EXTINF:6,\nsegment-000000.ts\n#EXT-X-ENDLIST\n" + : null); + + public Task OpenSegmentAsync( + Guid sessionId, + string accessToken, + string fileName, + CancellationToken cancellationToken) + => Task.FromResult( + IsValid(sessionId, accessToken) && fileName == "segment-000000.ts" + ? new TranscodingContent( + new MemoryStream([1, 2, 3], writable: false), + "video/mp2t", + fileName, + 3, + DateTimeOffset.UnixEpoch) + : null); + + public Task OpenSubtitleAsync( + Guid sessionId, + string accessToken, + string fileName, + CancellationToken cancellationToken) + => Task.FromResult(null); + + public Task OpenDirectAsync( + Guid sessionId, + string accessToken, + CancellationToken cancellationToken) + => Task.FromResult(null); + + public Task CancelAsync( + Guid sessionId, + string accessToken, + CancellationToken cancellationToken) + => Task.FromResult(IsValid(sessionId, accessToken)); + + public Task GetMetricsAsync(CancellationToken cancellationToken) + => Task.FromResult(new TranscodingMetricsSnapshot( + 0, + 0, + 4, + 1, + 1, + 2, + 4096, + 0.75, + 3.2, + 0.2)); + + private bool IsValid(Guid sessionId, string accessToken) + => sessionId == SessionId && accessToken == Token; + + private TranscodingSessionStatus CreateStatus() + => new( + SessionId, + Token, + TranscodingJobState.Ready, + TranscodingStrategy.Remux, + true, + true, + 1, + 3.2, + null, + null, + "h264", + "aac", + [], + 0); +} diff --git a/SecondDimensionWatcherReDive.IntegrationTest/Transcoding/TranscodingApiTests.cs b/SecondDimensionWatcherReDive.IntegrationTest/Transcoding/TranscodingApiTests.cs new file mode 100644 index 0000000..419ca35 --- /dev/null +++ b/SecondDimensionWatcherReDive.IntegrationTest/Transcoding/TranscodingApiTests.cs @@ -0,0 +1,72 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; + +namespace SecondDimensionWatcherReDive.IntegrationTest.Transcoding; + +[TestClass] +public sealed class TranscodingApiTests +{ + private WebDavWebApplicationFactory _factory = null!; + + [TestInitialize] + public void Setup() + { + _factory = new WebDavWebApplicationFactory(); + _factory.ResetState(); + } + + [TestCleanup] + public void Cleanup() => _factory.Dispose(); + + [TestMethod] + public async Task PrepareRequiresJwtButTokenizedHlsResourcesAreAnonymous() + { + using var anonymous = _factory.CreateUnauthenticatedClient(); + using var unauthorized = await anonymous.PostAsJsonAsync( + "/api/transcoding/prepare", + new { id = Guid.NewGuid(), path = "episode.mkv", quality = "auto" }); + Assert.AreEqual(HttpStatusCode.Unauthorized, unauthorized.StatusCode); + + using var jwt = _factory.CreateJwtClient(); + using var prepared = await jwt.PostAsJsonAsync( + "/api/transcoding/prepare", + new { id = Guid.NewGuid(), path = "episode.mkv", quality = "auto" }); + Assert.AreEqual(HttpStatusCode.OK, prepared.StatusCode); + using var payload = JsonDocument.Parse(await prepared.Content.ReadAsStringAsync()); + var playbackUrl = payload.RootElement.GetProperty("playbackUrl").GetString(); + var statusUrl = payload.RootElement.GetProperty("statusUrl").GetString(); + Assert.IsNotNull(playbackUrl); + Assert.IsNotNull(statusUrl); + + using var status = await anonymous.GetAsync(statusUrl); + Assert.AreEqual(HttpStatusCode.OK, status.StatusCode); + using var playlist = await anonymous.GetAsync(playbackUrl); + Assert.AreEqual(HttpStatusCode.OK, playlist.StatusCode); + Assert.AreEqual("application/vnd.apple.mpegurl", playlist.Content.Headers.ContentType?.MediaType); + var segmentUrl = (await playlist.Content.ReadAsStringAsync()) + .Split('\n', StringSplitOptions.RemoveEmptyEntries) + .Single(line => !line.StartsWith('#')); + StringAssert.Contains(segmentUrl, _factory.TranscodingService.Token); + using var segment = await anonymous.GetAsync(segmentUrl); + Assert.AreEqual(HttpStatusCode.OK, segment.StatusCode); + CollectionAssert.AreEqual(new byte[] { 1, 2, 3 }, await segment.Content.ReadAsByteArrayAsync()); + } + + [TestMethod] + public async Task InvalidSessionTokenIsRejectedAndMetricsRequireJwt() + { + using var anonymous = _factory.CreateUnauthenticatedClient(); + using var invalid = await anonymous.GetAsync( + $"/api/transcoding/sessions/{_factory.TranscodingService.SessionId}?token=wrong"); + Assert.AreEqual(HttpStatusCode.NotFound, invalid.StatusCode); + using var unauthorizedMetrics = await anonymous.GetAsync("/api/transcoding/metrics"); + Assert.AreEqual(HttpStatusCode.Unauthorized, unauthorizedMetrics.StatusCode); + + using var jwt = _factory.CreateJwtClient(); + using var metrics = await jwt.GetAsync("/api/transcoding/metrics"); + Assert.AreEqual(HttpStatusCode.OK, metrics.StatusCode); + using var payload = JsonDocument.Parse(await metrics.Content.ReadAsStringAsync()); + Assert.AreEqual(0.2, payload.RootElement.GetProperty("failureRate").GetDouble()); + } +} diff --git a/SecondDimensionWatcherReDive.IntegrationTest/WebDavWebApplicationFactory.cs b/SecondDimensionWatcherReDive.IntegrationTest/WebDavWebApplicationFactory.cs index ae5cf79..379a089 100644 --- a/SecondDimensionWatcherReDive.IntegrationTest/WebDavWebApplicationFactory.cs +++ b/SecondDimensionWatcherReDive.IntegrationTest/WebDavWebApplicationFactory.cs @@ -18,6 +18,7 @@ using SecondDimensionWatcherReDive.Framework.Tasks; using SecondDimensionWatcherReDive.IntegrationTest.Helpers; using SecondDimensionWatcherReDive.MigrationTasks; +using SecondDimensionWatcherReDive.Services.Transcoding; using FileMapping = SecondDimensionWatcherReDive.Framework.DataRepository.FileMapping; using ApplicationContext = SecondDimensionWatcherReDive.Models.ApplicationContext; @@ -52,6 +53,7 @@ static WebDavWebApplicationFactory() public Mock FileStoreMock { get; } = new(); public Mock FileStoreProviderMock { get; } = new(); public Helpers.FakeFileMappingRepository MappingRepository { get; } + public FakeTranscodingService TranscodingService { get; } = new(); private readonly object _mappingsLock = new(); @@ -122,6 +124,7 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) services.RemoveAll(); services.RemoveAll(); services.RemoveAll(); + services.RemoveAll(); services.AddSingleton(FileStoreMock.Object); services.AddSingleton(FileStoreProviderMock.Object); @@ -130,6 +133,7 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) services.AddSingleton(_ => new FakeWebDavTokenRepository(TestUserName, BCrypt.Net.BCrypt.HashPassword(TestPassword))); services.AddSingleton(); + services.AddSingleton(TranscodingService); }); } @@ -146,6 +150,7 @@ public void ResetState() FileStoreProviderMock .Setup(p => p.GetClient(It.IsAny())) .Returns(FileStoreMock.Object); + TranscodingService.Reset(); } public HttpClient CreateBasicAuthClient(string user = TestUserName, string pass = TestPassword) diff --git a/SecondDimensionWatcherReDive.Test/FfmpegProcessRunnerTests.cs b/SecondDimensionWatcherReDive.Test/FfmpegProcessRunnerTests.cs new file mode 100644 index 0000000..c1a5114 --- /dev/null +++ b/SecondDimensionWatcherReDive.Test/FfmpegProcessRunnerTests.cs @@ -0,0 +1,126 @@ +using System.Diagnostics; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using SecondDimensionWatcherReDive.Services.Transcoding; + +namespace SecondDimensionWatcherReDive.Test; + +[TestClass] +public class FfmpegProcessRunnerTests +{ + [TestMethod] + public async Task ProbeAndGenerateHlsAsync_ProducesProgressivePlaylistFromPipeInput() + { + var root = Path.Combine(Path.GetTempPath(), $"sdw-ffmpeg-test-{Guid.NewGuid():N}"); + Directory.CreateDirectory(root); + try + { + var sourcePath = Path.Combine(root, "sample.mkv"); + await CreateSampleAsync(sourcePath, CancellationToken.None); + var options = Options.Create(new TranscodingOptions + { + FfmpegPath = "ffmpeg", + FfprobePath = "ffprobe", + MaxThreadsPerJob = 1, + SegmentDurationSeconds = 2, + MaxMemoryBytesPerJob = 1024L * 1024 * 1024, + MaxDiskBytesPerJob = 64L * 1024 * 1024 + }); + var runner = new FfmpegProcessRunner( + options, + NullLogger.Instance); + MediaProbe probe; + await using (var source = File.OpenRead(sourcePath)) + probe = await runner.ProbeAsync(source, CancellationToken.None); + var sourceInfo = new FileInfo(sourcePath); + var sourceModel = new TranscodingSource( + Guid.NewGuid(), + Guid.NewGuid(), + "/Anime/Group/sample.mkv", + sourcePath, + "test", + sourceInfo.Name, + sourceInfo.Length, + sourceInfo.LastWriteTimeUtc); + var selection = TranscodingSelection.Create("auto", null, null, null, null); + var plan = TranscodingPlanner.CreatePlan(sourceModel, probe, selection, false); + Assert.AreEqual(TranscodingStrategy.Remux, plan.Strategy); + + var output = Path.Combine(root, "hls"); + Directory.CreateDirectory(output); + var updates = new List(); + FfmpegRunResult result; + await using (var source = File.OpenRead(sourcePath)) + result = await runner.GenerateHlsAsync( + source, + plan, + selection, + output, + useHardwareEncoder: false, + update => updates.Add(update), + CancellationToken.None); + + Assert.AreEqual(0, result.ExitCode, result.ErrorOutput); + Assert.IsTrue(File.Exists(Path.Combine(output, "media.m3u8"))); + Assert.IsTrue(Directory.EnumerateFiles(output, "segment-*.ts").Any()); + Assert.IsTrue(updates.Any(update => update.FirstSegmentReady)); + StringAssert.Contains( + await File.ReadAllTextAsync(Path.Combine(output, "media.m3u8")), + "#EXT-X-ENDLIST"); + IReadOnlyList subtitles; + await using (var source = File.OpenRead(sourcePath)) + subtitles = await runner.ExtractTextSubtitlesAsync( + source, + plan, + output, + CancellationToken.None); + Assert.AreEqual(1, subtitles.Count); + StringAssert.StartsWith( + await File.ReadAllTextAsync(Path.Combine(output, subtitles[0].FileName)), + "WEBVTT"); + } + finally + { + if (Directory.Exists(root)) Directory.Delete(root, recursive: true); + } + } + + private static async Task CreateSampleAsync(string path, CancellationToken cancellationToken) + { + var subtitlePath = Path.ChangeExtension(path, ".srt"); + await File.WriteAllTextAsync( + subtitlePath, + "1\n00:00:00,000 --> 00:00:01,000\nHello from SDW\n", + cancellationToken); + var startInfo = new ProcessStartInfo + { + FileName = "ffmpeg", + UseShellExecute = false, + RedirectStandardError = true, + RedirectStandardOutput = true, + CreateNoWindow = true + }; + foreach (var argument in new[] + { + "-hide_banner", "-loglevel", "error", "-y", + "-f", "lavfi", "-i", "testsrc=size=160x90:rate=10", + "-f", "lavfi", "-i", "sine=frequency=440:sample_rate=48000", + "-f", "srt", "-i", subtitlePath, + "-t", "2", + "-map", "0:v:0", "-map", "1:a:0", "-map", "2:s:0", + "-c:v", "libx264", "-preset", "ultrafast", "-pix_fmt", "yuv420p", + "-c:a", "aac", "-b:a", "64k", + "-c:s", "srt", + "-f", "matroska", path + }) + startInfo.ArgumentList.Add(argument); + using var process = Process.Start(startInfo) + ?? throw new InvalidOperationException("Unable to start FFmpeg test fixture generation."); + var errorTask = process.StandardError.ReadToEndAsync(cancellationToken); + var outputTask = process.StandardOutput.ReadToEndAsync(cancellationToken); + await process.WaitForExitAsync(cancellationToken); + var error = await errorTask; + await outputTask; + Assert.AreEqual(0, process.ExitCode, error); + } +} diff --git a/SecondDimensionWatcherReDive.Test/HlsTranscodingServiceTests.cs b/SecondDimensionWatcherReDive.Test/HlsTranscodingServiceTests.cs new file mode 100644 index 0000000..e77c747 --- /dev/null +++ b/SecondDimensionWatcherReDive.Test/HlsTranscodingServiceTests.cs @@ -0,0 +1,480 @@ +using Microsoft.AspNetCore.StaticFiles; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Moq; +using SecondDimensionWatcherReDive.Framework.DataRepository; +using SecondDimensionWatcherReDive.Framework.FileStore; +using SecondDimensionWatcherReDive.Services.Transcoding; + +namespace SecondDimensionWatcherReDive.Test; + +[TestClass] +public class HlsTranscodingServiceTests +{ + [TestMethod] + public async Task PrepareAsync_GeneratesPlayableHlsAndReusesCompletedCache() + { + var runner = new CompletingRunner(); + await using var fixture = await TranscodingFixture.CreateAsync(runner); + + var initial = await fixture.Service.PrepareAsync( + fixture.AnimationInfoId, + "episode.mkv", + TranscodingSelection.Create("auto", "ja", null, "en", null), + CancellationToken.None); + var ready = await WaitForStateAsync( + fixture.Service, + initial, + TranscodingJobState.Ready); + + Assert.IsTrue(ready.IsPlayable); + Assert.AreEqual(TranscodingStrategy.Remux, ready.Strategy); + Assert.AreEqual(1, ready.Subtitles.Count); + Assert.AreEqual(1, runner.GenerateCalls); + StringAssert.Contains( + await fixture.Service.GetPlaylistAsync( + ready.SessionId, + ready.AccessToken, + CancellationToken.None), + "segment-000000.ts"); + var segment = await fixture.Service.OpenSegmentAsync( + ready.SessionId, + ready.AccessToken, + "segment-000000.ts", + CancellationToken.None); + Assert.IsNotNull(segment); + Assert.AreEqual("video/mp2t", segment.ContentType); + await segment.Stream.DisposeAsync(); + + var repeated = await fixture.Service.PrepareAsync( + fixture.AnimationInfoId, + "episode.mkv", + TranscodingSelection.Create("auto", "ja", null, "en", null), + CancellationToken.None); + + Assert.AreEqual(TranscodingJobState.Ready, repeated.State); + Assert.IsTrue(repeated.CacheHit); + Assert.AreEqual(1, runner.GenerateCalls); + await fixture.RestartServiceAsync(); + var afterRestart = await fixture.Service.PrepareAsync( + fixture.AnimationInfoId, + "episode.mkv", + TranscodingSelection.Create("auto", "ja", null, "en", null), + CancellationToken.None); + Assert.AreEqual(TranscodingJobState.Ready, afterRestart.State); + Assert.IsTrue(afterRestart.CacheHit); + Assert.AreEqual(1, runner.GenerateCalls); + var metrics = await fixture.Service.GetMetricsAsync(CancellationToken.None); + Assert.AreEqual(1, metrics.CompletedJobs); + Assert.AreEqual(2, metrics.CacheHits); + Assert.IsTrue(metrics.CacheBytes > 0); + } + + [TestMethod] + public async Task PrepareAsync_SourceVersionChangeDoesNotReuseOldSegments() + { + var runner = new CompletingRunner(); + await using var fixture = await TranscodingFixture.CreateAsync(runner); + + var first = await fixture.Service.PrepareAsync( + fixture.AnimationInfoId, + "episode.mkv", + TranscodingSelection.Create("auto", null, null, null, null), + CancellationToken.None); + await WaitForStateAsync(fixture.Service, first, TranscodingJobState.Ready); + fixture.LastModifiedUtc = fixture.LastModifiedUtc.AddSeconds(1); + + var changed = await fixture.Service.PrepareAsync( + fixture.AnimationInfoId, + "episode.mkv", + TranscodingSelection.Create("auto", null, null, null, null), + CancellationToken.None); + await WaitForStateAsync(fixture.Service, changed, TranscodingJobState.Ready); + + Assert.IsFalse(changed.CacheHit); + Assert.AreEqual(2, runner.GenerateCalls); + } + + [TestMethod] + public async Task PrepareAsync_ConcurrentLimitQueuesAndRejectsOnlyWhenBoundedQueueIsFull() + { + var runner = new BlockingRunner(); + await using var fixture = await TranscodingFixture.CreateAsync( + runner, + queueCapacity: 1, + relativePaths: ["one.mkv", "two.mkv", "three.mkv"]); + + var first = await fixture.Service.PrepareAsync( + fixture.AnimationInfoId, + "one.mkv", + TranscodingSelection.Create("auto", null, null, null, null), + CancellationToken.None); + await runner.Started.Task.WaitAsync(TimeSpan.FromSeconds(3)); + var second = await fixture.Service.PrepareAsync( + fixture.AnimationInfoId, + "two.mkv", + TranscodingSelection.Create("auto", null, null, null, null), + CancellationToken.None); + + Assert.AreEqual(TranscodingJobState.Queued, second.State); + Assert.AreEqual(1, second.QueuePosition); + await Assert.ThrowsExactlyAsync(() => + fixture.Service.PrepareAsync( + fixture.AnimationInfoId, + "three.mkv", + TranscodingSelection.Create("auto", null, null, null, null), + CancellationToken.None)); + var metrics = await fixture.Service.GetMetricsAsync(CancellationToken.None); + Assert.AreEqual(1, metrics.ActiveJobs); + Assert.AreEqual(1, metrics.QueuedJobs); + + Assert.IsTrue(await fixture.Service.CancelAsync( + first.SessionId, + first.AccessToken, + CancellationToken.None)); + Assert.IsTrue(await fixture.Service.CancelAsync( + second.SessionId, + second.AccessToken, + CancellationToken.None)); + using var cleanupTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(3)); + TranscodingMetricsSnapshot afterCancellation; + do + { + afterCancellation = await fixture.Service.GetMetricsAsync(cleanupTimeout.Token); + if (afterCancellation.CanceledJobs == 2 + && !Directory.EnumerateDirectories(fixture.CachePath).Any()) break; + await Task.Delay(10, cleanupTimeout.Token); + } while (true); + Assert.AreEqual(2, afterCancellation.CanceledJobs); + } + + [TestMethod] + public async Task FailedJobDeletesPartialOutputAndReportsFailureRate() + { + await using var fixture = await TranscodingFixture.CreateAsync(new FailingRunner()); + var initial = await fixture.Service.PrepareAsync( + fixture.AnimationInfoId, + "episode.mkv", + TranscodingSelection.Create("auto", null, null, null, null), + CancellationToken.None); + + var failed = await WaitForStateAsync( + fixture.Service, + initial, + TranscodingJobState.Failed); + + StringAssert.Contains(failed.Error, "fixture FFmpeg failure"); + Assert.IsFalse(Directory.EnumerateDirectories(fixture.CachePath).Any()); + var metrics = await fixture.Service.GetMetricsAsync(CancellationToken.None); + Assert.AreEqual(1, metrics.FailedJobs); + Assert.AreEqual(1, metrics.FailureRate); + } + + private static async Task WaitForStateAsync( + IHlsTranscodingService service, + TranscodingSessionStatus session, + TranscodingJobState expected) + { + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + while (true) + { + var current = await service.GetStatusAsync( + session.SessionId, + session.AccessToken, + timeout.Token); + Assert.IsNotNull(current); + if (current.State == expected) return current; + if (current.State is TranscodingJobState.Failed or TranscodingJobState.Canceled) + Assert.Fail($"Transcoding ended in {current.State}: {current.Error}"); + await Task.Delay(10, timeout.Token); + } + } + + private sealed class CompletingRunner : IFfmpegProcessRunner + { + private int _generateCalls; + public int GenerateCalls => Volatile.Read(ref _generateCalls); + + public Task ProbeAsync(Stream source, CancellationToken cancellationToken) + => Task.FromResult(new MediaProbe( + "matroska", + TimeSpan.FromSeconds(30), + [ + new MediaStreamProbe(0, "video", "h264", null, null, true, false, false), + new MediaStreamProbe(1, "audio", "aac", "jpn", "Japanese", true, false, false), + new MediaStreamProbe(2, "subtitle", "ass", "eng", "English", true, false, false) + ])); + + public async Task GenerateHlsAsync( + Stream source, + TranscodingPlan plan, + TranscodingSelection selection, + string outputDirectory, + bool useHardwareEncoder, + Action onProgress, + CancellationToken cancellationToken) + { + Interlocked.Increment(ref _generateCalls); + await File.WriteAllBytesAsync( + Path.Combine(outputDirectory, "segment-000000.ts"), + [1, 2, 3], + cancellationToken); + await File.WriteAllTextAsync( + Path.Combine(outputDirectory, "media.m3u8"), + "#EXTM3U\n#EXTINF:6,\nsegment-000000.ts\n#EXT-X-ENDLIST\n", + cancellationToken); + onProgress(new FfmpegProgress(30, 2, true)); + return new FfmpegRunResult(0, string.Empty); + } + + public async Task> ExtractTextSubtitlesAsync( + Stream source, + TranscodingPlan plan, + string outputDirectory, + CancellationToken cancellationToken) + { + const string name = "subtitle-2.vtt"; + await File.WriteAllTextAsync( + Path.Combine(outputDirectory, name), + "WEBVTT\n", + cancellationToken); + return [new TranscodingSubtitle(name, "English", "eng", "vtt")]; + } + } + + private sealed class BlockingRunner : IFfmpegProcessRunner + { + public TaskCompletionSource Started { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + + public Task ProbeAsync(Stream source, CancellationToken cancellationToken) + => Task.FromResult(new MediaProbe( + "matroska", + TimeSpan.FromSeconds(30), + [ + new MediaStreamProbe(0, "video", "h264", null, null, true, false, false), + new MediaStreamProbe(1, "audio", "aac", null, null, true, false, false) + ])); + + public async Task GenerateHlsAsync( + Stream source, + TranscodingPlan plan, + TranscodingSelection selection, + string outputDirectory, + bool useHardwareEncoder, + Action onProgress, + CancellationToken cancellationToken) + { + await File.WriteAllTextAsync( + Path.Combine(outputDirectory, "partial.tmp"), + "partial", + cancellationToken); + Started.TrySetResult(); + await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); + return new FfmpegRunResult(0, string.Empty); + } + + public Task> ExtractTextSubtitlesAsync( + Stream source, + TranscodingPlan plan, + string outputDirectory, + CancellationToken cancellationToken) + => Task.FromResult>([]); + } + + private sealed class FailingRunner : IFfmpegProcessRunner + { + public Task ProbeAsync(Stream source, CancellationToken cancellationToken) + => Task.FromResult(new MediaProbe( + "matroska", + TimeSpan.FromSeconds(30), + [ + new MediaStreamProbe(0, "video", "h264", null, null, true, false, false), + new MediaStreamProbe(1, "audio", "aac", null, null, true, false, false) + ])); + + public async Task GenerateHlsAsync( + Stream source, + TranscodingPlan plan, + TranscodingSelection selection, + string outputDirectory, + bool useHardwareEncoder, + Action onProgress, + CancellationToken cancellationToken) + { + await File.WriteAllTextAsync( + Path.Combine(outputDirectory, "partial.tmp"), + "partial", + cancellationToken); + return new FfmpegRunResult(1, "fixture FFmpeg failure"); + } + + public Task> ExtractTextSubtitlesAsync( + Stream source, + TranscodingPlan plan, + string outputDirectory, + CancellationToken cancellationToken) + => Task.FromResult>([]); + } + + private sealed class TranscodingFixture : IAsyncDisposable + { + private readonly ServiceProvider _provider; + private readonly TranscodingMetrics _metrics; + private readonly string _cachePath; + private readonly IFfmpegProcessRunner _runner; + private readonly IOptions _options; + + private TranscodingFixture( + ServiceProvider provider, + TranscodingMetrics metrics, + IFfmpegProcessRunner runner, + IOptions options, + HlsTranscodingService service, + string cachePath, + Guid animationInfoId) + { + _provider = provider; + _metrics = metrics; + _runner = runner; + _options = options; + Service = service; + _cachePath = cachePath; + AnimationInfoId = animationInfoId; + } + + public HlsTranscodingService Service { get; private set; } + public Guid AnimationInfoId { get; } + public string CachePath => _cachePath; + public DateTimeOffset LastModifiedUtc { get; set; } = + new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero); + + public static async Task CreateAsync( + IFfmpegProcessRunner runner, + int queueCapacity = 8, + IReadOnlyList? relativePaths = null) + { + var cachePath = Path.Combine(Path.GetTempPath(), $"sdw-transcoding-test-{Guid.NewGuid():N}"); + var animationInfoId = Guid.NewGuid(); + var animation = new Animation(Guid.NewGuid(), "42", "Anime", "Anime", null); + var group = new AnimationGroup(Guid.NewGuid(), "Group"); + var info = new AnimationInfo( + animationInfoId, + "Episode", + string.Empty, + DateTimeOffset.UtcNow, + string.Empty, + string.Empty, + [], + string.Empty, + false, + DateTimeOffset.UnixEpoch, + DateTimeOffset.UnixEpoch, + true, + "test", + "/physical", + 1, + 1, + group, + animation, + true, + 0); + relativePaths ??= ["episode.mkv"]; + var mappings = relativePaths.ToDictionary( + relative => $"/Anime/Group/{relative}", + relative => new FileMapping( + Guid.NewGuid(), + animationInfoId, + $"/Anime/Group/{relative}", + $"/physical/{relative}", + "test")); + + var animationRepository = new Mock(); + animationRepository.Setup(repository => repository.FindByIdWithAnimationAsync( + animationInfoId, + It.IsAny())) + .ReturnsAsync(info); + var mappingRepository = new Mock(); + mappingRepository.Setup(repository => repository.FindByVirtualPathAsync( + It.IsAny(), + It.IsAny())) + .ReturnsAsync((string path, CancellationToken _) => + mappings.GetValueOrDefault(path)); + var store = new Mock(); + store.SetupGet(item => item.Name).Returns("test"); + var storeProvider = new Mock(); + storeProvider.Setup(provider => provider.GetRequiredClient("test")).Returns(store.Object); + storeProvider.Setup(provider => provider.GetClient("test")).Returns(store.Object); + + var services = new ServiceCollection(); + services.AddSingleton(animationRepository.Object); + services.AddSingleton(mappingRepository.Object); + services.AddSingleton(store.Object); + services.AddSingleton(storeProvider.Object); + var provider = services.BuildServiceProvider(); + var options = Options.Create(new TranscodingOptions + { + CachePath = cachePath, + MaxConcurrentJobs = 1, + QueueCapacity = queueCapacity, + CleanupInterval = TimeSpan.FromHours(1), + CacheTtl = TimeSpan.FromDays(1), + SessionTtl = TimeSpan.FromHours(1) + }); + var metrics = new TranscodingMetrics(); + var service = new HlsTranscodingService( + provider.GetRequiredService(), + runner, + metrics, + new FileExtensionContentTypeProvider(), + options, + NullLogger.Instance); + var fixture = new TranscodingFixture( + provider, + metrics, + runner, + options, + service, + cachePath, + animationInfoId); + store.Setup(item => item.FileInfoAsync( + It.IsAny(), + It.IsAny())) + .ReturnsAsync((string path, CancellationToken _) => new FileStoreInfo( + false, + path, + Path.GetFileName(path), + 1024, + fixture.LastModifiedUtc)); + store.Setup(item => item.OpenReadStreamAsync( + It.IsAny(), + It.IsAny())) + .ReturnsAsync(() => new MemoryStream([1, 2, 3])); + await service.StartAsync(CancellationToken.None); + return fixture; + } + + public async Task RestartServiceAsync() + { + await Service.StopAsync(CancellationToken.None); + Service.Dispose(); + Service = new HlsTranscodingService( + _provider.GetRequiredService(), + _runner, + _metrics, + new FileExtensionContentTypeProvider(), + _options, + NullLogger.Instance); + await Service.StartAsync(CancellationToken.None); + } + + public async ValueTask DisposeAsync() + { + await Service.StopAsync(CancellationToken.None); + Service.Dispose(); + _metrics.Dispose(); + await _provider.DisposeAsync(); + if (Directory.Exists(_cachePath)) Directory.Delete(_cachePath, recursive: true); + } + } +} diff --git a/SecondDimensionWatcherReDive.Test/TranscodingControllerTests.cs b/SecondDimensionWatcherReDive.Test/TranscodingControllerTests.cs new file mode 100644 index 0000000..be2779c --- /dev/null +++ b/SecondDimensionWatcherReDive.Test/TranscodingControllerTests.cs @@ -0,0 +1,178 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Routing; +using Microsoft.AspNetCore.Routing; +using Moq; +using SecondDimensionWatcherReDive.Controllers; +using SecondDimensionWatcherReDive.Controllers.External; +using SecondDimensionWatcherReDive.Services.Transcoding; + +namespace SecondDimensionWatcherReDive.Test; + +[TestClass] +public class TranscodingControllerTests +{ + private StubTranscodingService _service = null!; + private TranscodingController _controller = null!; + + [TestInitialize] + public void Setup() + { + _service = new StubTranscodingService(); + _controller = new TranscodingController(_service) + { + ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() }, + Url = CreateUrlHelper() + }; + } + + [TestMethod] + public async Task Prepare_QueuedJobReturnsAcceptedWithStatusAndCancelUrls() + { + var session = CreateStatus(TranscodingJobState.Queued, isPlayable: false); + _service.PrepareResult = session; + + var result = await _controller.Prepare( + new PrepareTranscodingRequest( + Guid.NewGuid(), + "episode.mkv", + "720p", + "ja", + null, + "en", + null), + CancellationToken.None); + + var accepted = Assert.IsInstanceOfType(result); + var response = Assert.IsInstanceOfType(accepted.Value); + Assert.AreEqual("queued", response.State); + Assert.IsNull(response.PlaybackUrl); + StringAssert.Contains(response.StatusUrl, session.SessionId.ToString()); + StringAssert.Contains(response.CancelUrl, session.AccessToken); + } + + [TestMethod] + public async Task Prepare_QueueFullReturns429AndRetryAfter() + { + _service.PrepareException = new TranscodingQueueFullException(); + + var result = await _controller.Prepare( + new PrepareTranscodingRequest(Guid.NewGuid(), "episode.mkv", null, null, null, null, null), + CancellationToken.None); + + var response = Assert.IsInstanceOfType(result); + Assert.AreEqual(StatusCodes.Status429TooManyRequests, response.StatusCode); + Assert.AreEqual("5", _controller.Response.Headers.RetryAfter.ToString()); + } + + [TestMethod] + public async Task GetPlaylist_RewritesEverySegmentWithSessionToken() + { + var sessionId = Guid.NewGuid(); + const string token = "secret-token"; + _service.Playlist = "#EXTM3U\n#EXTINF:6,\nsegment-000000.ts\n#EXT-X-ENDLIST\n"; + + var result = await _controller.GetPlaylist(sessionId, token, CancellationToken.None); + + var content = Assert.IsInstanceOfType(result); + StringAssert.Contains(content.Content, "#EXTM3U"); + StringAssert.Contains(content.Content, "GetSegment"); + StringAssert.Contains(content.Content, "segment-000000.ts"); + StringAssert.Contains(content.Content, token); + Assert.AreEqual("no-cache, no-store", _controller.Response.Headers.CacheControl.ToString()); + } + + private static TranscodingSessionStatus CreateStatus( + TranscodingJobState state, + bool isPlayable) + => new( + Guid.NewGuid(), + "access-token", + state, + state == TranscodingJobState.Queued ? null : TranscodingStrategy.Transcode, + isPlayable, + false, + null, + null, + state == TranscodingJobState.Queued ? 1 : null, + null, + null, + null, + [], + 0); + + private static IUrlHelper CreateUrlHelper() + { + var helper = new Mock(); + var httpContext = new DefaultHttpContext(); + httpContext.Request.Scheme = "https"; + httpContext.Request.Host = new HostString("example.test"); + helper.SetupGet(url => url.ActionContext) + .Returns(new ActionContext(httpContext, new RouteData(), new Microsoft.AspNetCore.Mvc.Abstractions.ActionDescriptor())); + helper.Setup(url => url.Action(It.IsAny())) + .Returns((UrlActionContext context) => + { + var values = new RouteValueDictionary(context.Values); + var suffix = string.Join("&", values.Select(pair => $"{pair.Key}={pair.Value}")); + return $"https://example.test/{context.Action}?{suffix}"; + }); + return helper.Object; + } + + private sealed class StubTranscodingService : IHlsTranscodingService + { + public TranscodingSessionStatus? PrepareResult { get; set; } + public Exception? PrepareException { get; set; } + public string? Playlist { get; set; } + + public Task PrepareAsync( + Guid animationInfoId, + string? relativePath, + TranscodingSelection selection, + CancellationToken cancellationToken) + => PrepareException is null + ? Task.FromResult(PrepareResult!) + : Task.FromException(PrepareException); + + public Task GetStatusAsync( + Guid sessionId, + string accessToken, + CancellationToken cancellationToken) + => Task.FromResult(PrepareResult); + + public Task GetPlaylistAsync( + Guid sessionId, + string accessToken, + CancellationToken cancellationToken) + => Task.FromResult(Playlist); + + public Task OpenSegmentAsync( + Guid sessionId, + string accessToken, + string fileName, + CancellationToken cancellationToken) + => Task.FromResult(null); + + public Task OpenSubtitleAsync( + Guid sessionId, + string accessToken, + string fileName, + CancellationToken cancellationToken) + => Task.FromResult(null); + + public Task OpenDirectAsync( + Guid sessionId, + string accessToken, + CancellationToken cancellationToken) + => Task.FromResult(null); + + public Task CancelAsync( + Guid sessionId, + string accessToken, + CancellationToken cancellationToken) + => Task.FromResult(false); + + public Task GetMetricsAsync(CancellationToken cancellationToken) + => Task.FromResult(new TranscodingMetricsSnapshot(0, 0, 0, 0, 0, 0, 0, null, null, 0)); + } +} diff --git a/SecondDimensionWatcherReDive.Test/TranscodingPlannerTests.cs b/SecondDimensionWatcherReDive.Test/TranscodingPlannerTests.cs new file mode 100644 index 0000000..a696c89 --- /dev/null +++ b/SecondDimensionWatcherReDive.Test/TranscodingPlannerTests.cs @@ -0,0 +1,145 @@ +using SecondDimensionWatcherReDive.Services.Transcoding; + +namespace SecondDimensionWatcherReDive.Test; + +[TestClass] +public class TranscodingPlannerTests +{ + [TestMethod] + public void CreatePlan_BrowserCompatibleMp4_UsesDirectPlay() + { + var plan = TranscodingPlanner.CreatePlan( + CreateSource("episode.mp4"), + CreateProbe(Video("h264"), Audio("aac")), + TranscodingSelection.Create("auto", null, null, null, null), + burnBitmapSubtitles: false); + + Assert.AreEqual(TranscodingStrategy.Direct, plan.Strategy); + Assert.IsTrue(plan.CopyVideo); + Assert.IsTrue(plan.CopyAudio); + } + + [TestMethod] + public void CreatePlan_CompatibleTracksInMkv_UsesLosslessRemux() + { + var plan = TranscodingPlanner.CreatePlan( + CreateSource("episode.mkv"), + CreateProbe(Video("h264"), Audio("aac")), + TranscodingSelection.Create("auto", null, null, null, null), + burnBitmapSubtitles: false); + + Assert.AreEqual(TranscodingStrategy.Remux, plan.Strategy); + Assert.IsTrue(plan.CopyVideo); + Assert.IsTrue(plan.CopyAudio); + } + + [TestMethod] + public void CreatePlan_UnsupportedCodecs_TranscodesAndSelectsPreferredAudio() + { + var japanese = Audio("flac", 1, "jpn", "Japanese"); + var english = Audio("aac", 2, "eng", "English", isDefault: true); + var plan = TranscodingPlanner.CreatePlan( + CreateSource("episode.mkv"), + CreateProbe(Video("hevc"), japanese, english), + TranscodingSelection.Create("720p", "ja", null, null, null), + burnBitmapSubtitles: false); + + Assert.AreEqual(TranscodingStrategy.Transcode, plan.Strategy); + Assert.AreEqual(japanese.Index, plan.Audio?.Index); + Assert.IsFalse(plan.CopyVideo); + Assert.IsFalse(plan.CopyAudio); + } + + [TestMethod] + public void CreatePlan_TextSubtitlesBecomeWebVttAndBitmapTrackCanBeBurned() + { + var ass = Subtitle("ass", 2, "eng", "English signs"); + var pgs = Subtitle("hdmv_pgs_subtitle", 3, "zho", "Chinese PGS"); + var plan = TranscodingPlanner.CreatePlan( + CreateSource("episode.mkv"), + CreateProbe(Video("h264"), Audio("aac"), ass, pgs), + TranscodingSelection.Create("auto", null, null, "zh", null), + burnBitmapSubtitles: true); + + Assert.AreEqual(TranscodingStrategy.Transcode, plan.Strategy); + CollectionAssert.AreEqual(new[] { ass.Index }, plan.TextSubtitles.Select(item => item.Index).ToArray()); + Assert.AreEqual(pgs.Index, plan.BitmapSubtitleToBurn?.Index); + Assert.AreEqual(0, plan.UnsupportedSubtitleCount); + } + + [TestMethod] + public void CreatePlan_SubtitlesOffNeverBurnsDefaultBitmapTrack() + { + var plan = TranscodingPlanner.CreatePlan( + CreateSource("episode.mkv"), + CreateProbe( + Video("h264"), + Audio("aac"), + Subtitle("hdmv_pgs_subtitle", 3, "zho", "Chinese PGS")), + TranscodingSelection.Create("auto", null, null, "off", null), + burnBitmapSubtitles: true); + + Assert.IsNull(plan.BitmapSubtitleToBurn); + Assert.AreEqual(TranscodingStrategy.Remux, plan.Strategy); + Assert.AreEqual(1, plan.UnsupportedSubtitleCount); + } + + [TestMethod] + public void BuildCacheKey_ChangesForSourceVersionTrackAndQuality() + { + var source = CreateSource("episode.mkv"); + var baseline = source.BuildCacheKey( + TranscodingSelection.Create("auto", "ja", null, "zh", null)); + + var changedSource = source with { LastModifiedUtc = source.LastModifiedUtc.AddSeconds(1) }; + var changedTrack = source.BuildCacheKey( + TranscodingSelection.Create("auto", "en", null, "zh", null)); + var changedQuality = source.BuildCacheKey( + TranscodingSelection.Create("720p", "ja", null, "zh", null)); + + Assert.AreNotEqual(baseline, changedSource.BuildCacheKey( + TranscodingSelection.Create("auto", "ja", null, "zh", null))); + Assert.AreNotEqual(baseline, changedTrack); + Assert.AreNotEqual(baseline, changedQuality); + } + + [TestMethod] + public void ToProgressFraction_UsesMediaDurationAndClamps() + { + Assert.AreEqual(0.5, FfmpegProcessRunner.ToProgressFraction(50, TimeSpan.FromSeconds(100))); + Assert.AreEqual(1, FfmpegProcessRunner.ToProgressFraction(110, TimeSpan.FromSeconds(100))); + Assert.IsNull(FfmpegProcessRunner.ToProgressFraction(1, null)); + } + + private static TranscodingSource CreateSource(string fileName) + => new( + Guid.NewGuid(), + Guid.NewGuid(), + $"/Anime/Group/{fileName}", + $"/media/{fileName}", + "test", + fileName, + 1024, + new DateTimeOffset(2026, 1, 2, 3, 4, 5, TimeSpan.Zero)); + + private static MediaProbe CreateProbe(params MediaStreamProbe[] streams) + => new("matroska", TimeSpan.FromMinutes(24), streams); + + private static MediaStreamProbe Video(string codec) + => new(0, "video", codec, null, null, true, false, false); + + private static MediaStreamProbe Audio( + string codec, + int index = 1, + string? language = null, + string? title = null, + bool isDefault = false) + => new(index, "audio", codec, language, title, isDefault, false, false); + + private static MediaStreamProbe Subtitle( + string codec, + int index, + string? language, + string? title) + => new(index, "subtitle", codec, language, title, false, false, false); +} diff --git a/SecondDimensionWatcherReDive/Controllers/External/AppJsonSerializerContext.cs b/SecondDimensionWatcherReDive/Controllers/External/AppJsonSerializerContext.cs index 0f20309..058878f 100644 --- a/SecondDimensionWatcherReDive/Controllers/External/AppJsonSerializerContext.cs +++ b/SecondDimensionWatcherReDive/Controllers/External/AppJsonSerializerContext.cs @@ -65,4 +65,9 @@ namespace SecondDimensionWatcherReDive.Controllers.External; [JsonSerializable(typeof(QueueMediaLibraryScanResponse))] [JsonSerializable(typeof(ApplicationSettingsResponse))] [JsonSerializable(typeof(PatchApplicationSettingsRequest))] +[JsonSerializable(typeof(PrepareTranscodingRequest))] +[JsonSerializable(typeof(TranscodingSessionResponse))] +[JsonSerializable(typeof(TranscodingSubtitleResponse))] +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(TranscodingMetricsResponse))] internal partial class AppJsonSerializerContext : JsonSerializerContext; diff --git a/SecondDimensionWatcherReDive/Controllers/External/Transcoding.cs b/SecondDimensionWatcherReDive/Controllers/External/Transcoding.cs new file mode 100644 index 0000000..f35dc34 --- /dev/null +++ b/SecondDimensionWatcherReDive/Controllers/External/Transcoding.cs @@ -0,0 +1,50 @@ +using System.ComponentModel.DataAnnotations; + +namespace SecondDimensionWatcherReDive.Controllers.External; + +internal sealed record PrepareTranscodingRequest( + [Required] Guid Id, + [Required] string Path, + string? Quality, + string? AudioLanguage, + string? AudioTrackLabel, + string? SubtitleLanguage, + string? SubtitleTrackLabel); + +internal sealed record TranscodingSubtitleResponse( + string Path, + string VirtualPath, + string? Language, + string Label, + string Format, + string Url); + +internal sealed record TranscodingSessionResponse( + Guid SessionId, + string State, + string? Strategy, + bool IsPlayable, + bool CacheHit, + double? Progress, + double? Speed, + int? QueuePosition, + string? Error, + string? VideoCodec, + string? AudioCodec, + string StatusUrl, + string CancelUrl, + string? PlaybackUrl, + IReadOnlyList Subtitles, + int UnsupportedSubtitleCount); + +internal sealed record TranscodingMetricsResponse( + int QueuedJobs, + int ActiveJobs, + long CompletedJobs, + long FailedJobs, + long CanceledJobs, + long CacheHits, + long CacheBytes, + double? AverageFirstSegmentSeconds, + double? AverageTranscodeSpeed, + double FailureRate); diff --git a/SecondDimensionWatcherReDive/Controllers/FileController.cs b/SecondDimensionWatcherReDive/Controllers/FileController.cs index 0d37050..8416c53 100644 --- a/SecondDimensionWatcherReDive/Controllers/FileController.cs +++ b/SecondDimensionWatcherReDive/Controllers/FileController.cs @@ -8,6 +8,7 @@ using Microsoft.Extensions.Caching.Distributed; using SecondDimensionWatcherReDive.Framework.DataRepository; using SecondDimensionWatcherReDive.Framework.FileStore; +using SecondDimensionWatcherReDive.Utils.FileStore; namespace SecondDimensionWatcherReDive.Controllers; @@ -41,7 +42,7 @@ public async Task GetFileLink([FromBody] External.FileLinkResultR return NotFound(); } - var virtualPath = ResolveVirtualPath(info, payload.Path); + var virtualPath = PlaybackPathResolver.ResolveVirtualPath(info, payload.Path); LogResolvedTargetPath(logger, virtualPath, "virtual path"); var token = GenerateToken(64); @@ -93,7 +94,7 @@ public async Task GetSubDir([FromQuery] [Required] Guid id, return NotFound(); } - var virtualPath = ResolveVirtualPath(info, relativeDir); + var virtualPath = PlaybackPathResolver.ResolveVirtualPath(info, relativeDir); LogListPathInfo(logger, virtualPath, true); var tokens = await fileExplorer.EnumerateDirectoryAsync( @@ -109,29 +110,6 @@ public async Task GetSubDir([FromQuery] [Required] Guid id, return Ok(results); } - private static string ResolveVirtualPath(AnimationInfo info, string? relative) - { - var root = GetAnimationVirtualRoot(info); - if (string.IsNullOrWhiteSpace(relative)) return root; - var trimmed = relative.Trim('/'); - return string.IsNullOrEmpty(trimmed) ? root : $"{root}/{trimmed}"; - } - - private static string GetAnimationVirtualRoot(AnimationInfo info) - { - if (info.Animation is null || info.Season is null) return "/unknown"; - var animationName = SanitizePathSegment(info.Animation.Name); - var subGroup = SanitizePathSegment(info.Group?.Name ?? "Unknown"); - return $"/{animationName}/{subGroup}"; - } - - private static string SanitizePathSegment(string name) - { - var invalid = Path.GetInvalidFileNameChars(); - var sanitized = string.Concat(name.Select(c => invalid.Contains(c) || c == '/' ? '_' : c)).Trim(); - return string.IsNullOrEmpty(sanitized) ? "Unknown" : sanitized; - } - [LoggerMessage(Level = LogLevel.Debug, Message = "GenerateLink request for animation {Id}, relative path: {Path}")] private static partial void LogGenerateLinkRequest(ILogger logger, Guid id, string? path); diff --git a/SecondDimensionWatcherReDive/Controllers/TranscodingController.cs b/SecondDimensionWatcherReDive/Controllers/TranscodingController.cs new file mode 100644 index 0000000..fa1c592 --- /dev/null +++ b/SecondDimensionWatcherReDive/Controllers/TranscodingController.cs @@ -0,0 +1,237 @@ +using System.ComponentModel.DataAnnotations; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Net.Http.Headers; +using SecondDimensionWatcherReDive.Services.Transcoding; + +namespace SecondDimensionWatcherReDive.Controllers; + +[ApiController] +[Route("api/transcoding")] +[Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)] +internal sealed class TranscodingController(IHlsTranscodingService transcodingService) : ControllerBase +{ + [HttpPost("prepare")] + public async Task Prepare( + [FromBody] External.PrepareTranscodingRequest request, + CancellationToken cancellationToken) + { + try + { + var selection = TranscodingSelection.Create( + request.Quality, + request.AudioLanguage, + request.AudioTrackLabel, + request.SubtitleLanguage, + request.SubtitleTrackLabel); + var status = await transcodingService.PrepareAsync( + request.Id, + request.Path, + selection, + cancellationToken); + var response = ToResponse(status); + return status.State == TranscodingJobState.Ready ? Ok(response) : Accepted(response); + } + catch (ArgumentException exception) + { + return BadRequest(new ProblemDetails { Title = "Invalid transcoding request", Detail = exception.Message }); + } + catch (KeyNotFoundException) + { + return NotFound(); + } + catch (TranscodingQueueFullException exception) + { + Response.Headers.RetryAfter = "5"; + return StatusCode(StatusCodes.Status429TooManyRequests, + new ProblemDetails { Title = "Transcoding queue full", Detail = exception.Message }); + } + catch (TranscodingDisabledException exception) + { + return StatusCode(StatusCodes.Status503ServiceUnavailable, + new ProblemDetails { Title = "Transcoding unavailable", Detail = exception.Message }); + } + } + + [AllowAnonymous] + [HttpGet("sessions/{sessionId:guid}")] + public async Task GetStatus( + Guid sessionId, + [FromQuery][Required] string token, + CancellationToken cancellationToken) + { + var status = await transcodingService.GetStatusAsync(sessionId, token, cancellationToken); + return status is null ? NotFound() : Ok(ToResponse(status)); + } + + [AllowAnonymous] + [HttpDelete("sessions/{sessionId:guid}")] + public async Task Cancel( + Guid sessionId, + [FromQuery][Required] string token, + CancellationToken cancellationToken) + { + return await transcodingService.CancelAsync(sessionId, token, cancellationToken) + ? NoContent() + : NotFound(); + } + + [AllowAnonymous] + [HttpGet("sessions/{sessionId:guid}/source")] + public async Task GetSource( + Guid sessionId, + [FromQuery][Required] string token, + CancellationToken cancellationToken) + { + var content = await transcodingService.OpenDirectAsync(sessionId, token, cancellationToken); + if (content is null) return NotFound(); + SetContentHeaders(content, immutable: false); + return File(content.Stream, content.ContentType, content.FileName, enableRangeProcessing: true); + } + + [AllowAnonymous] + [HttpGet("sessions/{sessionId:guid}/media.m3u8")] + public async Task GetPlaylist( + Guid sessionId, + [FromQuery][Required] string token, + CancellationToken cancellationToken) + { + var playlist = await transcodingService.GetPlaylistAsync(sessionId, token, cancellationToken); + if (playlist is null) return NotFound(); + + var rewritten = new List(); + using var reader = new StringReader(playlist); + while (reader.ReadLine() is { } line) + { + if (line.Length > 0 && line[0] != '#') + { + var segmentUrl = Url.ActionLink( + nameof(GetSegment), + values: new { sessionId, fileName = line, token }); + rewritten.Add(segmentUrl ?? line); + } + else + { + rewritten.Add(line); + } + } + Response.Headers.CacheControl = "no-cache, no-store"; + return Content(string.Join('\n', rewritten) + "\n", "application/vnd.apple.mpegurl"); + } + + [AllowAnonymous] + [HttpGet("sessions/{sessionId:guid}/segments/{fileName}")] + public async Task GetSegment( + Guid sessionId, + string fileName, + [FromQuery][Required] string token, + CancellationToken cancellationToken) + { + var content = await transcodingService.OpenSegmentAsync( + sessionId, + token, + fileName, + cancellationToken); + if (content is null) return NotFound(); + SetContentHeaders(content, immutable: true); + return File(content.Stream, content.ContentType, enableRangeProcessing: true); + } + + [AllowAnonymous] + [HttpGet("sessions/{sessionId:guid}/subtitles/{fileName}")] + public async Task GetSubtitle( + Guid sessionId, + string fileName, + [FromQuery][Required] string token, + CancellationToken cancellationToken) + { + var content = await transcodingService.OpenSubtitleAsync( + sessionId, + token, + fileName, + cancellationToken); + if (content is null) return NotFound(); + SetContentHeaders(content, immutable: true); + return File(content.Stream, content.ContentType, enableRangeProcessing: true); + } + + [HttpGet("metrics")] + public async Task GetMetrics(CancellationToken cancellationToken) + { + var snapshot = await transcodingService.GetMetricsAsync(cancellationToken); + return Ok(new External.TranscodingMetricsResponse( + snapshot.QueuedJobs, + snapshot.ActiveJobs, + snapshot.CompletedJobs, + snapshot.FailedJobs, + snapshot.CanceledJobs, + snapshot.CacheHits, + snapshot.CacheBytes, + snapshot.AverageFirstSegmentSeconds, + snapshot.AverageTranscodeSpeed, + snapshot.FailureRate)); + } + + private External.TranscodingSessionResponse ToResponse(TranscodingSessionStatus status) + { + var statusUrl = Url.ActionLink( + nameof(GetStatus), + values: new { sessionId = status.SessionId, token = status.AccessToken })!; + var cancelUrl = Url.ActionLink( + nameof(Cancel), + values: new { sessionId = status.SessionId, token = status.AccessToken })!; + var playbackUrl = status.IsPlayable + ? status.Strategy == TranscodingStrategy.Direct + ? Url.ActionLink( + nameof(GetSource), + values: new { sessionId = status.SessionId, token = status.AccessToken }) + : Url.ActionLink( + nameof(GetPlaylist), + values: new { sessionId = status.SessionId, token = status.AccessToken }) + : null; + var subtitles = status.Subtitles.Select(subtitle => + new External.TranscodingSubtitleResponse( + $"__server_subtitle_{subtitle.FileName}", + $"transcoding://subtitle/{subtitle.FileName}", + subtitle.Language, + subtitle.Label, + subtitle.Format, + Url.ActionLink( + nameof(GetSubtitle), + values: new + { + sessionId = status.SessionId, + fileName = subtitle.FileName, + token = status.AccessToken + })!)).ToArray(); + return new External.TranscodingSessionResponse( + status.SessionId, + status.State.ToString().ToLowerInvariant(), + status.Strategy?.ToString().ToLowerInvariant(), + status.IsPlayable, + status.CacheHit, + status.Progress, + status.Speed, + status.QueuePosition, + status.Error, + status.VideoCodec, + status.AudioCodec, + statusUrl, + cancelUrl, + playbackUrl, + subtitles, + status.UnsupportedSubtitleCount); + } + + private void SetContentHeaders(TranscodingContent content, bool immutable) + { + Response.Headers.CacheControl = immutable + ? "private, max-age=1209600, immutable" + : "private, no-cache"; + if (content.LastModifiedUtc is { } lastModified) + Response.Headers.LastModified = lastModified.ToUniversalTime().ToString("R"); + if (content.Length is { } length) Response.ContentLength = length; + Response.Headers[HeaderNames.AcceptRanges] = "bytes"; + } +} diff --git a/SecondDimensionWatcherReDive/Program.cs b/SecondDimensionWatcherReDive/Program.cs index 80f5f19..26f593a 100644 --- a/SecondDimensionWatcherReDive/Program.cs +++ b/SecondDimensionWatcherReDive/Program.cs @@ -27,6 +27,7 @@ using SecondDimensionWatcherReDive.Chat; using SecondDimensionWatcherReDive.Plugin; using SecondDimensionWatcherReDive.Services; +using SecondDimensionWatcherReDive.Services.Transcoding; using SecondDimensionWatcherReDive.MigrationTasks; using SecondDimensionWatcherReDive.Utils.Feed; using SecondDimensionWatcherReDive.Utils.FileDownload; @@ -92,6 +93,33 @@ var localStore = builder.Configuration["FileStore:Local"] ?? "./download"; options.DownloadRoot = Path.GetFullPath(localStore); }); +builder.Services.AddOptions() + .Bind(builder.Configuration.GetSection(TranscodingOptions.SectionName)) + .PostConfigure(options => + { + if (string.IsNullOrWhiteSpace(options.CachePath)) + options.CachePath = Path.Combine( + Path.GetDirectoryName(Path.GetFullPath(passwordFile))!, + "transcode-cache"); + else + options.CachePath = Path.GetFullPath(options.CachePath); + }) + .Validate(options => options.MaxConcurrentJobs > 0, "MaxConcurrentJobs must be positive.") + .Validate(options => options.QueueCapacity > 0, "QueueCapacity must be positive.") + .Validate(options => options.MaxThreadsPerJob > 0, "MaxThreadsPerJob must be positive.") + .Validate(options => options.MaxMemoryBytesPerJob > 0, "MaxMemoryBytesPerJob must be positive.") + .Validate(options => options.MaxDiskBytesPerJob > 0, "MaxDiskBytesPerJob must be positive.") + .Validate(options => options.MaxCacheBytes > 0, "MaxCacheBytes must be positive.") + .Validate(options => options.SegmentDurationSeconds is >= 2 and <= 30, + "SegmentDurationSeconds must be between 2 and 30.") + .Validate(options => options.VideoCrf is >= 0 and <= 51, "VideoCrf must be between 0 and 51.") + .Validate(options => !string.IsNullOrWhiteSpace(options.FfmpegPath), "FfmpegPath is required.") + .Validate(options => !string.IsNullOrWhiteSpace(options.FfprobePath), "FfprobePath is required.") + .Validate(options => options.JobTimeout > TimeSpan.Zero, "JobTimeout must be positive.") + .Validate(options => options.CacheTtl > TimeSpan.Zero, "CacheTtl must be positive.") + .Validate(options => options.CleanupInterval > TimeSpan.Zero, "CleanupInterval must be positive.") + .Validate(options => options.SessionTtl > TimeSpan.Zero, "SessionTtl must be positive.") + .ValidateOnStart(); builder.Services.AddDbContext(options => { @@ -216,12 +244,20 @@ // Persistent incident inbox and health probes. builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(sp => + sp.GetRequiredService()); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(sp => + sp.GetRequiredService()); //Add hosting services builder.Services.AddHostedService(); builder.Services.AddHostedService(); builder.Services.AddHostedService(); builder.Services.AddHostedService(); +builder.Services.AddHostedService(sp => sp.GetRequiredService()); builder.Services.AddSingleton(); builder.Services.AddSingleton(sp => sp.GetRequiredService()); diff --git a/SecondDimensionWatcherReDive/Services/Transcoding/FfmpegProcessRunner.cs b/SecondDimensionWatcherReDive/Services/Transcoding/FfmpegProcessRunner.cs new file mode 100644 index 0000000..c5189a2 --- /dev/null +++ b/SecondDimensionWatcherReDive/Services/Transcoding/FfmpegProcessRunner.cs @@ -0,0 +1,545 @@ +using System.Diagnostics; +using System.Globalization; +using System.Runtime.ExceptionServices; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Extensions.Options; + +namespace SecondDimensionWatcherReDive.Services.Transcoding; + +internal sealed record FfmpegProgress(double? ProcessedSeconds, double? Speed, bool FirstSegmentReady); + +internal sealed record FfmpegRunResult(int ExitCode, string ErrorOutput); + +internal interface IFfmpegProcessRunner +{ + Task ProbeAsync(Stream source, CancellationToken cancellationToken); + + Task GenerateHlsAsync( + Stream source, + TranscodingPlan plan, + TranscodingSelection selection, + string outputDirectory, + bool useHardwareEncoder, + Action onProgress, + CancellationToken cancellationToken); + + Task> ExtractTextSubtitlesAsync( + Stream source, + TranscodingPlan plan, + string outputDirectory, + CancellationToken cancellationToken); +} + +internal sealed partial class FfmpegProcessRunner( + IOptions options, + ILogger logger) : IFfmpegProcessRunner +{ + private readonly TranscodingOptions _options = options.Value; + private static readonly JsonSerializerOptions ProbeJsonOptions = new(JsonSerializerDefaults.Web); + + public async Task ProbeAsync(Stream source, CancellationToken cancellationToken) + { + var startInfo = CreateStartInfo(_options.FfprobePath, redirectOutput: true); + AddArguments(startInfo, + "-v", "error", + "-analyzeduration", "10000000", + "-probesize", "10000000", + "-read_intervals", "%+#32", + "-show_format", + "-show_streams", + "-of", "json", + "-i", "pipe:0"); + + using var process = Start(startInfo); + var pumpTask = PumpInputAsync(process, source, cancellationToken); + var outputTask = process.StandardOutput.ReadToEndAsync(); + var errorTask = process.StandardError.ReadToEndAsync(); + try + { + await process.WaitForExitAsync(cancellationToken); + } + catch + { + Kill(process); + await WaitForExitIgnoringErrorsAsync(process); + await IgnoreBrokenPipeAsync(pumpTask, cancellationToken); + await Task.WhenAll(outputTask, errorTask); + throw; + } + + await IgnoreBrokenPipeAsync(pumpTask, cancellationToken); + var output = await outputTask; + var error = await errorTask; + if (process.ExitCode != 0) + throw new InvalidOperationException( + $"ffprobe exited with code {process.ExitCode}: {TrimError(error)}"); + + var document = JsonSerializer.Deserialize(output, ProbeJsonOptions) + ?? throw new InvalidOperationException("ffprobe returned an empty response."); + var streams = (document.Streams ?? []) + .Where(stream => stream.Index is not null && !string.IsNullOrWhiteSpace(stream.CodecType)) + .Select(stream => new MediaStreamProbe( + stream.Index!.Value, + stream.CodecType!.Trim().ToLowerInvariant(), + string.IsNullOrWhiteSpace(stream.CodecName) + ? "unknown" + : stream.CodecName.Trim().ToLowerInvariant(), + stream.Tags?.Language, + stream.Tags?.Title, + stream.Disposition?.Default == 1, + stream.Disposition?.Forced == 1, + stream.Disposition?.AttachedPic == 1)) + .ToArray(); + var duration = ParseDuration(document.Format?.Duration) + ?? (document.Streams ?? []).Select(stream => ParseDuration(stream.Duration)).FirstOrDefault(value => value is not null); + return new MediaProbe(document.Format?.FormatName ?? "unknown", duration, streams); + } + + public async Task GenerateHlsAsync( + Stream source, + TranscodingPlan plan, + TranscodingSelection selection, + string outputDirectory, + bool useHardwareEncoder, + Action onProgress, + CancellationToken cancellationToken) + { + var startInfo = CreateStartInfo(_options.FfmpegPath, redirectOutput: false); + AddArguments(startInfo, "-hide_banner", "-y"); + if (useHardwareEncoder) + foreach (var argument in _options.HardwareInputArguments) startInfo.ArgumentList.Add(argument); + AddArguments(startInfo, "-i", "pipe:0"); + if (plan.BitmapSubtitleToBurn is null) + AddArguments(startInfo, "-map", $"0:{plan.Video.Index}"); + + if (plan.BitmapSubtitleToBurn is not null) + { + var maximumHeight = selection.Quality switch + { + "720p" => 720, + "1080p" => 1080, + _ => 0 + }; + var filter = $"[0:{plan.Video.Index}][0:{plan.BitmapSubtitleToBurn.Index}]overlay"; + if (maximumHeight > 0) filter += $",scale=-2:min({maximumHeight}\\,ih)"; + filter += "[vout]"; + AddArguments(startInfo, + "-filter_complex", + filter, + "-map", "[vout]"); + } + if (plan.Audio is not null) AddArguments(startInfo, "-map", $"0:{plan.Audio.Index}"); + + if (plan.CopyVideo) + { + AddArguments(startInfo, "-c:v", "copy"); + } + else + { + AddArguments(startInfo, + "-c:v", useHardwareEncoder ? _options.HardwareVideoEncoder! : "libx264"); + if (!useHardwareEncoder) + AddArguments(startInfo, "-preset", _options.VideoPreset, "-crf", _options.VideoCrf.ToString(CultureInfo.InvariantCulture)); + AddArguments(startInfo, "-pix_fmt", "yuv420p"); + var maximumHeight = selection.Quality switch + { + "720p" => 720, + "1080p" => 1080, + _ => 0 + }; + if (maximumHeight > 0 && plan.BitmapSubtitleToBurn is null) + AddArguments(startInfo, "-vf", $"scale=-2:min({maximumHeight}\\,ih)"); + AddArguments(startInfo, + "-force_key_frames", + $"expr:gte(t,n_forced*{_options.SegmentDurationSeconds})"); + } + + if (plan.Audio is not null) + { + if (plan.CopyAudio) AddArguments(startInfo, "-c:a", "copy"); + else AddArguments(startInfo, "-c:a", "aac", "-b:a", "192k", "-ac", "2"); + } + + var playlistPath = Path.Combine(outputDirectory, "media.m3u8"); + var segmentPattern = Path.Combine(outputDirectory, "segment-%06d.ts"); + AddArguments(startInfo, + "-sn", + "-threads", _options.MaxThreadsPerJob.ToString(CultureInfo.InvariantCulture), + "-max_muxing_queue_size", "1024", + "-f", "hls", + "-hls_time", _options.SegmentDurationSeconds.ToString(CultureInfo.InvariantCulture), + "-hls_list_size", "0", + "-hls_playlist_type", "event", + "-hls_flags", "independent_segments+temp_file", + "-hls_segment_filename", segmentPattern, + "-progress", "pipe:2", + "-nostats", + playlistPath); + + return await RunFfmpegAsync( + startInfo, + source, + outputDirectory, + onProgress, + cancellationToken); + } + + public async Task> ExtractTextSubtitlesAsync( + Stream source, + TranscodingPlan plan, + string outputDirectory, + CancellationToken cancellationToken) + { + if (plan.TextSubtitles.Count == 0) return []; + + var startInfo = CreateStartInfo(_options.FfmpegPath, redirectOutput: false); + AddArguments(startInfo, + "-hide_banner", "-y", + "-i", "pipe:0", + "-threads", _options.MaxThreadsPerJob.ToString(CultureInfo.InvariantCulture), + "-nostats"); + var pending = new List<(MediaStreamProbe Stream, string TemporaryPath, string FinalPath)>(); + foreach (var stream in plan.TextSubtitles) + { + var finalPath = Path.Combine(outputDirectory, $"subtitle-{stream.Index}.vtt"); + var temporaryPath = $"{finalPath}.tmp"; + pending.Add((stream, temporaryPath, finalPath)); + AddArguments(startInfo, + "-map", $"0:{stream.Index}", + "-c:s", "webvtt", + "-f", "webvtt", + temporaryPath); + } + var result = await RunFfmpegAsync( + startInfo, + source, + outputDirectory, + _ => { }, + cancellationToken, + detectFirstSegment: false); + if (result.ExitCode != 0) + { + LogSubtitleExtractionFailed(logger, result.ExitCode, result.ErrorOutput); + foreach (var item in pending) TryDelete(item.TemporaryPath); + return []; + } + + var subtitles = new List(); + foreach (var item in pending) + { + if (!File.Exists(item.TemporaryPath)) continue; + File.Move(item.TemporaryPath, item.FinalPath, overwrite: true); + subtitles.Add(new TranscodingSubtitle( + Path.GetFileName(item.FinalPath), + BuildSubtitleLabel(item.Stream, subtitles.Count + 1), + item.Stream.Language, + "vtt")); + } + return subtitles; + } + + private async Task RunFfmpegAsync( + ProcessStartInfo startInfo, + Stream source, + string outputDirectory, + Action onProgress, + CancellationToken cancellationToken, + bool detectFirstSegment = true) + { + using var process = Start(startInfo); + var recentErrors = new Queue(); + var errorGate = new object(); + double? lastSpeed = null; + double? lastProcessedSeconds = null; + var firstSegmentReady = 0; + string? resourceViolation = null; + var pumpTask = PumpInputAsync(process, source, cancellationToken); + var errorTask = Task.Run(async () => + { + while (await process.StandardError.ReadLineAsync(cancellationToken) is { } line) + { + if (TryParseProgress(line, out var processedSeconds, out var speed)) + { + if (processedSeconds is not null) lastProcessedSeconds = processedSeconds; + if (speed is not null) lastSpeed = speed; + onProgress(new FfmpegProgress( + lastProcessedSeconds, + lastSpeed, + Volatile.Read(ref firstSegmentReady) == 1)); + } + lock (errorGate) + { + recentErrors.Enqueue(line); + while (recentErrors.Count > 20) recentErrors.Dequeue(); + } + } + }, CancellationToken.None); + var monitorTask = Task.Run(async () => + { + while (!process.HasExited) + { + await Task.Delay(250, cancellationToken); + if (_options.MaxMemoryBytesPerJob > 0 + && TryGetWorkingSet(process, out var workingSet) + && workingSet > _options.MaxMemoryBytesPerJob) + { + resourceViolation = $"FFmpeg exceeded its {_options.MaxMemoryBytesPerJob} byte memory limit."; + Kill(process); + return; + } + + if (_options.MaxDiskBytesPerJob > 0 + && GetDirectorySize(outputDirectory) > _options.MaxDiskBytesPerJob) + { + resourceViolation = $"FFmpeg exceeded its {_options.MaxDiskBytesPerJob} byte disk limit."; + Kill(process); + return; + } + + if (detectFirstSegment + && Volatile.Read(ref firstSegmentReady) == 0 + && HasPlayableSegment(outputDirectory) + && Interlocked.Exchange(ref firstSegmentReady, 1) == 0) + { + onProgress(new FfmpegProgress(lastProcessedSeconds, lastSpeed, true)); + } + } + }, CancellationToken.None); + + Exception? waitException = null; + try + { + await process.WaitForExitAsync(cancellationToken); + } + catch (Exception exception) + { + Kill(process); + await WaitForExitIgnoringErrorsAsync(process); + waitException = exception; + } + finally + { + await IgnoreBrokenPipeAsync(pumpTask, cancellationToken); + } + + try { await errorTask; } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { } + try { await monitorTask; } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { } + if (waitException is not null) ExceptionDispatchInfo.Capture(waitException).Throw(); + if (resourceViolation is not null) throw new TranscodingResourceLimitException(resourceViolation); + if (detectFirstSegment + && Volatile.Read(ref firstSegmentReady) == 0 + && HasPlayableSegment(outputDirectory) + && Interlocked.Exchange(ref firstSegmentReady, 1) == 0) + onProgress(new FfmpegProgress(lastProcessedSeconds, lastSpeed, true)); + string errors; + lock (errorGate) errors = string.Join(" | ", recentErrors); + return new FfmpegRunResult(process.ExitCode, TrimError(errors)); + } + + private static ProcessStartInfo CreateStartInfo(string path, bool redirectOutput) + => new() + { + FileName = path, + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardInput = true, + RedirectStandardOutput = redirectOutput, + RedirectStandardError = true + }; + + private static Process Start(ProcessStartInfo startInfo) + { + try + { + return Process.Start(startInfo) + ?? throw new InvalidOperationException($"Unable to start {startInfo.FileName}."); + } + catch (Exception exception) when (exception is System.ComponentModel.Win32Exception or InvalidOperationException) + { + throw new InvalidOperationException( + $"Unable to start {startInfo.FileName}. Install FFmpeg or update Transcoding paths.", + exception); + } + } + + private static void AddArguments(ProcessStartInfo startInfo, params string[] arguments) + { + foreach (var argument in arguments) startInfo.ArgumentList.Add(argument); + } + + private static async Task PumpInputAsync( + Process process, + Stream source, + CancellationToken cancellationToken) + { + try + { + await source.CopyToAsync(process.StandardInput.BaseStream, cancellationToken); + } + finally + { + try { process.StandardInput.Close(); } + catch (IOException) { } + } + } + + private static async Task IgnoreBrokenPipeAsync(Task pumpTask, CancellationToken cancellationToken) + { + try { await pumpTask; } + catch (IOException) { } + catch (ObjectDisposedException) { } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { } + } + + private static bool TryParseProgress( + string line, + out double? processedSeconds, + out double? speed) + { + processedSeconds = null; + speed = null; + var separator = line.IndexOf('='); + if (separator <= 0) return false; + var key = line[..separator]; + var value = line[(separator + 1)..]; + if (key is "out_time_us" or "out_time_ms") + { + // Current FFmpeg reports microseconds for both legacy out_time_ms and out_time_us. + if (long.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var microseconds)) + { + processedSeconds = Math.Max(0, microseconds / 1_000_000d); + return true; + } + } + else if (key == "speed") + { + var normalized = value.TrimEnd('x'); + if (double.TryParse(normalized, NumberStyles.Float, CultureInfo.InvariantCulture, out var parsed)) + speed = parsed; + return true; + } + return key == "progress"; + } + + public static double? ToProgressFraction(double? processedSeconds, TimeSpan? duration) + { + if (processedSeconds is null || duration is null || duration.Value.TotalSeconds <= 0) return null; + return Math.Clamp(processedSeconds.Value / duration.Value.TotalSeconds, 0, 1); + } + + private static TimeSpan? ParseDuration(string? value) + => double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out var seconds) + && double.IsFinite(seconds) + && seconds > 0 + ? TimeSpan.FromSeconds(seconds) + : null; + + private static bool TryGetWorkingSet(Process process, out long workingSet) + { + try + { + process.Refresh(); + workingSet = process.WorkingSet64; + return true; + } + catch (InvalidOperationException) + { + workingSet = 0; + return false; + } + } + + private static long GetDirectorySize(string path) + { + try + { + return Directory.EnumerateFiles(path, "*", SearchOption.AllDirectories) + .Sum(file => + { + try { return new FileInfo(file).Length; } + catch (IOException) { return 0; } + }); + } + catch (DirectoryNotFoundException) + { + return 0; + } + } + + private static bool HasPlayableSegment(string outputDirectory) + { + var playlist = Path.Combine(outputDirectory, "media.m3u8"); + if (!File.Exists(playlist)) return false; + try + { + return Directory.EnumerateFiles(outputDirectory, "segment-*.ts") + .Any(path => new FileInfo(path).Length > 0); + } + catch (IOException) + { + return false; + } + } + + private static void Kill(Process process) + { + try + { + if (!process.HasExited) process.Kill(entireProcessTree: true); + } + catch (InvalidOperationException) { } + } + + private static async Task WaitForExitIgnoringErrorsAsync(Process process) + { + try { await process.WaitForExitAsync(CancellationToken.None); } + catch (InvalidOperationException) { } + } + + private static string BuildSubtitleLabel(MediaStreamProbe stream, int ordinal) + { + if (!string.IsNullOrWhiteSpace(stream.Title)) return stream.Title; + if (!string.IsNullOrWhiteSpace(stream.Language)) return $"{stream.Language.ToUpperInvariant()} · Embedded"; + return $"Embedded subtitle {ordinal}"; + } + + private static string TrimError(string error) + => error.Length <= 4000 ? error : error[^4000..]; + + private static void TryDelete(string path) + { + try { File.Delete(path); } + catch (IOException) { } + } + + [LoggerMessage(Level = LogLevel.Warning, Message = "FFmpeg subtitle extraction exited with code {ExitCode}: {Error}")] + private static partial void LogSubtitleExtractionFailed(ILogger logger, int exitCode, string error); + + private sealed record FfprobeDocument( + [property: JsonPropertyName("streams")] FfprobeStream[]? Streams, + [property: JsonPropertyName("format")] FfprobeFormat? Format); + + private sealed record FfprobeStream( + [property: JsonPropertyName("index")] int? Index, + [property: JsonPropertyName("codec_name")] string? CodecName, + [property: JsonPropertyName("codec_type")] string? CodecType, + [property: JsonPropertyName("duration")] string? Duration, + [property: JsonPropertyName("disposition")] FfprobeDisposition? Disposition, + [property: JsonPropertyName("tags")] FfprobeTags? Tags); + + private sealed record FfprobeDisposition( + [property: JsonPropertyName("default")] int Default, + [property: JsonPropertyName("forced")] int Forced, + [property: JsonPropertyName("attached_pic")] int AttachedPic); + + private sealed record FfprobeTags( + [property: JsonPropertyName("language")] string? Language, + [property: JsonPropertyName("title")] string? Title); + + private sealed record FfprobeFormat( + [property: JsonPropertyName("format_name")] string? FormatName, + [property: JsonPropertyName("duration")] string? Duration); +} diff --git a/SecondDimensionWatcherReDive/Services/Transcoding/HlsTranscodingService.cs b/SecondDimensionWatcherReDive/Services/Transcoding/HlsTranscodingService.cs new file mode 100644 index 0000000..dcf6537 --- /dev/null +++ b/SecondDimensionWatcherReDive/Services/Transcoding/HlsTranscodingService.cs @@ -0,0 +1,945 @@ +using System.Collections.Concurrent; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Threading.Channels; +using Microsoft.AspNetCore.StaticFiles; +using Microsoft.Extensions.Options; +using SecondDimensionWatcherReDive.Framework.DataRepository; +using SecondDimensionWatcherReDive.Framework.FileStore; +using SecondDimensionWatcherReDive.Utils.FileStore; + +namespace SecondDimensionWatcherReDive.Services.Transcoding; + +internal sealed partial class HlsTranscodingService : BackgroundService, IHlsTranscodingService +{ + private const int CacheManifestVersion = 1; + private const string CacheOwnershipMarker = ".sdw-transcode-cache"; + private readonly IServiceScopeFactory _scopeFactory; + private readonly IFfmpegProcessRunner _processRunner; + private readonly TranscodingMetrics _metrics; + private readonly IContentTypeProvider _contentTypeProvider; + private readonly ILogger _logger; + private readonly TranscodingOptions _options; + private readonly Channel _queue; + private readonly ConcurrentDictionary _jobs = new(StringComparer.Ordinal); + private readonly ConcurrentDictionary _sessions = new(); + private readonly SemaphoreSlim _creationGate = new(1, 1); + private long _queueOrdinal; + + public HlsTranscodingService( + IServiceScopeFactory scopeFactory, + IFfmpegProcessRunner processRunner, + TranscodingMetrics metrics, + IContentTypeProvider contentTypeProvider, + IOptions options, + ILogger logger) + { + _scopeFactory = scopeFactory; + _processRunner = processRunner; + _metrics = metrics; + _contentTypeProvider = contentTypeProvider; + _logger = logger; + _options = options.Value; + _queue = Channel.CreateBounded(new BoundedChannelOptions(_options.QueueCapacity) + { + FullMode = BoundedChannelFullMode.Wait, + SingleReader = _options.MaxConcurrentJobs == 1, + SingleWriter = false, + AllowSynchronousContinuations = false + }); + } + + public async Task PrepareAsync( + Guid animationInfoId, + string? relativePath, + TranscodingSelection selection, + CancellationToken cancellationToken) + { + if (!_options.Enabled) throw new TranscodingDisabledException(); + var source = await ResolveSourceAsync(animationInfoId, relativePath, cancellationToken); + var cacheKey = BuildCacheKey(source, selection); + + await _creationGate.WaitAsync(cancellationToken); + try + { + if (_jobs.TryGetValue(cacheKey, out var terminalJob) + && terminalJob.GetState() is TranscodingJobState.Failed or TranscodingJobState.Canceled) + _jobs.TryRemove(new KeyValuePair(cacheKey, terminalJob)); + + var isNewJob = false; + var cacheHit = false; + if (!_jobs.TryGetValue(cacheKey, out var job)) + { + var cacheDirectory = Path.Combine(_options.CachePath, cacheKey); + var manifest = await TryLoadManifestAsync(cacheDirectory, cancellationToken); + if (manifest is not null) + { + job = TranscodingJob.FromManifest( + cacheKey, + cacheDirectory, + source, + selection, + manifest); + cacheHit = true; + _metrics.RecordCacheHit(); + } + else + { + job = new TranscodingJob( + cacheKey, + cacheDirectory, + source, + selection, + Interlocked.Increment(ref _queueOrdinal)); + isNewJob = true; + } + _jobs[cacheKey] = job; + } + else + { + cacheHit = job.GetState() == TranscodingJobState.Ready; + if (cacheHit) _metrics.RecordCacheHit(); + } + + var session = new TranscodingSession(job, cacheHit, _options.SessionTtl); + _sessions[session.Id] = session; + job.AddSession(session.Id); + + if (isNewJob && !_queue.Writer.TryWrite(job)) + { + _sessions.TryRemove(session.Id, out _); + job.RemoveSession(session.Id); + _jobs.TryRemove(new KeyValuePair(cacheKey, job)); + job.Cancellation.Dispose(); + throw new TranscodingQueueFullException(); + } + + UpdateJobGauges(); + TouchCache(job, session); + return BuildStatus(session); + } + finally + { + _creationGate.Release(); + } + } + + public Task GetStatusAsync( + Guid sessionId, + string accessToken, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + var session = FindSession(sessionId, accessToken); + if (session is null) return Task.FromResult(null); + TouchCache(session.Job, session); + return Task.FromResult(BuildStatus(session)); + } + + public async Task GetPlaylistAsync( + Guid sessionId, + string accessToken, + CancellationToken cancellationToken) + { + var session = FindSession(sessionId, accessToken); + if (session is null || !session.Job.GetIsPlayable()) return null; + TouchCache(session.Job, session); + var playlistPath = Path.Combine(session.Job.CacheDirectory, "media.m3u8"); + for (var attempt = 0; attempt < 3; attempt++) + { + cancellationToken.ThrowIfCancellationRequested(); + try + { + return await File.ReadAllTextAsync(playlistPath, cancellationToken); + } + catch (IOException) when (attempt < 2) + { + await Task.Delay(25, cancellationToken); + } + catch (FileNotFoundException) + { + return null; + } + } + return null; + } + + public Task OpenSegmentAsync( + Guid sessionId, + string accessToken, + string fileName, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + var session = FindSession(sessionId, accessToken); + if (session is null || !session.Job.GetIsPlayable() || !IsSegmentName(fileName)) + return Task.FromResult(null); + + var path = Path.Combine(session.Job.CacheDirectory, fileName); + if (!File.Exists(path)) return Task.FromResult(null); + TouchCache(session.Job, session); + return Task.FromResult(OpenCachedContent(path, "video/mp2t")); + } + + public Task OpenSubtitleAsync( + Guid sessionId, + string accessToken, + string fileName, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + var session = FindSession(sessionId, accessToken); + if (session is null || !session.Job.HasSubtitle(fileName)) + return Task.FromResult(null); + + var path = Path.Combine(session.Job.CacheDirectory, fileName); + if (!File.Exists(path)) return Task.FromResult(null); + TouchCache(session.Job, session); + return Task.FromResult(OpenCachedContent(path, "text/vtt; charset=utf-8")); + } + + public async Task OpenDirectAsync( + Guid sessionId, + string accessToken, + CancellationToken cancellationToken) + { + var session = FindSession(sessionId, accessToken); + if (session is null || session.Job.GetStrategy() != TranscodingStrategy.Direct) return null; + session.Touch(_options.SessionTtl); + var stream = await OpenSourceStreamAsync(session.Job.Source, cancellationToken); + var contentType = _contentTypeProvider.TryGetContentType(session.Job.Source.FileName, out var type) + ? type + : "application/octet-stream"; + return new TranscodingContent( + stream, + contentType, + session.Job.Source.FileName, + session.Job.Source.Length, + session.Job.Source.LastModifiedUtc); + } + + public Task CancelAsync( + Guid sessionId, + string accessToken, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + var session = FindSession(sessionId, accessToken, touch: false); + if (session is null || !_sessions.TryRemove(sessionId, out _)) return Task.FromResult(false); + ReleaseSession(session); + return Task.FromResult(true); + } + + public Task GetMetricsAsync(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + return Task.FromResult(_metrics.Snapshot()); + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + Directory.CreateDirectory(_options.CachePath); + await CleanupCacheAsync(removeIncomplete: true, stoppingToken); + var workers = Enumerable.Range(0, _options.MaxConcurrentJobs) + .Select(_ => RunWorkerAsync(stoppingToken)) + .ToArray(); + var cleanup = RunCleanupLoopAsync(stoppingToken); + await Task.WhenAll(workers.Append(cleanup)); + } + + private async Task RunWorkerAsync(CancellationToken stoppingToken) + { + await foreach (var job in _queue.Reader.ReadAllAsync(stoppingToken)) + { + try + { + await ProcessJobAsync(job, stoppingToken); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + return; + } + } + } + + private async Task ProcessJobAsync(TranscodingJob job, CancellationToken stoppingToken) + { + if (job.Cancellation.IsCancellationRequested) + { + MarkCanceled(job); + return; + } + + using var timeout = new CancellationTokenSource(_options.JobTimeout); + using var linked = CancellationTokenSource.CreateLinkedTokenSource( + stoppingToken, + job.Cancellation.Token, + timeout.Token); + var cancellationToken = linked.Token; + var startedAt = DateTimeOffset.UtcNow; + try + { + job.SetState(TranscodingJobState.Probing); + UpdateJobGauges(); + MediaProbe probe; + await using (var source = await OpenSourceStreamAsync(job.Source, cancellationToken)) + probe = await _processRunner.ProbeAsync(source, cancellationToken); + var plan = TranscodingPlanner.CreatePlan( + job.Source, + probe, + job.Selection, + _options.BurnBitmapSubtitles); + job.SetPlan(plan); + + if (plan.Strategy == TranscodingStrategy.Direct) + { + job.MarkPlayable(); + _metrics.RecordFirstSegment(DateTimeOffset.UtcNow - startedAt); + _metrics.RecordCompleted(); + job.SetReady([]); + UpdateJobGauges(); + return; + } + + RecreateJobDirectory(job.CacheDirectory); + job.SetState(TranscodingJobState.Transcoding); + UpdateJobGauges(); + var firstSegmentRecorded = 0; + void OnProgress(FfmpegProgress update) + { + var fraction = FfmpegProcessRunner.ToProgressFraction(update.ProcessedSeconds, probe.Duration); + job.SetProgress(fraction, update.Speed); + if (update.FirstSegmentReady) + { + job.MarkPlayable(); + if (Interlocked.Exchange(ref firstSegmentRecorded, 1) == 0) + _metrics.RecordFirstSegment(DateTimeOffset.UtcNow - startedAt); + } + } + + var useHardware = !plan.CopyVideo && !string.IsNullOrWhiteSpace(_options.HardwareVideoEncoder); + FfmpegRunResult result; + await using (var source = await OpenSourceStreamAsync(job.Source, cancellationToken)) + result = await _processRunner.GenerateHlsAsync( + source, + plan, + job.Selection, + job.CacheDirectory, + useHardware, + OnProgress, + cancellationToken); + if (result.ExitCode != 0 && useHardware) + { + LogHardwareFallback(_logger, _options.HardwareVideoEncoder!, result.ErrorOutput); + DeleteGeneratedFiles(job.CacheDirectory); + await using var source = await OpenSourceStreamAsync(job.Source, cancellationToken); + result = await _processRunner.GenerateHlsAsync( + source, + plan, + job.Selection, + job.CacheDirectory, + useHardwareEncoder: false, + OnProgress, + cancellationToken); + } + if (result.ExitCode != 0) + throw new InvalidOperationException($"FFmpeg exited with code {result.ExitCode}: {result.ErrorOutput}"); + + IReadOnlyList subtitles; + await using (var source = await OpenSourceStreamAsync(job.Source, cancellationToken)) + subtitles = await _processRunner.ExtractTextSubtitlesAsync( + source, + plan, + job.CacheDirectory, + cancellationToken); + job.SetSubtitles(subtitles); + await WriteManifestAsync(job, cancellationToken); + _metrics.RecordCompleted(); + if (job.GetSpeed() is { } speed) _metrics.RecordSpeed(speed); + UpdateCacheBytes(); + job.SetReady(subtitles); + UpdateJobGauges(); + await CleanupCacheAsync(removeIncomplete: false, cancellationToken); + } + catch (OperationCanceledException) when (job.Cancellation.IsCancellationRequested) + { + MarkCanceled(job); + } + catch (OperationCanceledException) when (timeout.IsCancellationRequested) + { + MarkFailed(job, "The transcoding job exceeded its configured timeout."); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + MarkCanceled(job); + throw; + } + catch (Exception exception) + { + LogJobFailed(_logger, job.Source.VirtualPath, exception); + MarkFailed(job, exception.Message); + } + } + + private async Task RunCleanupLoopAsync(CancellationToken stoppingToken) + { + using var timer = new PeriodicTimer(_options.CleanupInterval); + while (await timer.WaitForNextTickAsync(stoppingToken)) + await CleanupCacheAsync(removeIncomplete: true, stoppingToken); + } + + private async Task ResolveSourceAsync( + Guid animationInfoId, + string? relativePath, + CancellationToken cancellationToken) + { + await using var scope = _scopeFactory.CreateAsyncScope(); + var animationRepository = scope.ServiceProvider.GetRequiredService(); + var info = await animationRepository.FindByIdWithAnimationAsync(animationInfoId, cancellationToken); + if (info is null || !info.IsDownloadFinished) + throw new KeyNotFoundException("The requested animation is not available for playback."); + + var virtualPath = PlaybackPathResolver.ResolveVirtualPath(info, relativePath); + var mapping = await scope.ServiceProvider.GetRequiredService() + .FindByVirtualPathAsync(virtualPath, cancellationToken); + if (mapping is null || mapping.AnimationInfoId != animationInfoId) + throw new KeyNotFoundException("The requested playback file mapping was not found."); + + var store = scope.ServiceProvider.GetRequiredService() + .GetRequiredClient(mapping.FileStore); + var fileInfo = await store.FileInfoAsync(mapping.PhysicalPath, cancellationToken); + if (fileInfo.IsDirectory) + throw new KeyNotFoundException("The requested playback path is a directory."); + return new TranscodingSource( + animationInfoId, + mapping.Id, + mapping.VirtualPath, + mapping.PhysicalPath, + mapping.FileStore, + fileInfo.FileName, + fileInfo.Length ?? 0, + fileInfo.LastModifiedUtc ?? DateTimeOffset.UnixEpoch); + } + + private async Task OpenSourceStreamAsync( + TranscodingSource source, + CancellationToken cancellationToken) + { + var scope = _scopeFactory.CreateAsyncScope(); + try + { + var store = scope.ServiceProvider.GetRequiredService() + .GetRequiredClient(source.FileStore); + var stream = await store.OpenReadStreamAsync(source.PhysicalPath, cancellationToken); + return new ScopeOwnedStream(stream, scope); + } + catch + { + await scope.DisposeAsync(); + throw; + } + } + + private TranscodingSession? FindSession(Guid id, string token, bool touch = true) + { + if (!_sessions.TryGetValue(id, out var session) || !TokensEqual(session.AccessToken, token)) + return null; + if (session.IsExpired) + { + if (_sessions.TryRemove(id, out _)) ReleaseSession(session); + return null; + } + if (touch) session.Touch(_options.SessionTtl); + return session; + } + + private TranscodingSessionStatus BuildStatus(TranscodingSession session) + { + var job = session.Job; + var state = job.GetState(); + int? queuePosition = state == TranscodingJobState.Queued + ? _jobs.Values.Count(candidate => + candidate.GetState() == TranscodingJobState.Queued + && candidate.QueueOrdinal <= job.QueueOrdinal) + : null; + return job.CreateStatus(session.Id, session.AccessToken, session.CacheHit, queuePosition); + } + + private async Task TryLoadManifestAsync( + string directory, + CancellationToken cancellationToken) + { + var path = Path.Combine(directory, "complete.json"); + if (!File.Exists(Path.Combine(directory, CacheOwnershipMarker)) + || !File.Exists(path) + || !File.Exists(Path.Combine(directory, "media.m3u8"))) return null; + try + { + await using var stream = File.OpenRead(path); + var manifest = await JsonSerializer.DeserializeAsync(stream, cancellationToken: cancellationToken); + return manifest?.Version == CacheManifestVersion ? manifest : null; + } + catch (Exception exception) when (exception is IOException or JsonException) + { + LogInvalidCacheManifest(_logger, path, exception); + TryDeleteDirectory(directory); + return null; + } + } + + private async Task WriteManifestAsync(TranscodingJob job, CancellationToken cancellationToken) + { + var manifest = job.CreateManifest(); + var path = Path.Combine(job.CacheDirectory, "complete.json"); + var temporaryPath = $"{path}.tmp"; + await using (var stream = new FileStream( + temporaryPath, + FileMode.Create, + FileAccess.Write, + FileShare.None, + 4096, + FileOptions.Asynchronous | FileOptions.WriteThrough)) + await JsonSerializer.SerializeAsync(stream, manifest, cancellationToken: cancellationToken); + File.Move(temporaryPath, path, overwrite: true); + TouchCache(job, null); + } + + private async Task CleanupCacheAsync(bool removeIncomplete, CancellationToken cancellationToken) + { + CleanupExpiredSessions(); + if (!Directory.Exists(_options.CachePath)) return; + var now = DateTimeOffset.UtcNow; + var candidates = new List(); + foreach (var directory in Directory.EnumerateDirectories(_options.CachePath)) + { + cancellationToken.ThrowIfCancellationRequested(); + var key = Path.GetFileName(directory); + if (!IsCacheKey(key)) continue; + if (!File.Exists(Path.Combine(directory, CacheOwnershipMarker))) continue; + var completePath = Path.Combine(directory, "complete.json"); + if (!File.Exists(completePath)) + { + if (removeIncomplete && !IsActive(key)) TryDeleteDirectory(directory); + continue; + } + var accessPath = Path.Combine(directory, ".access"); + var lastAccess = File.Exists(accessPath) + ? File.GetLastWriteTimeUtc(accessPath) + : File.GetLastWriteTimeUtc(completePath); + candidates.Add(new CacheDirectory(key, directory, lastAccess, GetDirectorySize(directory))); + } + + foreach (var expired in candidates + .Where(candidate => now - candidate.LastAccess > _options.CacheTtl) + .OrderBy(candidate => candidate.LastAccess) + .ToArray()) + { + if (IsInUse(expired.Key)) continue; + RemoveCacheDirectory(expired); + candidates.Remove(expired); + } + + var total = candidates.Sum(candidate => candidate.Size); + foreach (var candidate in candidates.OrderBy(candidate => candidate.LastAccess)) + { + if (total <= _options.MaxCacheBytes) break; + if (IsInUse(candidate.Key)) continue; + RemoveCacheDirectory(candidate); + total -= candidate.Size; + } + _metrics.SetCacheBytes(Math.Max(0, total)); + await Task.CompletedTask; + } + + private void CleanupExpiredSessions() + { + foreach (var pair in _sessions) + if (pair.Value.IsExpired && _sessions.TryRemove(pair.Key, out var session)) ReleaseSession(session); + } + + private void ReleaseSession(TranscodingSession session) + { + var remaining = session.Job.RemoveSession(session.Id); + if (remaining == 0 + && session.Job.GetState() is TranscodingJobState.Queued + or TranscodingJobState.Probing + or TranscodingJobState.Transcoding) + session.Job.Cancellation.Cancel(); + else if (remaining == 0 + && session.Job.GetState() == TranscodingJobState.Ready + && session.Job.GetStrategy() == TranscodingStrategy.Direct + && _jobs.TryRemove(new KeyValuePair( + session.Job.CacheKey, + session.Job))) + session.Job.Cancellation.Dispose(); + } + + private bool IsActive(string key) + => _jobs.TryGetValue(key, out var job) + && job.GetState() is TranscodingJobState.Queued + or TranscodingJobState.Probing + or TranscodingJobState.Transcoding; + + private bool IsInUse(string key) + => _jobs.TryGetValue(key, out var job) && (job.SessionCount > 0 || IsActive(key)); + + private void RemoveCacheDirectory(CacheDirectory candidate) + { + TryDeleteDirectory(candidate.Path); + if (_jobs.TryGetValue(candidate.Key, out var job) && job.GetState() == TranscodingJobState.Ready) + _jobs.TryRemove(new KeyValuePair(candidate.Key, job)); + } + + private void MarkCanceled(TranscodingJob job) + { + job.SetCanceled(); + _jobs.TryRemove(new KeyValuePair(job.CacheKey, job)); + TryDeleteDirectory(job.CacheDirectory); + _metrics.RecordCanceled(); + UpdateCacheBytes(); + UpdateJobGauges(); + } + + private void MarkFailed(TranscodingJob job, string error) + { + job.SetFailed(error); + _jobs.TryRemove(new KeyValuePair(job.CacheKey, job)); + TryDeleteDirectory(job.CacheDirectory); + _metrics.RecordFailed(); + UpdateCacheBytes(); + UpdateJobGauges(); + } + + private void UpdateJobGauges() + { + _metrics.SetQueued(_jobs.Values.Count(job => job.GetState() == TranscodingJobState.Queued)); + _metrics.SetActive(_jobs.Values.Count(job => + job.GetState() is TranscodingJobState.Probing or TranscodingJobState.Transcoding)); + } + + private void UpdateCacheBytes() => _metrics.SetCacheBytes(GetDirectorySize(_options.CachePath)); + + private void TouchCache(TranscodingJob job, TranscodingSession? session) + { + session?.Touch(_options.SessionTtl); + if (!Directory.Exists(job.CacheDirectory)) return; + if (session is not null && !session.ShouldTouchCache) return; + try + { + var marker = Path.Combine(job.CacheDirectory, ".access"); + if (!File.Exists(marker)) File.WriteAllText(marker, string.Empty); + File.SetLastWriteTimeUtc(marker, DateTime.UtcNow); + session?.MarkCacheTouched(); + } + catch (IOException) { } + } + + private string BuildCacheKey(TranscodingSource source, TranscodingSelection selection) + { + var material = string.Join('|', + source.BuildCacheKey(selection), + CacheManifestVersion, + _options.SegmentDurationSeconds, + _options.VideoCrf, + _options.VideoPreset, + _options.HardwareVideoEncoder ?? string.Empty, + string.Join('\u001f', _options.HardwareInputArguments), + _options.BurnBitmapSubtitles); + return Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(material))); + } + + private static TranscodingContent OpenCachedContent(string path, string contentType) + { + var info = new FileInfo(path); + var stream = new FileStream( + path, + FileMode.Open, + FileAccess.Read, + FileShare.ReadWrite | FileShare.Delete, + 64 * 1024, + FileOptions.Asynchronous | FileOptions.SequentialScan); + return new TranscodingContent( + stream, + contentType, + info.Name, + info.Length, + new DateTimeOffset(info.LastWriteTimeUtc, TimeSpan.Zero)); + } + + private static void RecreateJobDirectory(string path) + { + if (Directory.Exists(path) && !File.Exists(Path.Combine(path, CacheOwnershipMarker))) + throw new InvalidOperationException( + $"The transcoding cache path '{path}' is occupied by an unmanaged directory."); + TryDeleteDirectory(path); + Directory.CreateDirectory(path); + File.WriteAllText(Path.Combine(path, CacheOwnershipMarker), string.Empty); + } + + private static void DeleteGeneratedFiles(string directory) + { + foreach (var path in Directory.EnumerateFiles(directory)) + if (Path.GetFileName(path) is not (".access" or CacheOwnershipMarker)) + try { File.Delete(path); } + catch (IOException) { } + } + + private static void TryDeleteDirectory(string path) + { + try + { + if (Directory.Exists(path) + && File.Exists(Path.Combine(path, CacheOwnershipMarker))) + Directory.Delete(path, recursive: true); + } + catch (IOException) { } + catch (UnauthorizedAccessException) { } + } + + private static long GetDirectorySize(string path) + { + if (!Directory.Exists(path)) return 0; + try + { + return Directory.EnumerateFiles(path, "*", SearchOption.AllDirectories) + .Sum(file => + { + try { return new FileInfo(file).Length; } + catch (IOException) { return 0; } + }); + } + catch (IOException) + { + return 0; + } + } + + private static bool TokensEqual(string expected, string actual) + { + if (expected.Length != actual.Length) return false; + return CryptographicOperations.FixedTimeEquals( + Encoding.UTF8.GetBytes(expected), + Encoding.UTF8.GetBytes(actual)); + } + + private static bool IsSegmentName(string name) + => name == Path.GetFileName(name) + && name.StartsWith("segment-", StringComparison.Ordinal) + && name.EndsWith(".ts", StringComparison.Ordinal) + && name[8..^3].All(char.IsAsciiDigit); + + private static bool IsCacheKey(string name) + => name.Length == 64 && name.All(char.IsAsciiHexDigit); + + [LoggerMessage(Level = LogLevel.Warning, Message = "Hardware encoder {Encoder} failed; retrying with the CPU encoder. FFmpeg: {Error}")] + private static partial void LogHardwareFallback(ILogger logger, string encoder, string error); + + [LoggerMessage(Level = LogLevel.Error, Message = "Transcoding failed for {VirtualPath}")] + private static partial void LogJobFailed(ILogger logger, string virtualPath, Exception exception); + + [LoggerMessage(Level = LogLevel.Warning, Message = "Ignoring invalid transcoding cache manifest {Path}")] + private static partial void LogInvalidCacheManifest(ILogger logger, string path, Exception exception); + + private sealed record CacheDirectory(string Key, string Path, DateTimeOffset LastAccess, long Size); + + private sealed record CacheManifest( + int Version, + TranscodingStrategy Strategy, + string VideoCodec, + string? AudioCodec, + TranscodingSubtitle[] Subtitles, + int UnsupportedSubtitleCount); + + private sealed class TranscodingSession + { + private long _expiresAtTicks; + private long _lastCacheTouchTicks; + + public TranscodingSession(TranscodingJob job, bool cacheHit, TimeSpan ttl) + { + Job = job; + CacheHit = cacheHit; + Id = Guid.NewGuid(); + AccessToken = Convert.ToHexStringLower(RandomNumberGenerator.GetBytes(32)); + Touch(ttl); + } + + public Guid Id { get; } + public string AccessToken { get; } + public TranscodingJob Job { get; } + public bool CacheHit { get; } + public bool IsExpired => DateTimeOffset.UtcNow.UtcTicks > Interlocked.Read(ref _expiresAtTicks); + public bool ShouldTouchCache + => DateTimeOffset.UtcNow.UtcTicks - Interlocked.Read(ref _lastCacheTouchTicks) > TimeSpan.FromMinutes(1).Ticks; + + public void Touch(TimeSpan ttl) + => Interlocked.Exchange(ref _expiresAtTicks, (DateTimeOffset.UtcNow + ttl).UtcTicks); + + public void MarkCacheTouched() + => Interlocked.Exchange(ref _lastCacheTouchTicks, DateTimeOffset.UtcNow.UtcTicks); + } + + private sealed class TranscodingJob + { + private readonly object _gate = new(); + private readonly HashSet _sessions = []; + private TranscodingJobState _state = TranscodingJobState.Queued; + private TranscodingPlan? _plan; + private bool _isPlayable; + private double? _progress; + private double? _speed; + private string? _error; + private IReadOnlyList _subtitles = []; + + public TranscodingJob( + string cacheKey, + string cacheDirectory, + TranscodingSource source, + TranscodingSelection selection, + long queueOrdinal) + { + CacheKey = cacheKey; + CacheDirectory = cacheDirectory; + Source = source; + Selection = selection; + QueueOrdinal = queueOrdinal; + } + + public string CacheKey { get; } + public string CacheDirectory { get; } + public TranscodingSource Source { get; } + public TranscodingSelection Selection { get; } + public long QueueOrdinal { get; } + public CancellationTokenSource Cancellation { get; } = new(); + public int SessionCount { get { lock (_gate) return _sessions.Count; } } + + public static TranscodingJob FromManifest( + string cacheKey, + string cacheDirectory, + TranscodingSource source, + TranscodingSelection selection, + CacheManifest manifest) + { + var job = new TranscodingJob(cacheKey, cacheDirectory, source, selection, 0) + { + _state = TranscodingJobState.Ready, + _isPlayable = true, + _progress = 1, + _subtitles = manifest.Subtitles + }; + var video = new MediaStreamProbe(0, "video", manifest.VideoCodec, null, null, true, false, false); + var audio = manifest.AudioCodec is null + ? null + : new MediaStreamProbe(1, "audio", manifest.AudioCodec, null, null, true, false, false); + job._plan = new TranscodingPlan( + manifest.Strategy, + video, + audio, + null, + [], + manifest.UnsupportedSubtitleCount, + manifest.Strategy == TranscodingStrategy.Remux, + manifest.Strategy == TranscodingStrategy.Remux); + return job; + } + + public void AddSession(Guid id) { lock (_gate) _sessions.Add(id); } + public int RemoveSession(Guid id) { lock (_gate) { _sessions.Remove(id); return _sessions.Count; } } + public TranscodingJobState GetState() { lock (_gate) return _state; } + public TranscodingStrategy? GetStrategy() { lock (_gate) return _plan?.Strategy; } + public bool GetIsPlayable() { lock (_gate) return _isPlayable; } + public double? GetSpeed() { lock (_gate) return _speed; } + public bool HasSubtitle(string fileName) + { + lock (_gate) return fileName == Path.GetFileName(fileName) && _subtitles.Any(item => item.FileName == fileName); + } + + public void SetState(TranscodingJobState state) { lock (_gate) _state = state; } + public void SetPlan(TranscodingPlan plan) { lock (_gate) _plan = plan; } + public void MarkPlayable() { lock (_gate) _isPlayable = true; } + public void SetProgress(double? progress, double? speed) + { + lock (_gate) + { + _progress = progress; + if (speed is not null) _speed = speed; + } + } + + public void SetReady(IReadOnlyList subtitles) + { + lock (_gate) + { + _subtitles = subtitles; + _progress = 1; + _isPlayable = true; + _state = TranscodingJobState.Ready; + } + } + + public void SetSubtitles(IReadOnlyList subtitles) + { + lock (_gate) _subtitles = subtitles; + } + + public void SetCanceled() + { + lock (_gate) + { + _state = TranscodingJobState.Canceled; + _isPlayable = false; + _error = "The transcoding job was canceled."; + } + } + + public void SetFailed(string error) + { + lock (_gate) + { + _state = TranscodingJobState.Failed; + _isPlayable = false; + _error = error; + } + } + + public TranscodingSessionStatus CreateStatus( + Guid sessionId, + string token, + bool cacheHit, + int? queuePosition) + { + lock (_gate) + return new TranscodingSessionStatus( + sessionId, + token, + _state, + _plan?.Strategy, + _isPlayable, + cacheHit, + _progress, + _speed, + queuePosition, + _error, + _plan?.Video.CodecName, + _plan?.Audio?.CodecName, + _subtitles, + _plan?.UnsupportedSubtitleCount ?? 0); + } + + public CacheManifest CreateManifest() + { + lock (_gate) + { + var plan = _plan ?? throw new InvalidOperationException("A completed job has no transcoding plan."); + return new CacheManifest( + CacheManifestVersion, + plan.Strategy, + plan.Video.CodecName, + plan.Audio?.CodecName, + _subtitles.ToArray(), + plan.UnsupportedSubtitleCount); + } + } + } +} diff --git a/SecondDimensionWatcherReDive/Services/Transcoding/IHlsTranscodingService.cs b/SecondDimensionWatcherReDive/Services/Transcoding/IHlsTranscodingService.cs new file mode 100644 index 0000000..b2fc65a --- /dev/null +++ b/SecondDimensionWatcherReDive/Services/Transcoding/IHlsTranscodingService.cs @@ -0,0 +1,44 @@ +namespace SecondDimensionWatcherReDive.Services.Transcoding; + +internal interface IHlsTranscodingService +{ + Task PrepareAsync( + Guid animationInfoId, + string? relativePath, + TranscodingSelection selection, + CancellationToken cancellationToken); + + Task GetStatusAsync( + Guid sessionId, + string accessToken, + CancellationToken cancellationToken); + + Task GetPlaylistAsync( + Guid sessionId, + string accessToken, + CancellationToken cancellationToken); + + Task OpenSegmentAsync( + Guid sessionId, + string accessToken, + string fileName, + CancellationToken cancellationToken); + + Task OpenSubtitleAsync( + Guid sessionId, + string accessToken, + string fileName, + CancellationToken cancellationToken); + + Task OpenDirectAsync( + Guid sessionId, + string accessToken, + CancellationToken cancellationToken); + + Task CancelAsync( + Guid sessionId, + string accessToken, + CancellationToken cancellationToken); + + Task GetMetricsAsync(CancellationToken cancellationToken); +} diff --git a/SecondDimensionWatcherReDive/Services/Transcoding/ScopeOwnedStream.cs b/SecondDimensionWatcherReDive/Services/Transcoding/ScopeOwnedStream.cs new file mode 100644 index 0000000..362ea00 --- /dev/null +++ b/SecondDimensionWatcherReDive/Services/Transcoding/ScopeOwnedStream.cs @@ -0,0 +1,97 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace SecondDimensionWatcherReDive.Services.Transcoding; + +///

+/// Keeps the scoped file-store implementation alive for as long as its stream. +/// +internal sealed class ScopeOwnedStream(Stream inner, AsyncServiceScope scope) : Stream +{ + private int _disposed; + + public override bool CanRead => inner.CanRead; + public override bool CanSeek => inner.CanSeek; + public override bool CanWrite => inner.CanWrite; + public override long Length => inner.Length; + + public override long Position + { + get => inner.Position; + set => inner.Position = value; + } + + public override void Flush() => inner.Flush(); + + public override Task FlushAsync(CancellationToken cancellationToken) + => inner.FlushAsync(cancellationToken); + + public override int Read(byte[] buffer, int offset, int count) + => inner.Read(buffer, offset, count); + + public override int Read(Span buffer) => inner.Read(buffer); + + public override ValueTask ReadAsync( + Memory buffer, + CancellationToken cancellationToken = default) + => inner.ReadAsync(buffer, cancellationToken); + + public override Task ReadAsync( + byte[] buffer, + int offset, + int count, + CancellationToken cancellationToken) + => inner.ReadAsync(buffer, offset, count, cancellationToken); + + public override long Seek(long offset, SeekOrigin origin) => inner.Seek(offset, origin); + + public override void SetLength(long value) => inner.SetLength(value); + + public override void Write(byte[] buffer, int offset, int count) + => inner.Write(buffer, offset, count); + + public override void Write(ReadOnlySpan buffer) => inner.Write(buffer); + + public override ValueTask WriteAsync( + ReadOnlyMemory buffer, + CancellationToken cancellationToken = default) + => inner.WriteAsync(buffer, cancellationToken); + + public override Task WriteAsync( + byte[] buffer, + int offset, + int count, + CancellationToken cancellationToken) + => inner.WriteAsync(buffer, offset, count, cancellationToken); + + protected override void Dispose(bool disposing) + { + if (disposing && Interlocked.Exchange(ref _disposed, 1) == 0) + { + try + { + inner.Dispose(); + } + finally + { + scope.DisposeAsync().AsTask().GetAwaiter().GetResult(); + } + } + base.Dispose(disposing); + } + + public override async ValueTask DisposeAsync() + { + if (Interlocked.Exchange(ref _disposed, 1) == 0) + { + try + { + await inner.DisposeAsync(); + } + finally + { + await scope.DisposeAsync(); + } + } + GC.SuppressFinalize(this); + } +} diff --git a/SecondDimensionWatcherReDive/Services/Transcoding/TranscodingMetrics.cs b/SecondDimensionWatcherReDive/Services/Transcoding/TranscodingMetrics.cs new file mode 100644 index 0000000..a7fce59 --- /dev/null +++ b/SecondDimensionWatcherReDive/Services/Transcoding/TranscodingMetrics.cs @@ -0,0 +1,107 @@ +using System.Diagnostics.Metrics; + +namespace SecondDimensionWatcherReDive.Services.Transcoding; + +internal sealed class TranscodingMetrics : IDisposable +{ + private readonly Meter _meter = new("SecondDimensionWatcherReDive.Transcoding", "1.0.0"); + private readonly Counter _completedCounter; + private readonly Counter _failedCounter; + private readonly Counter _canceledCounter; + private readonly Counter _cacheHitCounter; + private readonly Histogram _firstSegmentHistogram; + private readonly Histogram _speedHistogram; + private long _completed; + private long _failed; + private long _canceled; + private long _cacheHits; + private long _cacheBytes; + private long _firstSegmentSamples; + private long _firstSegmentMilliseconds; + private long _speedSamples; + private double _speedTotal; + private readonly object _speedGate = new(); + private int _queued; + private int _active; + + public TranscodingMetrics() + { + _completedCounter = _meter.CreateCounter("sdw.transcoding.jobs.completed"); + _failedCounter = _meter.CreateCounter("sdw.transcoding.jobs.failed"); + _canceledCounter = _meter.CreateCounter("sdw.transcoding.jobs.canceled"); + _cacheHitCounter = _meter.CreateCounter("sdw.transcoding.cache.hits"); + _firstSegmentHistogram = _meter.CreateHistogram("sdw.transcoding.first_segment.seconds", "s"); + _speedHistogram = _meter.CreateHistogram("sdw.transcoding.speed", "x"); + _meter.CreateObservableGauge("sdw.transcoding.jobs.queued", () => Volatile.Read(ref _queued)); + _meter.CreateObservableGauge("sdw.transcoding.jobs.active", () => Volatile.Read(ref _active)); + _meter.CreateObservableGauge("sdw.transcoding.cache.bytes", () => Interlocked.Read(ref _cacheBytes), "By"); + } + + public void SetQueued(int value) => Volatile.Write(ref _queued, value); + public void SetActive(int value) => Volatile.Write(ref _active, value); + public void SetCacheBytes(long value) => Interlocked.Exchange(ref _cacheBytes, value); + + public void RecordCompleted() + { + Interlocked.Increment(ref _completed); + _completedCounter.Add(1); + } + + public void RecordFailed() + { + Interlocked.Increment(ref _failed); + _failedCounter.Add(1); + } + + public void RecordCanceled() + { + Interlocked.Increment(ref _canceled); + _canceledCounter.Add(1); + } + + public void RecordCacheHit() + { + Interlocked.Increment(ref _cacheHits); + _cacheHitCounter.Add(1); + } + + public void RecordFirstSegment(TimeSpan elapsed) + { + Interlocked.Increment(ref _firstSegmentSamples); + Interlocked.Add(ref _firstSegmentMilliseconds, (long)elapsed.TotalMilliseconds); + _firstSegmentHistogram.Record(elapsed.TotalSeconds); + } + + public void RecordSpeed(double speed) + { + if (!double.IsFinite(speed) || speed <= 0) return; + Interlocked.Increment(ref _speedSamples); + lock (_speedGate) _speedTotal += speed; + _speedHistogram.Record(speed); + } + + public TranscodingMetricsSnapshot Snapshot() + { + var firstSamples = Interlocked.Read(ref _firstSegmentSamples); + var speedSamples = Interlocked.Read(ref _speedSamples); + var completed = Interlocked.Read(ref _completed); + var failed = Interlocked.Read(ref _failed); + double speedTotal; + lock (_speedGate) speedTotal = _speedTotal; + return new TranscodingMetricsSnapshot( + Volatile.Read(ref _queued), + Volatile.Read(ref _active), + completed, + failed, + Interlocked.Read(ref _canceled), + Interlocked.Read(ref _cacheHits), + Interlocked.Read(ref _cacheBytes), + firstSamples == 0 + ? null + : Interlocked.Read(ref _firstSegmentMilliseconds) / 1000d / firstSamples, + speedSamples == 0 ? null : speedTotal / speedSamples, + completed + failed == 0 ? 0 : failed / (double)(completed + failed)); + } + + public void Dispose() => _meter.Dispose(); +} diff --git a/SecondDimensionWatcherReDive/Services/Transcoding/TranscodingModels.cs b/SecondDimensionWatcherReDive/Services/Transcoding/TranscodingModels.cs new file mode 100644 index 0000000..860130d --- /dev/null +++ b/SecondDimensionWatcherReDive/Services/Transcoding/TranscodingModels.cs @@ -0,0 +1,161 @@ +using System.Globalization; +using System.Security.Cryptography; +using System.Text; + +namespace SecondDimensionWatcherReDive.Services.Transcoding; + +internal enum TranscodingJobState +{ + Queued, + Probing, + Transcoding, + Ready, + Failed, + Canceled +} + +internal enum TranscodingStrategy +{ + Direct, + Remux, + Transcode +} + +internal sealed record TranscodingSelection( + string Quality, + string? AudioLanguage, + string? AudioTrackLabel, + string? SubtitleLanguage, + string? SubtitleTrackLabel) +{ + public static TranscodingSelection Create( + string? quality, + string? audioLanguage, + string? audioTrackLabel, + string? subtitleLanguage, + string? subtitleTrackLabel) + { + var normalizedQuality = string.IsNullOrWhiteSpace(quality) + ? "auto" + : quality.Trim().ToLowerInvariant(); + if (normalizedQuality is not ("auto" or "720p" or "1080p")) + throw new ArgumentException("Quality must be auto, 720p, or 1080p.", nameof(quality)); + + return new TranscodingSelection( + normalizedQuality, + Normalize(audioLanguage), + Normalize(audioTrackLabel), + Normalize(subtitleLanguage), + Normalize(subtitleTrackLabel)); + } + + private static string? Normalize(string? value) + => string.IsNullOrWhiteSpace(value) ? null : value.Trim().ToLowerInvariant(); +} + +internal sealed record TranscodingSource( + Guid AnimationInfoId, + Guid MappingId, + string VirtualPath, + string PhysicalPath, + string FileStore, + string FileName, + long Length, + DateTimeOffset LastModifiedUtc) +{ + public string BuildCacheKey(TranscodingSelection selection) + { + var material = string.Join('\n', + MappingId.ToString("N"), + VirtualPath, + PhysicalPath, + FileStore, + Length.ToString(CultureInfo.InvariantCulture), + LastModifiedUtc.UtcTicks.ToString(CultureInfo.InvariantCulture), + selection.Quality, + selection.AudioLanguage ?? string.Empty, + selection.AudioTrackLabel ?? string.Empty, + selection.SubtitleLanguage ?? string.Empty, + selection.SubtitleTrackLabel ?? string.Empty); + return Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(material))); + } +} + +internal sealed record MediaStreamProbe( + int Index, + string CodecType, + string CodecName, + string? Language, + string? Title, + bool IsDefault, + bool IsForced, + bool IsAttachedPicture); + +internal sealed record MediaProbe( + string Container, + TimeSpan? Duration, + IReadOnlyList Streams) +{ + public MediaStreamProbe? Video => Streams.FirstOrDefault(stream => + stream.CodecType == "video" && !stream.IsAttachedPicture); +} + +internal sealed record TranscodingPlan( + TranscodingStrategy Strategy, + MediaStreamProbe Video, + MediaStreamProbe? Audio, + MediaStreamProbe? BitmapSubtitleToBurn, + IReadOnlyList TextSubtitles, + int UnsupportedSubtitleCount, + bool CopyVideo, + bool CopyAudio); + +internal sealed record TranscodingSubtitle( + string FileName, + string Label, + string? Language, + string Format); + +internal sealed record TranscodingSessionStatus( + Guid SessionId, + string AccessToken, + TranscodingJobState State, + TranscodingStrategy? Strategy, + bool IsPlayable, + bool CacheHit, + double? Progress, + double? Speed, + int? QueuePosition, + string? Error, + string? VideoCodec, + string? AudioCodec, + IReadOnlyList Subtitles, + int UnsupportedSubtitleCount); + +internal sealed record TranscodingContent( + Stream Stream, + string ContentType, + string? FileName, + long? Length, + DateTimeOffset? LastModifiedUtc); + +internal sealed record TranscodingMetricsSnapshot( + int QueuedJobs, + int ActiveJobs, + long CompletedJobs, + long FailedJobs, + long CanceledJobs, + long CacheHits, + long CacheBytes, + double? AverageFirstSegmentSeconds, + double? AverageTranscodeSpeed, + double FailureRate); + +internal sealed class TranscodingQueueFullException() + : InvalidOperationException("The transcoding queue is full. Try again later."); + +internal sealed class TranscodingDisabledException() + : InvalidOperationException("Server-side transcoding is disabled."); + +internal sealed class TranscodingResourceLimitException(string message) + : InvalidOperationException(message); diff --git a/SecondDimensionWatcherReDive/Services/Transcoding/TranscodingOptions.cs b/SecondDimensionWatcherReDive/Services/Transcoding/TranscodingOptions.cs new file mode 100644 index 0000000..20e39fd --- /dev/null +++ b/SecondDimensionWatcherReDive/Services/Transcoding/TranscodingOptions.cs @@ -0,0 +1,27 @@ +namespace SecondDimensionWatcherReDive.Services.Transcoding; + +internal sealed class TranscodingOptions +{ + public const string SectionName = "Transcoding"; + + public bool Enabled { get; set; } = true; + public string CachePath { get; set; } = string.Empty; + public string FfmpegPath { get; set; } = "ffmpeg"; + public string FfprobePath { get; set; } = "ffprobe"; + public int MaxConcurrentJobs { get; set; } = 1; + public int QueueCapacity { get; set; } = 8; + public int MaxThreadsPerJob { get; set; } = 2; + public long MaxMemoryBytesPerJob { get; set; } = 2L * 1024 * 1024 * 1024; + public long MaxDiskBytesPerJob { get; set; } = 20L * 1024 * 1024 * 1024; + public long MaxCacheBytes { get; set; } = 100L * 1024 * 1024 * 1024; + public TimeSpan JobTimeout { get; set; } = TimeSpan.FromHours(6); + public TimeSpan CacheTtl { get; set; } = TimeSpan.FromDays(14); + public TimeSpan CleanupInterval { get; set; } = TimeSpan.FromMinutes(5); + public TimeSpan SessionTtl { get; set; } = TimeSpan.FromMinutes(15); + public int SegmentDurationSeconds { get; set; } = 6; + public int VideoCrf { get; set; } = 23; + public string VideoPreset { get; set; } = "veryfast"; + public string? HardwareVideoEncoder { get; set; } + public string[] HardwareInputArguments { get; set; } = []; + public bool BurnBitmapSubtitles { get; set; } +} diff --git a/SecondDimensionWatcherReDive/Services/Transcoding/TranscodingPlanner.cs b/SecondDimensionWatcherReDive/Services/Transcoding/TranscodingPlanner.cs new file mode 100644 index 0000000..ef4a2db --- /dev/null +++ b/SecondDimensionWatcherReDive/Services/Transcoding/TranscodingPlanner.cs @@ -0,0 +1,126 @@ +namespace SecondDimensionWatcherReDive.Services.Transcoding; + +internal static class TranscodingPlanner +{ + private static readonly HashSet TextSubtitleCodecs = new(StringComparer.OrdinalIgnoreCase) + { + "ass", "jacosub", "microdvd", "mov_text", "mpl2", "realtext", "sami", "ssa", + "subrip", "subviewer", "subviewer1", "text", "vplayer", "webvtt" + }; + + private static readonly HashSet HlsAudioCodecs = new(StringComparer.OrdinalIgnoreCase) + { + "aac", "mp3" + }; + + public static TranscodingPlan CreatePlan( + TranscodingSource source, + MediaProbe probe, + TranscodingSelection selection, + bool burnBitmapSubtitles) + { + var video = probe.Video + ?? throw new InvalidOperationException("The selected file has no video track."); + var audio = SelectStream( + probe.Streams.Where(stream => stream.CodecType == "audio"), + selection.AudioLanguage, + selection.AudioTrackLabel); + var subtitles = probe.Streams.Where(stream => stream.CodecType == "subtitle").ToArray(); + var textSubtitles = subtitles.Where(stream => TextSubtitleCodecs.Contains(stream.CodecName)).ToArray(); + var bitmapSubtitles = subtitles.Where(stream => !TextSubtitleCodecs.Contains(stream.CodecName)).ToArray(); + var hasSubtitlePreference = selection.SubtitleLanguage is not null + || selection.SubtitleTrackLabel is not null; + var bitmapToBurn = burnBitmapSubtitles + && selection.SubtitleLanguage != "off" + ? SelectStream( + bitmapSubtitles, + selection.SubtitleLanguage, + selection.SubtitleTrackLabel, + fallbackToDefault: !hasSubtitlePreference) + : null; + + var extension = Path.GetExtension(source.FileName); + var copyVideo = video.CodecName.Equals("h264", StringComparison.OrdinalIgnoreCase) + && selection.Quality == "auto" + && bitmapToBurn is null; + var copyAudio = audio is null || HlsAudioCodecs.Contains(audio.CodecName); + var direct = IsDirectPlayContainer(extension, video.CodecName, audio?.CodecName) + && selection.Quality == "auto" + && bitmapToBurn is null; + var strategy = direct + ? TranscodingStrategy.Direct + : copyVideo && copyAudio + ? TranscodingStrategy.Remux + : TranscodingStrategy.Transcode; + + return new TranscodingPlan( + strategy, + video, + audio, + bitmapToBurn, + textSubtitles, + bitmapSubtitles.Length - (bitmapToBurn is null ? 0 : 1), + copyVideo, + copyAudio); + } + + private static MediaStreamProbe? SelectStream( + IEnumerable streams, + string? preferredLanguage, + string? preferredLabel, + bool fallbackToDefault = true) + { + var candidates = streams.ToArray(); + if (preferredLabel is not null) + { + var labelMatch = candidates.FirstOrDefault(stream => + string.Equals(stream.Title, preferredLabel, StringComparison.OrdinalIgnoreCase)); + if (labelMatch is not null) return labelMatch; + } + + if (preferredLanguage is not null) + { + var languageMatch = candidates.FirstOrDefault(stream => + LanguagesMatch(stream.Language, preferredLanguage)); + if (languageMatch is not null) return languageMatch; + } + + return fallbackToDefault + ? candidates.FirstOrDefault(stream => stream.IsDefault) ?? candidates.FirstOrDefault() + : null; + } + + private static bool LanguagesMatch(string? actual, string preferred) + { + if (actual is null) return false; + var normalizedActual = NormalizeLanguage(actual); + return normalizedActual == NormalizeLanguage(preferred); + } + + private static string NormalizeLanguage(string language) + { + var normalized = language.Trim().ToLowerInvariant().Replace('_', '-'); + if (normalized is "chi" or "zho" || normalized.StartsWith("zh-", StringComparison.Ordinal)) return "zh"; + if (normalized is "jpn" || normalized.StartsWith("ja-", StringComparison.Ordinal)) return "ja"; + if (normalized is "eng" || normalized.StartsWith("en-", StringComparison.Ordinal)) return "en"; + var separator = normalized.IndexOf('-'); + return separator < 0 ? normalized : normalized[..separator]; + } + + private static bool IsDirectPlayContainer(string extension, string videoCodec, string? audioCodec) + { + if (extension.Equals(".mp4", StringComparison.OrdinalIgnoreCase) + || extension.Equals(".m4v", StringComparison.OrdinalIgnoreCase)) + return videoCodec.Equals("h264", StringComparison.OrdinalIgnoreCase) + && (audioCodec is null || audioCodec.Equals("aac", StringComparison.OrdinalIgnoreCase)); + + if (!extension.Equals(".webm", StringComparison.OrdinalIgnoreCase)) return false; + var supportedVideo = videoCodec.Equals("vp8", StringComparison.OrdinalIgnoreCase) + || videoCodec.Equals("vp9", StringComparison.OrdinalIgnoreCase) + || videoCodec.Equals("av1", StringComparison.OrdinalIgnoreCase); + var supportedAudio = audioCodec is null + || audioCodec.Equals("opus", StringComparison.OrdinalIgnoreCase) + || audioCodec.Equals("vorbis", StringComparison.OrdinalIgnoreCase); + return supportedVideo && supportedAudio; + } +} diff --git a/SecondDimensionWatcherReDive/Utils/FileStore/PlaybackPathResolver.cs b/SecondDimensionWatcherReDive/Utils/FileStore/PlaybackPathResolver.cs new file mode 100644 index 0000000..d818129 --- /dev/null +++ b/SecondDimensionWatcherReDive/Utils/FileStore/PlaybackPathResolver.cs @@ -0,0 +1,29 @@ +using SecondDimensionWatcherReDive.Framework.DataRepository; + +namespace SecondDimensionWatcherReDive.Utils.FileStore; + +internal static class PlaybackPathResolver +{ + public static string ResolveVirtualPath(AnimationInfo info, string? relative) + { + var root = GetAnimationVirtualRoot(info); + if (string.IsNullOrWhiteSpace(relative)) return root; + var trimmed = relative.Trim('/'); + return string.IsNullOrEmpty(trimmed) ? root : $"{root}/{trimmed}"; + } + + private static string GetAnimationVirtualRoot(AnimationInfo info) + { + if (info.Animation is null || info.Season is null) return "/unknown"; + var animationName = SanitizePathSegment(info.Animation.Name); + var subGroup = SanitizePathSegment(info.Group?.Name ?? "Unknown"); + return $"/{animationName}/{subGroup}"; + } + + private static string SanitizePathSegment(string name) + { + var invalid = Path.GetInvalidFileNameChars(); + var sanitized = string.Concat(name.Select(c => invalid.Contains(c) || c == '/' ? '_' : c)).Trim(); + return string.IsNullOrEmpty(sanitized) ? "Unknown" : sanitized; + } +} diff --git a/SecondDimensionWatcherReDive/appsettings.example.json b/SecondDimensionWatcherReDive/appsettings.example.json index da9d5ab..67745cc 100644 --- a/SecondDimensionWatcherReDive/appsettings.example.json +++ b/SecondDimensionWatcherReDive/appsettings.example.json @@ -27,6 +27,29 @@ "SettlingPeriod": "00:00:30", "MissingGracePeriod": "1.00:00:00" }, + // Server-side HLS fallback for containers/codecs that browsers cannot play. + // FFmpeg/ffprobe must be installed. HardwareVideoEncoder is optional (for example + // h264_nvenc or h264_vaapi); a failed hardware attempt automatically retries on CPU. + "Transcoding": { + "Enabled": true, + "CachePath": "/var/lib/sdw-redive/transcode-cache", + "FfmpegPath": "ffmpeg", + "FfprobePath": "ffprobe", + "MaxConcurrentJobs": 1, + "QueueCapacity": 8, + "MaxThreadsPerJob": 2, + "MaxMemoryBytesPerJob": 2147483648, + "MaxDiskBytesPerJob": 21474836480, + "MaxCacheBytes": 107374182400, + "JobTimeout": "06:00:00", + "CacheTtl": "14.00:00:00", + "CleanupInterval": "00:05:00", + "SessionTtl": "00:15:00", + "SegmentDurationSeconds": 6, + "HardwareVideoEncoder": null, + "HardwareInputArguments": [], + "BurnBitmapSubtitles": false + }, "MikananiFeeds": [], "TmdbApiKey": "", diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 9bfc688..f90b183 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -3,8 +3,7 @@ This project is licensed under the **Apache License 2.0** (see [`LICENSE`](LICENSE)). It bundles, redistributes, or links against the third-party software listed below. This file exists to satisfy the notice / attribution requirements those -licenses impose on downstream distributors. Components are used as published -except for the `@ffmpeg/ffmpeg` Parcel compatibility patch noted below. +licenses impose on downstream distributors. Components are used as published. If you ship our binaries or container images, you are also distributing some of these components — please carry this notice along. @@ -71,8 +70,7 @@ time. All are MIT-licensed unless noted. | mediabunny | **MPL-2.0** | https://github.com/Vanilagy/mediabunny | | media-captions | MIT | https://github.com/vidstack/media-captions | | matroska-subtitles | MIT | https://github.com/mathiasvr/matroska-subtitles | -| @ffmpeg/ffmpeg | MIT | https://github.com/ffmpegwasm/ffmpeg.wasm | -| @ffmpeg/core | **GPL-2.0-or-later** | https://github.com/ffmpegwasm/ffmpeg.wasm | +| hls.js | Apache-2.0 | https://github.com/video-dev/hls.js | | clsx | MIT | https://github.com/lukeed/clsx | | dayjs | MIT | https://github.com/iamkun/dayjs | | i18next + react-i18next + i18next-browser-languagedetector | MIT | https://github.com/i18next/i18next | @@ -85,20 +83,23 @@ Build-only / development dependencies (Parcel, Prettier, TypeScript, etc.) are listed in `SecondDimensionWatcherReDive.Client/package.json` — they are not embedded in shipping artifacts and are not enumerated here. -### Browser MKV support +### Browser media support `mediabunny` is distributed under MPL-2.0; modifications to MPL-covered files must remain available under that license. This project does not modify its -sources. - -The unsupported-codec fallback ships the `@ffmpeg/core` WebAssembly binary, -which is GPL-2.0-or-later. Distributors enabling or shipping the web client must -comply with the GPL's source and license requirements for that component. Its -corresponding source is the upstream `ffmpeg.wasm` project linked above. - -The MIT-licensed `@ffmpeg/ffmpeg` wrapper is patched locally so its worker uses -the ESM core loader accepted by Parcel. The complete patch is distributed in -`SecondDimensionWatcherReDive.Client/.yarn/patches/`. +sources. `hls.js` provides Media Source Extensions playback for the server-side +HLS fallback on browsers without native HLS support. + +### FFmpeg / ffprobe runtime dependency + +Server-side media probing, remuxing, transcoding, segmentation, and WebVTT +conversion invoke the separately installed FFmpeg command-line tools. Official +container images install their base distribution's FFmpeg package; Linux system +packages list FFmpeg as a runtime dependency. FFmpeg's effective +license depends on the codecs enabled by the distributor (the current container +build is GPL-licensed); downstream redistributors must carry the corresponding +distro package notices and source offer. The application does not statically link +FFmpeg or copy its libraries into the .NET binaries. --- diff --git a/deployments/podman-compose.yml b/deployments/podman-compose.yml index 7a4f463..afe8adf 100644 --- a/deployments/podman-compose.yml +++ b/deployments/podman-compose.yml @@ -57,6 +57,7 @@ services: MediaLibrary__MissingGracePeriod: "1.00:00:00" PasswordFile: "/app/data/password.json" DataProtection__KeyRingPath: "/app/data/data-protection-keys" + Transcoding__CachePath: "/app/data/transcode-cache" Torrent__Remote__Url: "http://qbittorrent:8080" Torrent__Remote__UserName: "" Torrent__Remote__Password: "" diff --git a/docs/container-deployment.md b/docs/container-deployment.md index f14757e..77aa138 100644 --- a/docs/container-deployment.md +++ b/docs/container-deployment.md @@ -19,7 +19,7 @@ - `downloads` — sdw-redive 和 qbittorrent **共享**,用于下载文件的读写 - `pgdata` — PostgreSQL 数据持久化 - `valkeydata` — Valkey 缓存数据持久化 -- `appdata` — 登录密码文件与运行时敏感配置的 Data Protection 密钥环 +- `appdata` — 登录密码、Data Protection 密钥环与可复用 HLS 转码缓存 ## 快速开始 @@ -120,6 +120,11 @@ podman logs qbittorrent 2>&1 | grep "temporary password" | `MediaLibrary__SettlingPeriod` | 新文件写入完成后的稳定等待时间 | `00:00:30` | | `MediaLibrary__MissingGracePeriod` | 条目缺失后保留观看/审核记录的宽限期 | `1.00:00:00` | | `MediaLibrary__AllowedRoots__0`, `__1`, ... | 允许导入的服务端根目录白名单 | `/media` | +| `Transcoding__CachePath` | 服务端 HLS 分片缓存(应挂载持久卷) | `/app/data/transcode-cache` | +| `Transcoding__MaxConcurrentJobs` | 同时运行的 FFmpeg 任务数 | `1` | +| `Transcoding__QueueCapacity` | 等待队列容量;满时返回 429 | `8` | +| `Transcoding__MaxMemoryBytesPerJob` | 单个 FFmpeg 工作集上限 | `2147483648` | +| `Transcoding__MaxCacheBytes` | HLS 缓存总上限 | `107374182400` | | `Torrent__Remote__Url` | qBittorrent API 地址 | `http://qbittorrent:8080` | | `Valkey__ConnectionString` | Valkey 连接字符串 | 空(使用内存缓存) | | `TmdbApiKey` | TMDB API 密钥 | 空(海报功能不可用) | @@ -135,6 +140,11 @@ podman logs qbittorrent 2>&1 | grep "temporary password" | `AI__CodexAppServer__BearerToken` | app-server / 反向代理要求的 Bearer token | 空 | | `AI__CodexAppServer__PermissionProfile` | `:read-only` 或管理员定义的 permission profile id | `:read-only` | +镜像已包含 FFmpeg。浏览器会继续优先直放兼容源;只有不兼容轨道才进入有界服务端队列, +首个 HLS 分片生成后立即开始播放。`appdata` 必须留有足够空间,缓存会按 TTL/LRU 自动清理。 +硬件转码需要额外映射 GPU 设备/驱动并设置 `Transcoding__HardwareVideoEncoder`;硬件失败会 +自动回退 CPU。 + ### 网页运行时设置 首次登录后可在「设置」中修改 AI/TMDB、qBittorrent、媒体库扫描、异常阈值和 NFS。网页值保存在 PostgreSQL,优先于上表的环境变量;敏感值加密后存储且不会通过 API 回显。`appdata` 卷中的 Data Protection 密钥环必须保留,否则重启后的应用无法解密已保存的密钥。 diff --git a/docs/server-deployment.md b/docs/server-deployment.md index 5814636..2ca46ca 100644 --- a/docs/server-deployment.md +++ b/docs/server-deployment.md @@ -15,6 +15,7 @@ - **ASP.NET Core 10 Runtime** — 应用以 framework-dependent 方式打包,需预先安装运行时 - **PostgreSQL** — 数据库 - **qBittorrent** — 开启 Web API +- **FFmpeg / ffprobe** — 浏览器不兼容媒体的服务端 HLS 探测、封装与转码 ### 安装 ASP.NET Core Runtime @@ -31,6 +32,19 @@ sudo dnf install aspnetcore-runtime-10.0 sudo pacman -S aspnet-runtime-10.0 ``` +通过系统包安装时,FFmpeg 会作为依赖一并安装。使用 tar.gz 或手动部署时请另外安装: + +```bash +# Debian / Ubuntu +sudo apt install ffmpeg + +# Fedora / RHEL +sudo dnf install ffmpeg + +# Arch Linux +sudo pacman -S ffmpeg +``` + ## 安装 ### 快速安装(推荐) @@ -67,6 +81,7 @@ sudo pacman -U sdw-redive-*.pkg.tar.zst | `/etc/sdw-redive/appsettings.yml` | 配置文件(YAML 格式,升级时保留用户修改) | | `/var/lib/sdw-redive/downloads/` | 默认下载存储目录 | | `/var/lib/sdw-redive/data-protection-keys/` | 网页保存的敏感配置所用持久加密密钥环 | +| `/var/lib/sdw-redive/transcode-cache/` | 可复用的 HLS 分片、WebVTT 字幕与缓存清单 | | `/usr/lib/systemd/system/sdw-redive.service` | systemd 服务单元 | 安装时自动创建 `sdw-redive` 系统用户和组用于运行服务。 @@ -103,6 +118,19 @@ MediaLibrary: SettlingPeriod: "00:00:30" MissingGracePeriod: "1.00:00:00" +Transcoding: + Enabled: true + CachePath: /var/lib/sdw-redive/transcode-cache + MaxConcurrentJobs: 1 + QueueCapacity: 8 + MaxThreadsPerJob: 2 + MaxMemoryBytesPerJob: 2147483648 # 2 GiB + MaxDiskBytesPerJob: 21474836480 # 20 GiB / job + MaxCacheBytes: 107374182400 # 100 GiB total + CacheTtl: "14.00:00:00" + SessionTtl: "00:15:00" + SegmentDurationSeconds: 6 + # TMDB API 密钥(用于海报和元数据) TmdbApiKey: "YOUR_TMDB_API_KEY" @@ -131,6 +159,31 @@ Inference: # InstanceName: "sdw-redive:" ``` +### 服务端流式播放与转码 + +网页播放器仍优先使用原文件直放;可由浏览器解码的 MKV 使用按需 Range 拆包。不兼容的 +容器或轨道才会提交到服务端:H.264/AAC 等兼容轨道优先无损封装为 HLS,只有不兼容轨道 +才转码。首个分片完成后即可播放、拖动已生成范围并同步观看进度,源文件不会先完整下载到 +浏览器。文本内封字幕会转换为 WebVTT;位图字幕默认明确标记为不可用,可用 +`Transcoding:BurnBitmapSubtitles=true` 按字幕偏好烧录(会强制视频转码)。 + +同一源版本、音轨/字幕偏好与质量会复用缓存。源文件长度或修改时间变化时会生成新缓存键; +后台按 `CacheTtl` 和 LRU 清理,且始终优先保留正在播放或生成的任务。并发数、队列长度、 +FFmpeg 线程、工作集内存、单任务磁盘、总缓存和任务超时均可配置。队列满时 API 返回 429, +不会启动额外 FFmpeg 进程。登录用户可通过 `GET /api/transcoding/metrics` 查看排队/活动任务、 +成功/失败/取消、失败率、缓存命中与占用、平均首分片时间和平均转码速度。 + +可选硬件编码示例(实际编码器及输入参数取决于主机 FFmpeg 构建和设备映射): + +```yaml +Transcoding: + HardwareVideoEncoder: h264_nvenc + HardwareInputArguments: ["-hwaccel", "cuda"] +``` + +硬件进程失败时会删除不完整输出并自动用 `libx264` 重试。容器部署还需把对应 GPU 设备和 +驱动映射进容器;未配置硬件编码器时始终使用 CPU。 + `DataProtection:KeyRingPath` 是运行时敏感设置的解密根密钥,不是普通缓存。请持久化并备份该目录,权限应仅允许应用服务账号读取。多副本连接同一个 PostgreSQL 数据库时,**所有副本必须挂载同一份共享密钥环**;否则一个副本写入的 API key/密码无法被其他副本解密。所有副本也必须保持应用内置的 Data Protection application name 一致(`SecondDimensionWatcherReDive`)。 > **注意**:配置文件在包升级时不会被覆盖(标记为 conffile / noreplace)。 diff --git a/packaging/appsettings.yml b/packaging/appsettings.yml index 4cf660f..6bccbac 100644 --- a/packaging/appsettings.yml +++ b/packaging/appsettings.yml @@ -29,6 +29,27 @@ MediaLibrary: SettlingPeriod: "00:00:30" MissingGracePeriod: "1.00:00:00" +# 浏览器不兼容媒体的服务端 HLS 后备路径。硬件编码器失败时自动回退 CPU。 +Transcoding: + Enabled: true + CachePath: /var/lib/sdw-redive/transcode-cache + FfmpegPath: ffmpeg + FfprobePath: ffprobe + MaxConcurrentJobs: 1 + QueueCapacity: 8 + MaxThreadsPerJob: 2 + MaxMemoryBytesPerJob: 2147483648 + MaxDiskBytesPerJob: 21474836480 + MaxCacheBytes: 107374182400 + JobTimeout: "06:00:00" + CacheTtl: "14.00:00:00" + CleanupInterval: "00:05:00" + SessionTtl: "00:15:00" + SegmentDurationSeconds: 6 + HardwareVideoEncoder: null + HardwareInputArguments: [] + BurnBitmapSubtitles: false + # 密码文件路径 PasswordFile: /var/lib/sdw-redive/password.json diff --git a/packaging/nfpm.yaml b/packaging/nfpm.yaml index 3dcd289..3a0a580 100644 --- a/packaging/nfpm.yaml +++ b/packaging/nfpm.yaml @@ -11,6 +11,7 @@ license: "Apache-2.0" depends: - aspnetcore-runtime-10.0 + - ffmpeg recommends: - valkey @@ -19,6 +20,7 @@ overrides: archlinux: depends: - aspnet-runtime-10.0 + - ffmpeg recommends: - valkey @@ -77,6 +79,13 @@ contents: owner: sdw-redive group: sdw-redive + - dst: /var/lib/sdw-redive/transcode-cache + type: dir + file_info: + mode: 0750 + owner: sdw-redive + group: sdw-redive + scripts: postinstall: ./packaging/postinstall.sh preremove: ./packaging/preremove.sh diff --git a/packaging/postinstall.sh b/packaging/postinstall.sh index 1530a32..016e6bd 100755 --- a/packaging/postinstall.sh +++ b/packaging/postinstall.sh @@ -44,6 +44,8 @@ chown -R sdw-redive:sdw-redive /var/lib/sdw-redive # private directory also protects keys created by future application runs. install -d -m 0700 -o sdw-redive -g sdw-redive \ /var/lib/sdw-redive/data-protection-keys +install -d -m 0750 -o sdw-redive -g sdw-redive \ + /var/lib/sdw-redive/transcode-cache if [ -f /var/lib/sdw-redive/password.json ]; then chown sdw-redive:sdw-redive /var/lib/sdw-redive/password.json chmod 0600 /var/lib/sdw-redive/password.json From b2c685c5d6d7eb1a7f4df3064cd10055267452cb Mon Sep 17 00:00:00 2001 From: mahoshojoHCG Date: Sat, 29 Aug 2026 23:31:41 +0800 Subject: [PATCH 03/37] feat: add searchable library and safe release upgrades --- .../Tools/TmdbTool.cs | 23 + .../mock-server.mjs | 560 ++++++-- .../src/Main.tsx | 10 + .../src/components/AppHeader.tsx | 39 +- .../components/SubscriptionPolicySheet.tsx | 104 +- .../src/i18n/locales/en/common.json | 2 + .../src/i18n/locales/en/feeds.json | 11 + .../src/i18n/locales/en/library.json | 70 + .../src/i18n/locales/ja/common.json | 2 + .../src/i18n/locales/ja/feeds.json | 11 + .../src/i18n/locales/ja/library.json | 70 + .../src/i18n/locales/zh-CN/common.json | 2 + .../src/i18n/locales/zh-CN/feeds.json | 11 + .../src/i18n/locales/zh-CN/library.json | 70 + .../src/i18n/resources.ts | 6 + .../src/library/api.ts | 36 + .../src/library/types.ts | 68 + .../src/pages/SearchPage.tsx | 542 ++++++++ .../src/subscriptionPolicy/types.ts | 13 +- .../DataRepository/AnimationInfo.cs | 15 +- .../IAnimationInfoRepository.cs | 2 + .../ILibrarySearchRepository.cs | 14 + .../DataRepository/LibrarySearch.cs | 101 ++ .../DataRepository/ReleaseUpgrade.cs | 100 ++ .../SubscriptionAutomationPolicy.cs | 5 +- .../Feed/AnimationAddRequest.cs | 4 +- .../Feed/IReleaseScoringService.cs | 12 + .../Feed/ReleaseIdentity.cs | 32 + .../FileMappingRepositoryPostgreSqlTests.cs | 131 ++ .../ReleaseUpgradeCoordinatorTests.cs | 122 ++ .../SubscriptionAutomationMatcherTests.cs | 24 + .../SyncFeedTests.cs | 33 +- .../Controllers/Converter.cs | 83 +- .../External/AppJsonSerializerContext.cs | 7 + .../Controllers/External/Library.cs | 84 ++ .../External/SubscriptionAutomationPolicy.cs | 10 +- .../Controllers/LibraryController.cs | 153 +++ .../SubscriptionPoliciesController.cs | 19 +- ...ibrarySearchAndReleaseUpgrades.Designer.cs | 1193 +++++++++++++++++ ...1303_AddLibrarySearchAndReleaseUpgrades.cs | 401 ++++++ .../ApplicationContextModelSnapshot.cs | 212 ++- .../Models/AnimationInfo.cs | 25 + .../Models/ApplicationContext.cs | 123 ++ .../Models/ReleaseUpgradeOperation.cs | 35 + .../Models/SubscriptionAutomationPolicy.cs | 6 + SecondDimensionWatcherReDive/Program.cs | 6 + .../Repositories/AnimationInfoRepository.cs | 26 + ...eMappingRepositoryPostgreSqlTestFixture.cs | 280 +++- .../Repositories/LibrarySearchRepository.cs | 322 +++++ .../Repositories/ReleaseUpgradeRepository.cs | 468 +++++++ .../Repositories/RepositoryConverter.cs | 54 +- .../CompleteDownloadBackgroundService.cs | 11 + .../Services/InferAnimationMetadata.cs | 10 + .../Services/MediaLibraryScanner.cs | 7 +- .../ReleaseUpgradeBackgroundService.cs | 55 + .../Services/SyncFeed.cs | 132 +- .../Feed/MikananiSubscriptionFeedReader.cs | 4 +- .../Utils/Feed/ReleaseScoringService.cs | 104 ++ .../IReleaseUpgradeCoordinator.cs | 27 + .../ReleaseUpgradeCoordinator.cs | 262 ++++ 60 files changed, 6176 insertions(+), 188 deletions(-) create mode 100644 SecondDimensionWatcherReDive.Client/src/i18n/locales/en/library.json create mode 100644 SecondDimensionWatcherReDive.Client/src/i18n/locales/ja/library.json create mode 100644 SecondDimensionWatcherReDive.Client/src/i18n/locales/zh-CN/library.json create mode 100644 SecondDimensionWatcherReDive.Client/src/library/api.ts create mode 100644 SecondDimensionWatcherReDive.Client/src/library/types.ts create mode 100644 SecondDimensionWatcherReDive.Client/src/pages/SearchPage.tsx create mode 100644 SecondDimensionWatcherReDive.Framework/DataRepository/ILibrarySearchRepository.cs create mode 100644 SecondDimensionWatcherReDive.Framework/DataRepository/LibrarySearch.cs create mode 100644 SecondDimensionWatcherReDive.Framework/DataRepository/ReleaseUpgrade.cs create mode 100644 SecondDimensionWatcherReDive.Framework/Feed/IReleaseScoringService.cs create mode 100644 SecondDimensionWatcherReDive.Framework/Feed/ReleaseIdentity.cs create mode 100644 SecondDimensionWatcherReDive.Test/ReleaseUpgradeCoordinatorTests.cs create mode 100644 SecondDimensionWatcherReDive/Controllers/External/Library.cs create mode 100644 SecondDimensionWatcherReDive/Controllers/LibraryController.cs create mode 100644 SecondDimensionWatcherReDive/Migrations/20260829151303_AddLibrarySearchAndReleaseUpgrades.Designer.cs create mode 100644 SecondDimensionWatcherReDive/Migrations/20260829151303_AddLibrarySearchAndReleaseUpgrades.cs create mode 100644 SecondDimensionWatcherReDive/Models/ReleaseUpgradeOperation.cs create mode 100644 SecondDimensionWatcherReDive/Repositories/LibrarySearchRepository.cs create mode 100644 SecondDimensionWatcherReDive/Repositories/ReleaseUpgradeRepository.cs create mode 100644 SecondDimensionWatcherReDive/Services/ReleaseUpgradeBackgroundService.cs create mode 100644 SecondDimensionWatcherReDive/Utils/Feed/ReleaseScoringService.cs create mode 100644 SecondDimensionWatcherReDive/Utils/ReleaseUpgrades/IReleaseUpgradeCoordinator.cs create mode 100644 SecondDimensionWatcherReDive/Utils/ReleaseUpgrades/ReleaseUpgradeCoordinator.cs diff --git a/Plugins/SecondDimensionWatcherReDive.Inference.AI/Tools/TmdbTool.cs b/Plugins/SecondDimensionWatcherReDive.Inference.AI/Tools/TmdbTool.cs index 0d30c24..22ef384 100644 --- a/Plugins/SecondDimensionWatcherReDive.Inference.AI/Tools/TmdbTool.cs +++ b/Plugins/SecondDimensionWatcherReDive.Inference.AI/Tools/TmdbTool.cs @@ -178,6 +178,29 @@ public async Task GetSeasonEpisodesAsync(int tmdbId, int seasonNumber, C } } + public async Task GetExpectedEpisodeCountAsync( + int tmdbId, + int seasonNumber, + CancellationToken cancellationToken) + { + var tmdbClient = GetClient(); + if (tmdbClient is null || seasonNumber <= 0) return null; + + try + { + var show = await tmdbClient.GetTvShowAsync(tmdbId, cancellationToken: cancellationToken); + var count = show?.Seasons? + .FirstOrDefault(season => season.SeasonNumber == seasonNumber) + ?.EpisodeCount; + return count is > 0 ? count : null; + } + catch (Exception ex) + { + LogGetSeasonsFailed(_logger, ex, tmdbId); + return null; + } + } + /// /// Fetches localized name, original name, and overview for a TV show from TMDB, /// using the server's current culture as the language. diff --git a/SecondDimensionWatcherReDive.Client/mock-server.mjs b/SecondDimensionWatcherReDive.Client/mock-server.mjs index f5ee4d4..443809f 100644 --- a/SecondDimensionWatcherReDive.Client/mock-server.mjs +++ b/SecondDimensionWatcherReDive.Client/mock-server.mjs @@ -385,6 +385,7 @@ function initAnimations() { } : null, isAiProcessed: !!entry.animeName, + isMediaLibraryImport: i === 2, }); }); } @@ -692,50 +693,153 @@ let feeds = [ // Per-feed subscription automation policies and historical releases. const POLICY_CREATED_AT = new Date(Date.now() - 86400_000).toISOString(); const subscriptionPolicies = new Map([ - [feeds[0].id, { - feedId: feeds[0].id, - subtitleGroups: ["LoliHouse", "喵萌奶茶屋"], - resolutions: ["1080p"], - codecs: ["HEVC"], - languages: ["简中", "繁中"], - minSizeBytes: 300 * 1024 * 1024, - maxSizeBytes: 1600 * 1024 * 1024, - excludedKeywords: ["合集", "NCOP"], - mode: "ManualConfirm", - createdAt: POLICY_CREATED_AT, - updatedAt: new Date(Date.now() - 3600_000 * 8).toISOString(), - }], - [feeds[1].id, { - feedId: feeds[1].id, - subtitleGroups: ["ANi", "SubsPlease"], - resolutions: ["1080p"], - codecs: [], - languages: ["繁中"], - minSizeBytes: null, - maxSizeBytes: 1400 * 1024 * 1024, - excludedKeywords: ["预告"], - mode: "AutoDownload", - createdAt: POLICY_CREATED_AT, - updatedAt: new Date(Date.now() - 3600_000 * 3).toISOString(), - }], + [ + feeds[0].id, + { + feedId: feeds[0].id, + subtitleGroups: ["LoliHouse", "喵萌奶茶屋"], + resolutions: ["1080p"], + codecs: ["HEVC"], + languages: ["简中", "繁中"], + minSizeBytes: 300 * 1024 * 1024, + maxSizeBytes: 1600 * 1024 * 1024, + excludedKeywords: ["合集", "NCOP"], + mode: "ManualConfirm", + enableVersionUpgrade: true, + minimumUpgradeScore: 80, + upgradeRollbackHours: 72, + createdAt: POLICY_CREATED_AT, + updatedAt: new Date(Date.now() - 3600_000 * 8).toISOString(), + }, + ], + [ + feeds[1].id, + { + feedId: feeds[1].id, + subtitleGroups: ["ANi", "SubsPlease"], + resolutions: ["1080p"], + codecs: [], + languages: ["繁中"], + minSizeBytes: null, + maxSizeBytes: 1400 * 1024 * 1024, + excludedKeywords: ["预告"], + mode: "AutoDownload", + enableVersionUpgrade: false, + minimumUpgradeScore: 25, + upgradeRollbackHours: 72, + createdAt: POLICY_CREATED_AT, + updatedAt: new Date(Date.now() - 3600_000 * 3).toISOString(), + }, + ], ]); const RELEASE_HISTORY_BY_FEED = new Map([ - [feeds[0].id, [ - { id: randomUUID(), title: "[LoliHouse] 葬送的芙莉莲 - 28 [WebRip 1080p HEVC-10bit AAC][简繁内封]", publishedAt: new Date(Date.now() - 3600_000 * 5).toISOString(), sizeBytes: 824 * 1024 * 1024, subtitleGroup: "LoliHouse", resolution: "1080p", codec: "HEVC", languages: ["简中", "繁中"] }, - { id: randomUUID(), title: "[ANi] 葬送的芙莉莲 - 28 [1080P][繁日双语]", publishedAt: new Date(Date.now() - 3600_000 * 12).toISOString(), sizeBytes: 516 * 1024 * 1024, subtitleGroup: "ANi", resolution: "1080p", codec: "AVC", languages: ["繁中", "日语"] }, - { id: randomUUID(), title: "[喵萌奶茶屋] 葬送的芙莉莲 01-28 合集 [1080p HEVC][简繁]", publishedAt: new Date(Date.now() - 86400_000).toISOString(), sizeBytes: 18.4 * 1024 * 1024 * 1024, subtitleGroup: "喵萌奶茶屋", resolution: "1080p", codec: "HEVC", languages: ["简中", "繁中"] }, - { id: randomUUID(), title: "[LoliHouse] 葬送的芙莉莲 - 27 [2160p HEVC][简繁]", publishedAt: new Date(Date.now() - 86400_000 * 2).toISOString(), sizeBytes: 2250 * 1024 * 1024, subtitleGroup: "LoliHouse", resolution: "2160p", codec: "HEVC", languages: ["简中", "繁中"] }, - ]], - [feeds[1].id, [ - { id: randomUUID(), title: "[ANi] 迷宫饭 - 24 [1080P][繁日双语]", publishedAt: new Date(Date.now() - 3600_000 * 7).toISOString(), sizeBytes: 612 * 1024 * 1024, subtitleGroup: "ANi", resolution: "1080p", codec: "AVC", languages: ["繁中", "日语"] }, - { id: randomUUID(), title: "[SubsPlease] Dungeon Meshi - 24 (1080p) [English]", publishedAt: new Date(Date.now() - 3600_000 * 18).toISOString(), sizeBytes: 1380 * 1024 * 1024, subtitleGroup: "SubsPlease", resolution: "1080p", codec: "AVC", languages: ["English"] }, - { id: randomUUID(), title: "[ANi] 迷宫饭 完结纪念预告 [1080P][繁中]", publishedAt: new Date(Date.now() - 86400_000 * 2).toISOString(), sizeBytes: 92 * 1024 * 1024, subtitleGroup: "ANi", resolution: "1080p", codec: "AVC", languages: ["繁中"] }, - ]], - [feeds[2].id, [ - { id: randomUUID(), title: "[LoliHouse] 药屋少女的呢喃 - 24 [WebRip 1080p HEVC][简繁]", publishedAt: new Date(Date.now() - 3600_000 * 10).toISOString(), sizeBytes: 745 * 1024 * 1024, subtitleGroup: "LoliHouse", resolution: "1080p", codec: "HEVC", languages: ["简中", "繁中"] }, - { id: randomUUID(), title: "[ANi] 药屋少女的呢喃 - 24 [720P][繁中]", publishedAt: new Date(Date.now() - 86400_000).toISOString(), sizeBytes: 324 * 1024 * 1024, subtitleGroup: "ANi", resolution: "720p", codec: "AVC", languages: ["繁中"] }, - ]], + [ + feeds[0].id, + [ + { + id: randomUUID(), + title: + "[LoliHouse] 葬送的芙莉莲 - 28 [WebRip 1080p HEVC-10bit AAC][简繁内封]", + publishedAt: new Date(Date.now() - 3600_000 * 5).toISOString(), + sizeBytes: 824 * 1024 * 1024, + subtitleGroup: "LoliHouse", + resolution: "1080p", + codec: "HEVC", + languages: ["简中", "繁中"], + }, + { + id: randomUUID(), + title: "[ANi] 葬送的芙莉莲 - 28 [1080P][繁日双语]", + publishedAt: new Date(Date.now() - 3600_000 * 12).toISOString(), + sizeBytes: 516 * 1024 * 1024, + subtitleGroup: "ANi", + resolution: "1080p", + codec: "AVC", + languages: ["繁中", "日语"], + }, + { + id: randomUUID(), + title: "[喵萌奶茶屋] 葬送的芙莉莲 01-28 合集 [1080p HEVC][简繁]", + publishedAt: new Date(Date.now() - 86400_000).toISOString(), + sizeBytes: 18.4 * 1024 * 1024 * 1024, + subtitleGroup: "喵萌奶茶屋", + resolution: "1080p", + codec: "HEVC", + languages: ["简中", "繁中"], + }, + { + id: randomUUID(), + title: "[LoliHouse] 葬送的芙莉莲 - 27 [2160p HEVC][简繁]", + publishedAt: new Date(Date.now() - 86400_000 * 2).toISOString(), + sizeBytes: 2250 * 1024 * 1024, + subtitleGroup: "LoliHouse", + resolution: "2160p", + codec: "HEVC", + languages: ["简中", "繁中"], + }, + ], + ], + [ + feeds[1].id, + [ + { + id: randomUUID(), + title: "[ANi] 迷宫饭 - 24 [1080P][繁日双语]", + publishedAt: new Date(Date.now() - 3600_000 * 7).toISOString(), + sizeBytes: 612 * 1024 * 1024, + subtitleGroup: "ANi", + resolution: "1080p", + codec: "AVC", + languages: ["繁中", "日语"], + }, + { + id: randomUUID(), + title: "[SubsPlease] Dungeon Meshi - 24 (1080p) [English]", + publishedAt: new Date(Date.now() - 3600_000 * 18).toISOString(), + sizeBytes: 1380 * 1024 * 1024, + subtitleGroup: "SubsPlease", + resolution: "1080p", + codec: "AVC", + languages: ["English"], + }, + { + id: randomUUID(), + title: "[ANi] 迷宫饭 完结纪念预告 [1080P][繁中]", + publishedAt: new Date(Date.now() - 86400_000 * 2).toISOString(), + sizeBytes: 92 * 1024 * 1024, + subtitleGroup: "ANi", + resolution: "1080p", + codec: "AVC", + languages: ["繁中"], + }, + ], + ], + [ + feeds[2].id, + [ + { + id: randomUUID(), + title: "[LoliHouse] 药屋少女的呢喃 - 24 [WebRip 1080p HEVC][简繁]", + publishedAt: new Date(Date.now() - 3600_000 * 10).toISOString(), + sizeBytes: 745 * 1024 * 1024, + subtitleGroup: "LoliHouse", + resolution: "1080p", + codec: "HEVC", + languages: ["简中", "繁中"], + }, + { + id: randomUUID(), + title: "[ANi] 药屋少女的呢喃 - 24 [720P][繁中]", + publishedAt: new Date(Date.now() - 86400_000).toISOString(), + sizeBytes: 324 * 1024 * 1024, + subtitleGroup: "ANi", + resolution: "720p", + codec: "AVC", + languages: ["繁中"], + }, + ], + ], ]); function simulatePolicy(feedId, policy) { @@ -755,28 +859,59 @@ function simulatePolicy(feedId, policy) { if (field === "resolution") { normalized = normalized.replace(/\s/g, ""); const aliases = { - "4K": "2160P", UHD: "2160P", "2160": "2160P", - "1440": "1440P", FHD: "1080P", "1080": "1080P", - HD: "720P", "720": "720P", "576": "576P", "480": "480P", + "4K": "2160P", + UHD: "2160P", + 2160: "2160P", + 1440: "1440P", + FHD: "1080P", + 1080: "1080P", + HD: "720P", + 720: "720P", + 576: "576P", + 480: "480P", }; return aliases[normalized] ?? normalized; } if (field === "codec") { normalized = normalized.replace(/[.\-\s]/g, ""); const aliases = { - H265: "HEVC", X265: "HEVC", H264: "AVC", X264: "AVC", + H265: "HEVC", + X265: "HEVC", + H264: "AVC", + X264: "AVC", }; return aliases[normalized] ?? normalized; } if (field === "languages") { normalized = normalized.replace(/[_\-\s]/g, ""); const aliases = { - CHS: "ZHHANS", SC: "ZHHANS", GB: "ZHHANS", ZHCN: "ZHHANS", - "简体": "ZHHANS", "简中": "ZHHANS", "簡中": "ZHHANS", "简体中文": "ZHHANS", - CHT: "ZHHANT", TC: "ZHHANT", BIG5: "ZHHANT", ZHTW: "ZHHANT", ZHHK: "ZHHANT", - "繁体": "ZHHANT", "繁體": "ZHHANT", "繁中": "ZHHANT", "繁體中文": "ZHHANT", - JPN: "JA", JAP: "JA", "日语": "JA", "日語": "JA", "日本語": "JA", JAPANESE: "JA", - ENG: "EN", "英语": "EN", "英語": "EN", ENGLISH: "EN", + CHS: "ZHHANS", + SC: "ZHHANS", + GB: "ZHHANS", + ZHCN: "ZHHANS", + 简体: "ZHHANS", + 简中: "ZHHANS", + 簡中: "ZHHANS", + 简体中文: "ZHHANS", + CHT: "ZHHANT", + TC: "ZHHANT", + BIG5: "ZHHANT", + ZHTW: "ZHHANT", + ZHHK: "ZHHANT", + 繁体: "ZHHANT", + 繁體: "ZHHANT", + 繁中: "ZHHANT", + 繁體中文: "ZHHANT", + JPN: "JA", + JAP: "JA", + 日语: "JA", + 日語: "JA", + 日本語: "JA", + JAPANESE: "JA", + ENG: "EN", + 英语: "EN", + 英語: "EN", + ENGLISH: "EN", }; return aliases[normalized] ?? normalized; } @@ -786,7 +921,13 @@ function simulatePolicy(feedId, policy) { const actual = actualValues.filter(Boolean); const expected = (expectedValues ?? []).filter(Boolean); if (expected.length === 0) { - return { field, passed: true, actual: actual.join(", ") || null, expected: null, message: "anyValueAllowed" }; + return { + field, + passed: true, + actual: actual.join(", ") || null, + expected: null, + message: "anyValueAllowed", + }; } const normalizedExpected = new Set( expected.map((value) => normalizeAllowedValue(field, value)), @@ -794,28 +935,47 @@ function simulatePolicy(feedId, policy) { const passed = actual.some((value) => normalizedExpected.has(normalizeAllowedValue(field, value)), ); - return { field, passed, actual: actual.join(", ") || null, expected: expected.join(", "), message: passed ? "allowedValueMatched" : "allowedValueMissed" }; + return { + field, + passed, + actual: actual.join(", ") || null, + expected: expected.join(", "), + message: passed ? "allowedValueMatched" : "allowedValueMissed", + }; }; const entries = history.map((item) => { const explanations = [ - checkAllowed("subtitleGroup", [item.subtitleGroup], policy.subtitleGroups), + checkAllowed( + "subtitleGroup", + [item.subtitleGroup], + policy.subtitleGroups, + ), checkAllowed("resolution", [item.resolution], policy.resolutions), checkAllowed("codec", [item.codec], policy.codecs), checkAllowed("languages", item.languages, policy.languages), ]; - const min = typeof policy.minSizeBytes === "number" ? policy.minSizeBytes : null; - const max = typeof policy.maxSizeBytes === "number" ? policy.maxSizeBytes : null; - const sizePassed = (min == null || item.sizeBytes >= min) && (max == null || item.sizeBytes <= max); + const min = + typeof policy.minSizeBytes === "number" ? policy.minSizeBytes : null; + const max = + typeof policy.maxSizeBytes === "number" ? policy.maxSizeBytes : null; + const sizePassed = + (min == null || item.sizeBytes >= min) && + (max == null || item.sizeBytes <= max); explanations.push({ field: "size", passed: sizePassed, actual: formatBytes(item.sizeBytes), - expected: min == null && max == null ? null : `${min == null ? "0 B" : formatBytes(min)} – ${max == null ? "∞" : formatBytes(max)}`, + expected: + min == null && max == null + ? null + : `${min == null ? "0 B" : formatBytes(min)} – ${max == null ? "∞" : formatBytes(max)}`, message: sizePassed ? "withinSizeRange" : "outsideSizeRange", }); const excluded = (policy.excludedKeywords ?? []).filter(Boolean); - const found = excluded.find((keyword) => item.title.toLowerCase().includes(keyword.toLowerCase())); + const found = excluded.find((keyword) => + item.title.toLowerCase().includes(keyword.toLowerCase()), + ); explanations.push({ field: "excludedKeywords", passed: !found, @@ -823,10 +983,21 @@ function simulatePolicy(feedId, policy) { expected: excluded.length > 0 ? excluded.join(", ") : null, message: found ? "excludedKeywordFound" : "noExcludedKeyword", }); - return { id: item.id, title: item.title, publishedAt: item.publishedAt, sizeBytes: item.sizeBytes, matched: explanations.every((reason) => reason.passed), explanations }; + return { + id: item.id, + title: item.title, + publishedAt: item.publishedAt, + sizeBytes: item.sizeBytes, + matched: explanations.every((reason) => reason.passed), + explanations, + }; }); - return { total: entries.length, matched: entries.filter((entry) => entry.matched).length, entries }; + return { + total: entries.length, + matched: entries.filter((entry) => entry.matched).length, + entries, + }; } // WebDAV access tokens @@ -1052,7 +1223,9 @@ function playablePaths() { !entry.isDirectory && /\.(mkv|mp4|webm|avi|flv|wmv|mov|m4v|ts|m2ts)$/i.test(entry.fileName) ) { - paths.push(directory ? `${directory}/${entry.fileName}` : entry.fileName); + paths.push( + directory ? `${directory}/${entry.fileName}` : entry.fileName, + ); } } } @@ -1122,7 +1295,9 @@ function associatedSubtitles(animation, videoPath) { entry.fileName.toLowerCase().startsWith(stem.toLowerCase()), ) .map((entry) => { - const path = directory ? `${directory}/${entry.fileName}` : entry.fileName; + const path = directory + ? `${directory}/${entry.fileName}` + : entry.fileName; const language = entry.fileName.includes("zh-Hans") ? "zh-Hans" : entry.fileName.includes(".en.") @@ -1153,7 +1328,8 @@ if (finishedForPlayback[0]) { }); } const previousEpisode = finishedForPlayback.find( - (animation) => animation.animation?.tmdbId === "209867" && animation.episode === 27, + (animation) => + animation.animation?.tmdbId === "209867" && animation.episode === 27, ); if (previousEpisode) { const path = "Season 1/EP01.mp4"; @@ -1172,7 +1348,8 @@ let mockIncidents = [ type: "feedFailure", severity: "error", title: "Mikan RSS returned HTTP 503", - detail: "The feed could not be refreshed during the last three sync attempts.", + detail: + "The feed could not be refreshed during the last three sync attempts.", sourceId: feeds[0]?.id ?? null, detectedAt: new Date(Date.now() - 42 * 60_000).toISOString(), updatedAt: new Date(Date.now() - 12 * 60_000).toISOString(), @@ -1187,7 +1364,8 @@ let mockIncidents = [ type: "downloadStalled", severity: "warning", title: "Download has not progressed for 20 minutes", - detail: "No peers are currently available. Retry will reannounce the torrent.", + detail: + "No peers are currently available. Retry will reannounce the torrent.", sourceId: finishedForPlayback[1]?.id ?? null, detectedAt: new Date(Date.now() - 25 * 60_000).toISOString(), updatedAt: new Date(Date.now() - 5 * 60_000).toISOString(), @@ -1217,7 +1395,8 @@ let mockIncidents = [ type: "fileMappingFailure", severity: "error", title: "Downloaded files could not be mapped", - detail: "The download completed, but no playable video mapping was produced.", + detail: + "The download completed, but no playable video mapping was produced.", sourceId: finishedForPlayback[2]?.id ?? null, detectedAt: new Date(Date.now() - 2 * 3600_000).toISOString(), updatedAt: new Date(Date.now() - 2 * 3600_000).toISOString(), @@ -1555,7 +1734,11 @@ async function route(method, pathname, searchParams, req, res) { if (method === "GET" && pathname === "/api/playback/context") { const animation = animations.get(searchParams.get("animationInfoId")); const path = searchParams.get("path"); - if (!animation || !animation.isDownloadFinished || !playablePaths().includes(path)) { + if ( + !animation || + !animation.isDownloadFinished || + !playablePaths().includes(path) + ) { return empty(res, 404); } return json(res, { @@ -1576,7 +1759,8 @@ async function route(method, pathname, searchParams, req, res) { if (method === "PUT" && pathname === "/api/playback/progress") { const body = await readBody(req); const animation = animations.get(body.animationInfoId); - if (!animation || !playablePaths().includes(body.path)) return empty(res, 404); + if (!animation || !playablePaths().includes(body.path)) + return empty(res, 404); const positionSeconds = Math.max(0, Number(body.positionSeconds) || 0); const durationSeconds = Math.max(0, Number(body.durationSeconds) || 0); const key = playbackKey(animation.id, body.path); @@ -1586,7 +1770,10 @@ async function route(method, pathname, searchParams, req, res) { (durationSeconds > 0 && positionSeconds / durationSeconds >= 0.9); const updatedAt = new Date().toISOString(); const stored = { - positionSeconds: Math.min(positionSeconds, durationSeconds || positionSeconds), + positionSeconds: Math.min( + positionSeconds, + durationSeconds || positionSeconds, + ), durationSeconds, isWatched, updatedAt, @@ -1599,7 +1786,8 @@ async function route(method, pathname, searchParams, req, res) { if (method === "PUT" && pathname === "/api/playback/watched") { const body = await readBody(req); const animation = animations.get(body.animationInfoId); - if (!animation || !playablePaths().includes(body.path)) return empty(res, 404); + if (!animation || !playablePaths().includes(body.path)) + return empty(res, 404); const key = playbackKey(animation.id, body.path); const previous = playbackProgress.get(key) ?? { positionSeconds: 0, @@ -1638,7 +1826,10 @@ async function route(method, pathname, searchParams, req, res) { if (method === "GET" && pathname === "/api/incidents") { const type = searchParams.get("type"); const includeResolved = searchParams.get("includeResolved") === "true"; - const skip = Math.max(0, parseInt(searchParams.get("skip") ?? "0", 10) || 0); + const skip = Math.max( + 0, + parseInt(searchParams.get("skip") ?? "0", 10) || 0, + ); const take = Math.min( 200, Math.max(1, parseInt(searchParams.get("take") ?? "50", 10) || 50), @@ -1680,7 +1871,8 @@ async function route(method, pathname, searchParams, req, res) { incident.lastRetryError = null; incident.canRetry = false; } else { - incident.lastRetryError = "Free space is still below the configured threshold"; + incident.lastRetryError = + "Free space is still below the configured threshold"; } results.push({ incidentId: incident.id, @@ -1701,15 +1893,21 @@ async function route(method, pathname, searchParams, req, res) { if (method === "POST" && match) { const incident = mockIncidents.find((item) => item.id === match[1]); if (!incident) return empty(res, 404); - if (incident.resolvedAt) return json(res, { error: "Already resolved" }, 409); + if (incident.resolvedAt) + return json(res, { error: "Already resolved" }, 409); incident.retryCount += 1; incident.lastRetryAt = new Date().toISOString(); incident.updatedAt = incident.lastRetryAt; if (incident.type === "diskSpaceLow") { - incident.lastRetryError = "Free space is still below the configured threshold"; + incident.lastRetryError = + "Free space is still below the configured threshold"; return json( res, - { incidentId: incident.id, success: false, error: incident.lastRetryError }, + { + incidentId: incident.id, + success: false, + error: incident.lastRetryError, + }, 422, ); } @@ -1990,6 +2188,165 @@ async function route(method, pathname, searchParams, req, res) { // --- Animation Info --- + if (method === "GET" && pathname === "/api/library/search") { + const q = (searchParams.get("q") ?? "").toLocaleLowerCase(); + const season = searchParams.get("season"); + const episode = searchParams.get("episode"); + const source = searchParams.get("source") ?? "Any"; + const downloadState = searchParams.get("downloadState") ?? "Any"; + const resolution = searchParams.get("resolution"); + const codec = searchParams.get("codec"); + const pathQuery = (searchParams.get("path") ?? "").toLocaleLowerCase(); + const take = Math.min( + 100, + Math.max(1, Number(searchParams.get("take") ?? 30)), + ); + let offset = 0; + try { + if (searchParams.get("cursor")) + offset = + Number( + Buffer.from(searchParams.get("cursor"), "base64url").toString( + "utf8", + ), + ) || 0; + } catch {} + + const mapped = [...animations.values()] + .map((item, index) => { + const group = item.group?.name ?? null; + const itemResolution = /2160p/i.test(item.title) ? "2160p" : "1080p"; + const itemCodec = /HEVC/i.test(item.title) ? "HEVC" : "AVC"; + const name = item.animation?.name ?? item.title; + const virtualPaths = item.isDownloadFinished + ? [ + `/${name}/${group ?? "Unknown"}/${name} S${String(item.season ?? 1).padStart(2, "0")}E${String(item.episode ?? 1).padStart(2, "0")}.mkv`, + ] + : []; + return { + animationInfoId: item.id, + title: item.title, + animationName: item.animation?.name ?? null, + animationOriginalName: item.animation?.originalName ?? null, + tmdbId: item.animation?.tmdbId ?? null, + season: item.season, + episode: item.episode, + subtitleGroup: group, + resolution: itemResolution, + codec: itemCodec, + languages: index % 2 ? ["ja"] : ["zh-CN"], + isDownloadTracked: item.isDownloadTracked, + isDownloadFinished: item.isDownloadFinished, + isMediaLibraryImport: item.isMediaLibraryImport, + isWatched: false, + playbackPositionSeconds: null, + virtualPaths, + releaseScore: 260 + (index % 5) * 55, + scoreReasons: [ + `resolution:${itemResolution}:+200`, + `codec:${itemCodec}:+40`, + ], + publishedAt: item.publishTime, + }; + }) + .filter((item) => { + const haystack = [ + item.title, + item.animationName, + item.animationOriginalName, + item.tmdbId, + item.subtitleGroup, + ...item.virtualPaths, + ] + .filter(Boolean) + .join(" ") + .toLocaleLowerCase(); + if (q && !haystack.includes(q)) return false; + if (season && item.season !== Number(season)) return false; + if (episode && item.episode !== Number(episode)) return false; + if (source === "MediaLibraryImport" && !item.isMediaLibraryImport) + return false; + if (source === "Torrent" && item.isMediaLibraryImport) return false; + if (downloadState === "Downloaded" && !item.isDownloadFinished) + return false; + if ( + downloadState === "Downloading" && + (!item.isDownloadTracked || item.isDownloadFinished) + ) + return false; + if (downloadState === "NotDownloaded" && item.isDownloadTracked) + return false; + if ( + resolution && + item.resolution.toLocaleLowerCase() !== resolution.toLocaleLowerCase() + ) + return false; + if ( + codec && + item.codec.toLocaleLowerCase() !== codec.toLocaleLowerCase() + ) + return false; + if ( + pathQuery && + !item.virtualPaths.some((path) => + path.toLocaleLowerCase().includes(pathQuery), + ) + ) + return false; + return true; + }); + const items = mapped.slice(offset, offset + take); + const nextCursor = + offset + take < mapped.length + ? Buffer.from(String(offset + take)).toString("base64url") + : null; + return json(res, { items, nextCursor }); + } + + if (method === "GET" && pathname === "/api/library/integrity") { + const values = [...animations.values()]; + const current = values[0]; + const candidate = values[21] ?? values[1]; + return json(res, [ + { + tmdbId: current.animation?.tmdbId ?? "209867", + animationName: current.animation?.name ?? "葬送的芙莉莲", + season: 1, + expectedEpisodeCount: 28, + missingEpisodes: [25], + duplicateEpisodes: [ + { episode: 28, releaseIds: [current.id, candidate.id] }, + ], + unidentifiedReleaseCount: 1, + upgradeCandidates: [ + { + currentReleaseId: current.id, + candidateReleaseId: candidate.id, + animationName: current.animation?.name ?? "葬送的芙莉莲", + season: 1, + episode: 28, + currentScore: 300, + candidateScore: 480, + scoreReasons: ["resolution:2160p:+400", "codec:AV1:+80"], + automatic: true, + }, + ], + }, + ]); + } + + if (method === "POST" && pathname === "/api/library/upgrades/execute") { + const body = await readBody(req); + return json(res, { + isSuccess: true, + outcome: body.dryRun ? "ready" : "download_queued", + dryRun: !!body.dryRun, + requiresDownload: true, + operation: body.dryRun ? null : { id: randomUUID() }, + validationErrors: [], + }); + } + if (method === "GET" && pathname === "/api/animationinfo") { const skip = parseInt(searchParams.get("skip") ?? "0", 10); const take = parseInt(searchParams.get("take") ?? "10", 10); @@ -2026,8 +2383,7 @@ async function route(method, pathname, searchParams, req, res) { g.episodes.sort( (a, b) => new Date(b.publishTime).getTime() - - new Date(a.publishTime).getTime() || - b.id.localeCompare(a.id), + new Date(a.publishTime).getTime() || b.id.localeCompare(a.id), ); g.episodeCount = g.episodes.length; return g; @@ -2324,11 +2680,15 @@ async function route(method, pathname, searchParams, req, res) { // POST /api/subscription-policies/:feedId/simulate { - const m = pathname.match(/^\/api\/subscription-policies\/([^/]+)\/simulate$/); + const m = pathname.match( + /^\/api\/subscription-policies\/([^/]+)\/simulate$/, + ); if (method === "POST" && m) { const feedId = decodeURIComponent(m[1]); if (!feeds.some((feed) => feed.id === feedId)) return empty(res, 404); - return readBody(req).then((body) => json(res, simulatePolicy(feedId, body))); + return readBody(req).then((body) => + json(res, simulatePolicy(feedId, body)), + ); } } @@ -2349,18 +2709,41 @@ async function route(method, pathname, searchParams, req, res) { const existing = subscriptionPolicies.get(feedId); const policy = { feedId, - subtitleGroups: Array.isArray(body.subtitleGroups) ? body.subtitleGroups : [], - resolutions: Array.isArray(body.resolutions) ? body.resolutions : [], + subtitleGroups: Array.isArray(body.subtitleGroups) + ? body.subtitleGroups + : [], + resolutions: Array.isArray(body.resolutions) + ? body.resolutions + : [], codecs: Array.isArray(body.codecs) ? body.codecs : [], languages: Array.isArray(body.languages) ? body.languages : [], - minSizeBytes: typeof body.minSizeBytes === "number" ? body.minSizeBytes : null, - maxSizeBytes: typeof body.maxSizeBytes === "number" ? body.maxSizeBytes : null, - excludedKeywords: Array.isArray(body.excludedKeywords) ? body.excludedKeywords : [], - mode: ["NotifyOnly", "ManualConfirm", "AutoDownload"].includes(body.mode) ? body.mode : "ManualConfirm", + minSizeBytes: + typeof body.minSizeBytes === "number" ? body.minSizeBytes : null, + maxSizeBytes: + typeof body.maxSizeBytes === "number" ? body.maxSizeBytes : null, + excludedKeywords: Array.isArray(body.excludedKeywords) + ? body.excludedKeywords + : [], + mode: ["NotifyOnly", "ManualConfirm", "AutoDownload"].includes( + body.mode, + ) + ? body.mode + : "ManualConfirm", + enableVersionUpgrade: !!body.enableVersionUpgrade, + minimumUpgradeScore: Number.isInteger(body.minimumUpgradeScore) + ? body.minimumUpgradeScore + : 25, + upgradeRollbackHours: Number.isInteger(body.upgradeRollbackHours) + ? body.upgradeRollbackHours + : 72, createdAt: existing?.createdAt ?? new Date().toISOString(), updatedAt: new Date().toISOString(), }; - if (policy.minSizeBytes != null && policy.maxSizeBytes != null && policy.minSizeBytes > policy.maxSizeBytes) { + if ( + policy.minSizeBytes != null && + policy.maxSizeBytes != null && + policy.minSizeBytes > policy.maxSizeBytes + ) { return json(res, { error: "Invalid size range" }, 400); } subscriptionPolicies.set(feedId, policy); @@ -2903,8 +3286,7 @@ server.listen(PORT, () => { (animation) => animation.isDownloadFinished, ).length; const downloadingCount = [...animations.values()].filter( - (animation) => - animation.isDownloadTracked && !animation.isDownloadFinished, + (animation) => animation.isDownloadTracked && !animation.isDownloadFinished, ).length; console.log( ` ${animations.size} anime entries (${finishedCount} finished, ${downloadingCount} active downloads, rest untracked)`, diff --git a/SecondDimensionWatcherReDive.Client/src/Main.tsx b/SecondDimensionWatcherReDive.Client/src/Main.tsx index 47a5f24..6e0a634 100644 --- a/SecondDimensionWatcherReDive.Client/src/Main.tsx +++ b/SecondDimensionWatcherReDive.Client/src/Main.tsx @@ -15,6 +15,7 @@ import { LoginPage } from "./pages/LoginPage"; import { EpisodeListPage, MainPage } from "./pages/MainPage"; import { MetadataReviewPage } from "./pages/MetadataReviewPage"; import { PlayerPage } from "./pages/PlayerPage"; +import { SearchPage } from "./pages/SearchPage"; import { SettingsPage } from "./pages/SettingsPage"; import { TasksPage } from "./pages/TasksPage"; @@ -73,6 +74,15 @@ const router = createBrowserRouter([ ), errorElement: , }, + { + path: "/search", + element: ( + + + + ), + errorElement: , + }, { path: "/play/:animationId", element: ( diff --git a/SecondDimensionWatcherReDive.Client/src/components/AppHeader.tsx b/SecondDimensionWatcherReDive.Client/src/components/AppHeader.tsx index 0a626d6..33cfcad 100644 --- a/SecondDimensionWatcherReDive.Client/src/components/AppHeader.tsx +++ b/SecondDimensionWatcherReDive.Client/src/components/AppHeader.tsx @@ -15,6 +15,7 @@ import { List, Menu, MessageSquare, + Search, Settings, User, } from "lucide-react"; @@ -44,6 +45,7 @@ interface NavItem { const createNavItems = (incidentCount?: number): NavItem[] => [ { icon: , labelKey: "nav.home", path: "/" }, + { icon: , labelKey: "nav.search", path: "/search" }, { icon: , labelKey: "nav.downloading", @@ -223,6 +225,17 @@ export const AppHeader: React.FC = () => { const { data: incidents } = useIncidents({ take: 1 }); const navigate = useNavigate(); const items = createNavItems(incidents?.openCount); + const location = useLocation(); + const [searchQuery, setSearchQuery] = React.useState( + location.pathname === "/search" + ? (new URLSearchParams(location.search).get("q") ?? "") + : "", + ); + + React.useEffect(() => { + if (location.pathname === "/search") + setSearchQuery(new URLSearchParams(location.search).get("q") ?? ""); + }, [location.pathname, location.search]); return (
@@ -248,7 +261,31 @@ export const AppHeader: React.FC = () => { ))} -
+
+ {status ? ( +
{ + event.preventDefault(); + const q = searchQuery.trim(); + navigate(q ? `/search?q=${encodeURIComponent(q)}` : "/search"); + }} + > + +
+ ) : null} {status ? ( ) : ( diff --git a/SecondDimensionWatcherReDive.Client/src/components/SubscriptionPolicySheet.tsx b/SecondDimensionWatcherReDive.Client/src/components/SubscriptionPolicySheet.tsx index f212a31..c85193d 100644 --- a/SecondDimensionWatcherReDive.Client/src/components/SubscriptionPolicySheet.tsx +++ b/SecondDimensionWatcherReDive.Client/src/components/SubscriptionPolicySheet.tsx @@ -130,6 +130,13 @@ export const SubscriptionPolicySheet: React.FC< draft.minSizeBytes != null && draft.maxSizeBytes != null && draft.minSizeBytes > draft.maxSizeBytes; + const upgradePolicyIsInvalid = + !Number.isInteger(draft.minimumUpgradeScore) || + draft.minimumUpgradeScore < 1 || + draft.minimumUpgradeScore > 1000 || + !Number.isInteger(draft.upgradeRollbackHours) || + draft.upgradeRollbackHours < 1 || + draft.upgradeRollbackHours > 720; const updateDraft = React.useCallback( (update: React.SetStateAction) => { @@ -153,7 +160,7 @@ export const SubscriptionPolicySheet: React.FC< ); const handleSave = React.useCallback(async () => { - if (!feed || saving || sizeRangeIsInvalid) return; + if (!feed || saving || sizeRangeIsInvalid || upgradePolicyIsInvalid) return; setSaving(true); try { const saved = await saveSubscriptionPolicy(feed.id, draft); @@ -172,7 +179,16 @@ export const SubscriptionPolicySheet: React.FC< } finally { setSaving(false); } - }, [feed, saving, sizeRangeIsInvalid, draft, onPolicyChanged, addToast, t]); + }, [ + feed, + saving, + sizeRangeIsInvalid, + upgradePolicyIsInvalid, + draft, + onPolicyChanged, + addToast, + t, + ]); const handleDelete = React.useCallback(async () => { if (!feed || !hasSavedPolicy) return; @@ -396,10 +412,85 @@ export const SubscriptionPolicySheet: React.FC<
+
+ + +
+ + +
+ {upgradePolicyIsInvalid ? ( +

+ + {t("automation.upgrades.rangeError")} +

+ ) : null} +
+
@@ -457,7 +548,12 @@ export const SubscriptionPolicySheet: React.FC< + + +
+
+

+ {t("filters.title", { count: activeFilterCount })} +

+ +
+
+ + + + + + + + + + + +
+
+ +
+
+

+ {t("results.title")} +

+ {data ? ( + + {t("results.pageCount", { count: data.items.length })} + + ) : null} +
+ {isLoading ? ( +
+ +
+ ) : error ? ( + } title={t("results.error")} /> + ) : !data?.items.length ? ( + } title={t("results.empty")} /> + ) : ( +
+ {data.items.map((item) => ( +
+
+
+
+ + {item.season != null && item.episode != null + ? `S${String(item.season).padStart(2, "0")}E${String(item.episode).padStart(2, "0")}` + : t("results.unidentified")} + + + {item.isMediaLibraryImport + ? t("values.MediaLibraryImport") + : t("values.Torrent")} + + + {new Date(item.publishedAt).toLocaleDateString()} + +
+

+ {item.animationName ?? item.title} +

+ {item.animationOriginalName ? ( +

+ {item.animationOriginalName} +

+ ) : null} +
+ {[ + item.subtitleGroup, + item.resolution, + item.codec, + ...item.languages, + ] + .filter(Boolean) + .map((value) => ( + + {value} + + ))} +
+ {item.virtualPaths.map((path) => ( +

+ {path} +

+ ))} +
+
+

+ {item.releaseScore} +

+

{t("results.score")}

+

+ {item.isDownloadFinished + ? t("values.Downloaded") + : item.isDownloadTracked + ? t("values.Downloading") + : t("values.NotDownloaded")} +

+
+
+ {item.scoreReasons.length ? ( +
    + {item.scoreReasons.map((reason) => ( +
  • + {reason}
  • + ))} +
+ ) : null} +
+ ))} +
+ )} + {data?.nextCursor ? ( +
+ +
+ ) : null} +
+ +
+
+
+

+ {t("integrity.title")} +

+

+ {t("integrity.description")} +

+
+ +
+
+ {integrity + ?.filter( + (item) => + item.missingEpisodes.length || + item.duplicateEpisodes.length || + item.unidentifiedReleaseCount || + item.upgradeCandidates.length, + ) + .map((item) => ( +
+
+
+

+ {item.animationName} +

+

+ {t("integrity.season", { + season: item.season, + count: item.expectedEpisodeCount ?? "?", + })} +

+
+ {item.missingEpisodes.length === 0 && + item.duplicateEpisodes.length === 0 && + item.unidentifiedReleaseCount === 0 ? ( + + ) : ( + + )} +
+
+ + entry.episode) + .join(", ") || "—" + } + /> + +
+ {item.upgradeCandidates.map((candidate) => ( +
+
+
+

+ {t("upgrade.episode", { episode: candidate.episode })} +

+

+ {candidate.currentScore} → {candidate.candidateScore}{" "} + (+{candidate.candidateScore - candidate.currentScore}) +

+
+
+ + +
+
+
    + {candidate.scoreReasons.map((reason) => ( +
  • + {reason}
  • + ))} +
+
+ ))} +
+ ))} +
+
+ + ); +}; + +const FilterInput: React.FC<{ + label: string; + name: string; + params: URLSearchParams; + onChange: (name: string, value: string) => void; + type?: string; +}> = ({ label, name, params, onChange, type = "text" }) => ( + +); + +const FilterSelect: React.FC<{ + label: string; + name: string; + params: URLSearchParams; + onChange: (name: string, value: string) => void; + options: string[]; + t: (key: string) => string; +}> = ({ label, name, params, onChange, options, t }) => ( + +); + +const Metric: React.FC<{ label: string; value: string }> = ({ + label, + value, +}) => ( +
+
{label}
+
+ {value} +
+
+); diff --git a/SecondDimensionWatcherReDive.Client/src/subscriptionPolicy/types.ts b/SecondDimensionWatcherReDive.Client/src/subscriptionPolicy/types.ts index 7456f60..2ca20d9 100644 --- a/SecondDimensionWatcherReDive.Client/src/subscriptionPolicy/types.ts +++ b/SecondDimensionWatcherReDive.Client/src/subscriptionPolicy/types.ts @@ -1,7 +1,5 @@ export type SubscriptionPolicyMode = - | "NotifyOnly" - | "ManualConfirm" - | "AutoDownload"; + "NotifyOnly" | "ManualConfirm" | "AutoDownload"; export interface ISubscriptionPolicyDraft { subtitleGroups: string[]; @@ -12,6 +10,9 @@ export interface ISubscriptionPolicyDraft { maxSizeBytes: number | null; excludedKeywords: string[]; mode: SubscriptionPolicyMode; + enableVersionUpgrade: boolean; + minimumUpgradeScore: number; + upgradeRollbackHours: number; } export interface ISubscriptionPolicy extends ISubscriptionPolicyDraft { @@ -51,6 +52,9 @@ export const createEmptySubscriptionPolicy = (): ISubscriptionPolicyDraft => ({ maxSizeBytes: null, excludedKeywords: [], mode: "ManualConfirm", + enableVersionUpgrade: false, + minimumUpgradeScore: 25, + upgradeRollbackHours: 72, }); export const toSubscriptionPolicyDraft = ( @@ -64,4 +68,7 @@ export const toSubscriptionPolicyDraft = ( maxSizeBytes: policy.maxSizeBytes, excludedKeywords: [...policy.excludedKeywords], mode: policy.mode, + enableVersionUpgrade: policy.enableVersionUpgrade ?? false, + minimumUpgradeScore: policy.minimumUpgradeScore ?? 25, + upgradeRollbackHours: policy.upgradeRollbackHours ?? 72, }); diff --git a/SecondDimensionWatcherReDive.Framework/DataRepository/AnimationInfo.cs b/SecondDimensionWatcherReDive.Framework/DataRepository/AnimationInfo.cs index e02fed8..be05aaa 100644 --- a/SecondDimensionWatcherReDive.Framework/DataRepository/AnimationInfo.cs +++ b/SecondDimensionWatcherReDive.Framework/DataRepository/AnimationInfo.cs @@ -34,4 +34,17 @@ public sealed record AnimationInfo( Guid? DownloadAttemptId = null, Guid? DownloadCancellationId = null, Guid? MediaLibrarySourceId = null, - DateTimeOffset? MediaLibraryMissingSince = null); + DateTimeOffset? MediaLibraryMissingSince = null, + string? ReleaseIdentity = null, + string? FeedItemGuid = null, + string? EnclosureId = null, + string? TorrentInfoHash = null, + string? ReleaseSubtitleGroup = null, + string? ReleaseResolution = null, + string? ReleaseCodec = null, + IReadOnlyList? ReleaseLanguages = null, + int ReleaseScore = 0, + string? ReleaseScoreReasonsJson = null, + int? ExpectedEpisodeCount = null, + DateTimeOffset? IngestedAt = null, + bool IsActiveRelease = true); diff --git a/SecondDimensionWatcherReDive.Framework/DataRepository/IAnimationInfoRepository.cs b/SecondDimensionWatcherReDive.Framework/DataRepository/IAnimationInfoRepository.cs index 60e555f..47c85b0 100644 --- a/SecondDimensionWatcherReDive.Framework/DataRepository/IAnimationInfoRepository.cs +++ b/SecondDimensionWatcherReDive.Framework/DataRepository/IAnimationInfoRepository.cs @@ -56,6 +56,8 @@ Task> GetDownloadedWithoutFileMappingsAsync( Task AddAsync(AnimationInfo info, CancellationToken cancellationToken); + Task TryAddReleaseAsync(AnimationInfo info, CancellationToken cancellationToken); + Task UpdateAsync(AnimationInfo info, CancellationToken cancellationToken); Task TryStartDownloadAsync( diff --git a/SecondDimensionWatcherReDive.Framework/DataRepository/ILibrarySearchRepository.cs b/SecondDimensionWatcherReDive.Framework/DataRepository/ILibrarySearchRepository.cs new file mode 100644 index 0000000..626da76 --- /dev/null +++ b/SecondDimensionWatcherReDive.Framework/DataRepository/ILibrarySearchRepository.cs @@ -0,0 +1,14 @@ +namespace SecondDimensionWatcherReDive.Framework.DataRepository; + +public interface ILibrarySearchRepository +{ + Task SearchAsync( + LibrarySearchRequest request, + CancellationToken cancellationToken); + + Task> GetIntegrityAsync( + string? tmdbId, + int? season, + CancellationToken cancellationToken); +} + diff --git a/SecondDimensionWatcherReDive.Framework/DataRepository/LibrarySearch.cs b/SecondDimensionWatcherReDive.Framework/DataRepository/LibrarySearch.cs new file mode 100644 index 0000000..c7bd196 --- /dev/null +++ b/SecondDimensionWatcherReDive.Framework/DataRepository/LibrarySearch.cs @@ -0,0 +1,101 @@ +namespace SecondDimensionWatcherReDive.Framework.DataRepository; + +public enum LibraryDownloadState +{ + Any, + NotDownloaded, + Downloading, + Downloaded +} + +public enum LibraryWatchState +{ + Any, + Unwatched, + InProgress, + Watched +} + +public enum LibrarySourceKind +{ + Any, + Torrent, + MediaLibraryImport +} + +public enum LibrarySearchSort +{ + PublishedDescending, + TitleAscending, + EpisodeAscending, + ScoreDescending +} + +public sealed record LibrarySearchRequest( + string? Query, + int? Season, + int? Episode, + string? SubtitleGroup, + string? Resolution, + string? Codec, + string? Language, + LibraryDownloadState DownloadState, + LibraryWatchState WatchState, + string? VirtualPath, + LibrarySourceKind Source, + LibrarySearchSort Sort, + string? Cursor, + int Take, + Guid UserId); + +public sealed record LibrarySearchItem( + Guid AnimationInfoId, + string Title, + string? AnimationName, + string? AnimationOriginalName, + string? TmdbId, + int? Season, + int? Episode, + string? SubtitleGroup, + string? Resolution, + string? Codec, + IReadOnlyList Languages, + bool IsDownloadTracked, + bool IsDownloadFinished, + bool IsMediaLibraryImport, + bool IsWatched, + double? PlaybackPositionSeconds, + IReadOnlyList VirtualPaths, + int ReleaseScore, + IReadOnlyList ScoreReasons, + DateTimeOffset PublishedAt); + +public sealed record LibrarySearchResult( + IReadOnlyList Items, + string? NextCursor); + +public sealed record EpisodeDuplicate( + int Episode, + IReadOnlyList ReleaseIds); + +public sealed record ReleaseUpgradeCandidate( + Guid CurrentReleaseId, + Guid CandidateReleaseId, + string AnimationName, + int Season, + int Episode, + int CurrentScore, + int CandidateScore, + IReadOnlyList ScoreReasons, + bool Automatic); + +public sealed record LibraryIntegritySummary( + string TmdbId, + string AnimationName, + int Season, + int? ExpectedEpisodeCount, + IReadOnlyList MissingEpisodes, + IReadOnlyList DuplicateEpisodes, + int UnidentifiedReleaseCount, + IReadOnlyList UpgradeCandidates); + diff --git a/SecondDimensionWatcherReDive.Framework/DataRepository/ReleaseUpgrade.cs b/SecondDimensionWatcherReDive.Framework/DataRepository/ReleaseUpgrade.cs new file mode 100644 index 0000000..547cd5d --- /dev/null +++ b/SecondDimensionWatcherReDive.Framework/DataRepository/ReleaseUpgrade.cs @@ -0,0 +1,100 @@ +namespace SecondDimensionWatcherReDive.Framework.DataRepository; + +public enum ReleaseUpgradeStatus +{ + Downloading, + Verifying, + Applied, + Failed, + RolledBack, + Completed +} + +public enum ReleaseUpgradeMappingKind +{ + Previous, + Candidate +} + +public sealed record ReleaseUpgradeOperation( + Guid Id, + Guid CurrentReleaseId, + Guid CandidateReleaseId, + ReleaseUpgradeStatus Status, + int CurrentScore, + int CandidateScore, + DateTimeOffset CreatedAt, + DateTimeOffset? VerifiedAt, + DateTimeOffset? AppliedAt, + DateTimeOffset? RollbackUntil, + DateTimeOffset? CompletedAt, + string? FailureSummary); + +public sealed record ReleaseUpgradeMappingSnapshot( + Guid Id, + Guid OperationId, + ReleaseUpgradeMappingKind Kind, + Guid OriginalMappingId, + Guid AnimationInfoId, + string VirtualPath, + string PhysicalPath, + string FileStore); + +public sealed record ReleaseUpgradeActivation( + ReleaseUpgradeOperation Operation, + IReadOnlyList PreviousMappings, + IReadOnlyList CandidateMappings); + +public sealed record ReleaseUpgradeMutationResult( + bool IsSuccess, + string Outcome, + ReleaseUpgradeOperation? Operation); + +public interface IReleaseUpgradeRepository +{ + Task> GetCandidatesAsync( + bool automaticOnly, + int take, + CancellationToken cancellationToken); + + Task TryBeginAsync( + ReleaseUpgradeCandidate candidate, + DateTimeOffset createdAt, + CancellationToken cancellationToken); + + Task FindActiveByCandidateAsync( + Guid candidateReleaseId, + CancellationToken cancellationToken); + + Task> GetReadyCandidateIdsAsync( + int take, + CancellationToken cancellationToken); + + Task GetActivationAsync( + Guid candidateReleaseId, + CancellationToken cancellationToken); + + Task ActivateAsync( + Guid operationId, + DateTimeOffset verifiedAt, + DateTimeOffset rollbackUntil, + CancellationToken cancellationToken); + + Task MarkFailedAsync( + Guid operationId, + string failureSummary, + CancellationToken cancellationToken); + + Task RollbackAsync( + Guid operationId, + DateTimeOffset rolledBackAt, + CancellationToken cancellationToken); + + Task> GetHistoryAsync( + int take, + CancellationToken cancellationToken); + + Task CompleteExpiredAsync( + DateTimeOffset completedAt, + CancellationToken cancellationToken); +} diff --git a/SecondDimensionWatcherReDive.Framework/DataRepository/SubscriptionAutomationPolicy.cs b/SecondDimensionWatcherReDive.Framework/DataRepository/SubscriptionAutomationPolicy.cs index 11fec01..df78f8c 100644 --- a/SecondDimensionWatcherReDive.Framework/DataRepository/SubscriptionAutomationPolicy.cs +++ b/SecondDimensionWatcherReDive.Framework/DataRepository/SubscriptionAutomationPolicy.cs @@ -15,4 +15,7 @@ public sealed record SubscriptionAutomationPolicy( IReadOnlyList ExcludedKeywords, SubscriptionAutomationMode Mode, DateTimeOffset CreatedAt, - DateTimeOffset UpdatedAt); + DateTimeOffset UpdatedAt, + bool EnableVersionUpgrade = false, + int MinimumUpgradeScore = 25, + int UpgradeRollbackHours = 72); diff --git a/SecondDimensionWatcherReDive.Framework/Feed/AnimationAddRequest.cs b/SecondDimensionWatcherReDive.Framework/Feed/AnimationAddRequest.cs index 294951c..ace7c0f 100644 --- a/SecondDimensionWatcherReDive.Framework/Feed/AnimationAddRequest.cs +++ b/SecondDimensionWatcherReDive.Framework/Feed/AnimationAddRequest.cs @@ -8,4 +8,6 @@ public record AnimationAddRequest( string DownloadType, string AdditionalDownloadInfo, Guid? FeedId = null, - long? ContentLength = null); + long? ContentLength = null, + string? FeedItemGuid = null, + string? EnclosureId = null); diff --git a/SecondDimensionWatcherReDive.Framework/Feed/IReleaseScoringService.cs b/SecondDimensionWatcherReDive.Framework/Feed/IReleaseScoringService.cs new file mode 100644 index 0000000..07780bb --- /dev/null +++ b/SecondDimensionWatcherReDive.Framework/Feed/IReleaseScoringService.cs @@ -0,0 +1,12 @@ +using SecondDimensionWatcherReDive.Framework.DataRepository; + +namespace SecondDimensionWatcherReDive.Framework.Feed; + +public sealed record ReleaseScore(int Value, IReadOnlyList Reasons); + +public interface IReleaseScoringService +{ + ReleaseScore Score( + SubscriptionReleaseMetadata metadata, + SubscriptionAutomationPolicy? policy); +} diff --git a/SecondDimensionWatcherReDive.Framework/Feed/ReleaseIdentity.cs b/SecondDimensionWatcherReDive.Framework/Feed/ReleaseIdentity.cs new file mode 100644 index 0000000..668b622 --- /dev/null +++ b/SecondDimensionWatcherReDive.Framework/Feed/ReleaseIdentity.cs @@ -0,0 +1,32 @@ +using System.Security.Cryptography; +using System.Text; + +namespace SecondDimensionWatcherReDive.Framework.Feed; + +public static class ReleaseIdentity +{ + public static string Create( + Guid? feedId, + string? feedItemGuid, + string? enclosureId, + string? torrentInfoHash, + string downloadUrl) + { + if (!string.IsNullOrWhiteSpace(torrentInfoHash)) + return $"torrent:{torrentInfoHash.Trim().ToLowerInvariant()}"; + + var source = !string.IsNullOrWhiteSpace(feedItemGuid) + ? $"feed:{feedId?.ToString("N") ?? "static"}:{feedItemGuid.Trim()}" + : !string.IsNullOrWhiteSpace(enclosureId) + ? $"enclosure:{feedId?.ToString("N") ?? "static"}:{enclosureId.Trim()}" + : $"url:{downloadUrl.Trim()}"; + return $"external:{Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(source))).ToLowerInvariant()}"; + } + + public static string CreateMediaImport(Guid sourceId, string fileStore, string storePath) + { + var source = $"{sourceId:N}\n{fileStore}\n{storePath}"; + return $"import:{Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(source))).ToLowerInvariant()}"; + } +} + diff --git a/SecondDimensionWatcherReDive.IntegrationTest/PostgreSql/FileMappingRepositoryPostgreSqlTests.cs b/SecondDimensionWatcherReDive.IntegrationTest/PostgreSql/FileMappingRepositoryPostgreSqlTests.cs index 1a06401..91313ad 100644 --- a/SecondDimensionWatcherReDive.IntegrationTest/PostgreSql/FileMappingRepositoryPostgreSqlTests.cs +++ b/SecondDimensionWatcherReDive.IntegrationTest/PostgreSql/FileMappingRepositoryPostgreSqlTests.cs @@ -88,6 +88,137 @@ static async Task WriteAsync(FileMappingRepositoryPostgreSqlTestFixture fi CollectionAssert.AreEquivalent(new long[] { 0, 1 }, versions); } + [TestMethod] + public async Task StableReleaseIdentity_ConcurrentRepositoryInserts_PersistExactlyOnce() + { + var identity = "torrent:" + Guid.NewGuid().ToString("N"); + + var results = await Task.WhenAll( + Fixture.TryAddReleaseAsync(identity, CancellationToken.None), + Fixture.TryAddReleaseAsync(identity, CancellationToken.None)); + + Assert.AreEqual(1, results.Count(result => result)); + Assert.AreEqual(1, await Fixture.CountReleaseIdentityAsync(identity, CancellationToken.None)); + } + + [TestMethod] + public async Task Search_ComposesImportPathAndWatchFilters_AndCursorIgnoresConcurrentInsert() + { + var scenario = await Fixture.SeedLibraryScenarioAsync(CancellationToken.None); + var filtered = await Fixture.SearchAsync(new LibrarySearchRequest( + "Attack", 1, 2, null, "2160p", "AV1", "ja", + LibraryDownloadState.Downloaded, + LibraryWatchState.InProgress, + "Imported", + LibrarySourceKind.MediaLibraryImport, + LibrarySearchSort.ScoreDescending, + null, + 20, + scenario.UserId), + CancellationToken.None); + + Assert.HasCount(1, filtered.Items); + Assert.AreEqual(scenario.ImportedReleaseId, filtered.Items[0].AnimationInfoId); + Assert.IsTrue(filtered.Items[0].IsMediaLibraryImport); + + var firstPage = await Fixture.SearchAsync(AnySearch(scenario.UserId, null, 2), CancellationToken.None); + Assert.IsNotNull(firstPage.NextCursor); + var insertedId = await Fixture.InsertConcurrentSearchReleaseAsync(CancellationToken.None); + var secondPage = await Fixture.SearchAsync( + AnySearch(scenario.UserId, firstPage.NextCursor, 2), + CancellationToken.None); + + Assert.IsFalse(secondPage.Items.Any(item => item.AnimationInfoId == insertedId)); + Assert.IsFalse(firstPage.Items.Select(item => item.AnimationInfoId) + .Intersect(secondPage.Items.Select(item => item.AnimationInfoId)).Any()); + } + + [TestMethod] + public async Task Integrity_ReportsMissingDuplicateUnidentifiedAndExplainableUpgrade() + { + await Fixture.SeedLibraryScenarioAsync(CancellationToken.None); + + var summaries = await Fixture.GetIntegrityAsync(CancellationToken.None); + + Assert.HasCount(1, summaries); + var summary = summaries[0]; + CollectionAssert.AreEqual(new[] { 3 }, summary.MissingEpisodes.ToArray()); + Assert.HasCount(1, summary.DuplicateEpisodes); + Assert.AreEqual(1, summary.DuplicateEpisodes[0].Episode); + Assert.AreEqual(1, summary.UnidentifiedReleaseCount); + Assert.HasCount(1, summary.UpgradeCandidates); + CollectionAssert.Contains( + summary.UpgradeCandidates[0].ScoreReasons.ToArray(), + "resolution:2160p:+400"); + } + + [TestMethod] + public async Task Migration_CreatesReleaseUniquenessAndSearchIndexes() + { + var indexes = await Fixture.GetLibraryIndexNamesAsync(CancellationToken.None); + + var expected = new[] + { + "UX_AnimationInfo_ReleaseIdentity", + "IX_AnimationInfo_Title_Trgm", + "IX_Animations_Name_Trgm", + "IX_Animations_OriginalName_Trgm", + "IX_AnimationGroups_Name_Trgm", + "IX_FileMappings_VirtualPath_Trgm", + "IX_AnimationInfo_ReleaseLanguages_Gin" + }; + Assert.IsTrue(expected.All(indexes.Contains), + $"Missing indexes: {string.Join(", ", expected.Except(indexes))}"); + } + + [TestMethod] + public async Task UpgradeRace_ClaimsOnce_AtomicallySwapsMappings_AndRollsBack() + { + var scenario = await Fixture.SeedUpgradeScenarioAsync(CancellationToken.None); + var claims = await Task.WhenAll( + Fixture.BeginUpgradeAsync(scenario.Candidate, CancellationToken.None), + Fixture.BeginUpgradeAsync(scenario.Candidate, CancellationToken.None)); + var operation = claims.Single(claim => claim is not null)!; + var recoverableCandidates = await Fixture.GetReadyUpgradeCandidateIdsAsync(CancellationToken.None); + CollectionAssert.Contains(recoverableCandidates.ToArray(), scenario.Candidate.CandidateReleaseId); + + var beforeCurrent = await Fixture.GetMappingsAsync( + scenario.Candidate.CurrentReleaseId, CancellationToken.None); + Assert.HasCount(1, beforeCurrent); + Assert.AreEqual(scenario.CanonicalPath, beforeCurrent[0].VirtualPath); + + var applied = await Fixture.ActivateUpgradeAsync(operation.Id, CancellationToken.None); + Assert.IsTrue(applied.IsSuccess); + Assert.AreEqual(ReleaseUpgradeStatus.Applied, applied.Operation!.Status); + Assert.IsEmpty(await Fixture.GetMappingsAsync( + scenario.Candidate.CurrentReleaseId, CancellationToken.None)); + var activeCandidate = await Fixture.GetMappingsAsync( + scenario.Candidate.CandidateReleaseId, CancellationToken.None); + Assert.HasCount(1, activeCandidate); + Assert.AreEqual(scenario.CanonicalPath, activeCandidate[0].VirtualPath); + + var rolledBack = await Fixture.RollbackUpgradeAsync(operation.Id, CancellationToken.None); + Assert.IsTrue(rolledBack.IsSuccess); + Assert.AreEqual(ReleaseUpgradeStatus.RolledBack, rolledBack.Operation!.Status); + var restored = await Fixture.GetMappingsAsync( + scenario.Candidate.CurrentReleaseId, CancellationToken.None); + Assert.HasCount(1, restored); + Assert.AreEqual(scenario.CanonicalPath, restored[0].VirtualPath); + Assert.IsEmpty(await Fixture.GetMappingsAsync( + scenario.Candidate.CandidateReleaseId, CancellationToken.None)); + } + private static FileMapping Mapping(Guid animationInfoId, string virtualPath) => new(Guid.NewGuid(), animationInfoId, virtualPath, "/physical/" + Guid.NewGuid(), "local"); + + private static LibrarySearchRequest AnySearch(Guid userId, string? cursor, int take) => + new(null, null, null, null, null, null, null, + LibraryDownloadState.Any, + LibraryWatchState.Any, + null, + LibrarySourceKind.Any, + LibrarySearchSort.PublishedDescending, + cursor, + take, + userId); } diff --git a/SecondDimensionWatcherReDive.Test/ReleaseUpgradeCoordinatorTests.cs b/SecondDimensionWatcherReDive.Test/ReleaseUpgradeCoordinatorTests.cs new file mode 100644 index 0000000..e048ecb --- /dev/null +++ b/SecondDimensionWatcherReDive.Test/ReleaseUpgradeCoordinatorTests.cs @@ -0,0 +1,122 @@ +using Microsoft.Extensions.Logging; +using Moq; +using SecondDimensionWatcherReDive.Framework.DataRepository; +using SecondDimensionWatcherReDive.Framework.FileDownload; +using SecondDimensionWatcherReDive.Framework.FileStore; +using SecondDimensionWatcherReDive.Utils.Incidents; +using SecondDimensionWatcherReDive.Utils.ReleaseUpgrades; + +namespace SecondDimensionWatcherReDive.Test; + +[TestClass] +public sealed class ReleaseUpgradeCoordinatorTests +{ + [TestMethod] + public async Task CandidateValidationFailure_DoesNotInvokeAtomicSwap_AndRecordsFailure() + { + var fixture = new CoordinatorFixture(fileExists: false); + + var result = await fixture.Coordinator.TryActivateCandidateAsync( + fixture.Operation.CandidateReleaseId, + CancellationToken.None); + + Assert.IsNotNull(result); + Assert.IsFalse(result.IsSuccess); + fixture.UpgradeRepository.Verify(repository => repository.ActivateAsync( + It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny()), Times.Never); + fixture.UpgradeRepository.Verify(repository => repository.MarkFailedAsync( + fixture.Operation.Id, + It.Is(summary => summary.Contains("missing", StringComparison.OrdinalIgnoreCase)), + It.IsAny()), Times.Once); + fixture.IncidentReporter.Verify(reporter => reporter.ReportAsync( + It.Is(report => report.Title == "Release upgrade failed"), + It.IsAny()), Times.Once); + } + + [TestMethod] + public async Task ValidCandidate_InvokesAtomicSwapOnlyAfterEveryFilePasses() + { + var fixture = new CoordinatorFixture(fileExists: true); + + var result = await fixture.Coordinator.TryActivateCandidateAsync( + fixture.Operation.CandidateReleaseId, + CancellationToken.None); + + Assert.IsNotNull(result); + Assert.IsTrue(result.IsSuccess); + fixture.FileStore.Verify(store => store.ExistAsync( + "/store/new.mkv", It.IsAny()), Times.Once); + fixture.FileStore.Verify(store => store.FileInfoAsync( + "/store/new.mkv", It.IsAny()), Times.Once); + fixture.UpgradeRepository.Verify(repository => repository.ActivateAsync( + fixture.Operation.Id, + It.IsAny(), + It.Is(until => until > DateTimeOffset.UtcNow.AddHours(71)), + It.IsAny()), Times.Once); + } + + private sealed class CoordinatorFixture + { + public Mock UpgradeRepository { get; } = new(); + public Mock FileStore { get; } = new(); + public Mock IncidentReporter { get; } = new(); + public ReleaseUpgradeOperation Operation { get; } + public IReleaseUpgradeCoordinator Coordinator { get; } + + public CoordinatorFixture(bool fileExists) + { + Operation = new ReleaseUpgradeOperation( + Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid(), + ReleaseUpgradeStatus.Verifying, 200, 500, + DateTimeOffset.UtcNow, null, null, null, null, null); + var previous = new FileMapping( + Guid.NewGuid(), Operation.CurrentReleaseId, "/show/e01.mkv", "/store/old.mkv", "local"); + var candidate = new FileMapping( + Guid.NewGuid(), Operation.CandidateReleaseId, "/show/e01 (2).mkv", "/store/new.mkv", "local"); + UpgradeRepository.Setup(repository => repository.FindActiveByCandidateAsync( + Operation.CandidateReleaseId, It.IsAny())) + .ReturnsAsync(Operation); + UpgradeRepository.Setup(repository => repository.GetActivationAsync( + Operation.CandidateReleaseId, It.IsAny())) + .ReturnsAsync(new ReleaseUpgradeActivation(Operation, [previous], [candidate])); + UpgradeRepository.Setup(repository => repository.MarkFailedAsync( + Operation.Id, It.IsAny(), It.IsAny())) + .ReturnsAsync(new ReleaseUpgradeMutationResult(true, "failed", Operation)); + UpgradeRepository.Setup(repository => repository.ActivateAsync( + Operation.Id, It.IsAny(), It.IsAny(), + It.IsAny())) + .ReturnsAsync(new ReleaseUpgradeMutationResult(true, "applied", + Operation with { Status = ReleaseUpgradeStatus.Applied })); + + FileStore.Setup(store => store.ExistAsync( + candidate.PhysicalPath, It.IsAny())) + .ReturnsAsync(fileExists); + FileStore.Setup(store => store.FileInfoAsync( + candidate.PhysicalPath, It.IsAny())) + .ReturnsAsync(new FileStoreInfo(false, candidate.PhysicalPath, "new.mkv", 1024)); + var storeProvider = new Mock(); + storeProvider.Setup(provider => provider.GetRequiredClient("local")) + .Returns(FileStore.Object); + + var animationRepository = new Mock(); + animationRepository.Setup(repository => repository.FindByIdAsync( + Operation.CandidateReleaseId, It.IsAny())) + .ReturnsAsync(new AnimationInfo( + Operation.CandidateReleaseId, "candidate", "", DateTimeOffset.UtcNow, + "https://example.test/new", FileDownloadTypes.TorrentDownload, [], "", + true, default, default, true, "local", "/store/new", 1, 1, + null, null, true, 0)); + + Coordinator = new ReleaseUpgradeCoordinator( + UpgradeRepository.Object, + animationRepository.Object, + Mock.Of(), + Mock.Of(), + Mock.Of(), + storeProvider.Object, + IncidentReporter.Object, + Mock.Of>()); + } + } +} diff --git a/SecondDimensionWatcherReDive.Test/SubscriptionAutomationMatcherTests.cs b/SecondDimensionWatcherReDive.Test/SubscriptionAutomationMatcherTests.cs index ef29419..e318e6a 100644 --- a/SecondDimensionWatcherReDive.Test/SubscriptionAutomationMatcherTests.cs +++ b/SecondDimensionWatcherReDive.Test/SubscriptionAutomationMatcherTests.cs @@ -200,6 +200,30 @@ public void Evaluate_ExcludedKeywordOnlyAppliesToReleaseTitle() Assert.IsTrue(Explanation(result, "excludedKeywords").Passed); } + [TestMethod] + public void Score_CombinesQualityPreferencesAndReturnsExplainableReasons() + { + var scorer = new ReleaseScoringService(); + var metadata = new SubscriptionReleaseMetadata( + "LoliHouse", + "2160p", + "AV1", + ["ja", "zh-CN"], + 3L * 1024 * 1024 * 1024); + var policy = Policy( + subtitleGroups: ["Other", "LoliHouse"], + languages: ["ja"]); + + var result = scorer.Score(metadata, policy); + + Assert.AreEqual(575, result.Value); + CollectionAssert.Contains(result.Reasons.ToArray(), "resolution:2160p:+400"); + CollectionAssert.Contains(result.Reasons.ToArray(), "codec:AV1:+80"); + CollectionAssert.Contains(result.Reasons.ToArray(), "subtitleGroup:LoliHouse:+45"); + CollectionAssert.Contains(result.Reasons.ToArray(), "language:zh-CN:+5"); + CollectionAssert.Contains(result.Reasons.ToArray(), "size:3.00GiB:+25"); + } + private static SubscriptionAutomationExplanation Explanation( SubscriptionAutomationEvaluation result, string field) diff --git a/SecondDimensionWatcherReDive.Test/SyncFeedTests.cs b/SecondDimensionWatcherReDive.Test/SyncFeedTests.cs index 599c1eb..94c1087 100644 --- a/SecondDimensionWatcherReDive.Test/SyncFeedTests.cs +++ b/SecondDimensionWatcherReDive.Test/SyncFeedTests.cs @@ -29,9 +29,9 @@ public void Setup() _mockPolicyRepo = new Mock(); _mockDownloadProvider = new Mock(); _mockDownloadClient = new Mock(); - _mockRepo.Setup(repository => repository.AddAsync( + _mockRepo.Setup(repository => repository.TryAddReleaseAsync( It.IsAny(), It.IsAny())) - .Returns(Task.CompletedTask); + .ReturnsAsync(true); _mockRepo.Setup(repository => repository.UpdateAsync( It.IsAny(), It.IsAny())) .Returns(Task.CompletedTask); @@ -68,7 +68,7 @@ public void Setup() } [TestMethod] - public async Task ProcessSingle_ExistingTitle_SkipsAdd() + public async Task ProcessSingle_ExistingReleaseIdentity_SkipsDuplicate() { var request = new AnimationAddRequest( DateTimeOffset.UtcNow, @@ -78,23 +78,16 @@ public async Task ProcessSingle_ExistingTitle_SkipsAdd() FileDownloadTypes.HttpDownload, ""); - _mockRepo - .Setup(r => r.FindByTitleAsync("Existing Title", It.IsAny())) - .ReturnsAsync(new AnimationInfo( - Guid.NewGuid(), "Existing Title", "Description", - DateTimeOffset.UtcNow, "https://example.com/download", - FileDownloadTypes.HttpDownload, - Array.Empty(), "", - false, default, default, false, - null, null, null, null, null, null, - false, 0)); + _mockRepo.Setup(repository => repository.TryAddReleaseAsync( + It.IsAny(), It.IsAny())) + .ReturnsAsync(false); await (Task)_processSingleMethod.Invoke( _syncFeed, new object[] { request, CancellationToken.None })!; _mockRepo.Verify( - r => r.AddAsync(It.IsAny(), It.IsAny()), - Times.Never); + r => r.TryAddReleaseAsync(It.IsAny(), It.IsAny()), + Times.Once); } [TestMethod] @@ -117,7 +110,7 @@ public async Task ProcessSingle_NewTitle_AddsRecord() _syncFeed, new object[] { request, CancellationToken.None })!; _mockRepo.Verify( - r => r.AddAsync( + r => r.TryAddReleaseAsync( It.Is(info => info.Title == "New Title" && info.Description == "New Description" && @@ -142,7 +135,7 @@ public async Task ProcessSingle_PolicyDoesNotMatch_SkipsRecordAndDownload() await InvokeProcessSingleAsync(request); - _mockRepo.Verify(repository => repository.AddAsync( + _mockRepo.Verify(repository => repository.TryAddReleaseAsync( It.IsAny(), It.IsAny()), Times.Never); _mockDownloadClient.Verify(client => client.SubmitDownloadTaskAsync( It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), @@ -156,10 +149,10 @@ public async Task ProcessSingle_NotifyOnly_PersistsNotifiedOutcomeAndExplanation var request = PolicyRequest(feedId, "[Group] Anime [1080p HEVC][CHS]"); SetupNewPolicyRequest(request, Policy(feedId, SubscriptionAutomationMode.NotifyOnly)); AnimationInfo? added = null; - _mockRepo.Setup(repository => repository.AddAsync( + _mockRepo.Setup(repository => repository.TryAddReleaseAsync( It.IsAny(), It.IsAny())) .Callback((info, _) => added = info) - .Returns(Task.CompletedTask); + .ReturnsAsync(true); await InvokeProcessSingleAsync(request); @@ -182,7 +175,7 @@ public async Task ProcessSingle_ManualConfirm_PersistsPendingConfirmationWithout await InvokeProcessSingleAsync(request); - _mockRepo.Verify(repository => repository.AddAsync( + _mockRepo.Verify(repository => repository.TryAddReleaseAsync( It.Is(info => info.AutomationDisposition == SubscriptionAutomationDisposition.PendingConfirmation && !info.IsDownloadTracked), diff --git a/SecondDimensionWatcherReDive/Controllers/Converter.cs b/SecondDimensionWatcherReDive/Controllers/Converter.cs index 742d740..bcd3678 100644 --- a/SecondDimensionWatcherReDive/Controllers/Converter.cs +++ b/SecondDimensionWatcherReDive/Controllers/Converter.cs @@ -62,7 +62,10 @@ public static External.SubscriptionAutomationPolicy ToExternal( record.ExcludedKeywords, record.Mode.ToString(), record.CreatedAt, - record.UpdatedAt); + record.UpdatedAt, + record.EnableVersionUpgrade, + record.MinimumUpgradeScore, + record.UpgradeRollbackHours); public static External.SubscriptionAutomationSimulationResult ToExternal( this Framework.Feed.SubscriptionAutomationSimulationResult result) => @@ -82,6 +85,84 @@ public static External.SubscriptionAutomationSimulationResult ToExternal( explanation.Expected, explanation.Message)).ToList())).ToList()); + public static External.LibrarySearchItemResponse ToExternal(this LibrarySearchItem item) => + new(item.AnimationInfoId, + item.Title, + item.AnimationName, + item.AnimationOriginalName, + item.TmdbId, + item.Season, + item.Episode, + item.SubtitleGroup, + item.Resolution, + item.Codec, + item.Languages, + item.IsDownloadTracked, + item.IsDownloadFinished, + item.IsMediaLibraryImport, + item.IsWatched, + item.PlaybackPositionSeconds, + item.VirtualPaths, + item.ReleaseScore, + item.ScoreReasons, + item.PublishedAt); + + public static External.LibrarySearchResponse ToExternal(this LibrarySearchResult result) => + new(result.Items.Select(item => item.ToExternal()).ToList(), result.NextCursor); + + public static External.ReleaseUpgradeCandidateResponse ToExternal( + this ReleaseUpgradeCandidate candidate) => + new(candidate.CurrentReleaseId, + candidate.CandidateReleaseId, + candidate.AnimationName, + candidate.Season, + candidate.Episode, + candidate.CurrentScore, + candidate.CandidateScore, + candidate.ScoreReasons, + candidate.Automatic); + + public static External.LibraryIntegritySummaryResponse ToExternal( + this LibraryIntegritySummary summary) => + new(summary.TmdbId, + summary.AnimationName, + summary.Season, + summary.ExpectedEpisodeCount, + summary.MissingEpisodes, + summary.DuplicateEpisodes.Select(duplicate => new External.EpisodeDuplicateResponse( + duplicate.Episode, + duplicate.ReleaseIds)).ToList(), + summary.UnidentifiedReleaseCount, + summary.UpgradeCandidates.Select(candidate => candidate.ToExternal()).ToList()); + + public static External.ReleaseUpgradeOperationResponse ToExternal( + this ReleaseUpgradeOperation operation) => + new(operation.Id, + operation.CurrentReleaseId, + operation.CandidateReleaseId, + operation.Status.ToString(), + operation.CurrentScore, + operation.CandidateScore, + operation.CreatedAt, + operation.VerifiedAt, + operation.AppliedAt, + operation.RollbackUntil, + operation.CompletedAt, + operation.FailureSummary); + + public static External.ReleaseUpgradeMutationResponse ToExternal( + this ReleaseUpgradeMutationResult result) => + new(result.IsSuccess, result.Outcome, result.Operation?.ToExternal()); + + public static External.ReleaseUpgradeExecutionResponse ToExternal( + this Utils.ReleaseUpgrades.ReleaseUpgradeExecutionResult result) => + new(result.IsSuccess, + result.Outcome, + result.DryRun, + result.RequiresDownload, + result.Operation?.ToExternal(), + result.ValidationErrors); + public static External.WebDavTokenSummary ToExternal(this WebDavToken record) => new(record.Id, record.Username, record.Description, record.CreatedAt); diff --git a/SecondDimensionWatcherReDive/Controllers/External/AppJsonSerializerContext.cs b/SecondDimensionWatcherReDive/Controllers/External/AppJsonSerializerContext.cs index 0f20309..4e0efb5 100644 --- a/SecondDimensionWatcherReDive/Controllers/External/AppJsonSerializerContext.cs +++ b/SecondDimensionWatcherReDive/Controllers/External/AppJsonSerializerContext.cs @@ -65,4 +65,11 @@ namespace SecondDimensionWatcherReDive.Controllers.External; [JsonSerializable(typeof(QueueMediaLibraryScanResponse))] [JsonSerializable(typeof(ApplicationSettingsResponse))] [JsonSerializable(typeof(PatchApplicationSettingsRequest))] +[JsonSerializable(typeof(ExecuteReleaseUpgradeRequest))] +[JsonSerializable(typeof(LibrarySearchResponse))] +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(ReleaseUpgradeMutationResponse))] +[JsonSerializable(typeof(ReleaseUpgradeExecutionResponse))] internal partial class AppJsonSerializerContext : JsonSerializerContext; diff --git a/SecondDimensionWatcherReDive/Controllers/External/Library.cs b/SecondDimensionWatcherReDive/Controllers/External/Library.cs new file mode 100644 index 0000000..999e287 --- /dev/null +++ b/SecondDimensionWatcherReDive/Controllers/External/Library.cs @@ -0,0 +1,84 @@ +namespace SecondDimensionWatcherReDive.Controllers.External; + +internal sealed record ExecuteReleaseUpgradeRequest( + Guid CurrentReleaseId, + Guid CandidateReleaseId, + bool DryRun = true); + +internal sealed record LibrarySearchItemResponse( + Guid AnimationInfoId, + string Title, + string? AnimationName, + string? AnimationOriginalName, + string? TmdbId, + int? Season, + int? Episode, + string? SubtitleGroup, + string? Resolution, + string? Codec, + IReadOnlyList Languages, + bool IsDownloadTracked, + bool IsDownloadFinished, + bool IsMediaLibraryImport, + bool IsWatched, + double? PlaybackPositionSeconds, + IReadOnlyList VirtualPaths, + int ReleaseScore, + IReadOnlyList ScoreReasons, + DateTimeOffset PublishedAt); + +internal sealed record LibrarySearchResponse( + IReadOnlyList Items, + string? NextCursor); + +internal sealed record EpisodeDuplicateResponse( + int Episode, + IReadOnlyList ReleaseIds); + +internal sealed record ReleaseUpgradeCandidateResponse( + Guid CurrentReleaseId, + Guid CandidateReleaseId, + string AnimationName, + int Season, + int Episode, + int CurrentScore, + int CandidateScore, + IReadOnlyList ScoreReasons, + bool Automatic); + +internal sealed record LibraryIntegritySummaryResponse( + string TmdbId, + string AnimationName, + int Season, + int? ExpectedEpisodeCount, + IReadOnlyList MissingEpisodes, + IReadOnlyList DuplicateEpisodes, + int UnidentifiedReleaseCount, + IReadOnlyList UpgradeCandidates); + +internal sealed record ReleaseUpgradeOperationResponse( + Guid Id, + Guid CurrentReleaseId, + Guid CandidateReleaseId, + string Status, + int CurrentScore, + int CandidateScore, + DateTimeOffset CreatedAt, + DateTimeOffset? VerifiedAt, + DateTimeOffset? AppliedAt, + DateTimeOffset? RollbackUntil, + DateTimeOffset? CompletedAt, + string? FailureSummary); + +internal sealed record ReleaseUpgradeMutationResponse( + bool IsSuccess, + string Outcome, + ReleaseUpgradeOperationResponse? Operation); + +internal sealed record ReleaseUpgradeExecutionResponse( + bool IsSuccess, + string Outcome, + bool DryRun, + bool RequiresDownload, + ReleaseUpgradeOperationResponse? Operation, + IReadOnlyList ValidationErrors); diff --git a/SecondDimensionWatcherReDive/Controllers/External/SubscriptionAutomationPolicy.cs b/SecondDimensionWatcherReDive/Controllers/External/SubscriptionAutomationPolicy.cs index d27d33a..2bc1fa9 100644 --- a/SecondDimensionWatcherReDive/Controllers/External/SubscriptionAutomationPolicy.cs +++ b/SecondDimensionWatcherReDive/Controllers/External/SubscriptionAutomationPolicy.cs @@ -8,7 +8,10 @@ internal sealed record UpsertSubscriptionAutomationPolicyRequest( long? MinSizeBytes, long? MaxSizeBytes, IReadOnlyList? ExcludedKeywords, - string Mode); + string Mode, + bool EnableVersionUpgrade = false, + int? MinimumUpgradeScore = null, + int? UpgradeRollbackHours = null); internal sealed record SubscriptionAutomationPolicy( Guid FeedId, @@ -21,7 +24,10 @@ internal sealed record SubscriptionAutomationPolicy( IReadOnlyList ExcludedKeywords, string Mode, DateTimeOffset CreatedAt, - DateTimeOffset UpdatedAt); + DateTimeOffset UpdatedAt, + bool EnableVersionUpgrade, + int MinimumUpgradeScore, + int UpgradeRollbackHours); internal sealed record SubscriptionAutomationExplanation( string Field, diff --git a/SecondDimensionWatcherReDive/Controllers/LibraryController.cs b/SecondDimensionWatcherReDive/Controllers/LibraryController.cs new file mode 100644 index 0000000..d2dbfeb --- /dev/null +++ b/SecondDimensionWatcherReDive/Controllers/LibraryController.cs @@ -0,0 +1,153 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using SecondDimensionWatcherReDive.Framework.DataRepository; +using SecondDimensionWatcherReDive.Utils.ReleaseUpgrades; + +namespace SecondDimensionWatcherReDive.Controllers; + +[ApiController] +[Route("api/library")] +[Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)] +internal sealed class LibraryController( + ILibrarySearchRepository searchRepository, + IReleaseUpgradeRepository upgradeRepository, + IReleaseUpgradeCoordinator upgradeCoordinator) : ControllerBase +{ + [HttpGet("search")] + public async Task SearchAsync( + [FromQuery(Name = "q")] string? query, + [FromQuery] int? season, + [FromQuery] int? episode, + [FromQuery] string? subtitleGroup, + [FromQuery] string? resolution, + [FromQuery] string? codec, + [FromQuery] string? language, + [FromQuery] string? downloadState, + [FromQuery] string? watchState, + [FromQuery(Name = "path")] string? virtualPath, + [FromQuery] string? source, + [FromQuery] string? sort, + [FromQuery] string? cursor, + [FromQuery] int take = 30, + CancellationToken cancellationToken = default) + { + if (!TryGetUserId(out var userId)) return Unauthorized(); + if (take is < 1 or > 100 || season is < 0 or > 100 || episode is < 0 or > 100000) + return BadRequest(new { message = "Invalid pagination, season, or episode value." }); + if (!TryParse(downloadState, LibraryDownloadState.Any, out LibraryDownloadState parsedDownload) || + !TryParse(watchState, LibraryWatchState.Any, out LibraryWatchState parsedWatch) || + !TryParse(source, LibrarySourceKind.Any, out LibrarySourceKind parsedSource) || + !TryParse(sort, LibrarySearchSort.PublishedDescending, out LibrarySearchSort parsedSort)) + return BadRequest(new { message = "One or more search enum values are invalid." }); + + try + { + var result = await searchRepository.SearchAsync(new LibrarySearchRequest( + Normalize(query), + season, + episode, + Normalize(subtitleGroup), + Normalize(resolution), + Normalize(codec), + Normalize(language), + parsedDownload, + parsedWatch, + Normalize(virtualPath), + parsedSource, + parsedSort, + Normalize(cursor), + take, + userId), + cancellationToken); + return Ok(result.ToExternal()); + } + catch (ArgumentException exception) + { + return BadRequest(new { message = exception.Message }); + } + } + + [HttpGet("integrity")] + public async Task IntegrityAsync( + [FromQuery] string? tmdbId, + [FromQuery] int? season, + CancellationToken cancellationToken) + { + if (season is < 0 or > 100) return BadRequest(new { message = "Invalid season." }); + var result = await searchRepository.GetIntegrityAsync(Normalize(tmdbId), season, cancellationToken); + return Ok(result.Select(item => item.ToExternal()).ToList()); + } + + [HttpGet("upgrades")] + public async Task UpgradesAsync( + [FromQuery] bool automaticOnly = false, + [FromQuery] int take = 50, + CancellationToken cancellationToken = default) + { + if (take is < 1 or > 200) return BadRequest(new { message = "take must be between 1 and 200." }); + var result = await upgradeRepository.GetCandidatesAsync(automaticOnly, take, cancellationToken); + return Ok(result.Select(item => item.ToExternal()).ToList()); + } + + [HttpPost("upgrades/execute")] + public async Task ExecuteUpgradeAsync( + [FromBody] External.ExecuteReleaseUpgradeRequest request, + CancellationToken cancellationToken) + { + var candidate = (await upgradeRepository.GetCandidatesAsync(false, 200, cancellationToken)) + .SingleOrDefault(item => item.CurrentReleaseId == request.CurrentReleaseId && + item.CandidateReleaseId == request.CandidateReleaseId); + if (candidate is null) + return Conflict(new { message = "The requested release is no longer an available upgrade." }); + + var result = await upgradeCoordinator.ExecuteAsync(candidate, request.DryRun, cancellationToken); + var response = result.ToExternal(); + return result.IsSuccess ? Ok(response) : UnprocessableEntity(response); + } + + [HttpPost("upgrades/{operationId:guid}/rollback")] + public async Task RollbackUpgradeAsync( + [FromRoute] Guid operationId, + CancellationToken cancellationToken) + { + var result = await upgradeCoordinator.RollbackAsync(operationId, cancellationToken); + var response = result.ToExternal(); + if (result.Outcome == "not_found") return NotFound(response); + return result.IsSuccess ? Ok(response) : Conflict(response); + } + + [HttpGet("upgrade-history")] + public async Task UpgradeHistoryAsync( + [FromQuery] int take = 50, + CancellationToken cancellationToken = default) + { + if (take is < 1 or > 200) return BadRequest(new { message = "take must be between 1 and 200." }); + var result = await upgradeRepository.GetHistoryAsync(take, cancellationToken); + return Ok(result.Select(item => item.ToExternal()).ToList()); + } + + private bool TryGetUserId(out Guid userId) + { + var raw = User.FindFirstValue("Id") + ?? User.FindFirstValue(ClaimTypes.NameIdentifier) + ?? User.FindFirstValue("sub"); + return Guid.TryParse(raw, out userId); + } + + private static string? Normalize(string? value) => + string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + + private static bool TryParse(string? value, T defaultValue, out T parsed) + where T : struct, Enum + { + if (string.IsNullOrWhiteSpace(value)) + { + parsed = defaultValue; + return true; + } + + return Enum.TryParse(value, ignoreCase: true, out parsed) && Enum.IsDefined(parsed); + } +} diff --git a/SecondDimensionWatcherReDive/Controllers/SubscriptionPoliciesController.cs b/SecondDimensionWatcherReDive/Controllers/SubscriptionPoliciesController.cs index cf1a204..53ffc87 100644 --- a/SecondDimensionWatcherReDive/Controllers/SubscriptionPoliciesController.cs +++ b/SecondDimensionWatcherReDive/Controllers/SubscriptionPoliciesController.cs @@ -128,6 +128,20 @@ private static bool TryCreatePolicy( return false; } + var minimumUpgradeScore = request.MinimumUpgradeScore ?? 25; + if (minimumUpgradeScore is < 1 or > 1000) + { + error = "minimumUpgradeScore must be between 1 and 1000."; + return false; + } + + var rollbackHours = request.UpgradeRollbackHours ?? 72; + if (rollbackHours is < 1 or > 720) + { + error = "upgradeRollbackHours must be between 1 and 720."; + return false; + } + policy = new SubscriptionAutomationPolicy( feedId, subtitleGroups, @@ -139,7 +153,10 @@ private static bool TryCreatePolicy( excludedKeywords, mode, timestamp, - timestamp); + timestamp, + request.EnableVersionUpgrade, + minimumUpgradeScore, + rollbackHours); error = null; return true; } diff --git a/SecondDimensionWatcherReDive/Migrations/20260829151303_AddLibrarySearchAndReleaseUpgrades.Designer.cs b/SecondDimensionWatcherReDive/Migrations/20260829151303_AddLibrarySearchAndReleaseUpgrades.Designer.cs new file mode 100644 index 0000000..c4f0658 --- /dev/null +++ b/SecondDimensionWatcherReDive/Migrations/20260829151303_AddLibrarySearchAndReleaseUpgrades.Designer.cs @@ -0,0 +1,1193 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using SecondDimensionWatcherReDive.Models; + +#nullable disable + +namespace SecondDimensionWatcherReDive.Migrations +{ + [DbContext(typeof(ApplicationContext))] + [Migration("20260829151303_AddLibrarySearchAndReleaseUpgrades")] + partial class AddLibrarySearchAndReleaseUpgrades + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.11") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.Animation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("OriginalName") + .IsRequired() + .HasColumnType("text"); + + b.Property("PosterPath") + .HasColumnType("text"); + + b.Property("TmdbId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("TmdbId") + .IsUnique(); + + b.ToTable("Animations"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.AnimationGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("AnimationGroups"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.AnimationInfo", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AdditionalDownloadInfo") + .IsRequired() + .HasColumnType("text"); + + b.Property("AiRetryCount") + .HasColumnType("integer"); + + b.Property("AnimationId") + .HasColumnType("uuid"); + + b.Property("AutomationDisposition") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("AutomationExplanationJson") + .HasColumnType("text"); + + b.Property("CachedDownloadData") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("CurrentMetadataReviewOperationId") + .HasColumnType("uuid"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("DownloadAttemptId") + .HasColumnType("uuid"); + + b.Property("DownloadCancellationId") + .HasColumnType("uuid"); + + b.Property("DownloadEndTime") + .HasColumnType("timestamp with time zone"); + + b.Property("DownloadStartTime") + .HasColumnType("timestamp with time zone"); + + b.Property("DownloadType") + .IsRequired() + .HasColumnType("text"); + + b.Property("DownloadUrl") + .IsRequired() + .HasColumnType("text"); + + b.Property("EnclosureId") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("Episode") + .HasColumnType("integer"); + + b.Property("ExpectedEpisodeCount") + .HasColumnType("integer"); + + b.Property("FeedItemGuid") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("FileStore") + .HasColumnType("text"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("IngestedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("IsActiveRelease") + .HasColumnType("boolean"); + + b.Property("IsAiProcessed") + .HasColumnType("boolean"); + + b.Property("IsDownloadFinished") + .HasColumnType("boolean"); + + b.Property("IsDownloadTracked") + .HasColumnType("boolean"); + + b.Property("MediaLibraryMissingSince") + .HasColumnType("timestamp with time zone"); + + b.Property("MediaLibrarySourceId") + .HasColumnType("uuid"); + + b.Property("MetadataConfidence") + .HasColumnType("double precision"); + + b.Property("MetadataLastError") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("MetadataReviewedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("MetadataStatus") + .HasColumnType("integer"); + + b.Property("PublishTime") + .HasColumnType("timestamp with time zone"); + + b.Property("ReleaseCodec") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("ReleaseIdentity") + .HasMaxLength(192) + .HasColumnType("character varying(192)"); + + b.PrimitiveCollection("ReleaseLanguages") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("ReleaseResolution") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("ReleaseScore") + .HasColumnType("integer"); + + b.Property("ReleaseScoreReasonsJson") + .HasColumnType("text"); + + b.Property("ReleaseSizeBytes") + .HasColumnType("bigint"); + + b.Property("ReleaseSubtitleGroup") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Season") + .HasColumnType("integer"); + + b.Property("SourceFeedId") + .HasColumnType("uuid"); + + b.Property("StateVersion") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.Property("StorePath") + .HasColumnType("text"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.Property("TorrentInfoHash") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.HasKey("Id"); + + b.HasIndex("AnimationId"); + + b.HasIndex("CurrentMetadataReviewOperationId") + .IsUnique(); + + b.HasIndex("GroupId"); + + b.HasIndex("MediaLibrarySourceId"); + + b.HasIndex("ReleaseIdentity") + .IsUnique() + .HasDatabaseName("UX_AnimationInfo_ReleaseIdentity") + .HasFilter("\"ReleaseIdentity\" IS NOT NULL"); + + b.HasIndex("SourceFeedId"); + + b.HasIndex("FileStore", "StorePath") + .IsUnique() + .HasFilter("\"DownloadType\" = 'http://schemas.hcgstudio.com/ws/2023/06/sdw/downloadtype/media-library-import'"); + + b.HasIndex("MetadataStatus", "PublishTime"); + + b.HasIndex("Season", "Episode", "ReleaseScore"); + + b.ToTable("AnimationInfo", t => + { + t.HasCheckConstraint("CK_AnimationInfo_ExpectedEpisodeCount_Positive", "\"ExpectedEpisodeCount\" IS NULL OR \"ExpectedEpisodeCount\" > 0"); + + t.HasCheckConstraint("CK_AnimationInfo_MetadataConfidence_Range", "\"MetadataConfidence\" IS NULL OR (\"MetadataConfidence\" >= 0 AND \"MetadataConfidence\" <= 1)"); + + t.HasCheckConstraint("CK_AnimationInfo_ReleaseScore_NonNegative", "\"ReleaseScore\" >= 0"); + }); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.ApplicationSettings", b => + { + b.Property("Id") + .HasColumnType("integer"); + + b.Property("ProtectedSecrets") + .HasColumnType("text"); + + b.Property("Revision") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ValuesJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.HasKey("Id"); + + b.ToTable("ApplicationSettings", t => + { + t.HasCheckConstraint("CK_ApplicationSettings_Revision_Positive", "\"Revision\" > 0"); + + t.HasCheckConstraint("CK_ApplicationSettings_Singleton", "\"Id\" = 1"); + }); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.BangumiSubgroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("MikanSubgroupId") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("ScrapedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SeasonBangumiId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("SeasonBangumiId", "MikanSubgroupId") + .IsUnique(); + + b.ToTable("BangumiSubgroups"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.ChatConversation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Title") + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("ChatConversations"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.ChatMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Content") + .HasColumnType("text"); + + b.Property("ConversationId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Order") + .HasColumnType("integer"); + + b.Property("Role") + .IsRequired() + .HasColumnType("text"); + + b.Property("ToolCallId") + .HasColumnType("text"); + + b.Property("ToolCallsJson") + .HasColumnType("text"); + + b.Property("ToolName") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ConversationId"); + + b.ToTable("ChatMessages"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.Feed", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Url") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Feeds"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.FileMapping", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AnimationInfoId") + .HasColumnType("uuid"); + + b.Property("FileStore") + .IsRequired() + .HasColumnType("text"); + + b.Property("PhysicalPath") + .IsRequired() + .HasColumnType("text"); + + b.Property("VirtualPath") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("AnimationInfoId"); + + b.HasIndex("VirtualPath") + .IsUnique(); + + b.ToTable("FileMappings"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.FileNameRegexRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AnimationId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Pattern") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.HasKey("Id"); + + b.HasIndex("AnimationId", "CreatedAt"); + + b.HasIndex("AnimationId", "Pattern") + .IsUnique(); + + b.ToTable("FileNameRegexRules"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.Incident", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Detail") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("DetectedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Fingerprint") + .IsRequired() + .HasMaxLength(96) + .HasColumnType("character varying(96)"); + + b.Property("LastRetryAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastRetryError") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("ResolvedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RetryCount") + .HasColumnType("integer"); + + b.Property("Severity") + .HasColumnType("integer"); + + b.Property("SourceId") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("Fingerprint") + .IsUnique(); + + b.HasIndex("ResolvedAt", "Type", "UpdatedAt"); + + b.ToTable("Incidents"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.MediaLibrarySource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsMonitoring") + .HasColumnType("boolean"); + + b.Property("LastError") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("LastImportedCount") + .HasColumnType("integer"); + + b.Property("LastRemovedCount") + .HasColumnType("integer"); + + b.Property("LastScanAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSkippedCount") + .HasColumnType("integer"); + + b.Property("LastUpdatedCount") + .HasColumnType("integer"); + + b.Property("Path") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Path") + .IsUnique(); + + b.ToTable("MediaLibrarySources"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.MetadataReviewMappingSnapshot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("FileStore") + .IsRequired() + .HasColumnType("text"); + + b.Property("Kind") + .HasColumnType("integer"); + + b.Property("OperationId") + .HasColumnType("uuid"); + + b.Property("PhysicalPath") + .IsRequired() + .HasColumnType("text"); + + b.Property("VirtualPath") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("OperationId", "Kind", "VirtualPath") + .IsUnique(); + + b.ToTable("MetadataReviewMappingSnapshots"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.MetadataReviewOperation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AnimationInfoId") + .HasColumnType("uuid"); + + b.Property("AppliedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("AppliedVersion") + .HasColumnType("bigint"); + + b.Property("BaseFileStore") + .HasColumnType("text"); + + b.Property("BaseIsDownloadFinished") + .HasColumnType("boolean"); + + b.Property("BaseStorePath") + .HasColumnType("text"); + + b.Property("BaseVersion") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PreviousAiRetryCount") + .HasColumnType("integer"); + + b.Property("PreviousAnimationId") + .HasColumnType("uuid"); + + b.Property("PreviousConfidence") + .HasColumnType("double precision"); + + b.Property("PreviousCurrentOperationId") + .HasColumnType("uuid"); + + b.Property("PreviousDescription") + .HasColumnType("text"); + + b.Property("PreviousEpisode") + .HasColumnType("integer"); + + b.Property("PreviousGroupId") + .HasColumnType("uuid"); + + b.Property("PreviousIsAiProcessed") + .HasColumnType("boolean"); + + b.Property("PreviousLastError") + .HasColumnType("text"); + + b.Property("PreviousMetadataStatus") + .HasColumnType("integer"); + + b.Property("PreviousReviewedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PreviousSeason") + .HasColumnType("integer"); + + b.Property("ProposedAnimationName") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProposedAnimationOriginalName") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProposedAnimationPosterPath") + .HasColumnType("text"); + + b.Property("ProposedAnimationTmdbId") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProposedDescription") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProposedEpisode") + .HasColumnType("integer"); + + b.Property("ProposedGroupName") + .HasColumnType("text"); + + b.Property("ProposedSeason") + .HasColumnType("integer"); + + b.Property("State") + .HasColumnType("integer"); + + b.Property("UndoneAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("AnimationInfoId", "AppliedVersion") + .IsUnique(); + + b.HasIndex("AnimationInfoId", "State"); + + b.HasIndex("State", "ExpiresAt"); + + b.ToTable("MetadataReviewOperations", t => + { + t.HasCheckConstraint("CK_MetadataReviewOperations_Expiry", "\"ExpiresAt\" > \"CreatedAt\""); + }); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.MigrationMarker", b => + { + b.Property("Key") + .HasColumnType("text"); + + b.Property("AppliedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Key"); + + b.ToTable("MigrationMarkers"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.PlaybackPreference", b => + { + b.Property("UserId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AudioLanguage") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("AudioTrackLabel") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("AutoPlayNext") + .HasColumnType("boolean"); + + b.Property("SubtitleLanguage") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("SubtitleTrackLabel") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("UserId"); + + b.ToTable("PlaybackPreferences"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.PlaybackProgress", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AnimationInfoId") + .HasColumnType("uuid"); + + b.Property("DurationSeconds") + .HasColumnType("double precision"); + + b.Property("IsWatched") + .HasColumnType("boolean"); + + b.Property("PositionSeconds") + .HasColumnType("double precision"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("VirtualPath") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("WatchedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("AnimationInfoId"); + + b.HasIndex("UserId", "AnimationInfoId", "VirtualPath") + .IsUnique(); + + b.HasIndex("UserId", "IsWatched", "UpdatedAt"); + + b.ToTable("PlaybackProgresses", t => + { + t.HasCheckConstraint("CK_PlaybackProgresses_Duration_NonNegative", "\"DurationSeconds\" >= 0"); + + t.HasCheckConstraint("CK_PlaybackProgresses_Position_NonNegative", "\"PositionSeconds\" >= 0"); + }); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.ReleaseUpgradeMappingSnapshot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AnimationInfoId") + .HasColumnType("uuid"); + + b.Property("FileStore") + .IsRequired() + .HasColumnType("text"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("OperationId") + .HasColumnType("uuid"); + + b.Property("OriginalMappingId") + .HasColumnType("uuid"); + + b.Property("PhysicalPath") + .IsRequired() + .HasColumnType("text"); + + b.Property("VirtualPath") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.HasKey("Id"); + + b.HasIndex("OperationId", "Kind", "OriginalMappingId") + .IsUnique(); + + b.ToTable("ReleaseUpgradeMappingSnapshots"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.ReleaseUpgradeOperation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AppliedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CandidateReleaseId") + .HasColumnType("uuid"); + + b.Property("CandidateScore") + .HasColumnType("integer"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrentReleaseId") + .HasColumnType("uuid"); + + b.Property("CurrentScore") + .HasColumnType("integer"); + + b.Property("FailureSummary") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("RollbackUntil") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("VerifiedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CandidateReleaseId") + .IsUnique(); + + b.HasIndex("CurrentReleaseId") + .IsUnique() + .HasDatabaseName("UX_ReleaseUpgradeOperations_ActiveCurrentRelease") + .HasFilter("\"Status\" IN ('Downloading', 'Verifying', 'Applied')"); + + b.HasIndex("Status", "CreatedAt"); + + b.ToTable("ReleaseUpgradeOperations", t => + { + t.HasCheckConstraint("CK_ReleaseUpgradeOperations_ScoreIncrease", "\"CandidateScore\" > \"CurrentScore\""); + }); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.SeasonBangumi", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("DayOfWeek") + .HasColumnType("integer"); + + b.Property("ImageUrl") + .HasColumnType("text"); + + b.Property("MikanId") + .HasColumnType("integer"); + + b.Property("ScrapedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("MikanId") + .IsUnique(); + + b.ToTable("SeasonBangumis"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.SubscriptionAutomationPolicy", b => + { + b.Property("FeedId") + .HasColumnType("uuid"); + + b.PrimitiveCollection("Codecs") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EnableVersionUpgrade") + .HasColumnType("boolean"); + + b.PrimitiveCollection("ExcludedKeywords") + .IsRequired() + .HasColumnType("text[]"); + + b.PrimitiveCollection("Languages") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("MaxSizeBytes") + .HasColumnType("bigint"); + + b.Property("MinSizeBytes") + .HasColumnType("bigint"); + + b.Property("MinimumUpgradeScore") + .HasColumnType("integer"); + + b.Property("Mode") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.PrimitiveCollection("Resolutions") + .IsRequired() + .HasColumnType("text[]"); + + b.PrimitiveCollection("SubtitleGroups") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpgradeRollbackHours") + .HasColumnType("integer"); + + b.HasKey("FeedId"); + + b.HasIndex("UpdatedAt"); + + b.ToTable("SubscriptionAutomationPolicies", t => + { + t.HasCheckConstraint("CK_SubscriptionAutomationPolicies_MinimumUpgradeScore", "\"MinimumUpgradeScore\" >= 1 AND \"MinimumUpgradeScore\" <= 1000"); + + t.HasCheckConstraint("CK_SubscriptionAutomationPolicies_UpgradeRollbackHours", "\"UpgradeRollbackHours\" >= 1 AND \"UpgradeRollbackHours\" <= 720"); + }); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.WebDavToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("TokenHash") + .IsRequired() + .HasColumnType("text"); + + b.Property("Username") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("WebDavTokens"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.AnimationInfo", b => + { + b.HasOne("SecondDimensionWatcherReDive.Models.Animation", "Animation") + .WithMany() + .HasForeignKey("AnimationId"); + + b.HasOne("SecondDimensionWatcherReDive.Models.AnimationGroup", "Group") + .WithMany() + .HasForeignKey("GroupId"); + + b.HasOne("SecondDimensionWatcherReDive.Models.MediaLibrarySource", null) + .WithMany() + .HasForeignKey("MediaLibrarySourceId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("SecondDimensionWatcherReDive.Models.Feed", null) + .WithMany() + .HasForeignKey("SourceFeedId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Animation"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.BangumiSubgroup", b => + { + b.HasOne("SecondDimensionWatcherReDive.Models.SeasonBangumi", "SeasonBangumi") + .WithMany("Subgroups") + .HasForeignKey("SeasonBangumiId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SeasonBangumi"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.ChatMessage", b => + { + b.HasOne("SecondDimensionWatcherReDive.Models.ChatConversation", "Conversation") + .WithMany("Messages") + .HasForeignKey("ConversationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Conversation"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.FileNameRegexRule", b => + { + b.HasOne("SecondDimensionWatcherReDive.Models.Animation", null) + .WithMany() + .HasForeignKey("AnimationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.MetadataReviewMappingSnapshot", b => + { + b.HasOne("SecondDimensionWatcherReDive.Models.MetadataReviewOperation", "Operation") + .WithMany("MappingSnapshots") + .HasForeignKey("OperationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Operation"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.MetadataReviewOperation", b => + { + b.HasOne("SecondDimensionWatcherReDive.Models.AnimationInfo", "AnimationInfo") + .WithMany() + .HasForeignKey("AnimationInfoId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AnimationInfo"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.PlaybackProgress", b => + { + b.HasOne("SecondDimensionWatcherReDive.Models.AnimationInfo", "AnimationInfo") + .WithMany() + .HasForeignKey("AnimationInfoId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AnimationInfo"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.ReleaseUpgradeMappingSnapshot", b => + { + b.HasOne("SecondDimensionWatcherReDive.Models.ReleaseUpgradeOperation", "Operation") + .WithMany("MappingSnapshots") + .HasForeignKey("OperationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Operation"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.ReleaseUpgradeOperation", b => + { + b.HasOne("SecondDimensionWatcherReDive.Models.AnimationInfo", "CandidateRelease") + .WithMany() + .HasForeignKey("CandidateReleaseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SecondDimensionWatcherReDive.Models.AnimationInfo", "CurrentRelease") + .WithMany() + .HasForeignKey("CurrentReleaseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("CandidateRelease"); + + b.Navigation("CurrentRelease"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.SubscriptionAutomationPolicy", b => + { + b.HasOne("SecondDimensionWatcherReDive.Models.Feed", "Feed") + .WithOne() + .HasForeignKey("SecondDimensionWatcherReDive.Models.SubscriptionAutomationPolicy", "FeedId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Feed"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.ChatConversation", b => + { + b.Navigation("Messages"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.MetadataReviewOperation", b => + { + b.Navigation("MappingSnapshots"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.ReleaseUpgradeOperation", b => + { + b.Navigation("MappingSnapshots"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.SeasonBangumi", b => + { + b.Navigation("Subgroups"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/SecondDimensionWatcherReDive/Migrations/20260829151303_AddLibrarySearchAndReleaseUpgrades.cs b/SecondDimensionWatcherReDive/Migrations/20260829151303_AddLibrarySearchAndReleaseUpgrades.cs new file mode 100644 index 0000000..b4ab9af --- /dev/null +++ b/SecondDimensionWatcherReDive/Migrations/20260829151303_AddLibrarySearchAndReleaseUpgrades.cs @@ -0,0 +1,401 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace SecondDimensionWatcherReDive.Migrations +{ + /// + public partial class AddLibrarySearchAndReleaseUpgrades : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "EnableVersionUpgrade", + table: "SubscriptionAutomationPolicies", + type: "boolean", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "MinimumUpgradeScore", + table: "SubscriptionAutomationPolicies", + type: "integer", + nullable: false, + defaultValue: 25); + + migrationBuilder.AddColumn( + name: "UpgradeRollbackHours", + table: "SubscriptionAutomationPolicies", + type: "integer", + nullable: false, + defaultValue: 72); + + migrationBuilder.AddColumn( + name: "EnclosureId", + table: "AnimationInfo", + type: "character varying(2048)", + maxLength: 2048, + nullable: true); + + migrationBuilder.AddColumn( + name: "ExpectedEpisodeCount", + table: "AnimationInfo", + type: "integer", + nullable: true); + + migrationBuilder.AddColumn( + name: "FeedItemGuid", + table: "AnimationInfo", + type: "character varying(1024)", + maxLength: 1024, + nullable: true); + + migrationBuilder.AddColumn( + name: "IngestedAt", + table: "AnimationInfo", + type: "timestamp with time zone", + nullable: false, + defaultValueSql: "CURRENT_TIMESTAMP"); + + migrationBuilder.AddColumn( + name: "IsActiveRelease", + table: "AnimationInfo", + type: "boolean", + nullable: false, + defaultValue: false); + + migrationBuilder.Sql( + """ + UPDATE "AnimationInfo" + SET "IsActiveRelease" = TRUE; + + WITH ranked_releases AS ( + SELECT info."Id", + row_number() OVER ( + PARTITION BY info."AnimationId", info."Season", info."Episode" + ORDER BY (info."IsDownloadFinished" AND EXISTS ( + SELECT 1 + FROM "FileMappings" mapping + WHERE mapping."AnimationInfoId" = info."Id" + )) DESC, + info."PublishTime" DESC, + info."Id" + ) AS rank + FROM "AnimationInfo" info + WHERE info."AnimationId" IS NOT NULL + AND info."Season" IS NOT NULL + AND info."Episode" IS NOT NULL + ) + UPDATE "AnimationInfo" info + SET "IsActiveRelease" = ranked.rank = 1 + FROM ranked_releases ranked + WHERE info."Id" = ranked."Id"; + """); + + migrationBuilder.AddColumn( + name: "ReleaseCodec", + table: "AnimationInfo", + type: "character varying(32)", + maxLength: 32, + nullable: true); + + migrationBuilder.AddColumn( + name: "ReleaseIdentity", + table: "AnimationInfo", + type: "character varying(192)", + maxLength: 192, + nullable: true); + + migrationBuilder.Sql( + """ + UPDATE "AnimationInfo" + SET "ReleaseIdentity" = 'legacy:' || replace(lower("Id"::text), '-', '') + WHERE "ReleaseIdentity" IS NULL; + """); + + migrationBuilder.Sql("CREATE EXTENSION IF NOT EXISTS pg_trgm;"); + + migrationBuilder.AddColumn( + name: "ReleaseLanguages", + table: "AnimationInfo", + type: "text[]", + nullable: false, + defaultValue: new string[0]); + + migrationBuilder.AddColumn( + name: "ReleaseResolution", + table: "AnimationInfo", + type: "character varying(32)", + maxLength: 32, + nullable: true); + + migrationBuilder.AddColumn( + name: "ReleaseScore", + table: "AnimationInfo", + type: "integer", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn( + name: "ReleaseScoreReasonsJson", + table: "AnimationInfo", + type: "text", + nullable: true); + + migrationBuilder.AddColumn( + name: "ReleaseSubtitleGroup", + table: "AnimationInfo", + type: "character varying(256)", + maxLength: 256, + nullable: true); + + migrationBuilder.AddColumn( + name: "TorrentInfoHash", + table: "AnimationInfo", + type: "character varying(64)", + maxLength: 64, + nullable: true); + + migrationBuilder.CreateTable( + name: "ReleaseUpgradeOperations", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + CurrentReleaseId = table.Column(type: "uuid", nullable: false), + CandidateReleaseId = table.Column(type: "uuid", nullable: false), + Status = table.Column(type: "character varying(32)", maxLength: 32, nullable: false), + CurrentScore = table.Column(type: "integer", nullable: false), + CandidateScore = table.Column(type: "integer", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + VerifiedAt = table.Column(type: "timestamp with time zone", nullable: true), + AppliedAt = table.Column(type: "timestamp with time zone", nullable: true), + RollbackUntil = table.Column(type: "timestamp with time zone", nullable: true), + CompletedAt = table.Column(type: "timestamp with time zone", nullable: true), + FailureSummary = table.Column(type: "character varying(2048)", maxLength: 2048, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_ReleaseUpgradeOperations", x => x.Id); + table.CheckConstraint("CK_ReleaseUpgradeOperations_ScoreIncrease", "\"CandidateScore\" > \"CurrentScore\""); + table.ForeignKey( + name: "FK_ReleaseUpgradeOperations_AnimationInfo_CandidateReleaseId", + column: x => x.CandidateReleaseId, + principalTable: "AnimationInfo", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_ReleaseUpgradeOperations_AnimationInfo_CurrentReleaseId", + column: x => x.CurrentReleaseId, + principalTable: "AnimationInfo", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "ReleaseUpgradeMappingSnapshots", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + OperationId = table.Column(type: "uuid", nullable: false), + Kind = table.Column(type: "character varying(32)", maxLength: 32, nullable: false), + OriginalMappingId = table.Column(type: "uuid", nullable: false), + AnimationInfoId = table.Column(type: "uuid", nullable: false), + VirtualPath = table.Column(type: "character varying(2048)", maxLength: 2048, nullable: false), + PhysicalPath = table.Column(type: "text", nullable: false), + FileStore = table.Column(type: "text", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ReleaseUpgradeMappingSnapshots", x => x.Id); + table.ForeignKey( + name: "FK_ReleaseUpgradeMappingSnapshots_ReleaseUpgradeOperations_Ope~", + column: x => x.OperationId, + principalTable: "ReleaseUpgradeOperations", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.AddCheckConstraint( + name: "CK_SubscriptionAutomationPolicies_MinimumUpgradeScore", + table: "SubscriptionAutomationPolicies", + sql: "\"MinimumUpgradeScore\" >= 1 AND \"MinimumUpgradeScore\" <= 1000"); + + migrationBuilder.AddCheckConstraint( + name: "CK_SubscriptionAutomationPolicies_UpgradeRollbackHours", + table: "SubscriptionAutomationPolicies", + sql: "\"UpgradeRollbackHours\" >= 1 AND \"UpgradeRollbackHours\" <= 720"); + + migrationBuilder.CreateIndex( + name: "IX_AnimationInfo_Season_Episode_ReleaseScore", + table: "AnimationInfo", + columns: new[] { "Season", "Episode", "ReleaseScore" }); + + migrationBuilder.CreateIndex( + name: "UX_AnimationInfo_ReleaseIdentity", + table: "AnimationInfo", + column: "ReleaseIdentity", + unique: true, + filter: "\"ReleaseIdentity\" IS NOT NULL"); + + migrationBuilder.AddCheckConstraint( + name: "CK_AnimationInfo_ExpectedEpisodeCount_Positive", + table: "AnimationInfo", + sql: "\"ExpectedEpisodeCount\" IS NULL OR \"ExpectedEpisodeCount\" > 0"); + + migrationBuilder.AddCheckConstraint( + name: "CK_AnimationInfo_ReleaseScore_NonNegative", + table: "AnimationInfo", + sql: "\"ReleaseScore\" >= 0"); + + migrationBuilder.CreateIndex( + name: "IX_ReleaseUpgradeMappingSnapshots_OperationId_Kind_OriginalMap~", + table: "ReleaseUpgradeMappingSnapshots", + columns: new[] { "OperationId", "Kind", "OriginalMappingId" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_ReleaseUpgradeOperations_CandidateReleaseId", + table: "ReleaseUpgradeOperations", + column: "CandidateReleaseId", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_ReleaseUpgradeOperations_Status_CreatedAt", + table: "ReleaseUpgradeOperations", + columns: new[] { "Status", "CreatedAt" }); + + migrationBuilder.CreateIndex( + name: "UX_ReleaseUpgradeOperations_ActiveCurrentRelease", + table: "ReleaseUpgradeOperations", + column: "CurrentReleaseId", + unique: true, + filter: "\"Status\" IN ('Downloading', 'Verifying', 'Applied')"); + + migrationBuilder.Sql( + """ + CREATE INDEX "IX_AnimationInfo_Title_Trgm" + ON "AnimationInfo" USING GIN ("Title" gin_trgm_ops); + CREATE INDEX "IX_Animations_Name_Trgm" + ON "Animations" USING GIN ("Name" gin_trgm_ops); + CREATE INDEX "IX_Animations_OriginalName_Trgm" + ON "Animations" USING GIN ("OriginalName" gin_trgm_ops); + CREATE INDEX "IX_AnimationGroups_Name_Trgm" + ON "AnimationGroups" USING GIN ("Name" gin_trgm_ops); + CREATE INDEX "IX_FileMappings_VirtualPath_Trgm" + ON "FileMappings" USING GIN ("VirtualPath" gin_trgm_ops); + CREATE INDEX "IX_AnimationInfo_ReleaseLanguages_Gin" + ON "AnimationInfo" USING GIN ("ReleaseLanguages"); + """); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql( + """ + DROP INDEX IF EXISTS "IX_AnimationInfo_Title_Trgm"; + DROP INDEX IF EXISTS "IX_Animations_Name_Trgm"; + DROP INDEX IF EXISTS "IX_Animations_OriginalName_Trgm"; + DROP INDEX IF EXISTS "IX_AnimationGroups_Name_Trgm"; + DROP INDEX IF EXISTS "IX_FileMappings_VirtualPath_Trgm"; + DROP INDEX IF EXISTS "IX_AnimationInfo_ReleaseLanguages_Gin"; + """); + + migrationBuilder.DropTable( + name: "ReleaseUpgradeMappingSnapshots"); + + migrationBuilder.DropTable( + name: "ReleaseUpgradeOperations"); + + migrationBuilder.DropCheckConstraint( + name: "CK_SubscriptionAutomationPolicies_MinimumUpgradeScore", + table: "SubscriptionAutomationPolicies"); + + migrationBuilder.DropCheckConstraint( + name: "CK_SubscriptionAutomationPolicies_UpgradeRollbackHours", + table: "SubscriptionAutomationPolicies"); + + migrationBuilder.DropIndex( + name: "IX_AnimationInfo_Season_Episode_ReleaseScore", + table: "AnimationInfo"); + + migrationBuilder.DropIndex( + name: "UX_AnimationInfo_ReleaseIdentity", + table: "AnimationInfo"); + + migrationBuilder.DropCheckConstraint( + name: "CK_AnimationInfo_ExpectedEpisodeCount_Positive", + table: "AnimationInfo"); + + migrationBuilder.DropCheckConstraint( + name: "CK_AnimationInfo_ReleaseScore_NonNegative", + table: "AnimationInfo"); + + migrationBuilder.DropColumn( + name: "EnableVersionUpgrade", + table: "SubscriptionAutomationPolicies"); + + migrationBuilder.DropColumn( + name: "MinimumUpgradeScore", + table: "SubscriptionAutomationPolicies"); + + migrationBuilder.DropColumn( + name: "UpgradeRollbackHours", + table: "SubscriptionAutomationPolicies"); + + migrationBuilder.DropColumn( + name: "EnclosureId", + table: "AnimationInfo"); + + migrationBuilder.DropColumn( + name: "ExpectedEpisodeCount", + table: "AnimationInfo"); + + migrationBuilder.DropColumn( + name: "FeedItemGuid", + table: "AnimationInfo"); + + migrationBuilder.DropColumn( + name: "IngestedAt", + table: "AnimationInfo"); + + migrationBuilder.DropColumn( + name: "IsActiveRelease", + table: "AnimationInfo"); + + migrationBuilder.DropColumn( + name: "ReleaseCodec", + table: "AnimationInfo"); + + migrationBuilder.DropColumn( + name: "ReleaseIdentity", + table: "AnimationInfo"); + + migrationBuilder.DropColumn( + name: "ReleaseLanguages", + table: "AnimationInfo"); + + migrationBuilder.DropColumn( + name: "ReleaseResolution", + table: "AnimationInfo"); + + migrationBuilder.DropColumn( + name: "ReleaseScore", + table: "AnimationInfo"); + + migrationBuilder.DropColumn( + name: "ReleaseScoreReasonsJson", + table: "AnimationInfo"); + + migrationBuilder.DropColumn( + name: "ReleaseSubtitleGroup", + table: "AnimationInfo"); + + migrationBuilder.DropColumn( + name: "TorrentInfoHash", + table: "AnimationInfo"); + } + } +} diff --git a/SecondDimensionWatcherReDive/Migrations/ApplicationContextModelSnapshot.cs b/SecondDimensionWatcherReDive/Migrations/ApplicationContextModelSnapshot.cs index 8126b9e..78612ac 100644 --- a/SecondDimensionWatcherReDive/Migrations/ApplicationContextModelSnapshot.cs +++ b/SecondDimensionWatcherReDive/Migrations/ApplicationContextModelSnapshot.cs @@ -123,15 +123,34 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsRequired() .HasColumnType("text"); + b.Property("EnclosureId") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + b.Property("Episode") .HasColumnType("integer"); + b.Property("ExpectedEpisodeCount") + .HasColumnType("integer"); + + b.Property("FeedItemGuid") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + b.Property("FileStore") .HasColumnType("text"); b.Property("GroupId") .HasColumnType("uuid"); + b.Property("IngestedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("IsActiveRelease") + .HasColumnType("boolean"); + b.Property("IsAiProcessed") .HasColumnType("boolean"); @@ -163,9 +182,35 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("PublishTime") .HasColumnType("timestamp with time zone"); + b.Property("ReleaseCodec") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("ReleaseIdentity") + .HasMaxLength(192) + .HasColumnType("character varying(192)"); + + b.PrimitiveCollection("ReleaseLanguages") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("ReleaseResolution") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("ReleaseScore") + .HasColumnType("integer"); + + b.Property("ReleaseScoreReasonsJson") + .HasColumnType("text"); + b.Property("ReleaseSizeBytes") .HasColumnType("bigint"); + b.Property("ReleaseSubtitleGroup") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + b.Property("Season") .HasColumnType("integer"); @@ -183,6 +228,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsRequired() .HasColumnType("text"); + b.Property("TorrentInfoHash") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + b.HasKey("Id"); b.HasIndex("AnimationId"); @@ -194,6 +243,11 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("MediaLibrarySourceId"); + b.HasIndex("ReleaseIdentity") + .IsUnique() + .HasDatabaseName("UX_AnimationInfo_ReleaseIdentity") + .HasFilter("\"ReleaseIdentity\" IS NOT NULL"); + b.HasIndex("SourceFeedId"); b.HasIndex("FileStore", "StorePath") @@ -202,9 +256,15 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("MetadataStatus", "PublishTime"); + b.HasIndex("Season", "Episode", "ReleaseScore"); + b.ToTable("AnimationInfo", t => { + t.HasCheckConstraint("CK_AnimationInfo_ExpectedEpisodeCount_Positive", "\"ExpectedEpisodeCount\" IS NULL OR \"ExpectedEpisodeCount\" > 0"); + t.HasCheckConstraint("CK_AnimationInfo_MetadataConfidence_Range", "\"MetadataConfidence\" IS NULL OR (\"MetadataConfidence\" >= 0 AND \"MetadataConfidence\" <= 1)"); + + t.HasCheckConstraint("CK_AnimationInfo_ReleaseScore_NonNegative", "\"ReleaseScore\" >= 0"); }); }); @@ -753,6 +813,107 @@ protected override void BuildModel(ModelBuilder modelBuilder) }); }); + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.ReleaseUpgradeMappingSnapshot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AnimationInfoId") + .HasColumnType("uuid"); + + b.Property("FileStore") + .IsRequired() + .HasColumnType("text"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("OperationId") + .HasColumnType("uuid"); + + b.Property("OriginalMappingId") + .HasColumnType("uuid"); + + b.Property("PhysicalPath") + .IsRequired() + .HasColumnType("text"); + + b.Property("VirtualPath") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.HasKey("Id"); + + b.HasIndex("OperationId", "Kind", "OriginalMappingId") + .IsUnique(); + + b.ToTable("ReleaseUpgradeMappingSnapshots"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.ReleaseUpgradeOperation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AppliedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CandidateReleaseId") + .HasColumnType("uuid"); + + b.Property("CandidateScore") + .HasColumnType("integer"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrentReleaseId") + .HasColumnType("uuid"); + + b.Property("CurrentScore") + .HasColumnType("integer"); + + b.Property("FailureSummary") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("RollbackUntil") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("VerifiedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CandidateReleaseId") + .IsUnique(); + + b.HasIndex("CurrentReleaseId") + .IsUnique() + .HasDatabaseName("UX_ReleaseUpgradeOperations_ActiveCurrentRelease") + .HasFilter("\"Status\" IN ('Downloading', 'Verifying', 'Applied')"); + + b.HasIndex("Status", "CreatedAt"); + + b.ToTable("ReleaseUpgradeOperations", t => + { + t.HasCheckConstraint("CK_ReleaseUpgradeOperations_ScoreIncrease", "\"CandidateScore\" > \"CurrentScore\""); + }); + }); + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.SeasonBangumi", b => { b.Property("Id") @@ -795,6 +956,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("CreatedAt") .HasColumnType("timestamp with time zone"); + b.Property("EnableVersionUpgrade") + .HasColumnType("boolean"); + b.PrimitiveCollection("ExcludedKeywords") .IsRequired() .HasColumnType("text[]"); @@ -809,6 +973,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("MinSizeBytes") .HasColumnType("bigint"); + b.Property("MinimumUpgradeScore") + .HasColumnType("integer"); + b.Property("Mode") .IsRequired() .HasMaxLength(32) @@ -825,11 +992,19 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("UpdatedAt") .HasColumnType("timestamp with time zone"); + b.Property("UpgradeRollbackHours") + .HasColumnType("integer"); + b.HasKey("FeedId"); b.HasIndex("UpdatedAt"); - b.ToTable("SubscriptionAutomationPolicies"); + b.ToTable("SubscriptionAutomationPolicies", t => + { + t.HasCheckConstraint("CK_SubscriptionAutomationPolicies_MinimumUpgradeScore", "\"MinimumUpgradeScore\" >= 1 AND \"MinimumUpgradeScore\" <= 1000"); + + t.HasCheckConstraint("CK_SubscriptionAutomationPolicies_UpgradeRollbackHours", "\"UpgradeRollbackHours\" >= 1 AND \"UpgradeRollbackHours\" <= 720"); + }); }); modelBuilder.Entity("SecondDimensionWatcherReDive.Models.WebDavToken", b => @@ -949,6 +1124,36 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("AnimationInfo"); }); + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.ReleaseUpgradeMappingSnapshot", b => + { + b.HasOne("SecondDimensionWatcherReDive.Models.ReleaseUpgradeOperation", "Operation") + .WithMany("MappingSnapshots") + .HasForeignKey("OperationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Operation"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.ReleaseUpgradeOperation", b => + { + b.HasOne("SecondDimensionWatcherReDive.Models.AnimationInfo", "CandidateRelease") + .WithMany() + .HasForeignKey("CandidateReleaseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SecondDimensionWatcherReDive.Models.AnimationInfo", "CurrentRelease") + .WithMany() + .HasForeignKey("CurrentReleaseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("CandidateRelease"); + + b.Navigation("CurrentRelease"); + }); + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.SubscriptionAutomationPolicy", b => { b.HasOne("SecondDimensionWatcherReDive.Models.Feed", "Feed") @@ -970,6 +1175,11 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("MappingSnapshots"); }); + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.ReleaseUpgradeOperation", b => + { + b.Navigation("MappingSnapshots"); + }); + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.SeasonBangumi", b => { b.Navigation("Subgroups"); diff --git a/SecondDimensionWatcherReDive/Models/AnimationInfo.cs b/SecondDimensionWatcherReDive/Models/AnimationInfo.cs index 976702e..f929279 100644 --- a/SecondDimensionWatcherReDive/Models/AnimationInfo.cs +++ b/SecondDimensionWatcherReDive/Models/AnimationInfo.cs @@ -5,6 +5,7 @@ namespace SecondDimensionWatcherReDive.Models; public class AnimationInfo { public Guid Id { get; set; } + public DateTimeOffset IngestedAt { get; set; } = DateTimeOffset.UtcNow; public string Title { get; set; } = string.Empty; public string Description { get; set; } = string.Empty; @@ -69,4 +70,28 @@ public class AnimationInfo public Guid? MediaLibrarySourceId { get; set; } public DateTimeOffset? MediaLibraryMissingSince { get; set; } + + public string? ReleaseIdentity { get; set; } + + public string? FeedItemGuid { get; set; } + + public string? EnclosureId { get; set; } + + public string? TorrentInfoHash { get; set; } + + public string? ReleaseSubtitleGroup { get; set; } + + public string? ReleaseResolution { get; set; } + + public string? ReleaseCodec { get; set; } + + public string[] ReleaseLanguages { get; set; } = []; + + public int ReleaseScore { get; set; } + + public string? ReleaseScoreReasonsJson { get; set; } + + public int? ExpectedEpisodeCount { get; set; } + + public bool IsActiveRelease { get; set; } = true; } diff --git a/SecondDimensionWatcherReDive/Models/ApplicationContext.cs b/SecondDimensionWatcherReDive/Models/ApplicationContext.cs index 59764ac..dab05a9 100644 --- a/SecondDimensionWatcherReDive/Models/ApplicationContext.cs +++ b/SecondDimensionWatcherReDive/Models/ApplicationContext.cs @@ -33,6 +33,8 @@ public ApplicationContext(DbContextOptions options) public DbSet PlaybackPreferences { get; set; } public DbSet MediaLibrarySources { get; set; } public DbSet ApplicationSettings { get; set; } + public DbSet ReleaseUpgradeOperations { get; set; } + public DbSet ReleaseUpgradeMappingSnapshots { get; set; } protected override void OnModelCreating(ModelBuilder modelBuilder) { @@ -71,6 +73,10 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .Property(info => info.StateVersion) .IsConcurrencyToken(); + modelBuilder.Entity() + .Property(info => info.IngestedAt) + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + modelBuilder.Entity() .Property(info => info.MetadataLastError) .HasMaxLength(1024); @@ -83,6 +89,54 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) modelBuilder.Entity() .HasIndex(info => new { info.MetadataStatus, info.PublishTime }); + modelBuilder.Entity() + .HasIndex(info => info.ReleaseIdentity) + .IsUnique() + .HasFilter("\"ReleaseIdentity\" IS NOT NULL") + .HasDatabaseName("UX_AnimationInfo_ReleaseIdentity"); + + modelBuilder.Entity() + .HasIndex(info => new { info.Season, info.Episode, info.ReleaseScore }); + + modelBuilder.Entity() + .Property(info => info.ReleaseIdentity) + .HasMaxLength(192); + + modelBuilder.Entity() + .Property(info => info.FeedItemGuid) + .HasMaxLength(1024); + + modelBuilder.Entity() + .Property(info => info.EnclosureId) + .HasMaxLength(2048); + + modelBuilder.Entity() + .Property(info => info.TorrentInfoHash) + .HasMaxLength(64); + + modelBuilder.Entity() + .Property(info => info.ReleaseSubtitleGroup) + .HasMaxLength(256); + + modelBuilder.Entity() + .Property(info => info.ReleaseResolution) + .HasMaxLength(32); + + modelBuilder.Entity() + .Property(info => info.ReleaseCodec) + .HasMaxLength(32); + + modelBuilder.Entity() + .ToTable(table => + { + table.HasCheckConstraint( + "CK_AnimationInfo_ReleaseScore_NonNegative", + "\"ReleaseScore\" >= 0"); + table.HasCheckConstraint( + "CK_AnimationInfo_ExpectedEpisodeCount_Positive", + "\"ExpectedEpisodeCount\" IS NULL OR \"ExpectedEpisodeCount\" > 0"); + }); + modelBuilder.Entity() .HasIndex(info => info.CurrentMetadataReviewOperationId) .IsUnique(); @@ -282,6 +336,75 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) modelBuilder.Entity() .HasIndex(policy => policy.UpdatedAt); + modelBuilder.Entity() + .ToTable(table => + { + table.HasCheckConstraint( + "CK_SubscriptionAutomationPolicies_MinimumUpgradeScore", + "\"MinimumUpgradeScore\" >= 1 AND \"MinimumUpgradeScore\" <= 1000"); + table.HasCheckConstraint( + "CK_SubscriptionAutomationPolicies_UpgradeRollbackHours", + "\"UpgradeRollbackHours\" >= 1 AND \"UpgradeRollbackHours\" <= 720"); + }); + + modelBuilder.Entity() + .Property(operation => operation.Status) + .HasConversion() + .HasMaxLength(32); + + modelBuilder.Entity() + .Property(operation => operation.FailureSummary) + .HasMaxLength(2048); + + modelBuilder.Entity() + .HasOne(operation => operation.CurrentRelease) + .WithMany() + .HasForeignKey(operation => operation.CurrentReleaseId) + .OnDelete(DeleteBehavior.Restrict); + + modelBuilder.Entity() + .HasOne(operation => operation.CandidateRelease) + .WithMany() + .HasForeignKey(operation => operation.CandidateReleaseId) + .OnDelete(DeleteBehavior.Restrict); + + modelBuilder.Entity() + .HasIndex(operation => operation.CandidateReleaseId) + .IsUnique(); + + modelBuilder.Entity() + .HasIndex(operation => operation.CurrentReleaseId) + .IsUnique() + .HasFilter("\"Status\" IN ('Downloading', 'Verifying', 'Applied')") + .HasDatabaseName("UX_ReleaseUpgradeOperations_ActiveCurrentRelease"); + + modelBuilder.Entity() + .HasIndex(operation => new { operation.Status, operation.CreatedAt }); + + modelBuilder.Entity() + .ToTable(table => table.HasCheckConstraint( + "CK_ReleaseUpgradeOperations_ScoreIncrease", + "\"CandidateScore\" > \"CurrentScore\"")); + + modelBuilder.Entity() + .Property(snapshot => snapshot.Kind) + .HasConversion() + .HasMaxLength(32); + + modelBuilder.Entity() + .Property(snapshot => snapshot.VirtualPath) + .HasMaxLength(2048); + + modelBuilder.Entity() + .HasOne(snapshot => snapshot.Operation) + .WithMany(operation => operation.MappingSnapshots) + .HasForeignKey(snapshot => snapshot.OperationId) + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity() + .HasIndex(snapshot => new { snapshot.OperationId, snapshot.Kind, snapshot.OriginalMappingId }) + .IsUnique(); + modelBuilder.Entity() .HasIndex(s => new { s.SeasonBangumiId, s.MikanSubgroupId }) .IsUnique(); diff --git a/SecondDimensionWatcherReDive/Models/ReleaseUpgradeOperation.cs b/SecondDimensionWatcherReDive/Models/ReleaseUpgradeOperation.cs new file mode 100644 index 0000000..df6fd62 --- /dev/null +++ b/SecondDimensionWatcherReDive/Models/ReleaseUpgradeOperation.cs @@ -0,0 +1,35 @@ +using SecondDimensionWatcherReDive.Framework.DataRepository; + +namespace SecondDimensionWatcherReDive.Models; + +public class ReleaseUpgradeOperation +{ + public Guid Id { get; set; } + public Guid CurrentReleaseId { get; set; } + public AnimationInfo CurrentRelease { get; set; } = null!; + public Guid CandidateReleaseId { get; set; } + public AnimationInfo CandidateRelease { get; set; } = null!; + public ReleaseUpgradeStatus Status { get; set; } + public int CurrentScore { get; set; } + public int CandidateScore { get; set; } + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset? VerifiedAt { get; set; } + public DateTimeOffset? AppliedAt { get; set; } + public DateTimeOffset? RollbackUntil { get; set; } + public DateTimeOffset? CompletedAt { get; set; } + public string? FailureSummary { get; set; } + public ICollection MappingSnapshots { get; set; } = []; +} + +public class ReleaseUpgradeMappingSnapshot +{ + public Guid Id { get; set; } + public Guid OperationId { get; set; } + public ReleaseUpgradeOperation Operation { get; set; } = null!; + public ReleaseUpgradeMappingKind Kind { get; set; } + public Guid OriginalMappingId { get; set; } + public Guid AnimationInfoId { get; set; } + public string VirtualPath { get; set; } = string.Empty; + public string PhysicalPath { get; set; } = string.Empty; + public string FileStore { get; set; } = string.Empty; +} diff --git a/SecondDimensionWatcherReDive/Models/SubscriptionAutomationPolicy.cs b/SecondDimensionWatcherReDive/Models/SubscriptionAutomationPolicy.cs index aec6c49..472d4bf 100644 --- a/SecondDimensionWatcherReDive/Models/SubscriptionAutomationPolicy.cs +++ b/SecondDimensionWatcherReDive/Models/SubscriptionAutomationPolicy.cs @@ -27,4 +27,10 @@ public class SubscriptionAutomationPolicy public DateTimeOffset CreatedAt { get; set; } public DateTimeOffset UpdatedAt { get; set; } + + public bool EnableVersionUpgrade { get; set; } + + public int MinimumUpgradeScore { get; set; } = 25; + + public int UpgradeRollbackHours { get; set; } = 72; } diff --git a/SecondDimensionWatcherReDive/Program.cs b/SecondDimensionWatcherReDive/Program.cs index 80f5f19..77870e7 100644 --- a/SecondDimensionWatcherReDive/Program.cs +++ b/SecondDimensionWatcherReDive/Program.cs @@ -33,6 +33,7 @@ using SecondDimensionWatcherReDive.Utils.FileStore; using SecondDimensionWatcherReDive.Utils.MetadataReview; using SecondDimensionWatcherReDive.Utils.Incidents; +using SecondDimensionWatcherReDive.Utils.ReleaseUpgrades; using SecondDimensionWatcherReDive.Utils.Scraper; var builder = WebApplication.CreateBuilder(args); @@ -226,6 +227,7 @@ builder.Services.AddSingleton(sp => sp.GetRequiredService()); builder.Services.AddHostedService(); +builder.Services.AddHostedService(); //Add scheduled tasks builder.Services.AddSingleton(); @@ -257,6 +259,7 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddScoped(); builder.Services.AddTransient(); @@ -277,9 +280,12 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddSingleton(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); //Add AI Inference // Register all engines even when initially unconfigured. Runtime settings can then enable or diff --git a/SecondDimensionWatcherReDive/Repositories/AnimationInfoRepository.cs b/SecondDimensionWatcherReDive/Repositories/AnimationInfoRepository.cs index d90f6b6..2bfa7e0 100644 --- a/SecondDimensionWatcherReDive/Repositories/AnimationInfoRepository.cs +++ b/SecondDimensionWatcherReDive/Repositories/AnimationInfoRepository.cs @@ -1,5 +1,6 @@ using System.Runtime.CompilerServices; using Microsoft.EntityFrameworkCore; +using Npgsql; using SecondDimensionWatcherReDive.Framework.DataRepository; using SecondDimensionWatcherReDive.Framework.FileDownload; @@ -322,6 +323,31 @@ public async Task AddAsync(AnimationInfo info, CancellationToken cancellationTok await context.SaveChangesAsync(cancellationToken); } + public async Task TryAddReleaseAsync( + AnimationInfo info, + CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(info.ReleaseIdentity)) + throw new ArgumentException("A stable release identity is required.", nameof(info)); + + var entity = info.ToEntity(); + await context.AnimationInfo.AddAsync(entity, cancellationToken); + try + { + await context.SaveChangesAsync(cancellationToken); + return true; + } + catch (DbUpdateException exception) when (exception.InnerException is PostgresException + { + SqlState: PostgresErrorCodes.UniqueViolation, + ConstraintName: "UX_AnimationInfo_ReleaseIdentity" + }) + { + context.Entry(entity).State = EntityState.Detached; + return false; + } + } + public async Task UpdateAsync(AnimationInfo info, CancellationToken cancellationToken) { var entity = await context.AnimationInfo.FindAsync([info.Id], cancellationToken) diff --git a/SecondDimensionWatcherReDive/Repositories/FileMappingRepositoryPostgreSqlTestFixture.cs b/SecondDimensionWatcherReDive/Repositories/FileMappingRepositoryPostgreSqlTestFixture.cs index 34b17f5..dd21bf3 100644 --- a/SecondDimensionWatcherReDive/Repositories/FileMappingRepositoryPostgreSqlTestFixture.cs +++ b/SecondDimensionWatcherReDive/Repositories/FileMappingRepositoryPostgreSqlTestFixture.cs @@ -1,5 +1,6 @@ using Microsoft.EntityFrameworkCore; using SecondDimensionWatcherReDive.Framework.DataRepository; +using SecondDimensionWatcherReDive.Framework.FileDownload; namespace SecondDimensionWatcherReDive.Repositories; @@ -24,7 +25,7 @@ public async Task ResetAsync(CancellationToken cancellationToken) { await using var context = new Models.ApplicationContext(_contextOptions); await context.Database.ExecuteSqlRawAsync( - "TRUNCATE TABLE \"FileMappings\", \"AnimationInfo\" RESTART IDENTITY CASCADE", + "TRUNCATE TABLE \"FileMappings\", \"AnimationInfo\", \"Animations\", \"AnimationGroups\" RESTART IDENTITY CASCADE", cancellationToken); } @@ -83,4 +84,281 @@ public async Task GetAnimationInfoStateVersionsAsync(CancellationToken c .Select(info => info.StateVersion) .ToArrayAsync(cancellationToken); } + + public async Task TryAddReleaseAsync( + string releaseIdentity, + CancellationToken cancellationToken) + { + await using var context = new Models.ApplicationContext(_contextOptions); + var repository = new AnimationInfoRepository(context, _contextOptions); + return await repository.TryAddReleaseAsync(new AnimationInfo( + Guid.NewGuid(), + "concurrent release", + string.Empty, + DateTimeOffset.UtcNow, + "https://example.test/" + Guid.NewGuid().ToString("N"), + FileDownloadTypes.HttpDownload, + [], + string.Empty, + false, + default, + default, + false, + null, + null, + null, + null, + null, + null, + false, + 0, + ReleaseIdentity: releaseIdentity), + cancellationToken); + } + + public async Task CountReleaseIdentityAsync( + string releaseIdentity, + CancellationToken cancellationToken) + { + await using var context = new Models.ApplicationContext(_contextOptions); + return await context.AnimationInfo.CountAsync( + info => info.ReleaseIdentity == releaseIdentity, + cancellationToken); + } + + public async Task SeedLibraryScenarioAsync(CancellationToken cancellationToken) + { + await using var context = new Models.ApplicationContext(_contextOptions); + var now = DateTimeOffset.UtcNow; + var animation = new Models.Animation + { + Id = Guid.NewGuid(), + TmdbId = "tv-1399", + Name = "進撃の巨人", + OriginalName = "Attack on Titan" + }; + var group = new Models.AnimationGroup { Id = Guid.NewGuid(), Name = "LoliHouse" }; + var current = Release(animation, group, 1, 1, 320, now.AddMinutes(-10), + FileDownloadTypes.TorrentDownload, true, "torrent:current", 3); + current.ReleaseResolution = "1080p"; + current.ReleaseCodec = "HEVC"; + current.ReleaseLanguages = ["zh-CN"]; + current.ReleaseScoreReasonsJson = "[\"resolution:1080p:+200\",\"codec:HEVC:+60\"]"; + var duplicate = Release(animation, group, 1, 1, 250, now.AddMinutes(-9), + FileDownloadTypes.TorrentDownload, true, "torrent:duplicate", 3); + duplicate.IsActiveRelease = false; + var imported = Release(animation, group, 1, 2, 520, now.AddMinutes(-8), + FileDownloadTypes.MediaLibraryImport, true, "import:episode-2", 3); + imported.ReleaseResolution = "2160p"; + imported.ReleaseCodec = "AV1"; + imported.ReleaseLanguages = ["ja"]; + var upgrade = Release(animation, group, 1, 1, 480, now.AddMinutes(-7), + FileDownloadTypes.TorrentDownload, false, "torrent:upgrade", 3); + upgrade.IsActiveRelease = false; + upgrade.ReleaseResolution = "2160p"; + upgrade.ReleaseCodec = "AV1"; + upgrade.ReleaseScoreReasonsJson = "[\"resolution:2160p:+400\",\"codec:AV1:+80\"]"; + var unidentified = Release(animation, group, 1, null, 100, now.AddMinutes(-6), + FileDownloadTypes.TorrentDownload, false, "torrent:unknown", 3); + context.AnimationInfo.AddRange(current, duplicate, imported, upgrade, unidentified); + context.FileMappings.AddRange( + MappingEntity(current.Id, "/進撃の巨人/LoliHouse/進撃の巨人 S01E01.mkv"), + MappingEntity(duplicate.Id, "/進撃の巨人/LoliHouse/進撃の巨人 S01E01 (2).mkv"), + MappingEntity(imported.Id, "/進撃の巨人/Imported/進撃の巨人 S01E02.mkv")); + var userId = Guid.NewGuid(); + context.PlaybackProgresses.Add(new Models.PlaybackProgress + { + Id = Guid.NewGuid(), + UserId = userId, + AnimationInfoId = imported.Id, + VirtualPath = "/進撃の巨人/Imported/進撃の巨人 S01E02.mkv", + PositionSeconds = 120, + DurationSeconds = 1200, + IsWatched = false, + UpdatedAt = now + }); + await context.SaveChangesAsync(cancellationToken); + return new LibraryScenario(userId, current.Id, imported.Id, upgrade.Id); + } + + public async Task InsertConcurrentSearchReleaseAsync(CancellationToken cancellationToken) + { + await using var context = new Models.ApplicationContext(_contextOptions); + var info = new Models.AnimationInfo + { + Id = Guid.NewGuid(), + Title = "new concurrent release", + Description = string.Empty, + PublishTime = DateTimeOffset.UtcNow.AddHours(1), + IngestedAt = DateTimeOffset.UtcNow, + DownloadUrl = "https://example.test/new", + DownloadType = FileDownloadTypes.TorrentDownload, + ReleaseIdentity = "torrent:inserted-" + Guid.NewGuid().ToString("N") + }; + context.AnimationInfo.Add(info); + await context.SaveChangesAsync(cancellationToken); + return info.Id; + } + + public async Task SearchAsync( + LibrarySearchRequest request, + CancellationToken cancellationToken) + { + await using var context = new Models.ApplicationContext(_contextOptions); + return await new LibrarySearchRepository(context).SearchAsync(request, cancellationToken); + } + + public async Task> GetIntegrityAsync( + CancellationToken cancellationToken) + { + await using var context = new Models.ApplicationContext(_contextOptions); + return await new LibrarySearchRepository(context) + .GetIntegrityAsync("tv-1399", 1, cancellationToken); + } + + public async Task> GetLibraryIndexNamesAsync(CancellationToken cancellationToken) + { + await using var context = new Models.ApplicationContext(_contextOptions); + return await context.Database.SqlQueryRaw( + """ + SELECT indexname AS "Value" + FROM pg_indexes + WHERE schemaname = 'public' + AND (indexname LIKE 'IX_%_Trgm' + OR indexname = 'IX_AnimationInfo_ReleaseLanguages_Gin' + OR indexname = 'UX_AnimationInfo_ReleaseIdentity') + ORDER BY indexname + """) + .ToListAsync(cancellationToken); + } + + public async Task SeedUpgradeScenarioAsync(CancellationToken cancellationToken) + { + await using var context = new Models.ApplicationContext(_contextOptions); + var animation = new Models.Animation + { + Id = Guid.NewGuid(), TmdbId = "upgrade-show", Name = "Upgrade Show", OriginalName = "Upgrade Show" + }; + var current = Release(animation, null, 1, 1, 200, DateTimeOffset.UtcNow.AddMinutes(-2), + FileDownloadTypes.TorrentDownload, true, "torrent:old-" + Guid.NewGuid().ToString("N"), 1); + var candidate = Release(animation, null, 1, 1, 500, DateTimeOffset.UtcNow.AddMinutes(-1), + FileDownloadTypes.TorrentDownload, true, "torrent:new-" + Guid.NewGuid().ToString("N"), 1); + candidate.IsActiveRelease = false; + context.AnimationInfo.AddRange(current, candidate); + context.FileMappings.AddRange( + new Models.FileMapping + { + Id = Guid.NewGuid(), AnimationInfoId = current.Id, + VirtualPath = "/Upgrade Show/Old/Upgrade Show S01E01.mkv", + PhysicalPath = "/store/old.mkv", FileStore = "local" + }, + new Models.FileMapping + { + Id = Guid.NewGuid(), AnimationInfoId = candidate.Id, + VirtualPath = "/Upgrade Show/New/Upgrade Show S01E01 (2).mkv", + PhysicalPath = "/store/new.mkv", FileStore = "local" + }); + await context.SaveChangesAsync(cancellationToken); + return new UpgradeScenario(new ReleaseUpgradeCandidate( + current.Id, candidate.Id, animation.Name, 1, 1, 200, 500, + ["resolution:2160p:+400"], false), + "/Upgrade Show/Old/Upgrade Show S01E01.mkv"); + } + + public async Task BeginUpgradeAsync( + ReleaseUpgradeCandidate candidate, + CancellationToken cancellationToken) + { + await using var context = new Models.ApplicationContext(_contextOptions); + return await new ReleaseUpgradeRepository(context, _contextOptions) + .TryBeginAsync(candidate, DateTimeOffset.UtcNow, cancellationToken); + } + + public async Task ActivateUpgradeAsync( + Guid operationId, + CancellationToken cancellationToken) + { + await using var context = new Models.ApplicationContext(_contextOptions); + return await new ReleaseUpgradeRepository(context, _contextOptions) + .ActivateAsync(operationId, DateTimeOffset.UtcNow, DateTimeOffset.UtcNow.AddHours(24), cancellationToken); + } + + public async Task> GetReadyUpgradeCandidateIdsAsync( + CancellationToken cancellationToken) + { + await using var context = new Models.ApplicationContext(_contextOptions); + return await new ReleaseUpgradeRepository(context, _contextOptions) + .GetReadyCandidateIdsAsync(20, cancellationToken); + } + + public async Task RollbackUpgradeAsync( + Guid operationId, + CancellationToken cancellationToken) + { + await using var context = new Models.ApplicationContext(_contextOptions); + return await new ReleaseUpgradeRepository(context, _contextOptions) + .RollbackAsync(operationId, DateTimeOffset.UtcNow, cancellationToken); + } + + public async Task> GetMappingsAsync( + Guid animationInfoId, + CancellationToken cancellationToken) + { + await using var context = new Models.ApplicationContext(_contextOptions); + return await new FileMappingRepository(context, _contextOptions) + .GetForAnimationInfoAsync(animationInfoId, cancellationToken); + } + + private static Models.AnimationInfo Release( + Models.Animation animation, + Models.AnimationGroup? group, + int season, + int? episode, + int score, + DateTimeOffset ingestedAt, + string downloadType, + bool downloaded, + string identity, + int expected) => new() + { + Id = Guid.NewGuid(), + Animation = animation, + Group = group, + Title = $"{animation.Name} S{season:D2}E{episode:D2}", + Description = animation.OriginalName, + PublishTime = ingestedAt, + IngestedAt = ingestedAt, + DownloadUrl = "https://example.test/" + Guid.NewGuid().ToString("N"), + DownloadType = downloadType, + IsDownloadTracked = downloaded, + IsDownloadFinished = downloaded, + FileStore = downloaded ? "local" : null, + StorePath = downloaded ? "/store/" + Guid.NewGuid().ToString("N") : null, + Season = season, + Episode = episode, + ReleaseIdentity = identity, + ReleaseSubtitleGroup = group?.Name, + ReleaseScore = score, + ExpectedEpisodeCount = expected, + IsAiProcessed = true + }; + + private static Models.FileMapping MappingEntity(Guid animationInfoId, string path) => new() + { + Id = Guid.NewGuid(), + AnimationInfoId = animationInfoId, + VirtualPath = path, + PhysicalPath = "/store/" + Guid.NewGuid().ToString("N") + ".mkv", + FileStore = "local" + }; } + +internal sealed record LibraryScenario( + Guid UserId, + Guid CurrentReleaseId, + Guid ImportedReleaseId, + Guid UpgradeReleaseId); + +internal sealed record UpgradeScenario( + ReleaseUpgradeCandidate Candidate, + string CanonicalPath); diff --git a/SecondDimensionWatcherReDive/Repositories/LibrarySearchRepository.cs b/SecondDimensionWatcherReDive/Repositories/LibrarySearchRepository.cs new file mode 100644 index 0000000..d54de7e --- /dev/null +++ b/SecondDimensionWatcherReDive/Repositories/LibrarySearchRepository.cs @@ -0,0 +1,322 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using SecondDimensionWatcherReDive.Framework.DataRepository; +using SecondDimensionWatcherReDive.Framework.FileDownload; + +namespace SecondDimensionWatcherReDive.Repositories; + +public sealed class LibrarySearchRepository(Models.ApplicationContext context) + : ILibrarySearchRepository +{ + private sealed record SearchCursor(int Offset, DateTimeOffset SnapshotUtc, string Signature); + + public async Task SearchAsync( + LibrarySearchRequest request, + CancellationToken cancellationToken) + { + if (request.Take is < 1 or > 100) + throw new ArgumentOutOfRangeException(nameof(request), "Take must be between 1 and 100."); + + var signature = Signature(request); + var cursor = DecodeCursor(request.Cursor); + if (cursor is not null && !string.Equals(cursor.Signature, signature, StringComparison.Ordinal)) + throw new ArgumentException("The search cursor does not match the active filters.", nameof(request)); + var snapshot = cursor?.SnapshotUtc ?? DateTimeOffset.UtcNow; + var offset = cursor?.Offset ?? 0; + if (offset is < 0 or > 1_000_000) + throw new ArgumentException("The search cursor is outside the supported range.", nameof(request)); + + var query = context.AnimationInfo + .AsNoTracking() + .Include(info => info.Animation) + .Include(info => info.Group) + .Where(info => info.MediaLibraryMissingSince == null && info.IngestedAt <= snapshot); + + if (!string.IsNullOrWhiteSpace(request.Query)) + { + var pattern = ContainsPattern(request.Query); + query = query.Where(info => + EF.Functions.ILike(info.Title, pattern, "\\") || + EF.Functions.ILike(info.Description, pattern, "\\") || + (info.Animation != null && + (EF.Functions.ILike(info.Animation.Name, pattern, "\\") || + EF.Functions.ILike(info.Animation.OriginalName, pattern, "\\") || + EF.Functions.ILike(info.Animation.TmdbId, pattern, "\\"))) || + (info.Group != null && EF.Functions.ILike(info.Group.Name, pattern, "\\")) || + context.FileMappings.Any(mapping => + mapping.AnimationInfoId == info.Id && + EF.Functions.ILike(mapping.VirtualPath, pattern, "\\"))); + } + + if (request.Season is { } season) query = query.Where(info => info.Season == season); + if (request.Episode is { } episode) query = query.Where(info => info.Episode == episode); + if (!string.IsNullOrWhiteSpace(request.SubtitleGroup)) + query = query.Where(info => info.ReleaseSubtitleGroup == request.SubtitleGroup); + if (!string.IsNullOrWhiteSpace(request.Resolution)) + query = query.Where(info => info.ReleaseResolution == request.Resolution); + if (!string.IsNullOrWhiteSpace(request.Codec)) + query = query.Where(info => info.ReleaseCodec == request.Codec); + if (!string.IsNullOrWhiteSpace(request.Language)) + query = query.Where(info => info.ReleaseLanguages.Contains(request.Language)); + if (!string.IsNullOrWhiteSpace(request.VirtualPath)) + { + var pathPattern = ContainsPattern(request.VirtualPath); + query = query.Where(info => context.FileMappings.Any(mapping => + mapping.AnimationInfoId == info.Id && + EF.Functions.ILike(mapping.VirtualPath, pathPattern, "\\"))); + } + + query = request.DownloadState switch + { + LibraryDownloadState.NotDownloaded => query.Where(info => !info.IsDownloadTracked), + LibraryDownloadState.Downloading => query.Where(info => + info.IsDownloadTracked && !info.IsDownloadFinished), + LibraryDownloadState.Downloaded => query.Where(info => info.IsDownloadFinished), + _ => query + }; + query = request.Source switch + { + LibrarySourceKind.Torrent => query.Where(info => + info.DownloadType == FileDownloadTypes.TorrentDownload), + LibrarySourceKind.MediaLibraryImport => query.Where(info => + info.DownloadType == FileDownloadTypes.MediaLibraryImport), + _ => query + }; + query = request.WatchState switch + { + LibraryWatchState.Watched => query.Where(info => context.PlaybackProgresses.Any(progress => + progress.UserId == request.UserId && progress.AnimationInfoId == info.Id && progress.IsWatched)), + LibraryWatchState.InProgress => query.Where(info => context.PlaybackProgresses.Any(progress => + progress.UserId == request.UserId && progress.AnimationInfoId == info.Id && + !progress.IsWatched && progress.PositionSeconds > 0)), + LibraryWatchState.Unwatched => query.Where(info => !context.PlaybackProgresses.Any(progress => + progress.UserId == request.UserId && progress.AnimationInfoId == info.Id && + (progress.IsWatched || progress.PositionSeconds > 0))), + _ => query + }; + + var ordered = request.Sort switch + { + LibrarySearchSort.TitleAscending => query + .OrderBy(info => info.Animation == null ? info.Title : info.Animation.Name) + .ThenBy(info => info.Season) + .ThenBy(info => info.Episode) + .ThenBy(info => info.Id), + LibrarySearchSort.EpisodeAscending => query + .OrderBy(info => info.Animation == null ? info.Title : info.Animation.Name) + .ThenBy(info => info.Season) + .ThenBy(info => info.Episode) + .ThenBy(info => info.Id), + LibrarySearchSort.ScoreDescending => query + .OrderByDescending(info => info.ReleaseScore) + .ThenByDescending(info => info.PublishTime) + .ThenBy(info => info.Id), + _ => query + .OrderByDescending(info => info.PublishTime) + .ThenBy(info => info.Id) + }; + + var page = await ordered.Skip(offset).Take(request.Take + 1).ToListAsync(cancellationToken); + var hasMore = page.Count > request.Take; + if (hasMore) page.RemoveAt(page.Count - 1); + var ids = page.Select(info => info.Id).ToArray(); + var mappings = await context.FileMappings.AsNoTracking() + .Where(mapping => ids.Contains(mapping.AnimationInfoId)) + .OrderBy(mapping => mapping.VirtualPath) + .ToListAsync(cancellationToken); + var mappingsByRelease = mappings + .GroupBy(mapping => mapping.AnimationInfoId) + .ToDictionary(group => group.Key, group => group.Select(item => item.VirtualPath).ToList()); + var progressByRelease = (await context.PlaybackProgresses.AsNoTracking() + .Where(progress => progress.UserId == request.UserId && ids.Contains(progress.AnimationInfoId)) + .OrderByDescending(progress => progress.UpdatedAt) + .ToListAsync(cancellationToken)) + .GroupBy(progress => progress.AnimationInfoId) + .ToDictionary(group => group.Key, group => group.ToList()); + + var items = page.Select(info => + { + var progress = progressByRelease.GetValueOrDefault(info.Id) ?? []; + return new LibrarySearchItem( + info.Id, + info.Title, + info.Animation?.Name, + info.Animation?.OriginalName, + info.Animation?.TmdbId, + info.Season, + info.Episode, + info.ReleaseSubtitleGroup ?? info.Group?.Name, + info.ReleaseResolution, + info.ReleaseCodec, + info.ReleaseLanguages, + info.IsDownloadTracked, + info.IsDownloadFinished, + info.DownloadType == FileDownloadTypes.MediaLibraryImport, + progress.Any(item => item.IsWatched), + progress.Count == 0 ? null : progress.Max(item => item.PositionSeconds), + mappingsByRelease.GetValueOrDefault(info.Id) ?? [], + info.ReleaseScore, + ParseReasons(info.ReleaseScoreReasonsJson), + info.PublishTime); + }).ToList(); + + var nextCursor = hasMore + ? EncodeCursor(new SearchCursor(offset + request.Take, snapshot, signature)) + : null; + return new LibrarySearchResult(items, nextCursor); + } + + public async Task> GetIntegrityAsync( + string? tmdbId, + int? season, + CancellationToken cancellationToken) + { + var query = context.AnimationInfo.AsNoTracking() + .Include(info => info.Animation) + .Where(info => info.Animation != null && info.MediaLibraryMissingSince == null); + if (!string.IsNullOrWhiteSpace(tmdbId)) + query = query.Where(info => info.Animation!.TmdbId == tmdbId); + if (season is not null) query = query.Where(info => info.Season == season); + + var releases = await query.ToListAsync(cancellationToken); + var releaseIds = releases.Select(info => info.Id).ToArray(); + var mappedIds = await context.FileMappings.AsNoTracking() + .Where(mapping => releaseIds.Contains(mapping.AnimationInfoId)) + .Select(mapping => mapping.AnimationInfoId) + .Distinct() + .ToHashSetAsync(cancellationToken); + var policies = await context.SubscriptionAutomationPolicies.AsNoTracking() + .ToDictionaryAsync(policy => policy.FeedId, cancellationToken); + + return releases + .Where(info => info.Season is > 0) + .GroupBy(info => new + { + info.Animation!.TmdbId, + info.Animation.Name, + Season = info.Season!.Value + }) + .Select(group => BuildIntegrity(group, mappedIds, policies)) + .OrderBy(item => item.AnimationName) + .ThenBy(item => item.Season) + .ToList(); + } + + private static LibraryIntegritySummary BuildIntegrity( + IEnumerable source, + IReadOnlySet mappedIds, + IReadOnlyDictionary policies) + { + var releases = source.ToList(); + var first = releases[0]; + var downloaded = releases + .Where(info => info.IsDownloadFinished && mappedIds.Contains(info.Id) && info.Episode is > 0) + .ToList(); + var expected = releases.Max(info => info.ExpectedEpisodeCount); + var present = downloaded.Select(info => info.Episode!.Value).ToHashSet(); + var missing = expected is { } count + ? Enumerable.Range(1, count).Where(episode => !present.Contains(episode)).ToList() + : []; + var duplicates = downloaded + .GroupBy(info => info.Episode!.Value) + .Where(group => group.Count() > 1) + .Select(group => new EpisodeDuplicate( + group.Key, + group.OrderByDescending(item => item.ReleaseScore).Select(item => item.Id).ToList())) + .OrderBy(item => item.Episode) + .ToList(); + var candidates = new List(); + foreach (var episodeGroup in releases.Where(info => info.Episode is > 0).GroupBy(info => info.Episode!.Value)) + { + var current = episodeGroup + .Where(info => info.IsActiveRelease && info.IsDownloadFinished && mappedIds.Contains(info.Id)) + .OrderByDescending(info => info.ReleaseScore) + .ThenByDescending(info => info.PublishTime) + .FirstOrDefault(); + if (current is null) continue; + var candidate = episodeGroup + .Where(info => info.Id != current.Id && info.ReleaseScore > current.ReleaseScore) + .OrderByDescending(info => info.ReleaseScore) + .ThenByDescending(info => info.PublishTime) + .FirstOrDefault(); + if (candidate is null) continue; + var automatic = candidate.SourceFeedId is { } feedId && + policies.TryGetValue(feedId, out var policy) && + policy.EnableVersionUpgrade && + candidate.ReleaseScore - current.ReleaseScore >= policy.MinimumUpgradeScore; + candidates.Add(new ReleaseUpgradeCandidate( + current.Id, + candidate.Id, + first.Animation!.Name, + first.Season!.Value, + episodeGroup.Key, + current.ReleaseScore, + candidate.ReleaseScore, + ParseReasons(candidate.ReleaseScoreReasonsJson), + automatic)); + } + + return new LibraryIntegritySummary( + first.Animation!.TmdbId, + first.Animation.Name, + first.Season!.Value, + expected, + missing, + duplicates, + releases.Count(info => info.Episode is null), + candidates.OrderBy(item => item.Episode).ToList()); + } + + private static string ContainsPattern(string value) => + $"%{value.Trim().Replace("\\", "\\\\", StringComparison.Ordinal) + .Replace("%", "\\%", StringComparison.Ordinal) + .Replace("_", "\\_", StringComparison.Ordinal)}%"; + + private static IReadOnlyList ParseReasons(string? json) + { + if (string.IsNullOrWhiteSpace(json)) return []; + try + { + return JsonSerializer.Deserialize(json) ?? []; + } + catch (JsonException) + { + return []; + } + } + + private static string Signature(LibrarySearchRequest request) + { + var value = string.Join('\n', + request.Query?.Trim(), request.Season, request.Episode, + request.SubtitleGroup, request.Resolution, request.Codec, request.Language, + request.DownloadState, request.WatchState, request.VirtualPath, + request.Source, request.Sort, request.Take, request.UserId); + return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(value)))[..24]; + } + + private static string EncodeCursor(SearchCursor cursor) + { + var bytes = JsonSerializer.SerializeToUtf8Bytes(cursor); + return Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_'); + } + + private static SearchCursor? DecodeCursor(string? value) + { + if (string.IsNullOrWhiteSpace(value)) return null; + if (value.Length > 512) throw new ArgumentException("The search cursor is too long."); + try + { + var normalized = value.Replace('-', '+').Replace('_', '/'); + normalized = normalized.PadRight((normalized.Length + 3) / 4 * 4, '='); + return JsonSerializer.Deserialize(Convert.FromBase64String(normalized)) + ?? throw new ArgumentException("The search cursor is invalid."); + } + catch (Exception exception) when (exception is FormatException or JsonException) + { + throw new ArgumentException("The search cursor is invalid.", exception); + } + } +} diff --git a/SecondDimensionWatcherReDive/Repositories/ReleaseUpgradeRepository.cs b/SecondDimensionWatcherReDive/Repositories/ReleaseUpgradeRepository.cs new file mode 100644 index 0000000..cff38da --- /dev/null +++ b/SecondDimensionWatcherReDive/Repositories/ReleaseUpgradeRepository.cs @@ -0,0 +1,468 @@ +using Microsoft.EntityFrameworkCore; +using Npgsql; +using SecondDimensionWatcherReDive.Framework.DataRepository; + +namespace SecondDimensionWatcherReDive.Repositories; + +public sealed class ReleaseUpgradeRepository( + Models.ApplicationContext context, + DbContextOptions contextOptions) : IReleaseUpgradeRepository +{ + public async Task> GetCandidatesAsync( + bool automaticOnly, + int take, + CancellationToken cancellationToken) + { + if (take is < 1 or > 200) + throw new ArgumentOutOfRangeException(nameof(take)); + + var releases = await context.AnimationInfo.AsNoTracking() + .Include(info => info.Animation) + .Where(info => info.Animation != null && info.Season != null && info.Episode != null && + info.MediaLibraryMissingSince == null) + .ToListAsync(cancellationToken); + var ids = releases.Select(info => info.Id).ToArray(); + var mapped = await context.FileMappings.AsNoTracking() + .Where(mapping => ids.Contains(mapping.AnimationInfoId)) + .Select(mapping => mapping.AnimationInfoId) + .Distinct() + .ToHashSetAsync(cancellationToken); + var policies = await context.SubscriptionAutomationPolicies.AsNoTracking() + .ToDictionaryAsync(policy => policy.FeedId, cancellationToken); + var attempted = await context.ReleaseUpgradeOperations.AsNoTracking() + .Select(operation => operation.CandidateReleaseId) + .ToHashSetAsync(cancellationToken); + + var candidates = new List(); + foreach (var episode in releases.GroupBy(info => new + { + AnimationId = info.Animation!.Id, + info.Season, + info.Episode + })) + { + var current = episode + .Where(info => info.IsActiveRelease && info.IsDownloadFinished && mapped.Contains(info.Id)) + .OrderByDescending(info => info.ReleaseScore) + .ThenByDescending(info => info.PublishTime) + .FirstOrDefault(); + if (current is null) continue; + + var candidate = episode + .Where(info => info.Id != current.Id && + info.ReleaseScore > current.ReleaseScore && + !attempted.Contains(info.Id)) + .OrderByDescending(info => info.ReleaseScore) + .ThenByDescending(info => info.PublishTime) + .FirstOrDefault(); + if (candidate is null) continue; + + var automatic = candidate.SourceFeedId is { } feedId && + policies.TryGetValue(feedId, out var policy) && + policy.EnableVersionUpgrade && + candidate.ReleaseScore - current.ReleaseScore >= policy.MinimumUpgradeScore; + if (automaticOnly && !automatic) continue; + + candidates.Add(new ReleaseUpgradeCandidate( + current.Id, + candidate.Id, + current.Animation!.Name, + current.Season!.Value, + current.Episode!.Value, + current.ReleaseScore, + candidate.ReleaseScore, + ParseReasons(candidate.ReleaseScoreReasonsJson), + automatic)); + } + + return candidates + .OrderByDescending(candidate => candidate.CandidateScore - candidate.CurrentScore) + .ThenBy(candidate => candidate.AnimationName) + .ThenBy(candidate => candidate.Season) + .ThenBy(candidate => candidate.Episode) + .Take(take) + .ToList(); + } + + public async Task TryBeginAsync( + ReleaseUpgradeCandidate candidate, + DateTimeOffset createdAt, + CancellationToken cancellationToken) + { + var strategy = context.Database.CreateExecutionStrategy(); + return await strategy.ExecuteAsync(async () => + { + await using var writeContext = new Models.ApplicationContext(contextOptions); + await using var transaction = await writeContext.Database.BeginTransactionAsync(cancellationToken); + await MappingTransactionLock.AcquireAsync(writeContext, cancellationToken); + var releases = await MappingTransactionLock.LockAnimationInfosAsync( + writeContext, + [candidate.CurrentReleaseId, candidate.CandidateReleaseId], + cancellationToken); + if (!releases.TryGetValue(candidate.CurrentReleaseId, out var current) || + !releases.TryGetValue(candidate.CandidateReleaseId, out var next)) + return null; + await writeContext.Entry(current).Reference(info => info.Animation).LoadAsync(cancellationToken); + await writeContext.Entry(next).Reference(info => info.Animation).LoadAsync(cancellationToken); + if (current.Animation is null || next.Animation is null || + current.Animation.Id != next.Animation.Id || + current.Season != next.Season || + current.Episode != next.Episode || + next.ReleaseScore <= current.ReleaseScore || + !current.IsDownloadFinished || + !await writeContext.FileMappings.AnyAsync( + mapping => mapping.AnimationInfoId == current.Id, + cancellationToken)) + return null; + + if (await writeContext.ReleaseUpgradeOperations.AnyAsync( + operation => operation.CandidateReleaseId == next.Id || + (operation.CurrentReleaseId == current.Id && + (operation.Status == ReleaseUpgradeStatus.Downloading || + operation.Status == ReleaseUpgradeStatus.Verifying || + operation.Status == ReleaseUpgradeStatus.Applied)), + cancellationToken)) + return null; + + var entity = new Models.ReleaseUpgradeOperation + { + Id = Guid.NewGuid(), + CurrentReleaseId = current.Id, + CandidateReleaseId = next.Id, + Status = next.IsDownloadFinished + ? ReleaseUpgradeStatus.Verifying + : ReleaseUpgradeStatus.Downloading, + CurrentScore = current.ReleaseScore, + CandidateScore = next.ReleaseScore, + CreatedAt = createdAt + }; + writeContext.ReleaseUpgradeOperations.Add(entity); + try + { + await writeContext.SaveChangesAsync(cancellationToken); + await transaction.CommitAsync(cancellationToken); + return entity.ToRecord(); + } + catch (DbUpdateException exception) when (exception.InnerException is PostgresException + { SqlState: PostgresErrorCodes.UniqueViolation }) + { + return null; + } + }); + } + + public async Task FindActiveByCandidateAsync( + Guid candidateReleaseId, + CancellationToken cancellationToken) + { + var operation = await context.ReleaseUpgradeOperations.AsNoTracking() + .FirstOrDefaultAsync(item => item.CandidateReleaseId == candidateReleaseId && + (item.Status == ReleaseUpgradeStatus.Downloading || + item.Status == ReleaseUpgradeStatus.Verifying), + cancellationToken); + return operation?.ToRecord(); + } + + public async Task> GetReadyCandidateIdsAsync( + int take, + CancellationToken cancellationToken) + { + if (take is < 1 or > 200) throw new ArgumentOutOfRangeException(nameof(take)); + return await context.ReleaseUpgradeOperations.AsNoTracking() + .Where(operation => + (operation.Status == ReleaseUpgradeStatus.Downloading || + operation.Status == ReleaseUpgradeStatus.Verifying) && + operation.CandidateRelease.IsDownloadFinished && + context.FileMappings.Any(mapping => + mapping.AnimationInfoId == operation.CandidateReleaseId)) + .OrderBy(operation => operation.CreatedAt) + .Select(operation => operation.CandidateReleaseId) + .Take(take) + .ToListAsync(cancellationToken); + } + + public async Task GetActivationAsync( + Guid candidateReleaseId, + CancellationToken cancellationToken) + { + var operation = await context.ReleaseUpgradeOperations.AsNoTracking() + .FirstOrDefaultAsync(item => item.CandidateReleaseId == candidateReleaseId && + (item.Status == ReleaseUpgradeStatus.Downloading || + item.Status == ReleaseUpgradeStatus.Verifying), + cancellationToken); + if (operation is null) return null; + + var mappings = await context.FileMappings.AsNoTracking() + .Where(mapping => mapping.AnimationInfoId == operation.CurrentReleaseId || + mapping.AnimationInfoId == operation.CandidateReleaseId) + .OrderBy(mapping => mapping.VirtualPath) + .ToListAsync(cancellationToken); + return new ReleaseUpgradeActivation( + operation.ToRecord(), + mappings.Where(mapping => mapping.AnimationInfoId == operation.CurrentReleaseId) + .Select(mapping => mapping.ToRecord()).ToList(), + mappings.Where(mapping => mapping.AnimationInfoId == operation.CandidateReleaseId) + .Select(mapping => mapping.ToRecord()).ToList()); + } + + public async Task ActivateAsync( + Guid operationId, + DateTimeOffset verifiedAt, + DateTimeOffset rollbackUntil, + CancellationToken cancellationToken) + { + var strategy = context.Database.CreateExecutionStrategy(); + return await strategy.ExecuteAsync(async () => + { + await using var writeContext = new Models.ApplicationContext(contextOptions); + await using var transaction = await writeContext.Database.BeginTransactionAsync(cancellationToken); + await MappingTransactionLock.AcquireAsync(writeContext, cancellationToken); + var operation = await writeContext.ReleaseUpgradeOperations + .Include(item => item.MappingSnapshots) + .SingleOrDefaultAsync(item => item.Id == operationId, cancellationToken); + if (operation is null) + return new ReleaseUpgradeMutationResult(false, "not_found", null); + if (operation.Status == ReleaseUpgradeStatus.Applied) + return new ReleaseUpgradeMutationResult(true, "already_applied", operation.ToRecord()); + if (operation.Status is not (ReleaseUpgradeStatus.Downloading or ReleaseUpgradeStatus.Verifying)) + return new ReleaseUpgradeMutationResult(false, "invalid_state", operation.ToRecord()); + + var infos = await MappingTransactionLock.LockAnimationInfosAsync( + writeContext, + [operation.CurrentReleaseId, operation.CandidateReleaseId], + cancellationToken); + if (!infos.TryGetValue(operation.CurrentReleaseId, out var current) || + !infos.TryGetValue(operation.CandidateReleaseId, out var candidate) || + !candidate.IsDownloadFinished) + return new ReleaseUpgradeMutationResult(false, "candidate_not_ready", operation.ToRecord()); + + var mappings = await writeContext.FileMappings + .Where(mapping => mapping.AnimationInfoId == current.Id || + mapping.AnimationInfoId == candidate.Id) + .OrderBy(mapping => mapping.VirtualPath) + .ToListAsync(cancellationToken); + var previous = mappings.Where(mapping => mapping.AnimationInfoId == current.Id).ToList(); + var next = mappings.Where(mapping => mapping.AnimationInfoId == candidate.Id).ToList(); + if (previous.Count == 0 || next.Count == 0) + return new ReleaseUpgradeMutationResult(false, "mapping_missing", operation.ToRecord()); + + var snapshots = previous + .Select(mapping => ToSnapshot( + operation.Id, + mapping, + ReleaseUpgradeMappingKind.Previous)) + .Concat(next.Select(mapping => ToSnapshot( + operation.Id, + mapping, + ReleaseUpgradeMappingKind.Candidate))) + .ToList(); + await writeContext.ReleaseUpgradeMappingSnapshots.AddRangeAsync( + snapshots, + cancellationToken); + + writeContext.FileMappings.RemoveRange(mappings); + var replacement = BuildCandidateReplacement(previous, next, candidate.Id); + await writeContext.FileMappings.AddRangeAsync(replacement, cancellationToken); + await writeContext.AnimationInfo + .Where(info => info.Id == current.Id) + .ExecuteUpdateAsync(setters => setters + .SetProperty(info => info.StateVersion, info => info.StateVersion + 1) + .SetProperty(info => info.IsActiveRelease, false), + cancellationToken); + await writeContext.AnimationInfo + .Where(info => info.Id == candidate.Id) + .ExecuteUpdateAsync(setters => setters + .SetProperty(info => info.StateVersion, info => info.StateVersion + 1) + .SetProperty(info => info.IsActiveRelease, true), + cancellationToken); + operation.Status = ReleaseUpgradeStatus.Applied; + operation.VerifiedAt = verifiedAt; + operation.AppliedAt = verifiedAt; + operation.RollbackUntil = rollbackUntil; + await writeContext.SaveChangesAsync(cancellationToken); + await transaction.CommitAsync(cancellationToken); + return new ReleaseUpgradeMutationResult(true, "applied", operation.ToRecord()); + }); + } + + public async Task MarkFailedAsync( + Guid operationId, + string failureSummary, + CancellationToken cancellationToken) + { + var operation = await context.ReleaseUpgradeOperations + .SingleOrDefaultAsync(item => item.Id == operationId, cancellationToken); + if (operation is null) + return new ReleaseUpgradeMutationResult(false, "not_found", null); + if (operation.Status is ReleaseUpgradeStatus.Completed or ReleaseUpgradeStatus.RolledBack) + return new ReleaseUpgradeMutationResult(false, "invalid_state", operation.ToRecord()); + operation.Status = ReleaseUpgradeStatus.Failed; + operation.FailureSummary = failureSummary.Length <= 2048 + ? failureSummary + : failureSummary[..2048]; + operation.CompletedAt = DateTimeOffset.UtcNow; + await context.SaveChangesAsync(cancellationToken); + return new ReleaseUpgradeMutationResult(true, "failed", operation.ToRecord()); + } + + public async Task RollbackAsync( + Guid operationId, + DateTimeOffset rolledBackAt, + CancellationToken cancellationToken) + { + var strategy = context.Database.CreateExecutionStrategy(); + return await strategy.ExecuteAsync(async () => + { + await using var writeContext = new Models.ApplicationContext(contextOptions); + await using var transaction = await writeContext.Database.BeginTransactionAsync(cancellationToken); + await MappingTransactionLock.AcquireAsync(writeContext, cancellationToken); + var operation = await writeContext.ReleaseUpgradeOperations + .Include(item => item.MappingSnapshots) + .SingleOrDefaultAsync(item => item.Id == operationId, cancellationToken); + if (operation is null) + return new ReleaseUpgradeMutationResult(false, "not_found", null); + if (operation.Status == ReleaseUpgradeStatus.RolledBack) + return new ReleaseUpgradeMutationResult(true, "already_rolled_back", operation.ToRecord()); + if (operation.Status != ReleaseUpgradeStatus.Applied || operation.RollbackUntil < rolledBackAt) + return new ReleaseUpgradeMutationResult(false, "rollback_unavailable", operation.ToRecord()); + + var infos = await MappingTransactionLock.LockAnimationInfosAsync( + writeContext, + [operation.CurrentReleaseId, operation.CandidateReleaseId], + cancellationToken); + var previous = operation.MappingSnapshots + .Where(snapshot => snapshot.Kind == ReleaseUpgradeMappingKind.Previous) + .ToList(); + if (previous.Count == 0) + return new ReleaseUpgradeMutationResult(false, "snapshot_missing", operation.ToRecord()); + + await writeContext.FileMappings + .Where(mapping => mapping.AnimationInfoId == operation.CandidateReleaseId) + .ExecuteDeleteAsync(cancellationToken); + await writeContext.FileMappings.AddRangeAsync(previous.Select(snapshot => new Models.FileMapping + { + Id = snapshot.OriginalMappingId, + AnimationInfoId = snapshot.AnimationInfoId, + VirtualPath = snapshot.VirtualPath, + PhysicalPath = snapshot.PhysicalPath, + FileStore = snapshot.FileStore + }), cancellationToken); + await writeContext.AnimationInfo + .Where(info => info.Id == operation.CurrentReleaseId) + .ExecuteUpdateAsync(setters => setters + .SetProperty(info => info.StateVersion, info => info.StateVersion + 1) + .SetProperty(info => info.IsActiveRelease, true), + cancellationToken); + await writeContext.AnimationInfo + .Where(info => info.Id == operation.CandidateReleaseId) + .ExecuteUpdateAsync(setters => setters + .SetProperty(info => info.StateVersion, info => info.StateVersion + 1) + .SetProperty(info => info.IsActiveRelease, false), + cancellationToken); + operation.Status = ReleaseUpgradeStatus.RolledBack; + operation.CompletedAt = rolledBackAt; + await writeContext.SaveChangesAsync(cancellationToken); + await transaction.CommitAsync(cancellationToken); + return new ReleaseUpgradeMutationResult(true, "rolled_back", operation.ToRecord()); + }); + } + + public async Task> GetHistoryAsync( + int take, + CancellationToken cancellationToken) + { + if (take is < 1 or > 200) throw new ArgumentOutOfRangeException(nameof(take)); + return (await context.ReleaseUpgradeOperations.AsNoTracking() + .OrderByDescending(operation => operation.CreatedAt) + .Take(take) + .ToListAsync(cancellationToken)) + .Select(operation => operation.ToRecord()) + .ToList(); + } + + public Task CompleteExpiredAsync( + DateTimeOffset completedAt, + CancellationToken cancellationToken) => + context.ReleaseUpgradeOperations + .Where(operation => operation.Status == ReleaseUpgradeStatus.Applied && + operation.RollbackUntil <= completedAt) + .ExecuteUpdateAsync(setters => setters + .SetProperty(operation => operation.Status, ReleaseUpgradeStatus.Completed) + .SetProperty(operation => operation.CompletedAt, completedAt), + cancellationToken); + + private static Models.ReleaseUpgradeMappingSnapshot ToSnapshot( + Guid operationId, + Models.FileMapping mapping, + ReleaseUpgradeMappingKind kind) => new() + { + Id = Guid.NewGuid(), + OperationId = operationId, + Kind = kind, + OriginalMappingId = mapping.Id, + AnimationInfoId = mapping.AnimationInfoId, + VirtualPath = mapping.VirtualPath, + PhysicalPath = mapping.PhysicalPath, + FileStore = mapping.FileStore + }; + + private static IReadOnlyList BuildCandidateReplacement( + IReadOnlyList previous, + IReadOnlyList candidate, + Guid candidateReleaseId) + { + var remaining = new HashSet(candidate.Select(item => item.VirtualPath), StringComparer.Ordinal); + var used = new HashSet(StringComparer.Ordinal); + var result = new List(candidate.Count); + for (var index = 0; index < candidate.Count; index++) + { + var mapping = candidate[index]; + remaining.Remove(mapping.VirtualPath); + var preferred = index < previous.Count ? previous[index].VirtualPath : mapping.VirtualPath; + var virtualPath = !used.Contains(preferred) && !remaining.Contains(preferred) + ? preferred + : mapping.VirtualPath; + used.Add(virtualPath); + result.Add(new Models.FileMapping + { + Id = Guid.NewGuid(), + AnimationInfoId = candidateReleaseId, + VirtualPath = virtualPath, + PhysicalPath = mapping.PhysicalPath, + FileStore = mapping.FileStore + }); + } + + return result; + } + + private static IReadOnlyList ParseReasons(string? json) + { + if (string.IsNullOrWhiteSpace(json)) return []; + try + { + return System.Text.Json.JsonSerializer.Deserialize(json) ?? []; + } + catch (System.Text.Json.JsonException) + { + return []; + } + } +} + +internal static class ReleaseUpgradeRepositoryConverters +{ + public static ReleaseUpgradeOperation ToRecord(this Models.ReleaseUpgradeOperation operation) => + new(operation.Id, + operation.CurrentReleaseId, + operation.CandidateReleaseId, + operation.Status, + operation.CurrentScore, + operation.CandidateScore, + operation.CreatedAt, + operation.VerifiedAt, + operation.AppliedAt, + operation.RollbackUntil, + operation.CompletedAt, + operation.FailureSummary); +} diff --git a/SecondDimensionWatcherReDive/Repositories/RepositoryConverter.cs b/SecondDimensionWatcherReDive/Repositories/RepositoryConverter.cs index 260e342..4b264d0 100644 --- a/SecondDimensionWatcherReDive/Repositories/RepositoryConverter.cs +++ b/SecondDimensionWatcherReDive/Repositories/RepositoryConverter.cs @@ -47,7 +47,20 @@ public static DataRepo.AnimationInfo ToRecord(this Models.AnimationInfo entity) entity.DownloadAttemptId, entity.DownloadCancellationId, entity.MediaLibrarySourceId, - entity.MediaLibraryMissingSince); + entity.MediaLibraryMissingSince, + entity.ReleaseIdentity, + entity.FeedItemGuid, + entity.EnclosureId, + entity.TorrentInfoHash, + entity.ReleaseSubtitleGroup, + entity.ReleaseResolution, + entity.ReleaseCodec, + entity.ReleaseLanguages, + entity.ReleaseScore, + entity.ReleaseScoreReasonsJson, + entity.ExpectedEpisodeCount, + entity.IngestedAt, + entity.IsActiveRelease); public static DataRepo.Animation ToRecord(this Models.Animation entity) => new(entity.Id, @@ -103,7 +116,10 @@ public static DataRepo.SubscriptionAutomationPolicy ToRecord( entity.ExcludedKeywords, entity.Mode, entity.CreatedAt, - entity.UpdatedAt); + entity.UpdatedAt, + entity.EnableVersionUpgrade, + entity.MinimumUpgradeScore, + entity.UpgradeRollbackHours); public static DataRepo.WebDavToken ToRecord(this Models.WebDavToken entity) => new(entity.Id, @@ -168,7 +184,20 @@ public static Models.AnimationInfo ToEntity(this DataRepo.AnimationInfo record) DownloadAttemptId = record.DownloadAttemptId, DownloadCancellationId = record.DownloadCancellationId, MediaLibrarySourceId = record.MediaLibrarySourceId, - MediaLibraryMissingSince = record.MediaLibraryMissingSince + MediaLibraryMissingSince = record.MediaLibraryMissingSince, + ReleaseIdentity = record.ReleaseIdentity, + FeedItemGuid = record.FeedItemGuid, + EnclosureId = record.EnclosureId, + TorrentInfoHash = record.TorrentInfoHash, + ReleaseSubtitleGroup = record.ReleaseSubtitleGroup, + ReleaseResolution = record.ReleaseResolution, + ReleaseCodec = record.ReleaseCodec, + ReleaseLanguages = record.ReleaseLanguages?.ToArray() ?? [], + ReleaseScore = record.ReleaseScore, + ReleaseScoreReasonsJson = record.ReleaseScoreReasonsJson, + ExpectedEpisodeCount = record.ExpectedEpisodeCount, + IngestedAt = record.IngestedAt ?? DateTimeOffset.UtcNow, + IsActiveRelease = record.IsActiveRelease }; public static Models.Animation ToEntity(this DataRepo.Animation record) => @@ -252,7 +281,10 @@ public static Models.SubscriptionAutomationPolicy ToEntity( ExcludedKeywords = record.ExcludedKeywords.ToArray(), Mode = record.Mode, CreatedAt = record.CreatedAt, - UpdatedAt = record.UpdatedAt + UpdatedAt = record.UpdatedAt, + EnableVersionUpgrade = record.EnableVersionUpgrade, + MinimumUpgradeScore = record.MinimumUpgradeScore, + UpgradeRollbackHours = record.UpgradeRollbackHours }; public static Models.WebDavToken ToEntity(this DataRepo.WebDavToken record) => @@ -326,6 +358,17 @@ public static void ApplyTo(this DataRepo.AnimationInfo record, Models.AnimationI entity.DownloadCancellationId = record.DownloadCancellationId; entity.MediaLibrarySourceId = record.MediaLibrarySourceId; entity.MediaLibraryMissingSince = record.MediaLibraryMissingSince; + entity.ReleaseIdentity = record.ReleaseIdentity; + entity.FeedItemGuid = record.FeedItemGuid; + entity.EnclosureId = record.EnclosureId; + entity.TorrentInfoHash = record.TorrentInfoHash; + entity.ReleaseSubtitleGroup = record.ReleaseSubtitleGroup; + entity.ReleaseResolution = record.ReleaseResolution; + entity.ReleaseCodec = record.ReleaseCodec; + entity.ReleaseLanguages = record.ReleaseLanguages?.ToArray() ?? []; + entity.ReleaseScore = record.ReleaseScore; + entity.ReleaseScoreReasonsJson = record.ReleaseScoreReasonsJson; + entity.ExpectedEpisodeCount = record.ExpectedEpisodeCount; } public static void ApplyTo(this DataRepo.SeasonBangumi record, Models.SeasonBangumi entity) @@ -359,5 +402,8 @@ public static void ApplyTo( entity.Mode = record.Mode; entity.CreatedAt = record.CreatedAt; entity.UpdatedAt = record.UpdatedAt; + entity.EnableVersionUpgrade = record.EnableVersionUpgrade; + entity.MinimumUpgradeScore = record.MinimumUpgradeScore; + entity.UpgradeRollbackHours = record.UpgradeRollbackHours; } } diff --git a/SecondDimensionWatcherReDive/Services/CompleteDownloadBackgroundService.cs b/SecondDimensionWatcherReDive/Services/CompleteDownloadBackgroundService.cs index d6f2eb9..7116c8d 100644 --- a/SecondDimensionWatcherReDive/Services/CompleteDownloadBackgroundService.cs +++ b/SecondDimensionWatcherReDive/Services/CompleteDownloadBackgroundService.cs @@ -5,6 +5,7 @@ using SecondDimensionWatcherReDive.Plugin; using SecondDimensionWatcherReDive.Utils.FileStore; using SecondDimensionWatcherReDive.Utils.Incidents; +using SecondDimensionWatcherReDive.Utils.ReleaseUpgrades; namespace SecondDimensionWatcherReDive.Services; @@ -72,6 +73,9 @@ internal async Task ProcessRequestAsync( if (info is null) { + if (scope.ServiceProvider.GetService() is { } retryCoordinator && + await retryCoordinator.TryActivateCandidateAsync(request.ItemId, cancellationToken) is not null) + return; LogCompletionIgnored(logger, request.ItemId); return; } @@ -86,11 +90,13 @@ await incidentReporter.ResolveAsync( } // Build virtual-fs mappings for the downloaded files. + var mappingSucceeded = false; try { var fileMapper = scope.ServiceProvider.GetRequiredService(); if (!await fileMapper.MapDownloadAsync(request.ItemId, cancellationToken)) throw new InvalidOperationException("No file mapping could be produced."); + mappingSucceeded = true; if (incidentReporter is not null) { @@ -115,6 +121,11 @@ await incidentReporter.ReportAsync(new IncidentReport( } } + if (mappingSucceeded && scope.ServiceProvider.GetService() is { } coordinator) + { + await coordinator.TryActivateCandidateAsync(request.ItemId, cancellationToken); + } + // Fire plugin event try { diff --git a/SecondDimensionWatcherReDive/Services/InferAnimationMetadata.cs b/SecondDimensionWatcherReDive/Services/InferAnimationMetadata.cs index 517f322..36c8f66 100644 --- a/SecondDimensionWatcherReDive/Services/InferAnimationMetadata.cs +++ b/SecondDimensionWatcherReDive/Services/InferAnimationMetadata.cs @@ -89,6 +89,16 @@ private async Task ProcessItem( if (details != null && !string.IsNullOrEmpty(details.Overview)) item = item with { Description = details.Overview }; + if (result.Season is { } inferredSeason) + { + var expectedEpisodeCount = await tmdbTool.GetExpectedEpisodeCountAsync( + tmdbIdInt, + inferredSeason, + cancellationToken); + if (expectedEpisodeCount is not null) + item = item with { ExpectedEpisodeCount = expectedEpisodeCount }; + } + var animation = await animationRepository .FindByTmdbIdAsync(result.TmdbId, cancellationToken); diff --git a/SecondDimensionWatcherReDive/Services/MediaLibraryScanner.cs b/SecondDimensionWatcherReDive/Services/MediaLibraryScanner.cs index 8b17ee8..2e7d095 100644 --- a/SecondDimensionWatcherReDive/Services/MediaLibraryScanner.cs +++ b/SecondDimensionWatcherReDive/Services/MediaLibraryScanner.cs @@ -1,6 +1,7 @@ using Microsoft.Extensions.Options; using SecondDimensionWatcherReDive.Framework.DataRepository; using SecondDimensionWatcherReDive.Framework.FileDownload; +using SecondDimensionWatcherReDive.Framework.Feed; using SecondDimensionWatcherReDive.Framework.FileStore; using SecondDimensionWatcherReDive.Utils.FileStore; @@ -399,7 +400,11 @@ private static AnimationInfo CreateAnimationInfo( AiRetryCount: 0, ReleaseSizeBytes: candidate.TotalSize, MetadataStatus: MetadataReviewStatus.Pending, - MediaLibrarySourceId: source.Id); + MediaLibrarySourceId: source.Id, + ReleaseIdentity: ReleaseIdentity.CreateMediaImport( + source.Id, + FileStores.LocalDiskStore, + candidate.FullPath)); } private static string BuildDownloadUrl(Guid sourceId, string relativePath) => diff --git a/SecondDimensionWatcherReDive/Services/ReleaseUpgradeBackgroundService.cs b/SecondDimensionWatcherReDive/Services/ReleaseUpgradeBackgroundService.cs new file mode 100644 index 0000000..1ad96f2 --- /dev/null +++ b/SecondDimensionWatcherReDive/Services/ReleaseUpgradeBackgroundService.cs @@ -0,0 +1,55 @@ +using SecondDimensionWatcherReDive.Framework.DataRepository; +using SecondDimensionWatcherReDive.Utils.ReleaseUpgrades; + +namespace SecondDimensionWatcherReDive.Services; + +public sealed class ReleaseUpgradeBackgroundService( + IServiceScopeFactory scopeFactory, + ILogger logger) : BackgroundService +{ + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + using var timer = new PeriodicTimer(TimeSpan.FromMinutes(1)); + do + { + try + { + await ProcessAsync(stoppingToken); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + break; + } + catch (Exception exception) + { + logger.LogError(exception, "Automatic release-upgrade pass failed"); + } + } while (await timer.WaitForNextTickAsync(stoppingToken)); + } + + internal async Task ProcessAsync(CancellationToken cancellationToken) + { + await using var scope = scopeFactory.CreateAsyncScope(); + var repository = scope.ServiceProvider.GetRequiredService(); + await repository.CompleteExpiredAsync(DateTimeOffset.UtcNow, cancellationToken); + var coordinator = scope.ServiceProvider.GetRequiredService(); + var readyCandidates = await repository.GetReadyCandidateIdsAsync( + take: 20, + cancellationToken); + foreach (var candidateId in readyCandidates) + { + cancellationToken.ThrowIfCancellationRequested(); + await coordinator.TryActivateCandidateAsync(candidateId, cancellationToken); + } + + var candidates = await repository.GetCandidatesAsync( + automaticOnly: true, + take: 20, + cancellationToken); + foreach (var candidate in candidates) + { + cancellationToken.ThrowIfCancellationRequested(); + await coordinator.ExecuteAsync(candidate, dryRun: false, cancellationToken); + } + } +} diff --git a/SecondDimensionWatcherReDive/Services/SyncFeed.cs b/SecondDimensionWatcherReDive/Services/SyncFeed.cs index 6acd879..6ce3f57 100644 --- a/SecondDimensionWatcherReDive/Services/SyncFeed.cs +++ b/SecondDimensionWatcherReDive/Services/SyncFeed.cs @@ -20,7 +20,9 @@ public partial class SyncFeed( IHttpClientFactory httpClientFactory, IServiceScopeFactory scopeFactory, ISubscriptionAutomationMatcher automationMatcher, - IIncidentReporter? incidentReporter = null) + IIncidentReporter? incidentReporter = null, + ISubscriptionReleaseMetadataExtractor? metadataExtractor = null, + IReleaseScoringService? releaseScoringService = null) : ScheduledTaskBase { private readonly HttpClient _httpClient = httpClientFactory.CreateClient("Feed"); @@ -106,41 +108,44 @@ private async Task ProcessSingle(AnimationAddRequest request, CancellationToken await using var scope = scopeFactory.CreateAsyncScope(); var animationInfoRepository = scope.ServiceProvider.GetRequiredService(); - //Only process non-exist items - if (await animationInfoRepository.FindByTitleAsync(request.Title, cancellationToken) == null) + try { - try + SubscriptionAutomationPolicy? policy = null; + if (request.FeedId is { } feedId) { - SubscriptionAutomationPolicy? policy = null; - if (request.FeedId is { } feedId) - { - var policyRepository = scope.ServiceProvider - .GetRequiredService(); - policy = await policyRepository.FindByFeedIdAsync(feedId, cancellationToken); - } + var policyRepository = scope.ServiceProvider + .GetRequiredService(); + policy = await policyRepository.FindByFeedIdAsync(feedId, cancellationToken); + } - var torrentData = request.DownloadType switch - { - FileDownloadTypes.TorrentDownload => await DownloadTorrentData(request, cancellationToken), - _ => new TorrentData(Array.Empty(), request.AdditionalDownloadInfo, request.ContentLength) - }; + var torrentData = request.DownloadType switch + { + FileDownloadTypes.TorrentDownload => await DownloadTorrentData(request, cancellationToken), + _ => new TorrentData(Array.Empty(), request.AdditionalDownloadInfo, request.ContentLength) + }; - if (request.DownloadType == FileDownloadTypes.TorrentDownload && - request.ContentLength is { } advertisedSize && - advertisedSize != torrentData.PayloadSizeBytes) - throw new InvalidTorrentDataException(request.DownloadUrl, "advertised and declared payload sizes differ"); + if (request.DownloadType == FileDownloadTypes.TorrentDownload && + request.ContentLength is { } advertisedSize && + advertisedSize != torrentData.PayloadSizeBytes) + throw new InvalidTorrentDataException(request.DownloadUrl, "advertised and declared payload sizes differ"); - SubscriptionAutomationEvaluation? evaluation = null; - if (policy is not null) - { - evaluation = automationMatcher.Evaluate( - policy, - request with { ContentLength = torrentData.PayloadSizeBytes }); - if (!evaluation.Matched) - return; - } + var releaseWithSize = request with { ContentLength = torrentData.PayloadSizeBytes }; + SubscriptionAutomationEvaluation? evaluation = null; + if (policy is not null) + { + evaluation = automationMatcher.Evaluate(policy, releaseWithSize); + if (!evaluation.Matched) + return; + } - var info = new AnimationInfo( + var metadata = evaluation?.Metadata ?? metadataExtractor?.Extract(releaseWithSize) ?? + new SubscriptionReleaseMetadata(null, null, null, [], torrentData.PayloadSizeBytes); + var score = releaseScoringService?.Score(metadata, policy) ?? new ReleaseScore(0, []); + var torrentInfoHash = request.DownloadType == FileDownloadTypes.TorrentDownload + ? torrentData.Hash + : null; + + var info = new AnimationInfo( Guid.NewGuid(), request.Title, request.Description, @@ -174,35 +179,50 @@ request.ContentLength is { } advertisedSize && }, AutomationExplanationJson: evaluation is null ? null - : JsonSerializer.Serialize(evaluation.Explanations, ExplanationJsonOptions)); - await animationInfoRepository.AddAsync(info, cancellationToken); + : JsonSerializer.Serialize(evaluation.Explanations, ExplanationJsonOptions), + ReleaseIdentity: ReleaseIdentity.Create( + request.FeedId, + request.FeedItemGuid, + request.EnclosureId, + torrentInfoHash, + request.DownloadUrl), + FeedItemGuid: request.FeedItemGuid, + EnclosureId: request.EnclosureId, + TorrentInfoHash: torrentInfoHash, + ReleaseSubtitleGroup: metadata.SubtitleGroup, + ReleaseResolution: metadata.Resolution, + ReleaseCodec: metadata.Codec, + ReleaseLanguages: metadata.Languages, + ReleaseScore: score.Value, + ReleaseScoreReasonsJson: JsonSerializer.Serialize(score.Reasons, ExplanationJsonOptions)); + if (!await animationInfoRepository.TryAddReleaseAsync(info, cancellationToken)) + return; - if (incidentReporter is not null) - await incidentReporter.ResolveAsync( - IncidentType.FeedFailure, - request.DownloadUrl, - cancellationToken); + if (incidentReporter is not null) + await incidentReporter.ResolveAsync( + IncidentType.FeedFailure, + request.DownloadUrl, + cancellationToken); - if (policy?.Mode == SubscriptionAutomationMode.AutoDownload) - await QueueAutomaticDownloadAsync( - info, - animationInfoRepository, - scope.ServiceProvider.GetRequiredService(), - cancellationToken); - } - catch (InvalidTorrentDataException e) + if (policy?.Mode == SubscriptionAutomationMode.AutoDownload) + await QueueAutomaticDownloadAsync( + info, + animationInfoRepository, + scope.ServiceProvider.GetRequiredService(), + cancellationToken); + } + catch (InvalidTorrentDataException e) + { + LogSyncFeedWarning(logger, e.Message); + if (incidentReporter is not null) { - LogSyncFeedWarning(logger, e.Message); - if (incidentReporter is not null) - { - await incidentReporter.ReportAsync(new IncidentReport( - IncidentType.FeedFailure, - IncidentSeverity.Error, - "Feed item contains invalid torrent data", - e.Message, - request.DownloadUrl), - cancellationToken); - } + await incidentReporter.ReportAsync(new IncidentReport( + IncidentType.FeedFailure, + IncidentSeverity.Error, + "Feed item contains invalid torrent data", + e.Message, + request.DownloadUrl), + cancellationToken); } } } diff --git a/SecondDimensionWatcherReDive/Utils/Feed/MikananiSubscriptionFeedReader.cs b/SecondDimensionWatcherReDive/Utils/Feed/MikananiSubscriptionFeedReader.cs index 14f2cdc..ed06cad 100644 --- a/SecondDimensionWatcherReDive/Utils/Feed/MikananiSubscriptionFeedReader.cs +++ b/SecondDimensionWatcherReDive/Utils/Feed/MikananiSubscriptionFeedReader.cs @@ -50,7 +50,9 @@ item.Enclosure is null || FileDownloadTypes.TorrentDownload, string.Empty, feedId, - item.Torrent.ContentLength > 0 ? item.Torrent.ContentLength : null)); + item.Torrent.ContentLength > 0 ? item.Torrent.ContentLength : null, + item.Guid?.Text, + item.Enclosure.Url)); } return releases; diff --git a/SecondDimensionWatcherReDive/Utils/Feed/ReleaseScoringService.cs b/SecondDimensionWatcherReDive/Utils/Feed/ReleaseScoringService.cs new file mode 100644 index 0000000..1b405ac --- /dev/null +++ b/SecondDimensionWatcherReDive/Utils/Feed/ReleaseScoringService.cs @@ -0,0 +1,104 @@ +using SecondDimensionWatcherReDive.Framework.DataRepository; +using SecondDimensionWatcherReDive.Framework.Feed; + +namespace SecondDimensionWatcherReDive.Utils.Feed; + +public sealed class ReleaseScoringService : IReleaseScoringService +{ + public ReleaseScore Score( + SubscriptionReleaseMetadata metadata, + SubscriptionAutomationPolicy? policy) + { + var reasons = new List(); + var score = ScoreResolution(metadata.Resolution, reasons) + + ScoreCodec(metadata.Codec, reasons) + + ScoreSubtitleGroup(metadata.SubtitleGroup, policy, reasons) + + ScoreLanguages(metadata.Languages, policy, reasons) + + ScoreSize(metadata.SizeBytes, reasons); + return new ReleaseScore(score, reasons); + } + + private static int ScoreResolution(string? resolution, ICollection reasons) + { + var value = resolution?.Trim().ToUpperInvariant() switch + { + "2160P" or "4K" or "UHD" => 400, + "1440P" => 300, + "1080P" => 200, + "720P" => 100, + "576P" => 60, + "480P" => 40, + _ => 0 + }; + if (value > 0) reasons.Add($"resolution:{resolution}:+{value}"); + return value; + } + + private static int ScoreCodec(string? codec, ICollection reasons) + { + var value = codec?.Trim().ToUpperInvariant() switch + { + "AV1" => 80, + "HEVC" or "H265" or "H.265" => 60, + "AVC" or "H264" or "H.264" => 40, + "VP9" => 30, + _ => 0 + }; + if (value > 0) reasons.Add($"codec:{codec}:+{value}"); + return value; + } + + private static int ScoreSubtitleGroup( + string? group, + SubscriptionAutomationPolicy? policy, + ICollection reasons) + { + if (string.IsNullOrWhiteSpace(group)) return 0; + var index = policy?.SubtitleGroups + .Select((value, position) => (value, position)) + .Where(item => string.Equals( + item.value.Trim('[', ']', '【', '】'), + group.Trim('[', ']', '【', '】'), + StringComparison.OrdinalIgnoreCase)) + .Select(item => (int?)item.position) + .FirstOrDefault(); + var value = policy is { SubtitleGroups.Count: > 0 } + ? index is { } position ? Math.Max(10, 50 - position * 5) : 0 + : 20; + reasons.Add($"subtitleGroup:{group}:+{value}"); + return value; + } + + private static int ScoreLanguages( + IReadOnlyList languages, + SubscriptionAutomationPolicy? policy, + ICollection reasons) + { + var preferred = policy?.Languages ?? []; + var score = 0; + foreach (var language in languages.Distinct(StringComparer.OrdinalIgnoreCase)) + { + var value = preferred.Count == 0 || preferred.Contains(language, StringComparer.OrdinalIgnoreCase) + ? 20 + : 5; + score += value; + reasons.Add($"language:{language}:+{value}"); + } + return score; + } + + private static int ScoreSize(long? sizeBytes, ICollection reasons) + { + if (sizeBytes is not > 0) return 0; + var gibibytes = sizeBytes.Value / (1024d * 1024 * 1024); + var value = gibibytes switch + { + >= 8 => 40, + >= 2 => 25, + >= 0.7 => 10, + _ => 5 + }; + reasons.Add($"size:{gibibytes:F2}GiB:+{value}"); + return value; + } +} diff --git a/SecondDimensionWatcherReDive/Utils/ReleaseUpgrades/IReleaseUpgradeCoordinator.cs b/SecondDimensionWatcherReDive/Utils/ReleaseUpgrades/IReleaseUpgradeCoordinator.cs new file mode 100644 index 0000000..6690e65 --- /dev/null +++ b/SecondDimensionWatcherReDive/Utils/ReleaseUpgrades/IReleaseUpgradeCoordinator.cs @@ -0,0 +1,27 @@ +using SecondDimensionWatcherReDive.Framework.DataRepository; + +namespace SecondDimensionWatcherReDive.Utils.ReleaseUpgrades; + +public sealed record ReleaseUpgradeExecutionResult( + bool IsSuccess, + string Outcome, + bool DryRun, + bool RequiresDownload, + ReleaseUpgradeOperation? Operation, + IReadOnlyList ValidationErrors); + +public interface IReleaseUpgradeCoordinator +{ + Task ExecuteAsync( + ReleaseUpgradeCandidate candidate, + bool dryRun, + CancellationToken cancellationToken); + + Task TryActivateCandidateAsync( + Guid candidateReleaseId, + CancellationToken cancellationToken); + + Task RollbackAsync( + Guid operationId, + CancellationToken cancellationToken); +} diff --git a/SecondDimensionWatcherReDive/Utils/ReleaseUpgrades/ReleaseUpgradeCoordinator.cs b/SecondDimensionWatcherReDive/Utils/ReleaseUpgrades/ReleaseUpgradeCoordinator.cs new file mode 100644 index 0000000..2424349 --- /dev/null +++ b/SecondDimensionWatcherReDive/Utils/ReleaseUpgrades/ReleaseUpgradeCoordinator.cs @@ -0,0 +1,262 @@ +using SecondDimensionWatcherReDive.Framework.DataRepository; +using SecondDimensionWatcherReDive.Framework.FileDownload; +using SecondDimensionWatcherReDive.Framework.FileStore; +using SecondDimensionWatcherReDive.Utils.Incidents; + +namespace SecondDimensionWatcherReDive.Utils.ReleaseUpgrades; + +public sealed class ReleaseUpgradeCoordinator( + IReleaseUpgradeRepository upgradeRepository, + IAnimationInfoRepository animationInfoRepository, + ISubscriptionAutomationPolicyRepository policyRepository, + IFileMappingRepository fileMappingRepository, + IFileDownloadClientProvider downloadClientProvider, + IFileStoreProvider fileStoreProvider, + IIncidentReporter incidentReporter, + ILogger logger) : IReleaseUpgradeCoordinator +{ + public async Task ExecuteAsync( + ReleaseUpgradeCandidate candidate, + bool dryRun, + CancellationToken cancellationToken) + { + var next = await animationInfoRepository.FindByIdAsync( + candidate.CandidateReleaseId, + cancellationToken); + if (next is null) + return Result(false, "candidate_not_found", dryRun, false, null, ["Candidate release does not exist."]); + + var requiresDownload = !next.IsDownloadFinished; + if (dryRun) + { + var validationErrors = requiresDownload + ? Array.Empty() + : await ValidateCandidateAsync(candidate, cancellationToken); + return Result(validationErrors.Count == 0, + validationErrors.Count == 0 ? "ready" : "validation_failed", + true, + requiresDownload, + null, + validationErrors); + } + + var operation = await upgradeRepository.TryBeginAsync( + candidate, + DateTimeOffset.UtcNow, + cancellationToken); + if (operation is null) + return Result(false, "upgrade_already_started", false, requiresDownload, null, + ["Another worker already claimed this upgrade."]); + + if (!requiresDownload) + return await ActivateAsync(operation.CandidateReleaseId, cancellationToken); + + var downloadAttemptId = Guid.NewGuid(); + try + { + if (!await animationInfoRepository.TryStartDownloadAsync( + next.Id, + downloadAttemptId, + DateTimeOffset.UtcNow, + SubscriptionAutomationDisposition.AutoDownloadQueued, + cancellationToken)) + return await FailAsync(operation, "Candidate download state changed.", cancellationToken); + + var client = downloadClientProvider.GetRequiredClient(next.DownloadType); + if (!await client.SubmitDownloadTaskAsync( + next.Id, + next.DownloadUrl, + next.CachedDownloadData, + next.AdditionalDownloadInfo, + cancellationToken)) + { + await animationInfoRepository.TryCancelDownloadAsync( + next.Id, + downloadAttemptId, + SubscriptionAutomationDisposition.AutoDownloadFailed, + cancellationToken); + return await FailAsync(operation, "Download client rejected the candidate.", cancellationToken); + } + + return Result(true, "download_queued", false, true, operation, []); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception exception) + { + logger.LogWarning(exception, "Failed to queue release upgrade {OperationId}", operation.Id); + return await FailAsync(operation, exception.Message, cancellationToken); + } + } + + public async Task TryActivateCandidateAsync( + Guid candidateReleaseId, + CancellationToken cancellationToken) + { + var operation = await upgradeRepository.FindActiveByCandidateAsync( + candidateReleaseId, + cancellationToken); + return operation is null + ? null + : await ActivateAsync(candidateReleaseId, cancellationToken); + } + + public async Task RollbackAsync( + Guid operationId, + CancellationToken cancellationToken) + { + var result = await upgradeRepository.RollbackAsync( + operationId, + DateTimeOffset.UtcNow, + cancellationToken); + if (result.IsSuccess) + { + await incidentReporter.ResolveAsync( + IncidentType.FileMappingFailure, + UpgradeSource(operationId), + cancellationToken); + } + + return result; + } + + private async Task ActivateAsync( + Guid candidateReleaseId, + CancellationToken cancellationToken) + { + var activation = await upgradeRepository.GetActivationAsync( + candidateReleaseId, + cancellationToken); + if (activation is null) + return Result(false, "operation_not_found", false, false, null, + ["No active upgrade was found for this candidate."]); + + // Validation is deliberately external to the mapping transaction. Until every + // candidate file passes, the old virtual paths and physical files remain untouched. + var validationErrors = await ValidateActivationAsync(activation, cancellationToken); + if (validationErrors.Count > 0) + return await FailAsync(activation.Operation, string.Join(" ", validationErrors), cancellationToken, + validationErrors); + + var candidate = await animationInfoRepository.FindByIdAsync(candidateReleaseId, cancellationToken); + var rollbackHours = 72; + if (candidate?.SourceFeedId is { } feedId) + { + var policy = await policyRepository.FindByFeedIdAsync(feedId, cancellationToken); + rollbackHours = policy?.UpgradeRollbackHours ?? rollbackHours; + } + + var now = DateTimeOffset.UtcNow; + var mutation = await upgradeRepository.ActivateAsync( + activation.Operation.Id, + now, + now.AddHours(rollbackHours), + cancellationToken); + if (!mutation.IsSuccess) + return await FailAsync(activation.Operation, + $"Atomic mapping swap failed: {mutation.Outcome}.", + cancellationToken); + + await incidentReporter.ResolveAsync( + IncidentType.FileMappingFailure, + UpgradeSource(activation.Operation.Id), + cancellationToken); + return Result(true, mutation.Outcome, false, false, mutation.Operation, []); + } + + private async Task> ValidateCandidateAsync( + ReleaseUpgradeCandidate candidate, + CancellationToken cancellationToken) + { + var previous = await fileMappingRepository.GetForAnimationInfoAsync( + candidate.CurrentReleaseId, + cancellationToken); + var next = await fileMappingRepository.GetForAnimationInfoAsync( + candidate.CandidateReleaseId, + cancellationToken); + return await ValidateMappingsAsync(previous, next, cancellationToken); + } + + private async Task> ValidateActivationAsync( + ReleaseUpgradeActivation activation, + CancellationToken cancellationToken) => + await ValidateMappingsAsync( + activation.PreviousMappings, + activation.CandidateMappings, + cancellationToken); + + private async Task> ValidateMappingsAsync( + IReadOnlyList previousMappings, + IReadOnlyList candidateMappings, + CancellationToken cancellationToken) + { + var errors = new List(); + if (previousMappings.Count == 0) + errors.Add("The current release has no mapping to preserve."); + if (candidateMappings.Count == 0) + errors.Add("The candidate release has no mapped files."); + + var readableFiles = 0; + foreach (var mapping in candidateMappings) + { + cancellationToken.ThrowIfCancellationRequested(); + try + { + var store = fileStoreProvider.GetRequiredClient(mapping.FileStore); + if (!await store.ExistAsync(mapping.PhysicalPath, cancellationToken)) + { + errors.Add($"Candidate file is missing: {mapping.PhysicalPath}"); + continue; + } + + var info = await store.FileInfoAsync(mapping.PhysicalPath, cancellationToken); + if (info.IsDirectory || info.Length is <= 0) + errors.Add($"Candidate file is not a readable non-empty file: {mapping.PhysicalPath}"); + else + readableFiles++; + } + catch (Exception exception) when (exception is not OperationCanceledException) + { + errors.Add($"Candidate file validation failed for {mapping.PhysicalPath}: {exception.Message}"); + } + } + + if (candidateMappings.Count > 0 && readableFiles == 0) + errors.Add("Candidate validation found no readable file."); + return errors; + } + + private async Task FailAsync( + ReleaseUpgradeOperation operation, + string summary, + CancellationToken cancellationToken, + IReadOnlyList? errors = null) + { + var mutation = await upgradeRepository.MarkFailedAsync( + operation.Id, + summary, + cancellationToken); + await incidentReporter.ReportAsync(new IncidentReport( + IncidentType.FileMappingFailure, + IncidentSeverity.Error, + "Release upgrade failed", + summary, + UpgradeSource(operation.Id)), + cancellationToken); + return Result(false, "failed", false, false, mutation.Operation ?? operation, + errors ?? [summary]); + } + + private static string UpgradeSource(Guid operationId) => $"release-upgrade:{operationId:N}"; + + private static ReleaseUpgradeExecutionResult Result( + bool success, + string outcome, + bool dryRun, + bool requiresDownload, + ReleaseUpgradeOperation? operation, + IReadOnlyList errors) => + new(success, outcome, dryRun, requiresDownload, operation, errors); +} From 055089304bf3213fbcb47ed7f0bd499229795fbe Mon Sep 17 00:00:00 2001 From: mahoshojoHCG Date: Sat, 29 Aug 2026 23:34:06 +0800 Subject: [PATCH 04/37] feat: add durable job runtime reliability --- .../Engines/CodexAppServerEngine.cs | 32 +- .../ChatController.cs | 8 +- README.md | 6 + .../mock-server.mjs | 43 + .../src/i18n/locales/en/tasks.json | 31 + .../src/i18n/locales/ja/tasks.json | 31 + .../src/i18n/locales/zh-CN/tasks.json | 31 + .../src/pages/TasksPage.tsx | 141 ++- .../src/tasks/hooks.ts | 7 +- .../src/tasks/types.ts | 19 + .../src/tasks/utils.ts | 11 + .../DataRepository/IDurableJobRepository.cs | 110 ++ .../DataRepository/IReadinessRepository.cs | 6 + .../IScheduledTaskLeaseRepository.cs | 28 + .../IDownloadCompletionNotifier.cs | 15 + .../PluginParams/FileDownloadCompleteParam.cs | 14 +- .../Tasks/IScheduledTaskLeaseManager.cs | 23 + .../Tasks/ScheduledTaskBase.cs | 172 ++- .../Health/HealthEndpointTests.cs | 47 + .../FileMappingRepositoryPostgreSqlTests.cs | 193 +++ .../WebDavWebApplicationFactory.cs | 8 + .../CodexAppServerEngineTests.cs | 26 + .../CompleteDownloadBackgroundServiceTests.cs | 310 +++-- .../DurableJobsControllerTests.cs | 72 ++ .../ObservabilityTests.cs | 96 ++ .../ScheduledTaskBaseTests.cs | 168 +++ .../Controllers/DurableJobsController.cs | 120 ++ .../External/AppJsonSerializerContext.cs | 4 + .../Controllers/External/DurableJob.cs | 22 + ...27_AddDurableJobsAndTaskLeases.Designer.cs | 1086 +++++++++++++++++ ...60829145227_AddDurableJobsAndTaskLeases.cs | 79 ++ .../ApplicationContextModelSnapshot.cs | 103 ++ .../Models/ApplicationContext.cs | 55 + .../Models/DurableJob.cs | 22 + .../Models/ScheduledTaskState.cs | 13 + .../DurableJobMetricsBackgroundService.cs | 37 + .../Observability/ReadinessHealthChecks.cs | 111 ++ .../Observability/RuntimeTelemetry.cs | 143 +++ .../SensitiveTagRedactionProcessor.cs | 49 + SecondDimensionWatcherReDive/Program.cs | 165 ++- .../Repositories/AnimationInfoRepository.cs | 31 +- .../Repositories/DurableJobRepository.cs | 218 ++++ ...eMappingRepositoryPostgreSqlTestFixture.cs | 198 ++- .../Repositories/ReadinessRepository.cs | 11 + .../ScheduledTaskLeaseRepository.cs | 95 ++ .../SecondDimensionWatcherReDive.csproj | 7 + .../CompleteDownloadBackgroundService.cs | 392 ++++-- .../FetchRemoteTorrentBackgroundService.cs | 111 +- .../Services/MediaLibraryScanQueue.cs | 10 +- .../NullDownloadCompletionNotifier.cs | 12 + .../PostgresScheduledTaskLeaseManager.cs | 146 +++ .../ScheduledTaskBackgroundService.cs | 56 +- .../RemoteTorrentDownloadClient.cs | 6 +- .../appsettings.example.json | 15 + docs/runtime-reliability.md | 66 + packaging/appsettings.yml | 11 + 56 files changed, 4751 insertions(+), 291 deletions(-) create mode 100644 SecondDimensionWatcherReDive.Framework/DataRepository/IDurableJobRepository.cs create mode 100644 SecondDimensionWatcherReDive.Framework/DataRepository/IReadinessRepository.cs create mode 100644 SecondDimensionWatcherReDive.Framework/DataRepository/IScheduledTaskLeaseRepository.cs create mode 100644 SecondDimensionWatcherReDive.Framework/Notifications/IDownloadCompletionNotifier.cs create mode 100644 SecondDimensionWatcherReDive.Framework/Tasks/IScheduledTaskLeaseManager.cs create mode 100644 SecondDimensionWatcherReDive.IntegrationTest/Health/HealthEndpointTests.cs create mode 100644 SecondDimensionWatcherReDive.Test/DurableJobsControllerTests.cs create mode 100644 SecondDimensionWatcherReDive.Test/ObservabilityTests.cs create mode 100644 SecondDimensionWatcherReDive.Test/ScheduledTaskBaseTests.cs create mode 100644 SecondDimensionWatcherReDive/Controllers/DurableJobsController.cs create mode 100644 SecondDimensionWatcherReDive/Controllers/External/DurableJob.cs create mode 100644 SecondDimensionWatcherReDive/Migrations/20260829145227_AddDurableJobsAndTaskLeases.Designer.cs create mode 100644 SecondDimensionWatcherReDive/Migrations/20260829145227_AddDurableJobsAndTaskLeases.cs create mode 100644 SecondDimensionWatcherReDive/Models/DurableJob.cs create mode 100644 SecondDimensionWatcherReDive/Models/ScheduledTaskState.cs create mode 100644 SecondDimensionWatcherReDive/Observability/DurableJobMetricsBackgroundService.cs create mode 100644 SecondDimensionWatcherReDive/Observability/ReadinessHealthChecks.cs create mode 100644 SecondDimensionWatcherReDive/Observability/RuntimeTelemetry.cs create mode 100644 SecondDimensionWatcherReDive/Observability/SensitiveTagRedactionProcessor.cs create mode 100644 SecondDimensionWatcherReDive/Repositories/DurableJobRepository.cs create mode 100644 SecondDimensionWatcherReDive/Repositories/ReadinessRepository.cs create mode 100644 SecondDimensionWatcherReDive/Repositories/ScheduledTaskLeaseRepository.cs create mode 100644 SecondDimensionWatcherReDive/Services/NullDownloadCompletionNotifier.cs create mode 100644 SecondDimensionWatcherReDive/Services/PostgresScheduledTaskLeaseManager.cs create mode 100644 docs/runtime-reliability.md diff --git a/Plugins/SecondDimensionWatcherReDive.AI/Engines/CodexAppServerEngine.cs b/Plugins/SecondDimensionWatcherReDive.AI/Engines/CodexAppServerEngine.cs index 2735fe5..63d0ccb 100644 --- a/Plugins/SecondDimensionWatcherReDive.AI/Engines/CodexAppServerEngine.cs +++ b/Plugins/SecondDimensionWatcherReDive.AI/Engines/CodexAppServerEngine.cs @@ -226,14 +226,14 @@ await rpc.SendRequestAsync( turnId = GetRequiredNestedString(turnResult, "turn", "id"); state.SetTurnId(turnId); - while (state.Updates.TryDequeue(out var bufferedUpdate)) + while (state.TryDequeueUpdate(out var bufferedUpdate)) yield return bufferedUpdate; while (!state.IsTerminal) { var message = await rpc.ReceiveAsync(cancellationToken); await ProcessTurnMessageAsync(rpc, state, message, cancellationToken); - while (state.Updates.TryDequeue(out var update)) + while (state.TryDequeueUpdate(out var update)) yield return update; } @@ -488,8 +488,8 @@ await rpc.SendErrorAsync(requestId, -32602, } var argumentsJson = arguments.GetRawText(); - state.Updates.Enqueue(new ToolCallBegin(callId, toolName)); - state.Updates.Enqueue(new ToolCallDelta(callId, argumentsJson)); + state.TryEnqueueUpdate(new ToolCallBegin(callId, toolName)); + state.TryEnqueueUpdate(new ToolCallDelta(callId, argumentsJson)); IToolResult toolResult; var executionCanceled = false; @@ -516,7 +516,7 @@ await rpc.SendErrorAsync(requestId, -32602, var serializedResult = JsonSerializer.SerializeToElement( toolResult, toolResult.GetType(), ToolJsonOptions.Options); - state.Updates.Enqueue(new ToolResultUpdate(callId, serializedResult)); + state.TryEnqueueUpdate(new ToolResultUpdate(callId, serializedResult)); if (executionCanceled || cancellationToken.IsCancellationRequested) { @@ -827,17 +827,18 @@ private sealed class TurnState( IToolExecutor? toolExecutor, int maxDynamicToolCalls) { + private const int MaxBufferedUpdates = 1024; private readonly Dictionary _finalAgentTexts = new(StringComparer.Ordinal); private readonly HashSet _completedFinalAgentItems = new(StringComparer.Ordinal); private readonly HashSet _toolCallIds = new(StringComparer.Ordinal); + private readonly Queue _updates = new(); private int _dynamicToolCallCount; public string ThreadId { get; } = threadId; public string? TurnId { get; private set; } public IToolExecutor? ToolExecutor { get; } = toolExecutor; - public Queue Updates { get; } = new(); public bool IsTerminal { get; private set; } public bool ServerTerminalReceived { get; private set; } public string? Status { get; private set; } @@ -854,7 +855,7 @@ public void AppendFinalAgentDelta(string itemId, string delta) _finalAgentTexts[itemId] = accumulated + delta; if (delta.Length > 0) - Updates.Enqueue(new TextDelta(delta)); + TryEnqueueUpdate(new TextDelta(delta)); } public void CompleteFinalAgentItem(string itemId, string authoritativeText) @@ -870,9 +871,24 @@ public void CompleteFinalAgentItem(string itemId, string authoritativeText) var suffix = authoritativeText[accumulated.Length..]; _finalAgentTexts[itemId] = authoritativeText; if (suffix.Length > 0) - Updates.Enqueue(new TextDelta(suffix)); + TryEnqueueUpdate(new TextDelta(suffix)); } + public bool TryEnqueueUpdate(IChatUpdate update) + { + if (_updates.Count < MaxBufferedUpdates) + { + _updates.Enqueue(update); + return true; + } + + Fail($"Codex app-server buffered update limit ({MaxBufferedUpdates}) was exceeded."); + return false; + } + + public bool TryDequeueUpdate(out IChatUpdate update) => + _updates.TryDequeue(out update!); + public bool TryBeginToolCall(string callId, out string rejectionReason) { if (!_toolCallIds.Add(callId)) diff --git a/Plugins/SecondDimensionWatcherReDive.Chat/ChatController.cs b/Plugins/SecondDimensionWatcherReDive.Chat/ChatController.cs index 203ffc1..7420380 100644 --- a/Plugins/SecondDimensionWatcherReDive.Chat/ChatController.cs +++ b/Plugins/SecondDimensionWatcherReDive.Chat/ChatController.cs @@ -190,7 +190,13 @@ private async IAsyncEnumerable> StreamChatEvents( string? model, [EnumeratorCancellation] CancellationToken cancellationToken) { - var channel = Channel.CreateUnbounded>(); + var channel = Channel.CreateBounded>( + new BoundedChannelOptions(256) + { + SingleReader = true, + SingleWriter = true, + FullMode = BoundedChannelFullMode.Wait + }); // Producer: runs AI chat streaming in background, writes SSE items to channel. // Keep the task and await it during iterator disposal so a disconnected request cannot diff --git a/README.md b/README.md index a55a5f4..b6e0b47 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,8 @@ - [x] 按动画分组的主页展示(卡片 + 剧集列表) - [x] 当季番组发现(mikanani.me 爬取)+ 一键订阅 - [x] 后台任务仪表盘(查看状态、手动触发) +- [x] PostgreSQL 持久任务 / Outbox(崩溃恢复、指数重试、死信处理、多实例租约) +- [x] 存活与就绪探针、Prometheus 指标及 OpenTelemetry 链路 - [x] 一次性数据迁移框架(`MigrationMarkers` 表幂等记录) - [x] 插件事件系统(下载前 / 下载完成后钩子) - [x] 多语言界面(简体中文 / English / 日本語) @@ -105,11 +107,15 @@ bash <(curl -fsSL https://raw.githubusercontent.com/HCGStudio/SecondDimensionWat | `AI:CodexAppServer:Endpoint` / `BearerToken` / `Model` / `PermissionProfile` / `TimeoutSeconds` | Codex app-server WebSocket 端点;空模型使用服务端默认模型;权限配置默认 `:read-only`,也可填写管理员定义的 profile id | | `Inference:RateLimitDelayMs` | 推断 API 调用最小间隔(毫秒,默认 1000) | | `Valkey:ConnectionString` | Valkey / Redis 连接(可选;为空则使用内存缓存) | +| `Health:ValkeyRequired` / `QbittorrentRequired` / `StorageRequired` / `AIRequired` | `/health/ready` 的依赖要求;PostgreSQL 始终必需,AI 默认不阻塞就绪 | +| `OpenTelemetry:OtlpEndpoint` | 可选 OTLP Collector 地址;Prometheus `/metrics` 无需配置即启用 | > 使用现有媒体库导入前,必须至少配置一个 `MediaLibrary:AllowedRoots`。导入源必须位于白名单内,且不能与 `FileStore:Local` 管理的下载目录相同、互为父目录或以其他方式重叠。导入与后续对账只会修改数据库中的媒体记录和虚拟路径映射;系统绝不会移动、重命名或删除原文件。短暂缺失的条目会先撤下映射并保留观看/审核记录,超过 `MissingGracePeriod`(默认 24 小时)后才清理数据库记录。 > 从 v2.2 之前升级:旧的 `Inference:ApiKey/Provider/Model` 已迁移到 `AI:` 前缀。运行 `deployments/migrate-config.sh` 自动迁移;包管理器安装时 `postinstall.sh` 会自动执行。 +运行探针、持久任务恢复、死信操作和遥测标签约束详见 [运行可靠性与可观测性](docs/runtime-reliability.md)。 + ### 网页运行时设置 登录后打开「设置」,可修改 AI 执行模式与 Provider、AI/TMDB 密钥、qBittorrent、媒体库扫描、异常检测和 NFS。保存值存入 PostgreSQL,并覆盖部署文件或环境变量中的默认值;密钥和密码使用持久化 Data Protection 密钥环加密,API 不会回显明文。可对单个敏感项选择保留、替换、清除或恢复部署默认值。 diff --git a/SecondDimensionWatcherReDive.Client/mock-server.mjs b/SecondDimensionWatcherReDive.Client/mock-server.mjs index f5ee4d4..9c7c57f 100644 --- a/SecondDimensionWatcherReDive.Client/mock-server.mjs +++ b/SecondDimensionWatcherReDive.Client/mock-server.mjs @@ -2589,10 +2589,53 @@ async function route(method, pathname, searchParams, req, res) { }, ]; + const mockDurableJobs = + globalThis._mockDurableJobs ?? + (globalThis._mockDurableJobs = [ + { + id: randomUUID(), + type: "downloadCompletion", + status: "deadLetter", + stage: "mapFiles", + attemptCount: 8, + createdAt: new Date(Date.now() - 3_600_000).toISOString(), + updatedAt: new Date(Date.now() - 60_000).toISOString(), + nextAttemptAt: new Date(Date.now() - 60_000).toISOString(), + lastAttemptAt: new Date(Date.now() - 60_000).toISOString(), + completedAt: null, + lastError: "InvalidOperationException: No file mapping could be produced.", + }, + ]); + if (method === "GET" && pathname === "/api/tasks") { return json(res, MOCK_TASKS); } + if (method === "GET" && pathname === "/api/jobs") { + const status = searchParams.get("status"); + const items = status + ? mockDurableJobs.filter( + (job) => job.status.toLowerCase() === status.toLowerCase(), + ) + : mockDurableJobs; + return json(res, { items, totalCount: items.length }); + } + + if ( + method === "POST" && + (pathname === "/api/jobs/retry" || pathname === "/api/jobs/resolve") + ) { + const body = await readBody(req); + const ids = new Set(Array.isArray(body.ids) ? body.ids : []); + let affectedCount = 0; + for (let index = mockDurableJobs.length - 1; index >= 0; index--) { + if (!ids.has(mockDurableJobs[index].id)) continue; + mockDurableJobs.splice(index, 1); + affectedCount++; + } + return json(res, { affectedCount }); + } + // POST /api/tasks/:id/run { const m = pathname.match(/^\/api\/tasks\/(.+)\/run$/); diff --git a/SecondDimensionWatcherReDive.Client/src/i18n/locales/en/tasks.json b/SecondDimensionWatcherReDive.Client/src/i18n/locales/en/tasks.json index b00d2b8..551e15c 100644 --- a/SecondDimensionWatcherReDive.Client/src/i18n/locales/en/tasks.json +++ b/SecondDimensionWatcherReDive.Client/src/i18n/locales/en/tasks.json @@ -29,6 +29,37 @@ "success": "Task \"{{name}}\" finished", "failure": "Task \"{{name}}\" failed" }, + "deadLetters": { + "title": "Failed durable jobs", + "description": "Jobs that exhausted automatic retries. Retry them after fixing the cause, or mark them handled.", + "empty": "No durable jobs require attention.", + "loadFailed": "Could not load failed durable jobs.", + "retry": "Retry", + "resolve": "Mark handled", + "columns": { + "type": "Job", + "stage": "Failed stage", + "attempts": "Attempts", + "updated": "Last attempt", + "error": "Last error", + "actions": "Actions" + }, + "types": { + "downloadCompletion": "Download completion" + }, + "stages": { + "mapFiles": "Map files", + "notify": "Notify", + "invokePlugins": "Invoke plugins", + "done": "Finalize" + }, + "toast": { + "retrySuccess": "Job queued for retry", + "retryFailure": "Could not retry the job", + "resolveSuccess": "Job marked handled", + "resolveFailure": "Could not mark the job handled" + } + }, "metadata": { "SyncFeed": { "name": "SyncFeed", diff --git a/SecondDimensionWatcherReDive.Client/src/i18n/locales/ja/tasks.json b/SecondDimensionWatcherReDive.Client/src/i18n/locales/ja/tasks.json index 1112f71..9ef237f 100644 --- a/SecondDimensionWatcherReDive.Client/src/i18n/locales/ja/tasks.json +++ b/SecondDimensionWatcherReDive.Client/src/i18n/locales/ja/tasks.json @@ -26,6 +26,37 @@ "success": "タスク「{{name}}」を完了しました", "failure": "タスク「{{name}}」が失敗しました" }, + "deadLetters": { + "title": "失敗した永続ジョブ", + "description": "自動再試行を使い切ったジョブです。原因を修正して再試行するか、処理済みにしてください。", + "empty": "対応が必要な永続ジョブはありません。", + "loadFailed": "失敗した永続ジョブを読み込めませんでした。", + "retry": "再試行", + "resolve": "処理済みにする", + "columns": { + "type": "ジョブ", + "stage": "失敗ステージ", + "attempts": "試行回数", + "updated": "最終試行", + "error": "最終エラー", + "actions": "操作" + }, + "types": { + "downloadCompletion": "ダウンロード完了処理" + }, + "stages": { + "mapFiles": "ファイルのマッピング", + "notify": "通知", + "invokePlugins": "プラグインの呼び出し", + "done": "完了" + }, + "toast": { + "retrySuccess": "ジョブを再試行キューに追加しました", + "retryFailure": "ジョブを再試行できませんでした", + "resolveSuccess": "ジョブを処理済みにしました", + "resolveFailure": "ジョブを処理済みにできませんでした" + } + }, "metadata": { "SyncFeed": { "name": "SyncFeed", diff --git a/SecondDimensionWatcherReDive.Client/src/i18n/locales/zh-CN/tasks.json b/SecondDimensionWatcherReDive.Client/src/i18n/locales/zh-CN/tasks.json index fad79a7..b0ab651 100644 --- a/SecondDimensionWatcherReDive.Client/src/i18n/locales/zh-CN/tasks.json +++ b/SecondDimensionWatcherReDive.Client/src/i18n/locales/zh-CN/tasks.json @@ -26,6 +26,37 @@ "success": "任务「{{name}}」执行完成", "failure": "任务「{{name}}」执行失败" }, + "deadLetters": { + "title": "失败的持久任务", + "description": "这些任务已耗尽自动重试次数。修复原因后可重新执行,或将其标记为已处理。", + "empty": "当前没有需要处理的持久任务。", + "loadFailed": "无法加载失败的持久任务。", + "retry": "重试", + "resolve": "标记已处理", + "columns": { + "type": "任务", + "stage": "失败阶段", + "attempts": "尝试次数", + "updated": "最后尝试", + "error": "最后错误", + "actions": "操作" + }, + "types": { + "downloadCompletion": "下载完成处理" + }, + "stages": { + "mapFiles": "建立文件映射", + "notify": "发送通知", + "invokePlugins": "调用插件", + "done": "完成" + }, + "toast": { + "retrySuccess": "任务已加入重试队列", + "retryFailure": "无法重试该任务", + "resolveSuccess": "任务已标记为处理完成", + "resolveFailure": "无法标记该任务" + } + }, "metadata": { "SyncFeed": { "name": "SyncFeed", diff --git a/SecondDimensionWatcherReDive.Client/src/pages/TasksPage.tsx b/SecondDimensionWatcherReDive.Client/src/pages/TasksPage.tsx index 8e0626b..e23ed81 100644 --- a/SecondDimensionWatcherReDive.Client/src/pages/TasksPage.tsx +++ b/SecondDimensionWatcherReDive.Client/src/pages/TasksPage.tsx @@ -1,16 +1,23 @@ -import { AlertTriangle, Loader2, Play } from "lucide-react"; import React from "react"; import { useTranslation } from "react-i18next"; +import { + AlertTriangle, + CheckCircle2, + Loader2, + Play, + RotateCcw, +} from "lucide-react"; + import { useToast } from "../components/ToastProvider"; import { Button } from "../components/ui/Button"; import { EmptyPrompt } from "../components/ui/EmptyPrompt"; import { Spinner } from "../components/ui/Spinner"; import { Table, type TableColumn } from "../components/ui/Table"; -import { useTasks } from "../tasks/hooks"; +import { useDeadLetterJobs, useTasks } from "../tasks/hooks"; import { useTaskMetadata } from "../tasks/taskMetadata"; -import { runTask } from "../tasks/utils"; -import { ITask } from "../tasks/types"; +import { IDurableJob, ITask } from "../tasks/types"; +import { resolveJobs, retryJobs, runTask } from "../tasks/utils"; import { PageTemplate } from "./PageTemplate"; function useFormatInterval(): (interval: string) => string { @@ -43,8 +50,18 @@ export const TasksPage: React.FC = () => { const getTaskMetadata = useTaskMetadata(); const formatInterval = useFormatInterval(); const { data: tasks, error, mutate } = useTasks(); + const { + data: deadLetters, + error: deadLetterError, + mutate: mutateDeadLetters, + } = useDeadLetterJobs(); const { addToast } = useToast(); - const [runningTasks, setRunningTasks] = React.useState>(new Set()); + const [runningTasks, setRunningTasks] = React.useState>( + new Set(), + ); + const [mutatingJobs, setMutatingJobs] = React.useState>( + new Set(), + ); const onRun = React.useCallback( async (id: string) => { @@ -72,6 +89,36 @@ export const TasksPage: React.FC = () => { [mutate, addToast, t, getTaskMetadata], ); + const mutateJob = React.useCallback( + async (job: IDurableJob, action: "retry" | "resolve") => { + setMutatingJobs((previous) => new Set(previous).add(job.id)); + try { + const result = + action === "retry" + ? await retryJobs([job.id]) + : await resolveJobs([job.id]); + if (result.affectedCount !== 1) throw new Error("job state changed"); + await mutateDeadLetters(); + addToast({ + title: t(`tasks:deadLetters.toast.${action}Success`), + color: "success", + }); + } catch { + addToast({ + title: t(`tasks:deadLetters.toast.${action}Failure`), + color: "danger", + }); + } finally { + setMutatingJobs((previous) => { + const next = new Set(previous); + next.delete(job.id); + return next; + }); + } + }, + [addToast, mutateDeadLetters, t], + ); + const columns: TableColumn[] = [ { name: t("tasks:columns.name"), @@ -79,7 +126,8 @@ export const TasksPage: React.FC = () => { }, { name: t("tasks:columns.description"), - render: (_value: any, item: ITask) => getTaskMetadata(item.id).description, + render: (_value: any, item: ITask) => + getTaskMetadata(item.id).description, }, { field: "interval", @@ -122,6 +170,65 @@ export const TasksPage: React.FC = () => { }, ]; + const deadLetterColumns: TableColumn[] = [ + { + field: "type", + name: t("tasks:deadLetters.columns.type"), + render: () => t("tasks:deadLetters.types.downloadCompletion"), + }, + { + field: "stage", + name: t("tasks:deadLetters.columns.stage"), + render: (value: IDurableJob["stage"]) => + t(`tasks:deadLetters.stages.${value}`), + }, + { + field: "attemptCount", + name: t("tasks:deadLetters.columns.attempts"), + }, + { + field: "updatedAt", + name: t("tasks:deadLetters.columns.updated"), + render: (value: string) => new Date(value).toLocaleString(), + }, + { + field: "lastError", + name: t("tasks:deadLetters.columns.error"), + render: (value: string | null) => value ?? "-", + truncateText: true, + }, + { + name: t("tasks:deadLetters.columns.actions"), + render: (_value: unknown, item: IDurableJob) => { + const disabled = mutatingJobs.has(item.id); + return ( +
+ + +
+ ); + }, + width: "230px", + }, + ]; + return (

@@ -145,6 +252,28 @@ export const TasksPage: React.FC = () => { body={

{t("tasks:empty.body")}

} /> )} + +

+ {t("tasks:deadLetters.title")} +

+

+ {t("tasks:deadLetters.description")} +

+ {deadLetterError ? ( +

+ {t("tasks:deadLetters.loadFailed")} +

+ ) : !deadLetters ? ( +
+ +
+ ) : deadLetters.items.length > 0 ? ( + + ) : ( +

+ {t("tasks:deadLetters.empty")} +

+ )} ); }; diff --git a/SecondDimensionWatcherReDive.Client/src/tasks/hooks.ts b/SecondDimensionWatcherReDive.Client/src/tasks/hooks.ts index cacb821..f8361d9 100644 --- a/SecondDimensionWatcherReDive.Client/src/tasks/hooks.ts +++ b/SecondDimensionWatcherReDive.Client/src/tasks/hooks.ts @@ -1,7 +1,12 @@ import useSWR from "swr"; import fetcher from "../auth/httpClient"; -import { ITask } from "./types"; +import { IDurableJobPage, ITask } from "./types"; export const useTasks = () => useSWR("/api/tasks", fetcher, { refreshInterval: 3000 }); + +export const useDeadLetterJobs = () => + useSWR("/api/jobs?status=deadLetter&take=100", fetcher, { + refreshInterval: 5000, + }); diff --git a/SecondDimensionWatcherReDive.Client/src/tasks/types.ts b/SecondDimensionWatcherReDive.Client/src/tasks/types.ts index f065b84..2f89d3f 100644 --- a/SecondDimensionWatcherReDive.Client/src/tasks/types.ts +++ b/SecondDimensionWatcherReDive.Client/src/tasks/types.ts @@ -5,3 +5,22 @@ export interface ITask { lastRunAt: string | null; isRunning: boolean; } + +export interface IDurableJob { + id: string; + type: "downloadCompletion"; + status: "deadLetter"; + stage: "mapFiles" | "notify" | "invokePlugins" | "done"; + attemptCount: number; + createdAt: string; + updatedAt: string; + nextAttemptAt: string; + lastAttemptAt: string | null; + completedAt: string | null; + lastError: string | null; +} + +export interface IDurableJobPage { + items: IDurableJob[]; + totalCount: number; +} diff --git a/SecondDimensionWatcherReDive.Client/src/tasks/utils.ts b/SecondDimensionWatcherReDive.Client/src/tasks/utils.ts index 0c18130..aa4ec30 100644 --- a/SecondDimensionWatcherReDive.Client/src/tasks/utils.ts +++ b/SecondDimensionWatcherReDive.Client/src/tasks/utils.ts @@ -3,3 +3,14 @@ import fetcher from "../auth/httpClient"; export const runTask = async (id: string) => { return await fetcher(`/api/tasks/${id}/run`, { method: "POST" }); }; + +const mutateJobs = async (action: "retry" | "resolve", ids: string[]) => + await fetcher<{ affectedCount: number }>(`/api/jobs/${action}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ ids }), + }); + +export const retryJobs = async (ids: string[]) => mutateJobs("retry", ids); + +export const resolveJobs = async (ids: string[]) => mutateJobs("resolve", ids); diff --git a/SecondDimensionWatcherReDive.Framework/DataRepository/IDurableJobRepository.cs b/SecondDimensionWatcherReDive.Framework/DataRepository/IDurableJobRepository.cs new file mode 100644 index 0000000..7a496f0 --- /dev/null +++ b/SecondDimensionWatcherReDive.Framework/DataRepository/IDurableJobRepository.cs @@ -0,0 +1,110 @@ +namespace SecondDimensionWatcherReDive.Framework.DataRepository; + +public enum DurableJobType +{ + DownloadCompletion +} + +public enum DurableJobStatus +{ + Pending, + Processing, + Completed, + DeadLetter, + Resolved +} + +public enum DurableJobStage +{ + MapFiles, + Notify, + InvokePlugins, + Done +} + +public sealed record DurableJob( + Guid Id, + string DeduplicationKey, + DurableJobType Type, + DurableJobStatus Status, + DurableJobStage Stage, + string PayloadJson, + int AttemptCount, + DateTimeOffset CreatedAt, + DateTimeOffset UpdatedAt, + DateTimeOffset NextAttemptAt, + DateTimeOffset? LastAttemptAt, + DateTimeOffset? CompletedAt, + string? LeaseOwner, + DateTimeOffset? LeaseExpiresAt, + string? LastError); + +public sealed record DurableJobPage( + IReadOnlyList Items, + int TotalCount); + +public sealed record DurableJobStatistics( + int PendingCount, + int ProcessingCount, + int DeadLetterCount, + double OldestPendingAgeSeconds); + +public sealed record DownloadCompletionJobPayload( + Guid ItemId, + string StorePath, + string FileStore, + Guid? DownloadAttemptId); + +public interface IDurableJobRepository +{ + Task> ClaimDueAsync( + string workerId, + DateTimeOffset now, + DateTimeOffset leaseUntil, + int take, + CancellationToken cancellationToken); + + Task AdvanceStageAsync( + Guid id, + string workerId, + DurableJobStage expectedStage, + DurableJobStage nextStage, + DateTimeOffset now, + CancellationToken cancellationToken); + + Task RenewLeaseAsync( + Guid id, + string workerId, + DateTimeOffset now, + DateTimeOffset leaseUntil, + CancellationToken cancellationToken); + + Task MarkFailedAsync( + Guid id, + string workerId, + int attemptCount, + DateTimeOffset attemptedAt, + DateTimeOffset? nextAttemptAt, + string error, + CancellationToken cancellationToken); + + Task GetPageAsync( + DurableJobStatus? status, + int skip, + int take, + CancellationToken cancellationToken); + + Task RetryAsync( + IReadOnlyCollection ids, + DateTimeOffset now, + CancellationToken cancellationToken); + + Task ResolveAsync( + IReadOnlyCollection ids, + DateTimeOffset now, + CancellationToken cancellationToken); + + Task GetStatisticsAsync( + DateTimeOffset now, + CancellationToken cancellationToken); +} diff --git a/SecondDimensionWatcherReDive.Framework/DataRepository/IReadinessRepository.cs b/SecondDimensionWatcherReDive.Framework/DataRepository/IReadinessRepository.cs new file mode 100644 index 0000000..b65eba4 --- /dev/null +++ b/SecondDimensionWatcherReDive.Framework/DataRepository/IReadinessRepository.cs @@ -0,0 +1,6 @@ +namespace SecondDimensionWatcherReDive.Framework.DataRepository; + +public interface IReadinessRepository +{ + Task CanConnectAsync(CancellationToken cancellationToken); +} diff --git a/SecondDimensionWatcherReDive.Framework/DataRepository/IScheduledTaskLeaseRepository.cs b/SecondDimensionWatcherReDive.Framework/DataRepository/IScheduledTaskLeaseRepository.cs new file mode 100644 index 0000000..34b6424 --- /dev/null +++ b/SecondDimensionWatcherReDive.Framework/DataRepository/IScheduledTaskLeaseRepository.cs @@ -0,0 +1,28 @@ +namespace SecondDimensionWatcherReDive.Framework.DataRepository; + +public interface IScheduledTaskLeaseRepository +{ + Task TryAcquireAsync( + string taskId, + string ownerId, + DateTimeOffset now, + DateTimeOffset leaseUntil, + bool force, + CancellationToken cancellationToken); + + Task RenewAsync( + string taskId, + string ownerId, + DateTimeOffset now, + DateTimeOffset leaseUntil, + CancellationToken cancellationToken); + + Task CompleteAsync( + string taskId, + string ownerId, + DateTimeOffset completedAt, + DateTimeOffset leaseUntil, + bool succeeded, + string? error, + CancellationToken cancellationToken); +} diff --git a/SecondDimensionWatcherReDive.Framework/Notifications/IDownloadCompletionNotifier.cs b/SecondDimensionWatcherReDive.Framework/Notifications/IDownloadCompletionNotifier.cs new file mode 100644 index 0000000..94d95ef --- /dev/null +++ b/SecondDimensionWatcherReDive.Framework/Notifications/IDownloadCompletionNotifier.cs @@ -0,0 +1,15 @@ +using SecondDimensionWatcherReDive.Framework.DataRepository; + +namespace SecondDimensionWatcherReDive.Framework.Notifications; + +/// +/// Optional notification effect in the durable download-completion workflow. +/// Implementations must treat as an idempotency key. +/// +public interface IDownloadCompletionNotifier +{ + Task NotifyAsync( + Guid eventId, + DownloadCompletionJobPayload payload, + CancellationToken cancellationToken); +} diff --git a/SecondDimensionWatcherReDive.Framework/PluginParams/FileDownloadCompleteParam.cs b/SecondDimensionWatcherReDive.Framework/PluginParams/FileDownloadCompleteParam.cs index c0187bd..e0adc16 100644 --- a/SecondDimensionWatcherReDive.Framework/PluginParams/FileDownloadCompleteParam.cs +++ b/SecondDimensionWatcherReDive.Framework/PluginParams/FileDownloadCompleteParam.cs @@ -1,8 +1,18 @@ namespace SecondDimensionWatcherReDive.Framework.PluginParams; -public class FileDownloadCompleteParam(Guid itemId, string storePath, string fileStore) +public class FileDownloadCompleteParam( + Guid itemId, + string storePath, + string fileStore, + Guid? eventId = null) { + /// + /// Stable identifier for this completion workflow. Plugin handlers can persist + /// it as an idempotency key before performing externally visible work. + /// + public Guid EventId { get; } = eventId ?? itemId; + public Guid ItemId { get; set; } = itemId; public string StorePath { get; set; } = storePath; public string FileStore { get; set; } = fileStore; -} \ No newline at end of file +} diff --git a/SecondDimensionWatcherReDive.Framework/Tasks/IScheduledTaskLeaseManager.cs b/SecondDimensionWatcherReDive.Framework/Tasks/IScheduledTaskLeaseManager.cs new file mode 100644 index 0000000..2a0f1bd --- /dev/null +++ b/SecondDimensionWatcherReDive.Framework/Tasks/IScheduledTaskLeaseManager.cs @@ -0,0 +1,23 @@ +namespace SecondDimensionWatcherReDive.Framework.Tasks; + +public sealed class ScheduledTaskLeaseUnavailableException(Exception innerException) + : Exception("The scheduled-task lease store is unavailable.", innerException); + +public interface IScheduledTaskExecutionLease : IAsyncDisposable +{ + CancellationToken LeaseLostToken { get; } + + Task CompleteAsync( + bool succeeded, + string? error, + CancellationToken cancellationToken); +} + +public interface IScheduledTaskLeaseManager +{ + Task TryAcquireAsync( + string taskId, + TimeSpan interval, + bool force, + CancellationToken cancellationToken); +} diff --git a/SecondDimensionWatcherReDive.Framework/Tasks/ScheduledTaskBase.cs b/SecondDimensionWatcherReDive.Framework/Tasks/ScheduledTaskBase.cs index 7a9a2e0..fa025c7 100644 --- a/SecondDimensionWatcherReDive.Framework/Tasks/ScheduledTaskBase.cs +++ b/SecondDimensionWatcherReDive.Framework/Tasks/ScheduledTaskBase.cs @@ -4,10 +4,16 @@ namespace SecondDimensionWatcherReDive.Framework.Tasks; public abstract class ScheduledTaskBase : IScheduledTask { - private readonly Channel _runQueue = - Channel.CreateUnbounded( - new UnboundedChannelOptions { SingleReader = true }); - + private readonly Channel _runQueue = Channel.CreateBounded( + new BoundedChannelOptions(1) + { + SingleReader = true, + SingleWriter = false, + FullMode = BoundedChannelFullMode.DropWrite + }); + private readonly object _sync = new(); + private TaskCompletionSource? _pendingRun; + private bool _pendingForce; private volatile bool _isRunning; private DateTimeOffset? _lastRunAt; @@ -19,55 +25,157 @@ public abstract class ScheduledTaskBase : IScheduledTask public async Task RunNowAsync(CancellationToken cancellationToken) { - var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - - await using var registration = cancellationToken.Register( - () => tcs.TrySetCanceled(cancellationToken)); - - await _runQueue.Writer.WriteAsync(tcs, cancellationToken); - await tcs.Task; + var completion = QueueRun(force: true); + // Cancelling one HTTP request must not cancel the shared execution that + // other callers and the periodic scheduler are awaiting. + await completion.WaitAsync(cancellationToken); } /// - /// Enqueues a run request without waiting for completion. + /// Runs a periodic signal and reports whether this instance acquired the + /// distributed lease. Hosting services use the result to poll quickly + /// while another instance owns an unfinished run. /// - public void Enqueue() - { - var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - _runQueue.Writer.TryWrite(tcs); - } + public Task RunScheduledAsync(CancellationToken cancellationToken) => + QueueRun(force: false).WaitAsync(cancellationToken); /// - /// Sequentially processes queued run requests. Called by the hosting - /// BackgroundService; runs for the lifetime of the host. + /// Coalesces a run request without waiting for completion. At most one + /// pending signal exists while the current execution is in flight. /// - public async Task ProcessQueueAsync(CancellationToken cancellationToken) + public void Enqueue() => QueueRun(force: true); + + /// + /// Sequentially processes coalesced run requests. A PostgreSQL lease + /// ensures only one application instance executes a task at a time. + /// + public async Task ProcessQueueAsync( + IScheduledTaskLeaseManager leaseManager, + CancellationToken cancellationToken) { - await foreach (var tcs in _runQueue.Reader.ReadAllAsync(cancellationToken)) + await foreach (var _ in _runQueue.Reader.ReadAllAsync(cancellationToken)) { - if (tcs.Task.IsCanceled) continue; + TaskCompletionSource? completion; + bool force; + lock (_sync) + { + completion = _pendingRun; + force = _pendingForce; + } + if (completion is null) continue; - _isRunning = true; + IScheduledTaskExecutionLease? lease; try { - await ExecuteTaskAsync(cancellationToken); - _lastRunAt = DateTimeOffset.UtcNow; - tcs.TrySetResult(); + lease = await leaseManager.TryAcquireAsync( + Id, + Interval, + force, + cancellationToken); } - catch (OperationCanceledException ex) + catch (OperationCanceledException exception) when (cancellationToken.IsCancellationRequested) { - tcs.TrySetCanceled(ex.CancellationToken); + completion.TrySetCanceled(exception.CancellationToken); + FinishRun(completion); + throw; } - catch (Exception ex) + catch (Exception exception) { - tcs.TrySetException(ex); + completion.TrySetException( + new ScheduledTaskLeaseUnavailableException(exception)); + FinishRun(completion); + continue; } - finally + + if (lease is null) + { + // Another instance owns the same periodic task. Its local timer + // will drive the execution; this duplicate signal is complete. + completion.TrySetResult(false); + FinishRun(completion); + continue; + } + + await using (lease) { - _isRunning = false; + using var executionCancellation = CancellationTokenSource.CreateLinkedTokenSource( + cancellationToken, + lease.LeaseLostToken); + _isRunning = true; + try + { + await ExecuteTaskAsync(executionCancellation.Token); + _lastRunAt = DateTimeOffset.UtcNow; + await lease.CompleteAsync(true, null, cancellationToken); + completion.TrySetResult(true); + } + catch (OperationCanceledException exception) + { + // On host shutdown or lease loss, leave the lease to expire so + // another instance can resume without an overlapping run. + completion.TrySetCanceled(exception.CancellationToken); + if (cancellationToken.IsCancellationRequested) + throw; + } + catch (Exception exception) + { + try + { + await lease.CompleteAsync( + false, + exception.GetType().Name, + cancellationToken); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception leaseException) + { + completion.TrySetException( + new ScheduledTaskLeaseUnavailableException(leaseException)); + continue; + } + completion.TrySetException(exception); + } + finally + { + _isRunning = false; + FinishRun(completion); + } } } } protected abstract Task ExecuteTaskAsync(CancellationToken cancellationToken); + + private Task QueueRun(bool force) + { + lock (_sync) + { + if (_pendingRun is { Task.IsCompleted: false }) + { + _pendingForce |= force; + return _pendingRun.Task; + } + + _pendingForce = force; + _pendingRun = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + _runQueue.Writer.TryWrite(0); + return _pendingRun.Task; + } + } + + private void FinishRun(TaskCompletionSource completion) + { + lock (_sync) + { + if (ReferenceEquals(_pendingRun, completion)) + { + _pendingRun = null; + _pendingForce = false; + } + } + } } diff --git a/SecondDimensionWatcherReDive.IntegrationTest/Health/HealthEndpointTests.cs b/SecondDimensionWatcherReDive.IntegrationTest/Health/HealthEndpointTests.cs new file mode 100644 index 0000000..5297e13 --- /dev/null +++ b/SecondDimensionWatcherReDive.IntegrationTest/Health/HealthEndpointTests.cs @@ -0,0 +1,47 @@ +using System.Net; +using System.Text.Json; + +namespace SecondDimensionWatcherReDive.IntegrationTest.Health; + +[TestClass] +public sealed class HealthEndpointTests +{ + private WebDavWebApplicationFactory _factory = null!; + + [TestInitialize] + public void Setup() => _factory = new WebDavWebApplicationFactory(); + + [TestCleanup] + public void Cleanup() => _factory.Dispose(); + + [TestMethod] + public async Task Liveness_HasNoExternalChecks_WhenReadinessDependencyFails() + { + using var client = _factory.CreateUnauthenticatedClient(); + + using var live = await client.GetAsync("/health/live"); + using var ready = await client.GetAsync("/health/ready"); + + Assert.AreEqual(HttpStatusCode.OK, live.StatusCode); + using var liveBody = await JsonDocument.ParseAsync( + await live.Content.ReadAsStreamAsync()); + Assert.AreEqual(0, liveBody.RootElement.GetProperty("checks").EnumerateObject().Count()); + Assert.AreEqual(HttpStatusCode.ServiceUnavailable, ready.StatusCode); + } + + [TestMethod] + public async Task Metrics_ArePublicAndDoNotExposeRawPathLabels() + { + using var client = _factory.CreateUnauthenticatedClient(); + using var _ = await client.GetAsync("/health/live?private=value"); + + using var response = await client.GetAsync("/metrics"); + var body = await response.Content.ReadAsStringAsync(); + + Assert.AreEqual(HttpStatusCode.OK, response.StatusCode); + StringAssert.Contains(body, "target_info"); + Assert.IsFalse(body.Contains("url.path", StringComparison.OrdinalIgnoreCase)); + Assert.IsFalse(body.Contains("url_path", StringComparison.OrdinalIgnoreCase)); + Assert.IsFalse(body.Contains("private=value", StringComparison.Ordinal)); + } +} diff --git a/SecondDimensionWatcherReDive.IntegrationTest/PostgreSql/FileMappingRepositoryPostgreSqlTests.cs b/SecondDimensionWatcherReDive.IntegrationTest/PostgreSql/FileMappingRepositoryPostgreSqlTests.cs index 1a06401..3e53a88 100644 --- a/SecondDimensionWatcherReDive.IntegrationTest/PostgreSql/FileMappingRepositoryPostgreSqlTests.cs +++ b/SecondDimensionWatcherReDive.IntegrationTest/PostgreSql/FileMappingRepositoryPostgreSqlTests.cs @@ -1,4 +1,5 @@ using Microsoft.EntityFrameworkCore; +using System.Text.Json; using Testcontainers.PostgreSql; using SecondDimensionWatcherReDive.Framework.DataRepository; using SecondDimensionWatcherReDive.Repositories; @@ -88,6 +89,198 @@ static async Task WriteAsync(FileMappingRepositoryPostgreSqlTestFixture fi CollectionAssert.AreEquivalent(new long[] { 0, 1 }, versions); } + [TestMethod] + public async Task DownloadCompletion_CommitsStateAndOneDurableJobTogether() + { + var seed = await Fixture.SeedTrackedAnimationAsync(CancellationToken.None); + const string StorePath = "/store/completed"; + + await Fixture.CompleteTrackedAnimationAsync( + seed.ItemId, seed.AttemptId, StorePath, CancellationToken.None); + await Fixture.CompleteTrackedAnimationAsync( + seed.ItemId, seed.AttemptId, StorePath, CancellationToken.None); + + var state = await Fixture.GetCompletionStateAsync( + seed.ItemId, CancellationToken.None); + Assert.IsTrue(state.IsFinished); + Assert.AreEqual(1, state.JobCount); + Assert.AreEqual(seed.ItemId, state.Payload.ItemId); + Assert.AreEqual(seed.AttemptId, state.Payload.DownloadAttemptId); + Assert.AreEqual(StorePath, state.Payload.StorePath); + } + + [TestMethod] + public async Task DurableJobClaim_AllowsOnlyOneWorker() + { + var now = DateTimeOffset.UtcNow; + var job = Job(DurableJobStatus.Pending, now); + await Fixture.SeedDurableJobAsync(job, CancellationToken.None); + + var claims = await Task.WhenAll( + Fixture.ClaimDueJobsAsync("worker-a", now, CancellationToken.None), + Fixture.ClaimDueJobsAsync("worker-b", now, CancellationToken.None)); + + Assert.AreEqual(1, claims.Sum(claim => claim.Count)); + Assert.AreEqual(job.Id, claims.SelectMany(claim => claim).Single().Id); + } + + [TestMethod] + public async Task DurableJobLease_RenewalProtectsSlowConsumerAndStillExpires() + { + var now = DateTimeOffset.UtcNow; + var job = Job(DurableJobStatus.Pending, now); + await Fixture.SeedDurableJobAsync(job, CancellationToken.None); + Assert.HasCount(1, await Fixture.ClaimDueJobsAsync( + "worker-a", now, CancellationToken.None)); + Assert.IsTrue(await Fixture.RenewDurableJobLeaseAsync( + job.Id, + "worker-a", + now.AddSeconds(30), + now.AddMinutes(2), + CancellationToken.None)); + + Assert.IsEmpty(await Fixture.ClaimDueJobsAsync( + "worker-b", now.AddSeconds(61), CancellationToken.None)); + Assert.HasCount(1, await Fixture.ClaimDueJobsAsync( + "worker-b", now.AddSeconds(121), CancellationToken.None)); + } + + [TestMethod] + public async Task DurableJobLease_ExpiredOwnerCannotAdvanceStage() + { + var now = DateTimeOffset.UtcNow; + var job = Job(DurableJobStatus.Pending, now); + await Fixture.SeedDurableJobAsync(job, CancellationToken.None); + Assert.HasCount(1, await Fixture.ClaimDueJobsAsync( + "worker-a", now, CancellationToken.None)); + + Assert.IsFalse(await Fixture.AdvanceDurableJobAsync( + job.Id, + "worker-a", + DurableJobStage.MapFiles, + DurableJobStage.Notify, + now.AddMinutes(2), + CancellationToken.None)); + } + + [TestMethod] + public async Task ScheduledTaskLease_HasSingleOwnerAndExpiresForTakeover() + { + var now = DateTimeOffset.UtcNow; + var first = await Fixture.TryAcquireTaskLeaseAsync( + "SyncFeed", "instance-a", now, now.AddSeconds(30), false, CancellationToken.None); + var overlapping = await Fixture.TryAcquireTaskLeaseAsync( + "SyncFeed", "instance-b", now.AddSeconds(1), now.AddSeconds(31), false, CancellationToken.None); + var takeover = await Fixture.TryAcquireTaskLeaseAsync( + "SyncFeed", "instance-b", now.AddSeconds(31), now.AddSeconds(61), false, CancellationToken.None); + + Assert.IsTrue(first); + Assert.IsFalse(overlapping); + Assert.IsTrue(takeover); + } + + [TestMethod] + public async Task ScheduledTaskLease_NormalCompletionPreventsDuplicateUntilNextDueTime() + { + var now = DateTimeOffset.UtcNow; + Assert.IsTrue(await Fixture.TryAcquireTaskLeaseAsync( + "ScrapeSeasonBangumi", + "instance-a", + now, + now.AddSeconds(30), + false, + CancellationToken.None)); + await Fixture.CompleteTaskLeaseAsync( + "ScrapeSeasonBangumi", + "instance-a", + now.AddSeconds(5), + now.AddMinutes(10), + CancellationToken.None); + + Assert.IsFalse(await Fixture.TryAcquireTaskLeaseAsync( + "ScrapeSeasonBangumi", + "instance-b", + now.AddSeconds(31), + now.AddMinutes(1), + false, + CancellationToken.None)); + Assert.IsTrue(await Fixture.TryAcquireTaskLeaseAsync( + "ScrapeSeasonBangumi", + "instance-b", + now.AddMinutes(11), + now.AddMinutes(12), + false, + CancellationToken.None)); + } + + [TestMethod] + public async Task ScheduledTaskLease_ManualRunCanOverrideCompletedCooldown() + { + var now = DateTimeOffset.UtcNow; + Assert.IsTrue(await Fixture.TryAcquireTaskLeaseAsync( + "InferAnimationMetadata", + "instance-a", + now, + now.AddSeconds(30), + false, + CancellationToken.None)); + await Fixture.CompleteTaskLeaseAsync( + "InferAnimationMetadata", + "instance-a", + now.AddSeconds(5), + now.AddMinutes(30), + CancellationToken.None); + + Assert.IsTrue(await Fixture.TryAcquireTaskLeaseAsync( + "InferAnimationMetadata", + "instance-b", + now.AddSeconds(31), + now.AddMinutes(1), + true, + CancellationToken.None)); + } + + [TestMethod] + public async Task DeadLetterJobs_CanBeRetriedOrMarkedHandled() + { + var now = DateTimeOffset.UtcNow; + var retried = Job(DurableJobStatus.DeadLetter, now); + var resolved = Job(DurableJobStatus.DeadLetter, now); + await Fixture.SeedDurableJobAsync(retried, CancellationToken.None); + await Fixture.SeedDurableJobAsync(resolved, CancellationToken.None); + + Assert.AreEqual(1, await Fixture.RetryJobsAsync( + [retried.Id], now, CancellationToken.None)); + Assert.AreEqual(1, await Fixture.ResolveJobsAsync( + [resolved.Id], now, CancellationToken.None)); + Assert.AreEqual(DurableJobStatus.Pending, await Fixture.GetJobStatusAsync( + retried.Id, CancellationToken.None)); + Assert.AreEqual(DurableJobStatus.Resolved, await Fixture.GetJobStatusAsync( + resolved.Id, CancellationToken.None)); + } + private static FileMapping Mapping(Guid animationInfoId, string virtualPath) => new(Guid.NewGuid(), animationInfoId, virtualPath, "/physical/" + Guid.NewGuid(), "local"); + + private static DurableJob Job(DurableJobStatus status, DateTimeOffset now) + { + var id = Guid.NewGuid(); + return new DurableJob( + id, + $"test:{id:N}", + DurableJobType.DownloadCompletion, + status, + DurableJobStage.MapFiles, + JsonSerializer.Serialize(new DownloadCompletionJobPayload( + Guid.NewGuid(), "/store", "local", Guid.NewGuid())), + status == DurableJobStatus.DeadLetter ? 8 : 0, + now, + now, + now, + now, + null, + null, + null, + status == DurableJobStatus.DeadLetter ? "failed" : null); + } } diff --git a/SecondDimensionWatcherReDive.IntegrationTest/WebDavWebApplicationFactory.cs b/SecondDimensionWatcherReDive.IntegrationTest/WebDavWebApplicationFactory.cs index ae5cf79..00dde62 100644 --- a/SecondDimensionWatcherReDive.IntegrationTest/WebDavWebApplicationFactory.cs +++ b/SecondDimensionWatcherReDive.IntegrationTest/WebDavWebApplicationFactory.cs @@ -122,6 +122,7 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) services.RemoveAll(); services.RemoveAll(); services.RemoveAll(); + services.RemoveAll(); services.AddSingleton(FileStoreMock.Object); services.AddSingleton(FileStoreProviderMock.Object); @@ -130,6 +131,7 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) services.AddSingleton(_ => new FakeWebDavTokenRepository(TestUserName, BCrypt.Net.BCrypt.HashPassword(TestPassword))); services.AddSingleton(); + services.AddSingleton(); }); } @@ -190,6 +192,12 @@ public string GenerateScript( public bool HasPendingModelChanges() => false; } + private sealed class UnavailableReadinessRepository : IReadinessRepository + { + public Task CanConnectAsync(CancellationToken cancellationToken) => + Task.FromResult(false); + } + private sealed class FakeApplicationSettingsRepository : IApplicationSettingsRepository { private readonly object _gate = new(); diff --git a/SecondDimensionWatcherReDive.Test/CodexAppServerEngineTests.cs b/SecondDimensionWatcherReDive.Test/CodexAppServerEngineTests.cs index 887fe92..7e146e1 100644 --- a/SecondDimensionWatcherReDive.Test/CodexAppServerEngineTests.cs +++ b/SecondDimensionWatcherReDive.Test/CodexAppServerEngineTests.cs @@ -253,6 +253,32 @@ [new UserMessage("use tools")], Assert.IsNotNull(transport.SingleSent("turn/interrupt")); } + [TestMethod] + public async Task ChatAsync_BufferedUpdateOverflowFailsClosed() + { + var messages = new List + { + Response(1, "{}"), + Response(2, PermissionProfiles()), + Response(3, SafeThread()), + """{"method":"item/started","params":{"threadId":"thread-1","turnId":"turn-1","item":{"id":"message-1","type":"agentMessage","phase":"final_answer","text":""}}}""" + }; + messages.AddRange(Enumerable.Range(0, 1025).Select(_ => + """{"method":"item/agentMessage/delta","params":{"threadId":"thread-1","turnId":"turn-1","itemId":"message-1","delta":"x"}}""")); + messages.Add(Response(4, """{"turn":{"id":"turn-1"}}""")); + var transport = new ScriptedTransport(messages.ToArray()); + var (engine, _) = CreateEngine(transport); + + var exception = await Assert.ThrowsExactlyAsync(() => + CollectAsync(engine.ChatAsync( + [new UserMessage("overflow")], + null, + CancellationToken.None))); + + StringAssert.Contains(exception.Message, "buffered update limit (1024)"); + Assert.IsNotNull(transport.SingleSent("turn/interrupt")); + } + [TestMethod] public async Task GetAvailableModelsAsync_FollowsModelListPagination() { diff --git a/SecondDimensionWatcherReDive.Test/CompleteDownloadBackgroundServiceTests.cs b/SecondDimensionWatcherReDive.Test/CompleteDownloadBackgroundServiceTests.cs index 7c402db..1f5ebe2 100644 --- a/SecondDimensionWatcherReDive.Test/CompleteDownloadBackgroundServiceTests.cs +++ b/SecondDimensionWatcherReDive.Test/CompleteDownloadBackgroundServiceTests.cs @@ -1,14 +1,15 @@ +using System.Text.Json; using System.Threading.Channels; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Moq; using SecondDimensionWatcherReDive.Data; using SecondDimensionWatcherReDive.Framework.DataRepository; +using SecondDimensionWatcherReDive.Framework.Notifications; using SecondDimensionWatcherReDive.Framework.PluginParams; using SecondDimensionWatcherReDive.Plugin; using SecondDimensionWatcherReDive.Services; using SecondDimensionWatcherReDive.Utils.FileStore; -using SecondDimensionWatcherReDive.Utils.Incidents; namespace SecondDimensionWatcherReDive.Test; @@ -16,120 +17,261 @@ namespace SecondDimensionWatcherReDive.Test; public sealed class CompleteDownloadBackgroundServiceTests { [TestMethod] - public async Task ProcessRequestAsync_CancelledDownload_IgnoresLateCompletion() + public async Task ProcessClaimedJobAsync_ResumesAtPersistedStage() { - var request = new DownloadCompleteRequest( - Guid.NewGuid(), - "/downloads/item", - "local", - Guid.NewGuid()); - var repository = new Mock(); - repository.Setup(candidate => candidate.TryCompleteDownloadAsync( - request.ItemId, - request.DownloadAttemptId, - request.FileStore, - request.StorePath, + var job = CreateJob(DurableJobStage.Notify); + var payload = JsonSerializer.Deserialize(job.PayloadJson)!; + var repository = new Mock(); + repository.Setup(candidate => candidate.AdvanceStageAsync( + job.Id, + It.IsAny(), + It.IsAny(), + It.IsAny(), It.IsAny(), - CancellationToken.None)) - .ReturnsAsync((AnimationInfo?)null); + It.IsAny())) + .ReturnsAsync(true); var mapper = new Mock(); + var notifier = new Mock(); var plugin = new Mock>(); - var reporter = new Mock(); - using var provider = CreateProvider(repository.Object, mapper.Object, plugin.Object); - var service = new CompleteDownloadBackgroundService( - Channel.CreateUnbounded(), - provider.GetRequiredService(), - Mock.Of>(), - reporter.Object); + using var provider = CreateProvider( + repository.Object, + mapper.Object, + notifier.Object, + plugin.Object); + var service = CreateService(provider); - await service.ProcessRequestAsync(request, CancellationToken.None); + await service.ProcessClaimedJobAsync( + provider, + repository.Object, + job, + CancellationToken.None); mapper.Verify(candidate => candidate.MapDownloadAsync( It.IsAny(), It.IsAny()), Times.Never); + notifier.Verify(candidate => candidate.NotifyAsync( + job.Id, + It.Is(value => value == payload), + It.IsAny()), Times.Once); plugin.Verify(candidate => candidate.InvokeAsync( - It.IsAny(), It.IsAny()), Times.Never); - reporter.VerifyNoOtherCalls(); + It.Is(value => + value.EventId == job.Id + && value.ItemId == payload.ItemId + && value.StorePath == payload.StorePath + && value.FileStore == payload.FileStore), + It.IsAny()), Times.Once); + repository.Verify(candidate => candidate.AdvanceStageAsync( + job.Id, + It.IsAny(), + DurableJobStage.Notify, + DurableJobStage.InvokePlugins, + It.IsAny(), + It.IsAny()), Times.Once); + repository.Verify(candidate => candidate.AdvanceStageAsync( + job.Id, + It.IsAny(), + DurableJobStage.InvokePlugins, + DurableJobStage.Done, + It.IsAny(), + It.IsAny()), Times.Once); } [TestMethod] - public async Task ProcessRequestAsync_TrackedDownload_CompletesBeforeMappingAndPlugin() + public async Task ProcessClaimedJobAsync_PluginStageDoesNotReplayPriorEffects() { - var request = new DownloadCompleteRequest( - Guid.NewGuid(), - "/downloads/item", - "local", - Guid.NewGuid()); - var info = CreateInfo(request.ItemId); - var repository = new Mock(); - repository.Setup(candidate => candidate.TryCompleteDownloadAsync( - request.ItemId, - request.DownloadAttemptId, - request.FileStore, - request.StorePath, + var job = CreateJob(DurableJobStage.InvokePlugins); + var repository = new Mock(); + repository.Setup(candidate => candidate.AdvanceStageAsync( + job.Id, + It.IsAny(), + DurableJobStage.InvokePlugins, + DurableJobStage.Done, It.IsAny(), - CancellationToken.None)) - .ReturnsAsync(info); + It.IsAny())) + .ReturnsAsync(true); + var mapper = new Mock(); + var notifier = new Mock(); + var plugin = new Mock>(); + using var provider = CreateProvider( + repository.Object, + mapper.Object, + notifier.Object, + plugin.Object); + + await CreateService(provider).ProcessClaimedJobAsync( + provider, + repository.Object, + job, + CancellationToken.None); + + mapper.VerifyNoOtherCalls(); + notifier.VerifyNoOtherCalls(); + plugin.Verify(candidate => candidate.InvokeAsync( + It.Is(value => value.EventId == job.Id), + It.IsAny()), Times.Once); + } + + [TestMethod] + public async Task ProcessClaimedJobAsync_FailureSchedulesExponentialRetry() + { + var job = CreateJob(DurableJobStage.MapFiles, attemptCount: 2); + var repository = new Mock(); var mapper = new Mock(); mapper.Setup(candidate => candidate.MapDownloadAsync( - request.ItemId, - CancellationToken.None)) - .ReturnsAsync(true); + It.IsAny(), It.IsAny())) + .ReturnsAsync(false); + using var provider = CreateProvider( + repository.Object, + mapper.Object, + Mock.Of(), + Mock.Of>()); + var service = CreateService(provider); + + await service.ProcessClaimedJobAsync( + provider, + repository.Object, + job, + CancellationToken.None); + + repository.Verify(candidate => candidate.MarkFailedAsync( + job.Id, + It.IsAny(), + 3, + It.IsAny(), + It.Is(retry => retry.HasValue), + It.Is(error => error.Contains("InvalidOperationException")), + CancellationToken.None), Times.Once); + Assert.AreEqual(TimeSpan.FromSeconds(20), + CompleteDownloadBackgroundService.RetryDelay(3)); + } + + [TestMethod] + public async Task ProcessClaimedJobAsync_LastFailureEntersDeadLetter() + { + var job = CreateJob( + DurableJobStage.InvokePlugins, + CompleteDownloadBackgroundService.MaxAttempts - 1); + var repository = new Mock(); var plugin = new Mock>(); plugin.Setup(candidate => candidate.InvokeAsync( It.IsAny(), - CancellationToken.None)) - .Returns(Task.CompletedTask); - using var provider = CreateProvider(repository.Object, mapper.Object, plugin.Object); + It.IsAny())) + .ThrowsAsync(new InvalidOperationException("plugin unavailable")); + using var provider = CreateProvider( + repository.Object, + Mock.Of(), + Mock.Of(), + plugin.Object); + var service = CreateService(provider); + + await service.ProcessClaimedJobAsync( + provider, + repository.Object, + job, + CancellationToken.None); + + repository.Verify(candidate => candidate.MarkFailedAsync( + job.Id, + It.IsAny(), + CompleteDownloadBackgroundService.MaxAttempts, + It.IsAny(), + null, + It.IsAny(), + CancellationToken.None), Times.Once); + } + + [TestMethod] + public async Task ExecuteAsync_TemporaryRepositoryFailureDoesNotStopWorker() + { + var repository = new Mock(); + repository.SetupSequence(candidate => candidate.ClaimDueAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ThrowsAsync(new InvalidOperationException("database unavailable")) + .ReturnsAsync([]); + using var provider = CreateProvider( + repository.Object, + Mock.Of(), + Mock.Of(), + Mock.Of>()); + var channel = Channel.CreateBounded(1); var service = new CompleteDownloadBackgroundService( - Channel.CreateUnbounded(), + channel, provider.GetRequiredService(), - Mock.Of>(), - Mock.Of()); + Mock.Of>()); - await service.ProcessRequestAsync(request, CancellationToken.None); + await service.StartAsync(CancellationToken.None); + channel.Writer.TryWrite(new DownloadCompleteRequest( + Guid.NewGuid(), "/store", "local", Guid.NewGuid())); + await WaitUntilAsync( + () => repository.Invocations.Count(invocation => + invocation.Method.Name == nameof(IDurableJobRepository.ClaimDueAsync)) >= 2, + TimeSpan.FromSeconds(2)); + await service.StopAsync(CancellationToken.None); - mapper.Verify(candidate => candidate.MapDownloadAsync( - request.ItemId, CancellationToken.None), Times.Once); - plugin.Verify(candidate => candidate.InvokeAsync( - It.Is(parameter => - parameter.ItemId == request.ItemId - && parameter.StorePath == request.StorePath - && parameter.FileStore == request.FileStore), - CancellationToken.None), Times.Once); + repository.Verify(candidate => candidate.ClaimDueAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny()), Times.AtLeast(2)); } + private static CompleteDownloadBackgroundService CreateService( + ServiceProvider provider) => new( + Channel.CreateBounded(1), + provider.GetRequiredService(), + Mock.Of>()); + private static ServiceProvider CreateProvider( - IAnimationInfoRepository repository, + IDurableJobRepository repository, IFileMapper mapper, - IPluginEventTrigger plugin) - { - return new ServiceCollection() + IDownloadCompletionNotifier notifier, + IPluginEventTrigger plugin) => + new ServiceCollection() .AddSingleton(repository) .AddSingleton(mapper) + .AddSingleton(notifier) .AddSingleton(plugin) .BuildServiceProvider(); + + private static DurableJob CreateJob( + DurableJobStage stage, + int attemptCount = 0) + { + var now = DateTimeOffset.UtcNow; + return new DurableJob( + Guid.NewGuid(), + $"completion:{Guid.NewGuid():N}", + DurableJobType.DownloadCompletion, + DurableJobStatus.Processing, + stage, + JsonSerializer.Serialize(new DownloadCompletionJobPayload( + Guid.NewGuid(), + "/downloads/item", + "local", + Guid.NewGuid())), + attemptCount, + now, + now, + now, + null, + null, + "worker", + now.AddMinutes(1), + null); } - private static AnimationInfo CreateInfo(Guid id) => new( - id, - "Title", - "Description", - DateTimeOffset.UtcNow, - "https://example.test/item.torrent", - "torrent", - [], - "hash", - true, - DateTimeOffset.UtcNow, - DateTimeOffset.UtcNow, - true, - "local", - "/downloads/item", - 1, - 1, - null, - null, - true, - 0, - AutomationDisposition: SubscriptionAutomationDisposition.DownloadCompleted); + private static async Task WaitUntilAsync(Func condition, TimeSpan timeout) + { + var deadline = DateTimeOffset.UtcNow + timeout; + while (!condition()) + { + if (DateTimeOffset.UtcNow >= deadline) + Assert.Fail("Timed out waiting for the worker to retry."); + await Task.Delay(10); + } + } } diff --git a/SecondDimensionWatcherReDive.Test/DurableJobsControllerTests.cs b/SecondDimensionWatcherReDive.Test/DurableJobsControllerTests.cs new file mode 100644 index 0000000..952e4d9 --- /dev/null +++ b/SecondDimensionWatcherReDive.Test/DurableJobsControllerTests.cs @@ -0,0 +1,72 @@ +using Microsoft.AspNetCore.Mvc; +using Moq; +using SecondDimensionWatcherReDive.Controllers; +using SecondDimensionWatcherReDive.Controllers.External; +using SecondDimensionWatcherReDive.Framework.DataRepository; + +namespace SecondDimensionWatcherReDive.Test; + +[TestClass] +public sealed class DurableJobsControllerTests +{ + [TestMethod] + public async Task GetAsync_MapsDeadLettersWithoutPayloadOrLeaseData() + { + var now = DateTimeOffset.UtcNow; + var job = new DurableJob( + Guid.NewGuid(), + "secret-deduplication-key", + DurableJobType.DownloadCompletion, + DurableJobStatus.DeadLetter, + DurableJobStage.Notify, + "{\"storePath\":\"/secret/path\"}", + 8, + now, + now, + now, + now, + null, + "private-host:worker", + now, + "InvalidOperationException"); + var repository = new Mock(); + repository.Setup(candidate => candidate.GetPageAsync( + DurableJobStatus.DeadLetter, + 0, + 50, + CancellationToken.None)) + .ReturnsAsync(new DurableJobPage([job], 1)); + var controller = new DurableJobsController(repository.Object); + + var result = await controller.GetAsync( + "deadLetter", 0, 50, CancellationToken.None); + + var response = (DurableJobListResponse)((OkObjectResult)result).Value!; + Assert.HasCount(1, response.Items); + Assert.AreEqual(job.Id, response.Items[0].Id); + Assert.AreEqual("notify", response.Items[0].Stage); + Assert.IsNull(typeof(DurableJobItem).GetProperty("PayloadJson")); + Assert.IsNull(typeof(DurableJobItem).GetProperty("LeaseOwner")); + Assert.IsNull(typeof(DurableJobItem).GetProperty("DeduplicationKey")); + } + + [TestMethod] + public async Task RetryAsync_DeduplicatesIds() + { + var id = Guid.NewGuid(); + var repository = new Mock(); + repository.Setup(candidate => candidate.RetryAsync( + It.Is>(ids => ids.Count == 1 && ids.Contains(id)), + It.IsAny(), + CancellationToken.None)) + .ReturnsAsync(1); + var controller = new DurableJobsController(repository.Object); + + var result = await controller.RetryAsync( + new DurableJobMutationRequest([id, id]), + CancellationToken.None); + + var response = (DurableJobMutationResponse)((OkObjectResult)result).Value!; + Assert.AreEqual(1, response.AffectedCount); + } +} diff --git a/SecondDimensionWatcherReDive.Test/ObservabilityTests.cs b/SecondDimensionWatcherReDive.Test/ObservabilityTests.cs new file mode 100644 index 0000000..1be8fe3 --- /dev/null +++ b/SecondDimensionWatcherReDive.Test/ObservabilityTests.cs @@ -0,0 +1,96 @@ +using System.Diagnostics; +using System.Diagnostics.Metrics; +using SecondDimensionWatcherReDive.Framework.DataRepository; +using SecondDimensionWatcherReDive.Observability; + +namespace SecondDimensionWatcherReDive.Test; + +[TestClass] +public sealed class ObservabilityTests +{ + [TestMethod] + public void SensitiveTagProcessor_RemovesPathsQueriesAndStatements() + { + using var activity = new Activity("request").Start(); + activity.SetTag("url.path", "/anime/private-title"); + activity.SetTag("url.query", "token=secret"); + activity.SetTag("db.statement", "SELECT * FROM users"); + activity.SetTag("tool.arguments", "{ secret: true }"); + activity.SetTag("http.route", "/anime/{id}"); + + new SensitiveTagRedactionProcessor().OnEnd(activity); + + Assert.IsNull(activity.GetTagItem("url.path")); + Assert.IsNull(activity.GetTagItem("url.query")); + Assert.IsNull(activity.GetTagItem("db.statement")); + Assert.IsNull(activity.GetTagItem("tool.arguments")); + Assert.AreEqual("/anime/{id}", activity.GetTagItem("http.route")); + } + + [TestMethod] + public void DurableJobMetrics_UseOnlyFixedLowCardinalityTags() + { + var observedKeys = new HashSet(StringComparer.Ordinal); + using var listener = new MeterListener(); + listener.InstrumentPublished = (instrument, candidate) => + { + if (instrument.Meter.Name == RuntimeTelemetry.MeterName) + candidate.EnableMeasurementEvents(instrument); + }; + listener.SetMeasurementEventCallback((_, _, tags, _) => + { + foreach (var tag in tags) + observedKeys.Add(tag.Key); + }); + listener.SetMeasurementEventCallback((_, _, tags, _) => + { + foreach (var tag in tags) + observedKeys.Add(tag.Key); + }); + listener.Start(); + using var telemetry = new RuntimeTelemetry(); + + telemetry.RecordJobAttempt( + DurableJobType.DownloadCompletion, + DurableJobStage.MapFiles, + "retry", + TimeSpan.FromMilliseconds(20)); + + CollectionAssert.AreEquivalent( + new[] { "job.type", "job.stage", "outcome" }, + observedKeys.ToArray()); + Assert.IsFalse(observedKeys.Any(key => + key.Contains("path", StringComparison.OrdinalIgnoreCase) + || key.Contains("title", StringComparison.OrdinalIgnoreCase) + || key.Contains("argument", StringComparison.OrdinalIgnoreCase))); + } + + [TestMethod] + public void ScheduledTaskMetrics_NormalizeUnknownTaskIds() + { + var observed = new Dictionary(StringComparer.Ordinal); + using var listener = new MeterListener(); + listener.InstrumentPublished = (instrument, candidate) => + { + if (instrument.Meter.Name == RuntimeTelemetry.MeterName + && instrument.Name == "sdw.scheduled_task.runs") + candidate.EnableMeasurementEvents(instrument); + }; + listener.SetMeasurementEventCallback((_, _, tags, _) => + { + foreach (var tag in tags) + observed[tag.Key] = tag.Value?.ToString(); + }); + listener.Start(); + using var telemetry = new RuntimeTelemetry(); + + telemetry.RecordScheduledTask( + "private/title/or/tool-arguments", + "failed", + TimeSpan.FromMilliseconds(1)); + + Assert.AreEqual("other", observed["task.id"]); + Assert.AreEqual("failed", observed["outcome"]); + Assert.IsFalse(observed.Values.Contains("private/title/or/tool-arguments")); + } +} diff --git a/SecondDimensionWatcherReDive.Test/ScheduledTaskBaseTests.cs b/SecondDimensionWatcherReDive.Test/ScheduledTaskBaseTests.cs new file mode 100644 index 0000000..94b5105 --- /dev/null +++ b/SecondDimensionWatcherReDive.Test/ScheduledTaskBaseTests.cs @@ -0,0 +1,168 @@ +using SecondDimensionWatcherReDive.Framework.Tasks; +using SecondDimensionWatcherReDive.Services; + +namespace SecondDimensionWatcherReDive.Test; + +[TestClass] +public sealed class ScheduledTaskBaseTests +{ + [TestMethod] + public async Task RunNowAsync_ConcurrentRequestsAreCoalesced() + { + var task = new BlockingTask(); + var leaseManager = new FakeLeaseManager(); + using var cancellation = new CancellationTokenSource(); + var processor = task.ProcessQueueAsync(leaseManager, cancellation.Token); + + var first = task.RunNowAsync(CancellationToken.None); + await task.Started.Task.WaitAsync(TimeSpan.FromSeconds(2)); + var second = task.RunNowAsync(CancellationToken.None); + task.Release.TrySetResult(); + await Task.WhenAll(first, second).WaitAsync(TimeSpan.FromSeconds(2)); + + Assert.AreEqual(1, task.ExecutionCount); + Assert.AreEqual(1, leaseManager.AcquireCount); + Assert.AreEqual(1, leaseManager.Lease.CompletionCount); + + await cancellation.CancelAsync(); + await AssertCanceledAsync(processor); + } + + [TestMethod] + public async Task RunNowAsync_LeaseOwnedByAnotherInstance_SkipsExecution() + { + var task = new BlockingTask(); + var leaseManager = new FakeLeaseManager { Deny = true }; + using var cancellation = new CancellationTokenSource(); + var processor = task.ProcessQueueAsync(leaseManager, cancellation.Token); + + await task.RunNowAsync(CancellationToken.None).WaitAsync(TimeSpan.FromSeconds(2)); + + Assert.AreEqual(0, task.ExecutionCount); + Assert.AreEqual(1, leaseManager.AcquireCount); + Assert.IsTrue(leaseManager.LastForce); + + await cancellation.CancelAsync(); + await AssertCanceledAsync(processor); + } + + [TestMethod] + public async Task RunScheduledAsync_ContentionReportsSkippedWithoutForcingCooldown() + { + var task = new BlockingTask(); + var leaseManager = new FakeLeaseManager { Deny = true }; + using var cancellation = new CancellationTokenSource(); + var processor = task.ProcessQueueAsync(leaseManager, cancellation.Token); + + var executed = await task.RunScheduledAsync(CancellationToken.None) + .WaitAsync(TimeSpan.FromSeconds(2)); + + Assert.IsFalse(executed); + Assert.IsFalse(leaseManager.LastForce); + await cancellation.CancelAsync(); + await AssertCanceledAsync(processor); + } + + [TestMethod] + public async Task RunScheduledAsync_TemporaryLeaseStoreFailureDoesNotStopQueue() + { + var task = new BlockingTask(); + var leaseManager = new FakeLeaseManager + { + AcquireException = new InvalidOperationException("database unavailable") + }; + using var cancellation = new CancellationTokenSource(); + var processor = task.ProcessQueueAsync(leaseManager, cancellation.Token); + + var exception = await Assert.ThrowsExactlyAsync( + () => task.RunScheduledAsync(CancellationToken.None)); + Assert.IsInstanceOfType(exception.InnerException); + + leaseManager.AcquireException = null; + leaseManager.Deny = true; + Assert.IsFalse(await task.RunScheduledAsync(CancellationToken.None)); + await cancellation.CancelAsync(); + await AssertCanceledAsync(processor); + } + + [TestMethod] + public void MediaLibraryQueue_RejectsItemsBeyondCapacity() + { + var queue = new MediaLibraryScanQueue(); + var accepted = Enumerable.Range(0, MediaLibraryScanQueue.Capacity) + .Select(_ => queue.Enqueue(Guid.NewGuid())) + .ToList(); + + Assert.IsTrue(accepted.All(value => value)); + Assert.IsFalse(queue.Enqueue(Guid.NewGuid())); + } + + private sealed class BlockingTask : ScheduledTaskBase + { + public TaskCompletionSource Started { get; } = new( + TaskCreationOptions.RunContinuationsAsynchronously); + public TaskCompletionSource Release { get; } = new( + TaskCreationOptions.RunContinuationsAsynchronously); + public int ExecutionCount { get; private set; } + public override string Id => "test"; + public override TimeSpan Interval => TimeSpan.FromMinutes(1); + + protected override async Task ExecuteTaskAsync(CancellationToken cancellationToken) + { + ExecutionCount++; + Started.TrySetResult(); + await Release.Task.WaitAsync(cancellationToken); + } + } + + private static async Task AssertCanceledAsync(Task task) + { + try + { + await task; + Assert.Fail("Expected the queue processor to be cancelled."); + } + catch (OperationCanceledException) + { + } + } + + private sealed class FakeLeaseManager : IScheduledTaskLeaseManager + { + public FakeLease Lease { get; } = new(); + public bool Deny { get; set; } + public Exception? AcquireException { get; set; } + public int AcquireCount { get; private set; } + public bool LastForce { get; private set; } + + public Task TryAcquireAsync( + string taskId, + TimeSpan interval, + bool force, + CancellationToken cancellationToken) + { + AcquireCount++; + LastForce = force; + if (AcquireException is not null) + return Task.FromException(AcquireException); + return Task.FromResult(Deny ? null : Lease); + } + } + + private sealed class FakeLease : IScheduledTaskExecutionLease + { + public int CompletionCount { get; private set; } + public CancellationToken LeaseLostToken => CancellationToken.None; + + public Task CompleteAsync( + bool succeeded, + string? error, + CancellationToken cancellationToken) + { + CompletionCount++; + return Task.CompletedTask; + } + + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + } +} diff --git a/SecondDimensionWatcherReDive/Controllers/DurableJobsController.cs b/SecondDimensionWatcherReDive/Controllers/DurableJobsController.cs new file mode 100644 index 0000000..05f7e1f --- /dev/null +++ b/SecondDimensionWatcherReDive/Controllers/DurableJobsController.cs @@ -0,0 +1,120 @@ +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using SecondDimensionWatcherReDive.Framework.DataRepository; + +namespace SecondDimensionWatcherReDive.Controllers; + +[ApiController] +[Route("api/jobs")] +[Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)] +internal sealed class DurableJobsController(IDurableJobRepository repository) : ControllerBase +{ + [HttpGet] + public async Task GetAsync( + [FromQuery] string? status, + [FromQuery] int skip = 0, + [FromQuery] int take = 50, + CancellationToken cancellationToken = default) + { + if (skip < 0 || take is < 1 or > 200) + return BadRequest(new + { + message = "skip must be non-negative and take must be between 1 and 200." + }); + if (!TryParseStatus(status, out var parsedStatus)) + return BadRequest(new { message = $"Unknown job status '{status}'." }); + + var page = await repository.GetPageAsync( + parsedStatus, + skip, + take, + cancellationToken); + return Ok(new External.DurableJobListResponse( + page.Items.Select(ToExternal).ToList(), + page.TotalCount)); + } + + [HttpPost("retry")] + public async Task RetryAsync( + [FromBody] External.DurableJobMutationRequest request, + CancellationToken cancellationToken) + { + if (request.Ids is null || request.Ids.Count is < 1 or > 200) + return BadRequest(new { message = "ids must contain between 1 and 200 jobs." }); + + var affected = await repository.RetryAsync( + request.Ids.Distinct().ToList(), + DateTimeOffset.UtcNow, + cancellationToken); + return Ok(new External.DurableJobMutationResponse(affected)); + } + + [HttpPost("resolve")] + public async Task ResolveAsync( + [FromBody] External.DurableJobMutationRequest request, + CancellationToken cancellationToken) + { + if (request.Ids is null || request.Ids.Count is < 1 or > 200) + return BadRequest(new { message = "ids must contain between 1 and 200 jobs." }); + + var affected = await repository.ResolveAsync( + request.Ids.Distinct().ToList(), + DateTimeOffset.UtcNow, + cancellationToken); + return Ok(new External.DurableJobMutationResponse(affected)); + } + + private static External.DurableJobItem ToExternal(DurableJob job) => new( + job.Id, + ToApiValue(job.Type), + ToApiValue(job.Status), + ToApiValue(job.Stage), + job.AttemptCount, + job.CreatedAt, + job.UpdatedAt, + job.NextAttemptAt, + job.LastAttemptAt, + job.CompletedAt, + job.LastError); + + private static bool TryParseStatus(string? value, out DurableJobStatus? status) + { + status = value?.Trim().ToLowerInvariant() switch + { + null or "" => null, + "pending" => DurableJobStatus.Pending, + "processing" => DurableJobStatus.Processing, + "completed" => DurableJobStatus.Completed, + "deadletter" or "dead-letter" => DurableJobStatus.DeadLetter, + "resolved" => DurableJobStatus.Resolved, + _ => (DurableJobStatus?)(-1) + }; + return status != (DurableJobStatus?)(-1); + } + + private static string ToApiValue(DurableJobType type) => type switch + { + DurableJobType.DownloadCompletion => "downloadCompletion", + _ => throw new ArgumentOutOfRangeException(nameof(type), type, null) + }; + + private static string ToApiValue(DurableJobStatus status) => status switch + { + DurableJobStatus.Pending => "pending", + DurableJobStatus.Processing => "processing", + DurableJobStatus.Completed => "completed", + DurableJobStatus.DeadLetter => "deadLetter", + DurableJobStatus.Resolved => "resolved", + _ => throw new ArgumentOutOfRangeException(nameof(status), status, null) + }; + + private static string ToApiValue(DurableJobStage stage) => stage switch + { + DurableJobStage.MapFiles => "mapFiles", + DurableJobStage.Notify => "notify", + DurableJobStage.InvokePlugins => "invokePlugins", + DurableJobStage.Done => "done", + _ => throw new ArgumentOutOfRangeException(nameof(stage), stage, null) + }; +} diff --git a/SecondDimensionWatcherReDive/Controllers/External/AppJsonSerializerContext.cs b/SecondDimensionWatcherReDive/Controllers/External/AppJsonSerializerContext.cs index 0f20309..f11d162 100644 --- a/SecondDimensionWatcherReDive/Controllers/External/AppJsonSerializerContext.cs +++ b/SecondDimensionWatcherReDive/Controllers/External/AppJsonSerializerContext.cs @@ -58,6 +58,10 @@ namespace SecondDimensionWatcherReDive.Controllers.External; [JsonSerializable(typeof(IncidentListResponse))] [JsonSerializable(typeof(IncidentRetryError))] [JsonSerializable(typeof(IncidentRetryBatchResponse))] +[JsonSerializable(typeof(DurableJobItem))] +[JsonSerializable(typeof(DurableJobListResponse))] +[JsonSerializable(typeof(DurableJobMutationRequest))] +[JsonSerializable(typeof(DurableJobMutationResponse))] [JsonSerializable(typeof(MediaLibrarySourceResponse))] [JsonSerializable(typeof(List))] [JsonSerializable(typeof(CreateMediaLibrarySourceRequest))] diff --git a/SecondDimensionWatcherReDive/Controllers/External/DurableJob.cs b/SecondDimensionWatcherReDive/Controllers/External/DurableJob.cs new file mode 100644 index 0000000..32cfea3 --- /dev/null +++ b/SecondDimensionWatcherReDive/Controllers/External/DurableJob.cs @@ -0,0 +1,22 @@ +namespace SecondDimensionWatcherReDive.Controllers.External; + +public sealed record DurableJobItem( + Guid Id, + string Type, + string Status, + string Stage, + int AttemptCount, + DateTimeOffset CreatedAt, + DateTimeOffset UpdatedAt, + DateTimeOffset NextAttemptAt, + DateTimeOffset? LastAttemptAt, + DateTimeOffset? CompletedAt, + string? LastError); + +public sealed record DurableJobListResponse( + IReadOnlyList Items, + int TotalCount); + +public sealed record DurableJobMutationRequest(IReadOnlyList Ids); + +public sealed record DurableJobMutationResponse(int AffectedCount); diff --git a/SecondDimensionWatcherReDive/Migrations/20260829145227_AddDurableJobsAndTaskLeases.Designer.cs b/SecondDimensionWatcherReDive/Migrations/20260829145227_AddDurableJobsAndTaskLeases.Designer.cs new file mode 100644 index 0000000..5b9dcda --- /dev/null +++ b/SecondDimensionWatcherReDive/Migrations/20260829145227_AddDurableJobsAndTaskLeases.Designer.cs @@ -0,0 +1,1086 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using SecondDimensionWatcherReDive.Models; + +#nullable disable + +namespace SecondDimensionWatcherReDive.Migrations +{ + [DbContext(typeof(ApplicationContext))] + [Migration("20260829145227_AddDurableJobsAndTaskLeases")] + partial class AddDurableJobsAndTaskLeases + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.11") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.Animation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("OriginalName") + .IsRequired() + .HasColumnType("text"); + + b.Property("PosterPath") + .HasColumnType("text"); + + b.Property("TmdbId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("TmdbId") + .IsUnique(); + + b.ToTable("Animations"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.AnimationGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("AnimationGroups"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.AnimationInfo", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AdditionalDownloadInfo") + .IsRequired() + .HasColumnType("text"); + + b.Property("AiRetryCount") + .HasColumnType("integer"); + + b.Property("AnimationId") + .HasColumnType("uuid"); + + b.Property("AutomationDisposition") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("AutomationExplanationJson") + .HasColumnType("text"); + + b.Property("CachedDownloadData") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("CurrentMetadataReviewOperationId") + .HasColumnType("uuid"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("DownloadAttemptId") + .HasColumnType("uuid"); + + b.Property("DownloadCancellationId") + .HasColumnType("uuid"); + + b.Property("DownloadEndTime") + .HasColumnType("timestamp with time zone"); + + b.Property("DownloadStartTime") + .HasColumnType("timestamp with time zone"); + + b.Property("DownloadType") + .IsRequired() + .HasColumnType("text"); + + b.Property("DownloadUrl") + .IsRequired() + .HasColumnType("text"); + + b.Property("Episode") + .HasColumnType("integer"); + + b.Property("FileStore") + .HasColumnType("text"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("IsAiProcessed") + .HasColumnType("boolean"); + + b.Property("IsDownloadFinished") + .HasColumnType("boolean"); + + b.Property("IsDownloadTracked") + .HasColumnType("boolean"); + + b.Property("MediaLibraryMissingSince") + .HasColumnType("timestamp with time zone"); + + b.Property("MediaLibrarySourceId") + .HasColumnType("uuid"); + + b.Property("MetadataConfidence") + .HasColumnType("double precision"); + + b.Property("MetadataLastError") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("MetadataReviewedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("MetadataStatus") + .HasColumnType("integer"); + + b.Property("PublishTime") + .HasColumnType("timestamp with time zone"); + + b.Property("ReleaseSizeBytes") + .HasColumnType("bigint"); + + b.Property("Season") + .HasColumnType("integer"); + + b.Property("SourceFeedId") + .HasColumnType("uuid"); + + b.Property("StateVersion") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.Property("StorePath") + .HasColumnType("text"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("AnimationId"); + + b.HasIndex("CurrentMetadataReviewOperationId") + .IsUnique(); + + b.HasIndex("GroupId"); + + b.HasIndex("MediaLibrarySourceId"); + + b.HasIndex("SourceFeedId"); + + b.HasIndex("FileStore", "StorePath") + .IsUnique() + .HasFilter("\"DownloadType\" = 'http://schemas.hcgstudio.com/ws/2023/06/sdw/downloadtype/media-library-import'"); + + b.HasIndex("MetadataStatus", "PublishTime"); + + b.ToTable("AnimationInfo", t => + { + t.HasCheckConstraint("CK_AnimationInfo_MetadataConfidence_Range", "\"MetadataConfidence\" IS NULL OR (\"MetadataConfidence\" >= 0 AND \"MetadataConfidence\" <= 1)"); + }); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.ApplicationSettings", b => + { + b.Property("Id") + .HasColumnType("integer"); + + b.Property("ProtectedSecrets") + .HasColumnType("text"); + + b.Property("Revision") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ValuesJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.HasKey("Id"); + + b.ToTable("ApplicationSettings", t => + { + t.HasCheckConstraint("CK_ApplicationSettings_Revision_Positive", "\"Revision\" > 0"); + + t.HasCheckConstraint("CK_ApplicationSettings_Singleton", "\"Id\" = 1"); + }); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.BangumiSubgroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("MikanSubgroupId") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("ScrapedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SeasonBangumiId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("SeasonBangumiId", "MikanSubgroupId") + .IsUnique(); + + b.ToTable("BangumiSubgroups"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.ChatConversation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Title") + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("ChatConversations"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.ChatMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Content") + .HasColumnType("text"); + + b.Property("ConversationId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Order") + .HasColumnType("integer"); + + b.Property("Role") + .IsRequired() + .HasColumnType("text"); + + b.Property("ToolCallId") + .HasColumnType("text"); + + b.Property("ToolCallsJson") + .HasColumnType("text"); + + b.Property("ToolName") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ConversationId"); + + b.ToTable("ChatMessages"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.DurableJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AttemptCount") + .HasColumnType("integer"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeduplicationKey") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("LastAttemptAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastError") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("LeaseExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LeaseOwner") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("NextAttemptAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PayloadJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Stage") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(24) + .HasColumnType("character varying(24)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(48) + .HasColumnType("character varying(48)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("DeduplicationKey") + .IsUnique(); + + b.HasIndex("Status", "NextAttemptAt", "LeaseExpiresAt"); + + b.ToTable("DurableJobs"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.Feed", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Url") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Feeds"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.FileMapping", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AnimationInfoId") + .HasColumnType("uuid"); + + b.Property("FileStore") + .IsRequired() + .HasColumnType("text"); + + b.Property("PhysicalPath") + .IsRequired() + .HasColumnType("text"); + + b.Property("VirtualPath") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("AnimationInfoId"); + + b.HasIndex("VirtualPath") + .IsUnique(); + + b.ToTable("FileMappings"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.FileNameRegexRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AnimationId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Pattern") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.HasKey("Id"); + + b.HasIndex("AnimationId", "CreatedAt"); + + b.HasIndex("AnimationId", "Pattern") + .IsUnique(); + + b.ToTable("FileNameRegexRules"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.Incident", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Detail") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("DetectedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Fingerprint") + .IsRequired() + .HasMaxLength(96) + .HasColumnType("character varying(96)"); + + b.Property("LastRetryAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastRetryError") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("ResolvedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RetryCount") + .HasColumnType("integer"); + + b.Property("Severity") + .HasColumnType("integer"); + + b.Property("SourceId") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("Fingerprint") + .IsUnique(); + + b.HasIndex("ResolvedAt", "Type", "UpdatedAt"); + + b.ToTable("Incidents"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.MediaLibrarySource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsMonitoring") + .HasColumnType("boolean"); + + b.Property("LastError") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("LastImportedCount") + .HasColumnType("integer"); + + b.Property("LastRemovedCount") + .HasColumnType("integer"); + + b.Property("LastScanAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSkippedCount") + .HasColumnType("integer"); + + b.Property("LastUpdatedCount") + .HasColumnType("integer"); + + b.Property("Path") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Path") + .IsUnique(); + + b.ToTable("MediaLibrarySources"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.MetadataReviewMappingSnapshot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("FileStore") + .IsRequired() + .HasColumnType("text"); + + b.Property("Kind") + .HasColumnType("integer"); + + b.Property("OperationId") + .HasColumnType("uuid"); + + b.Property("PhysicalPath") + .IsRequired() + .HasColumnType("text"); + + b.Property("VirtualPath") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("OperationId", "Kind", "VirtualPath") + .IsUnique(); + + b.ToTable("MetadataReviewMappingSnapshots"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.MetadataReviewOperation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AnimationInfoId") + .HasColumnType("uuid"); + + b.Property("AppliedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("AppliedVersion") + .HasColumnType("bigint"); + + b.Property("BaseFileStore") + .HasColumnType("text"); + + b.Property("BaseIsDownloadFinished") + .HasColumnType("boolean"); + + b.Property("BaseStorePath") + .HasColumnType("text"); + + b.Property("BaseVersion") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PreviousAiRetryCount") + .HasColumnType("integer"); + + b.Property("PreviousAnimationId") + .HasColumnType("uuid"); + + b.Property("PreviousConfidence") + .HasColumnType("double precision"); + + b.Property("PreviousCurrentOperationId") + .HasColumnType("uuid"); + + b.Property("PreviousDescription") + .HasColumnType("text"); + + b.Property("PreviousEpisode") + .HasColumnType("integer"); + + b.Property("PreviousGroupId") + .HasColumnType("uuid"); + + b.Property("PreviousIsAiProcessed") + .HasColumnType("boolean"); + + b.Property("PreviousLastError") + .HasColumnType("text"); + + b.Property("PreviousMetadataStatus") + .HasColumnType("integer"); + + b.Property("PreviousReviewedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PreviousSeason") + .HasColumnType("integer"); + + b.Property("ProposedAnimationName") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProposedAnimationOriginalName") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProposedAnimationPosterPath") + .HasColumnType("text"); + + b.Property("ProposedAnimationTmdbId") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProposedDescription") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProposedEpisode") + .HasColumnType("integer"); + + b.Property("ProposedGroupName") + .HasColumnType("text"); + + b.Property("ProposedSeason") + .HasColumnType("integer"); + + b.Property("State") + .HasColumnType("integer"); + + b.Property("UndoneAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("AnimationInfoId", "AppliedVersion") + .IsUnique(); + + b.HasIndex("AnimationInfoId", "State"); + + b.HasIndex("State", "ExpiresAt"); + + b.ToTable("MetadataReviewOperations", t => + { + t.HasCheckConstraint("CK_MetadataReviewOperations_Expiry", "\"ExpiresAt\" > \"CreatedAt\""); + }); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.MigrationMarker", b => + { + b.Property("Key") + .HasColumnType("text"); + + b.Property("AppliedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Key"); + + b.ToTable("MigrationMarkers"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.PlaybackPreference", b => + { + b.Property("UserId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AudioLanguage") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("AudioTrackLabel") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("AutoPlayNext") + .HasColumnType("boolean"); + + b.Property("SubtitleLanguage") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("SubtitleTrackLabel") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("UserId"); + + b.ToTable("PlaybackPreferences"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.PlaybackProgress", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AnimationInfoId") + .HasColumnType("uuid"); + + b.Property("DurationSeconds") + .HasColumnType("double precision"); + + b.Property("IsWatched") + .HasColumnType("boolean"); + + b.Property("PositionSeconds") + .HasColumnType("double precision"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("VirtualPath") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("WatchedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("AnimationInfoId"); + + b.HasIndex("UserId", "AnimationInfoId", "VirtualPath") + .IsUnique(); + + b.HasIndex("UserId", "IsWatched", "UpdatedAt"); + + b.ToTable("PlaybackProgresses", t => + { + t.HasCheckConstraint("CK_PlaybackProgresses_Duration_NonNegative", "\"DurationSeconds\" >= 0"); + + t.HasCheckConstraint("CK_PlaybackProgresses_Position_NonNegative", "\"PositionSeconds\" >= 0"); + }); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.ScheduledTaskState", b => + { + b.Property("TaskId") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("LastCompletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastError") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("LastStartedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSucceededAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LeaseExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LeaseOwner") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("RunCount") + .HasColumnType("bigint"); + + b.HasKey("TaskId"); + + b.ToTable("ScheduledTaskStates"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.SeasonBangumi", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("DayOfWeek") + .HasColumnType("integer"); + + b.Property("ImageUrl") + .HasColumnType("text"); + + b.Property("MikanId") + .HasColumnType("integer"); + + b.Property("ScrapedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("MikanId") + .IsUnique(); + + b.ToTable("SeasonBangumis"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.SubscriptionAutomationPolicy", b => + { + b.Property("FeedId") + .HasColumnType("uuid"); + + b.PrimitiveCollection("Codecs") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.PrimitiveCollection("ExcludedKeywords") + .IsRequired() + .HasColumnType("text[]"); + + b.PrimitiveCollection("Languages") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("MaxSizeBytes") + .HasColumnType("bigint"); + + b.Property("MinSizeBytes") + .HasColumnType("bigint"); + + b.Property("Mode") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.PrimitiveCollection("Resolutions") + .IsRequired() + .HasColumnType("text[]"); + + b.PrimitiveCollection("SubtitleGroups") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("FeedId"); + + b.HasIndex("UpdatedAt"); + + b.ToTable("SubscriptionAutomationPolicies"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.WebDavToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("TokenHash") + .IsRequired() + .HasColumnType("text"); + + b.Property("Username") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("WebDavTokens"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.AnimationInfo", b => + { + b.HasOne("SecondDimensionWatcherReDive.Models.Animation", "Animation") + .WithMany() + .HasForeignKey("AnimationId"); + + b.HasOne("SecondDimensionWatcherReDive.Models.AnimationGroup", "Group") + .WithMany() + .HasForeignKey("GroupId"); + + b.HasOne("SecondDimensionWatcherReDive.Models.MediaLibrarySource", null) + .WithMany() + .HasForeignKey("MediaLibrarySourceId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("SecondDimensionWatcherReDive.Models.Feed", null) + .WithMany() + .HasForeignKey("SourceFeedId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Animation"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.BangumiSubgroup", b => + { + b.HasOne("SecondDimensionWatcherReDive.Models.SeasonBangumi", "SeasonBangumi") + .WithMany("Subgroups") + .HasForeignKey("SeasonBangumiId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SeasonBangumi"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.ChatMessage", b => + { + b.HasOne("SecondDimensionWatcherReDive.Models.ChatConversation", "Conversation") + .WithMany("Messages") + .HasForeignKey("ConversationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Conversation"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.FileNameRegexRule", b => + { + b.HasOne("SecondDimensionWatcherReDive.Models.Animation", null) + .WithMany() + .HasForeignKey("AnimationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.MetadataReviewMappingSnapshot", b => + { + b.HasOne("SecondDimensionWatcherReDive.Models.MetadataReviewOperation", "Operation") + .WithMany("MappingSnapshots") + .HasForeignKey("OperationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Operation"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.MetadataReviewOperation", b => + { + b.HasOne("SecondDimensionWatcherReDive.Models.AnimationInfo", "AnimationInfo") + .WithMany() + .HasForeignKey("AnimationInfoId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AnimationInfo"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.PlaybackProgress", b => + { + b.HasOne("SecondDimensionWatcherReDive.Models.AnimationInfo", "AnimationInfo") + .WithMany() + .HasForeignKey("AnimationInfoId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AnimationInfo"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.SubscriptionAutomationPolicy", b => + { + b.HasOne("SecondDimensionWatcherReDive.Models.Feed", "Feed") + .WithOne() + .HasForeignKey("SecondDimensionWatcherReDive.Models.SubscriptionAutomationPolicy", "FeedId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Feed"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.ChatConversation", b => + { + b.Navigation("Messages"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.MetadataReviewOperation", b => + { + b.Navigation("MappingSnapshots"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.SeasonBangumi", b => + { + b.Navigation("Subgroups"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/SecondDimensionWatcherReDive/Migrations/20260829145227_AddDurableJobsAndTaskLeases.cs b/SecondDimensionWatcherReDive/Migrations/20260829145227_AddDurableJobsAndTaskLeases.cs new file mode 100644 index 0000000..f23a1de --- /dev/null +++ b/SecondDimensionWatcherReDive/Migrations/20260829145227_AddDurableJobsAndTaskLeases.cs @@ -0,0 +1,79 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace SecondDimensionWatcherReDive.Migrations +{ + /// + public partial class AddDurableJobsAndTaskLeases : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "DurableJobs", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + DeduplicationKey = table.Column(type: "character varying(256)", maxLength: 256, nullable: false), + Type = table.Column(type: "character varying(48)", maxLength: 48, nullable: false), + Status = table.Column(type: "character varying(24)", maxLength: 24, nullable: false), + Stage = table.Column(type: "character varying(32)", maxLength: 32, nullable: false), + PayloadJson = table.Column(type: "jsonb", nullable: false), + AttemptCount = table.Column(type: "integer", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: false), + NextAttemptAt = table.Column(type: "timestamp with time zone", nullable: false), + LastAttemptAt = table.Column(type: "timestamp with time zone", nullable: true), + CompletedAt = table.Column(type: "timestamp with time zone", nullable: true), + LeaseOwner = table.Column(type: "character varying(128)", maxLength: 128, nullable: true), + LeaseExpiresAt = table.Column(type: "timestamp with time zone", nullable: true), + LastError = table.Column(type: "character varying(512)", maxLength: 512, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_DurableJobs", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "ScheduledTaskStates", + columns: table => new + { + TaskId = table.Column(type: "character varying(128)", maxLength: 128, nullable: false), + LeaseOwner = table.Column(type: "character varying(128)", maxLength: 128, nullable: true), + LeaseExpiresAt = table.Column(type: "timestamp with time zone", nullable: true), + LastStartedAt = table.Column(type: "timestamp with time zone", nullable: true), + LastCompletedAt = table.Column(type: "timestamp with time zone", nullable: true), + LastSucceededAt = table.Column(type: "timestamp with time zone", nullable: true), + RunCount = table.Column(type: "bigint", nullable: false), + LastError = table.Column(type: "character varying(256)", maxLength: 256, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_ScheduledTaskStates", x => x.TaskId); + }); + + migrationBuilder.CreateIndex( + name: "IX_DurableJobs_DeduplicationKey", + table: "DurableJobs", + column: "DeduplicationKey", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_DurableJobs_Status_NextAttemptAt_LeaseExpiresAt", + table: "DurableJobs", + columns: new[] { "Status", "NextAttemptAt", "LeaseExpiresAt" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "DurableJobs"); + + migrationBuilder.DropTable( + name: "ScheduledTaskStates"); + } + } +} diff --git a/SecondDimensionWatcherReDive/Migrations/ApplicationContextModelSnapshot.cs b/SecondDimensionWatcherReDive/Migrations/ApplicationContextModelSnapshot.cs index 8126b9e..a3a2407 100644 --- a/SecondDimensionWatcherReDive/Migrations/ApplicationContextModelSnapshot.cs +++ b/SecondDimensionWatcherReDive/Migrations/ApplicationContextModelSnapshot.cs @@ -322,6 +322,75 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("ChatMessages"); }); + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.DurableJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AttemptCount") + .HasColumnType("integer"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeduplicationKey") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("LastAttemptAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastError") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("LeaseExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LeaseOwner") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("NextAttemptAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PayloadJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Stage") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(24) + .HasColumnType("character varying(24)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(48) + .HasColumnType("character varying(48)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("DeduplicationKey") + .IsUnique(); + + b.HasIndex("Status", "NextAttemptAt", "LeaseExpiresAt"); + + b.ToTable("DurableJobs"); + }); + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.Feed", b => { b.Property("Id") @@ -753,6 +822,40 @@ protected override void BuildModel(ModelBuilder modelBuilder) }); }); + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.ScheduledTaskState", b => + { + b.Property("TaskId") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("LastCompletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastError") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("LastStartedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSucceededAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LeaseExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LeaseOwner") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("RunCount") + .HasColumnType("bigint"); + + b.HasKey("TaskId"); + + b.ToTable("ScheduledTaskStates"); + }); + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.SeasonBangumi", b => { b.Property("Id") diff --git a/SecondDimensionWatcherReDive/Models/ApplicationContext.cs b/SecondDimensionWatcherReDive/Models/ApplicationContext.cs index 59764ac..fadeb6c 100644 --- a/SecondDimensionWatcherReDive/Models/ApplicationContext.cs +++ b/SecondDimensionWatcherReDive/Models/ApplicationContext.cs @@ -33,9 +33,64 @@ public ApplicationContext(DbContextOptions options) public DbSet PlaybackPreferences { get; set; } public DbSet MediaLibrarySources { get; set; } public DbSet ApplicationSettings { get; set; } + public DbSet DurableJobs { get; set; } + public DbSet ScheduledTaskStates { get; set; } protected override void OnModelCreating(ModelBuilder modelBuilder) { + modelBuilder.Entity() + .HasIndex(job => job.DeduplicationKey) + .IsUnique(); + + modelBuilder.Entity() + .HasIndex(job => new { job.Status, job.NextAttemptAt, job.LeaseExpiresAt }); + + modelBuilder.Entity() + .Property(job => job.DeduplicationKey) + .HasMaxLength(256); + + modelBuilder.Entity() + .Property(job => job.Type) + .HasConversion() + .HasMaxLength(48); + + modelBuilder.Entity() + .Property(job => job.Status) + .HasConversion() + .HasMaxLength(24); + + modelBuilder.Entity() + .Property(job => job.Stage) + .HasConversion() + .HasMaxLength(32); + + modelBuilder.Entity() + .Property(job => job.PayloadJson) + .HasColumnType("jsonb"); + + modelBuilder.Entity() + .Property(job => job.LeaseOwner) + .HasMaxLength(128); + + modelBuilder.Entity() + .Property(job => job.LastError) + .HasMaxLength(512); + + modelBuilder.Entity() + .HasKey(state => state.TaskId); + + modelBuilder.Entity() + .Property(state => state.TaskId) + .HasMaxLength(128); + + modelBuilder.Entity() + .Property(state => state.LeaseOwner) + .HasMaxLength(128); + + modelBuilder.Entity() + .Property(state => state.LastError) + .HasMaxLength(256); + modelBuilder.Entity() .Property(settings => settings.Id) .ValueGeneratedNever(); diff --git a/SecondDimensionWatcherReDive/Models/DurableJob.cs b/SecondDimensionWatcherReDive/Models/DurableJob.cs new file mode 100644 index 0000000..b3fa530 --- /dev/null +++ b/SecondDimensionWatcherReDive/Models/DurableJob.cs @@ -0,0 +1,22 @@ +using SecondDimensionWatcherReDive.Framework.DataRepository; + +namespace SecondDimensionWatcherReDive.Models; + +public sealed class DurableJob +{ + public Guid Id { get; set; } + public string DeduplicationKey { get; set; } = string.Empty; + public DurableJobType Type { get; set; } + public DurableJobStatus Status { get; set; } + public DurableJobStage Stage { get; set; } + public string PayloadJson { get; set; } = string.Empty; + public int AttemptCount { get; set; } + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset UpdatedAt { get; set; } + public DateTimeOffset NextAttemptAt { get; set; } + public DateTimeOffset? LastAttemptAt { get; set; } + public DateTimeOffset? CompletedAt { get; set; } + public string? LeaseOwner { get; set; } + public DateTimeOffset? LeaseExpiresAt { get; set; } + public string? LastError { get; set; } +} diff --git a/SecondDimensionWatcherReDive/Models/ScheduledTaskState.cs b/SecondDimensionWatcherReDive/Models/ScheduledTaskState.cs new file mode 100644 index 0000000..257af3d --- /dev/null +++ b/SecondDimensionWatcherReDive/Models/ScheduledTaskState.cs @@ -0,0 +1,13 @@ +namespace SecondDimensionWatcherReDive.Models; + +public sealed class ScheduledTaskState +{ + public string TaskId { get; set; } = string.Empty; + public string? LeaseOwner { get; set; } + public DateTimeOffset? LeaseExpiresAt { get; set; } + public DateTimeOffset? LastStartedAt { get; set; } + public DateTimeOffset? LastCompletedAt { get; set; } + public DateTimeOffset? LastSucceededAt { get; set; } + public long RunCount { get; set; } + public string? LastError { get; set; } +} diff --git a/SecondDimensionWatcherReDive/Observability/DurableJobMetricsBackgroundService.cs b/SecondDimensionWatcherReDive/Observability/DurableJobMetricsBackgroundService.cs new file mode 100644 index 0000000..db781ea --- /dev/null +++ b/SecondDimensionWatcherReDive/Observability/DurableJobMetricsBackgroundService.cs @@ -0,0 +1,37 @@ +using SecondDimensionWatcherReDive.Framework.DataRepository; + +namespace SecondDimensionWatcherReDive.Observability; + +internal sealed partial class DurableJobMetricsBackgroundService( + IServiceScopeFactory scopeFactory, + RuntimeTelemetry telemetry, + ILogger logger) : BackgroundService +{ + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + using var timer = new PeriodicTimer(TimeSpan.FromSeconds(15)); + do + { + try + { + await using var scope = scopeFactory.CreateAsyncScope(); + var repository = scope.ServiceProvider.GetRequiredService(); + telemetry.UpdateJobStatistics(await repository.GetStatisticsAsync( + DateTimeOffset.UtcNow, + stoppingToken)); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + break; + } + catch (Exception exception) + { + LogCollectionFailed(logger, exception); + } + } while (await timer.WaitForNextTickAsync(stoppingToken)); + } + + [LoggerMessage(Level = LogLevel.Debug, + Message = "Durable job metric collection failed")] + private static partial void LogCollectionFailed(ILogger logger, Exception exception); +} diff --git a/SecondDimensionWatcherReDive/Observability/ReadinessHealthChecks.cs b/SecondDimensionWatcherReDive/Observability/ReadinessHealthChecks.cs new file mode 100644 index 0000000..34865e4 --- /dev/null +++ b/SecondDimensionWatcherReDive/Observability/ReadinessHealthChecks.cs @@ -0,0 +1,111 @@ +using System.Text.Json; +using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using SecondDimensionWatcherReDive.AI.Abstractions; +using SecondDimensionWatcherReDive.Framework.DataRepository; + +namespace SecondDimensionWatcherReDive.Observability; + +internal static class HealthTags +{ + public const string Ready = "ready"; +} + +internal sealed class DatabaseReadinessHealthCheck(IServiceScopeFactory scopeFactory) : IHealthCheck +{ + public async Task CheckHealthAsync( + HealthCheckContext context, + CancellationToken cancellationToken = default) + { + await using var scope = scopeFactory.CreateAsyncScope(); + var repository = scope.ServiceProvider.GetRequiredService(); + return await repository.CanConnectAsync(cancellationToken) + ? HealthCheckResult.Healthy() + : HealthCheckResult.Unhealthy(); + } +} + +internal sealed class DistributedCacheReadinessHealthCheck(IDistributedCache cache) : IHealthCheck +{ + public async Task CheckHealthAsync( + HealthCheckContext context, + CancellationToken cancellationToken = default) + { + await cache.GetAsync("health:ready", cancellationToken); + return HealthCheckResult.Healthy(); + } +} + +internal sealed class QbittorrentReadinessHealthCheck(IHttpClientFactory httpClientFactory) : IHealthCheck +{ + public async Task CheckHealthAsync( + HealthCheckContext context, + CancellationToken cancellationToken = default) + { + using var client = httpClientFactory.CreateClient("RemoteTorrentDownloadClient"); + using var response = await client.GetAsync( + "/api/v2/app/version", + HttpCompletionOption.ResponseHeadersRead, + cancellationToken); + return response.IsSuccessStatusCode + ? HealthCheckResult.Healthy() + : HealthCheckResult.Unhealthy(); + } +} + +internal sealed class LocalStorageReadinessHealthCheck(IConfiguration configuration) : IHealthCheck +{ + public Task CheckHealthAsync( + HealthCheckContext context, + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + var path = Path.GetFullPath(configuration["FileStore:Local"] ?? "./download"); + if (!Directory.Exists(path)) + return Task.FromResult(HealthCheckResult.Unhealthy()); + + // Force a real filesystem operation without creating or deleting data. + using var enumerator = Directory.EnumerateFileSystemEntries(path).GetEnumerator(); + _ = enumerator.MoveNext(); + return Task.FromResult(HealthCheckResult.Healthy()); + } +} + +internal sealed class AiReadinessHealthCheck(IServiceScopeFactory scopeFactory) : IHealthCheck +{ + public async Task CheckHealthAsync( + HealthCheckContext context, + CancellationToken cancellationToken = default) + { + await using var scope = scopeFactory.CreateAsyncScope(); + var engine = scope.ServiceProvider.GetRequiredService(); + await engine.GetAvailableModelsAsync(cancellationToken); + return HealthCheckResult.Healthy(); + } +} + +internal static class HealthResponseWriter +{ + public static async Task WriteAsync( + HttpContext context, + HealthReport report) + { + context.Response.ContentType = "application/json; charset=utf-8"; + await using var writer = new Utf8JsonWriter(context.Response.Body); + writer.WriteStartObject(); + writer.WriteString("status", report.Status.ToString().ToLowerInvariant()); + writer.WriteStartObject("checks"); + foreach (var entry in report.Entries.OrderBy(pair => pair.Key, StringComparer.Ordinal)) + { + writer.WriteStartObject(entry.Key); + writer.WriteString("status", entry.Value.Status.ToString().ToLowerInvariant()); + writer.WriteNumber("durationMs", entry.Value.Duration.TotalMilliseconds); + if (entry.Value.Exception is not null) + writer.WriteString("errorType", entry.Value.Exception.GetType().Name); + writer.WriteEndObject(); + } + writer.WriteEndObject(); + writer.WriteEndObject(); + await writer.FlushAsync(context.RequestAborted); + } +} diff --git a/SecondDimensionWatcherReDive/Observability/RuntimeTelemetry.cs b/SecondDimensionWatcherReDive/Observability/RuntimeTelemetry.cs new file mode 100644 index 0000000..fb35928 --- /dev/null +++ b/SecondDimensionWatcherReDive/Observability/RuntimeTelemetry.cs @@ -0,0 +1,143 @@ +using System.Diagnostics; +using System.Diagnostics.Metrics; +using SecondDimensionWatcherReDive.Framework.DataRepository; + +namespace SecondDimensionWatcherReDive.Observability; + +public sealed class RuntimeTelemetry : IDisposable +{ + public const string MeterName = "SecondDimensionWatcherReDive.Runtime"; + public const string ActivitySourceName = "SecondDimensionWatcherReDive.Runtime"; + + private readonly Meter _meter = new(MeterName); + private readonly Counter _jobAttempts; + private readonly Histogram _jobDuration; + private readonly Counter _scheduledTaskRuns; + private readonly Histogram _scheduledTaskDuration; + private int _pendingJobs; + private int _processingJobs; + private int _deadLetterJobs; + private double _oldestPendingAge; + + public RuntimeTelemetry() + { + _jobAttempts = _meter.CreateCounter( + "sdw.durable_job.attempts", + "{attempt}", + "Durable job execution attempts."); + _jobDuration = _meter.CreateHistogram( + "sdw.durable_job.duration", + "s", + "Durable job execution duration."); + _scheduledTaskRuns = _meter.CreateCounter( + "sdw.scheduled_task.runs", + "{run}", + "Scheduled task run outcomes."); + _scheduledTaskDuration = _meter.CreateHistogram( + "sdw.scheduled_task.duration", + "s", + "Scheduled task request duration."); + _meter.CreateObservableGauge( + "sdw.durable_jobs", + ObserveJobCounts, + "{job}", + "Current durable job counts by status."); + _meter.CreateObservableGauge( + "sdw.durable_job.oldest_pending_age", + () => Volatile.Read(ref _oldestPendingAge), + "s", + "Age of the oldest pending durable job."); + } + + public static Activity? StartDurableJob(DurableJob job) + { + var activity = TelemetryActivitySource.Instance.StartActivity( + "durable_job.process", + ActivityKind.Consumer); + activity?.SetTag("job.type", ToTag(job.Type)); + activity?.SetTag("job.stage", ToTag(job.Stage)); + return activity; + } + + public void RecordJobAttempt( + DurableJobType type, + DurableJobStage stage, + string outcome, + TimeSpan duration) + { + var tags = new TagList + { + { "job.type", ToTag(type) }, + { "job.stage", ToTag(stage) }, + { "outcome", outcome } + }; + _jobAttempts.Add(1, tags); + _jobDuration.Record(duration.TotalSeconds, tags); + } + + public void UpdateJobStatistics(DurableJobStatistics statistics) + { + Volatile.Write(ref _pendingJobs, statistics.PendingCount); + Volatile.Write(ref _processingJobs, statistics.ProcessingCount); + Volatile.Write(ref _deadLetterJobs, statistics.DeadLetterCount); + Volatile.Write(ref _oldestPendingAge, statistics.OldestPendingAgeSeconds); + } + + public void RecordScheduledTask( + string taskId, + string outcome, + TimeSpan duration) + { + var tags = new TagList + { + { "task.id", NormalizeTaskId(taskId) }, + { "outcome", outcome } + }; + _scheduledTaskRuns.Add(1, tags); + _scheduledTaskDuration.Record(duration.TotalSeconds, tags); + } + + public void Dispose() => _meter.Dispose(); + + private IEnumerable> ObserveJobCounts() + { + yield return new Measurement( + Volatile.Read(ref _pendingJobs), + new KeyValuePair("status", "pending")); + yield return new Measurement( + Volatile.Read(ref _processingJobs), + new KeyValuePair("status", "processing")); + yield return new Measurement( + Volatile.Read(ref _deadLetterJobs), + new KeyValuePair("status", "dead_letter")); + } + + private static string ToTag(DurableJobType type) => type switch + { + DurableJobType.DownloadCompletion => "download_completion", + _ => "unknown" + }; + + private static string ToTag(DurableJobStage stage) => stage switch + { + DurableJobStage.MapFiles => "map_files", + DurableJobStage.Notify => "notify", + DurableJobStage.InvokePlugins => "invoke_plugins", + DurableJobStage.Done => "done", + _ => "unknown" + }; + + private static string NormalizeTaskId(string taskId) => taskId switch + { + "SyncFeed" => "sync_feed", + "ScrapeSeasonBangumi" => "scrape_season_bangumi", + "ScanMediaLibraries" => "scan_media_libraries", + "InferAnimationMetadata" => "infer_animation_metadata", + _ => "other" + }; + + private static class TelemetryActivitySource + { + internal static readonly ActivitySource Instance = new(ActivitySourceName); + } +} diff --git a/SecondDimensionWatcherReDive/Observability/SensitiveTagRedactionProcessor.cs b/SecondDimensionWatcherReDive/Observability/SensitiveTagRedactionProcessor.cs new file mode 100644 index 0000000..be5c5e3 --- /dev/null +++ b/SecondDimensionWatcherReDive/Observability/SensitiveTagRedactionProcessor.cs @@ -0,0 +1,49 @@ +using System.Diagnostics; +using OpenTelemetry; + +namespace SecondDimensionWatcherReDive.Observability; + +internal sealed class SensitiveTagRedactionProcessor : BaseProcessor +{ + private static readonly HashSet RemovedTags = new(StringComparer.Ordinal) + { + "db.statement", + "db.query.text", + "url.full", + "url.path", + "url.query", + "http.url", + "http.target", + "tool.arguments", + "tool.result" + }; + + public override void OnEnd(Activity activity) + { + foreach (var key in activity.TagObjects + .Select(pair => pair.Key) + .Where(ShouldRemove) + .ToArray()) + activity.SetTag(key, null); + + if (activity.Source.Name.Contains("EntityFrameworkCore", StringComparison.Ordinal)) + activity.DisplayName = "database.query"; + else if (activity.Source.Name.Contains("HttpClient", StringComparison.Ordinal)) + activity.DisplayName = $"HTTP {GetTag(activity, "http.request.method") ?? "request"}"; + else if (activity.Source.Name.Contains("AspNetCore", StringComparison.Ordinal)) + { + var method = GetTag(activity, "http.request.method") ?? "request"; + var route = GetTag(activity, "http.route"); + activity.DisplayName = route is null ? $"HTTP {method}" : $"{method} {route}"; + } + } + + private static string? GetTag(Activity activity, string key) => + activity.GetTagItem(key)?.ToString(); + + private static bool ShouldRemove(string key) => + RemovedTags.Contains(key) + || key.StartsWith("db.query.parameter.", StringComparison.Ordinal) + || key.StartsWith("tool.argument", StringComparison.Ordinal) + || key.StartsWith("tool.result", StringComparison.Ordinal); +} diff --git a/SecondDimensionWatcherReDive/Program.cs b/SecondDimensionWatcherReDive/Program.cs index 80f5f19..33db7e2 100644 --- a/SecondDimensionWatcherReDive/Program.cs +++ b/SecondDimensionWatcherReDive/Program.cs @@ -4,12 +4,16 @@ using System.Text; using System.Threading.Channels; using AspSpaService; +using Microsoft.AspNetCore.Diagnostics.HealthChecks; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.StaticFiles; using Microsoft.EntityFrameworkCore; using Microsoft.IdentityModel.Tokens; using Microsoft.Net.Http.Headers; +using OpenTelemetry.Metrics; +using OpenTelemetry.Resources; +using OpenTelemetry.Trace; using SecondDimensionWatcherReDive; using SecondDimensionWatcherReDive.Auth; using SecondDimensionWatcherReDive.Configuration; @@ -19,10 +23,12 @@ using SecondDimensionWatcherReDive.Framework.FileDownload; using SecondDimensionWatcherReDive.Framework.FileStore; using SecondDimensionWatcherReDive.Framework.DataRepository; +using SecondDimensionWatcherReDive.Framework.Notifications; using SecondDimensionWatcherReDive.Framework.Tasks; using SecondDimensionWatcherReDive.Inference.AI; using SecondDimensionWatcherReDive.Models; using SecondDimensionWatcherReDive.NFS; +using SecondDimensionWatcherReDive.Observability; using SecondDimensionWatcherReDive.Repositories; using SecondDimensionWatcherReDive.Chat; using SecondDimensionWatcherReDive.Plugin; @@ -109,6 +115,116 @@ }); }); +var healthChecks = builder.Services.AddHealthChecks() + .AddCheck( + "postgresql", + tags: [HealthTags.Ready], + timeout: TimeSpan.FromSeconds(10)); +if (!string.IsNullOrEmpty(builder.Configuration["Valkey:ConnectionString"]) + && builder.Configuration.GetValue("Health:ValkeyRequired", true)) + healthChecks.AddCheck( + "valkey", + tags: [HealthTags.Ready], + timeout: TimeSpan.FromSeconds(5)); +if (builder.Configuration.GetValue("Health:QbittorrentRequired", true)) + healthChecks.AddCheck( + "qbittorrent", + tags: [HealthTags.Ready], + timeout: TimeSpan.FromSeconds(5)); +if (builder.Configuration.GetValue("Health:StorageRequired", true)) + healthChecks.AddCheck( + "storage", + tags: [HealthTags.Ready], + timeout: TimeSpan.FromSeconds(5)); +if (builder.Configuration.GetValue("Health:AIRequired", false)) + healthChecks.AddCheck( + "ai", + tags: [HealthTags.Ready], + timeout: TimeSpan.FromSeconds(10)); + +builder.Services.AddSingleton(); +var otlpEndpoint = Uri.TryCreate( + builder.Configuration["OpenTelemetry:OtlpEndpoint"], + UriKind.Absolute, + out var configuredOtlpEndpoint) + ? configuredOtlpEndpoint + : null; +builder.Services.AddOpenTelemetry() + .ConfigureResource(resource => resource.AddService("SecondDimensionWatcherReDive")) + .WithTracing(tracing => + { + tracing + .AddSource(RuntimeTelemetry.ActivitySourceName) + .AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation() + // Query parameter capture is disabled by default. The processor below + // also removes statement/query-text tags before export. + .AddEntityFrameworkCoreInstrumentation() + .AddProcessor(new SensitiveTagRedactionProcessor()); + if (otlpEndpoint is not null) + tracing.AddOtlpExporter(options => options.Endpoint = otlpEndpoint); + }) + .WithMetrics(metrics => + { + metrics + .AddMeter(RuntimeTelemetry.MeterName) + .AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation() + .AddRuntimeInstrumentation() + .AddView( + "http.server.request.duration", + new ExplicitBucketHistogramConfiguration + { + TagKeys = + [ + "http.request.method", + "http.response.status_code", + "http.route", + "network.protocol.version" + ] + }) + .AddView( + "http.client.request.duration", + new ExplicitBucketHistogramConfiguration + { + TagKeys = + [ + "http.request.method", + "http.response.status_code", + "server.address", + "server.port", + "network.protocol.version" + ] + }) + .AddView( + "sdw.durable_job.attempts", + new MetricStreamConfiguration + { + TagKeys = ["job.type", "job.stage", "outcome"] + }) + .AddView( + "sdw.durable_job.duration", + new ExplicitBucketHistogramConfiguration + { + TagKeys = ["job.type", "job.stage", "outcome"] + }) + .AddView( + "sdw.durable_jobs", + new MetricStreamConfiguration { TagKeys = ["status"] }) + .AddView( + "sdw.scheduled_task.runs", + new MetricStreamConfiguration { TagKeys = ["task.id", "outcome"] }) + .AddView( + "sdw.scheduled_task.duration", + new ExplicitBucketHistogramConfiguration + { + TagKeys = ["task.id", "outcome"] + }) + .AddPrometheusExporter(); + if (otlpEndpoint is not null) + metrics.AddOtlpExporter(options => options.Endpoint = otlpEndpoint); + }); + //Configure JWT var key = Encoding.ASCII.GetBytes(builder.Configuration["JwtSecret"] ?? throw new ApplicationException("JwtSecret must present in the config file.")); @@ -208,10 +324,29 @@ contentTypeProvider.Mappings.Add(".mkv", "video/x-matroska"); builder.Services.AddSingleton(contentTypeProvider); -//Add channels -builder.Services.AddSingleton(Channel.CreateUnbounded()); -builder.Services.AddSingleton(Channel.CreateUnbounded()); -builder.Services.AddSingleton(Channel.CreateUnbounded()); +// In-process channels are bounded. Download completion is persisted before its +// wake hint is emitted, while high-frequency progress may safely drop old samples. +builder.Services.AddSingleton(Channel.CreateBounded( + new BoundedChannelOptions(1024) + { + SingleReader = true, + SingleWriter = false, + FullMode = BoundedChannelFullMode.Wait + })); +builder.Services.AddSingleton(Channel.CreateBounded( + new BoundedChannelOptions(1024) + { + SingleReader = true, + SingleWriter = true, + FullMode = BoundedChannelFullMode.DropOldest + })); +builder.Services.AddSingleton(Channel.CreateBounded( + new BoundedChannelOptions(128) + { + SingleReader = true, + SingleWriter = true, + FullMode = BoundedChannelFullMode.DropOldest + })); // Persistent incident inbox and health probes. builder.Services.AddSingleton(); @@ -219,6 +354,7 @@ //Add hosting services builder.Services.AddHostedService(); +builder.Services.AddHostedService(); builder.Services.AddHostedService(); builder.Services.AddHostedService(); builder.Services.AddHostedService(); @@ -226,8 +362,10 @@ builder.Services.AddSingleton(sp => sp.GetRequiredService()); builder.Services.AddHostedService(); +builder.Services.AddSingleton(); //Add scheduled tasks +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(sp => sp.GetRequiredService()); builder.Services.AddHostedService>(); @@ -277,6 +415,9 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddSingleton(); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -320,13 +461,27 @@ app.UseRouting(); +app.MapHealthChecks("/health/live", new HealthCheckOptions +{ + Predicate = _ => false, + ResponseWriter = HealthResponseWriter.WriteAsync +}).AllowAnonymous(); +app.MapHealthChecks("/health/ready", new HealthCheckOptions +{ + Predicate = registration => registration.Tags.Contains(HealthTags.Ready), + ResponseWriter = HealthResponseWriter.WriteAsync +}).AllowAnonymous(); +app.MapPrometheusScrapingEndpoint("/metrics").AllowAnonymous(); + app.MapControllers(); if (app.Environment.IsDevelopment()) { app.UseWhen( context => !context.Request.Path.StartsWithSegments("/api") && - !context.Request.Path.StartsWithSegments("/webdav"), + !context.Request.Path.StartsWithSegments("/webdav") && + !context.Request.Path.StartsWithSegments("/health") && + !context.Request.Path.StartsWithSegments("/metrics"), then => { then.UseSpa(config => diff --git a/SecondDimensionWatcherReDive/Repositories/AnimationInfoRepository.cs b/SecondDimensionWatcherReDive/Repositories/AnimationInfoRepository.cs index d90f6b6..65b0b44 100644 --- a/SecondDimensionWatcherReDive/Repositories/AnimationInfoRepository.cs +++ b/SecondDimensionWatcherReDive/Repositories/AnimationInfoRepository.cs @@ -1,4 +1,5 @@ using System.Runtime.CompilerServices; +using System.Text.Json; using Microsoft.EntityFrameworkCore; using SecondDimensionWatcherReDive.Framework.DataRepository; using SecondDimensionWatcherReDive.Framework.FileDownload; @@ -490,9 +491,37 @@ SubscriptionAutomationDisposition.AutoDownloadQueued or if (changed) { entity.StateVersion = checked(entity.StateVersion + 1); - await writeContext.SaveChangesAsync(cancellationToken); } + // Commit the durable side-effect workflow in the same transaction as + // the download completion state. A retry sees the same unique key and + // cannot create a second workflow. + var deduplicationKey = + $"download-completion:{id:N}:{downloadAttemptId?.ToString("N") ?? "legacy"}"; + if (!await writeContext.DurableJobs.AnyAsync( + job => job.DeduplicationKey == deduplicationKey, + cancellationToken)) + { + writeContext.DurableJobs.Add(new Models.DurableJob + { + Id = Guid.NewGuid(), + DeduplicationKey = deduplicationKey, + Type = DurableJobType.DownloadCompletion, + Status = DurableJobStatus.Pending, + Stage = DurableJobStage.MapFiles, + PayloadJson = JsonSerializer.Serialize(new DownloadCompletionJobPayload( + id, + storePath, + fileStore, + downloadAttemptId)), + CreatedAt = completedAt, + UpdatedAt = completedAt, + NextAttemptAt = completedAt + }); + } + + await writeContext.SaveChangesAsync(cancellationToken); + await writeContext.Entry(entity) .Reference(info => info.Animation) .LoadAsync(cancellationToken); diff --git a/SecondDimensionWatcherReDive/Repositories/DurableJobRepository.cs b/SecondDimensionWatcherReDive/Repositories/DurableJobRepository.cs new file mode 100644 index 0000000..853e54d --- /dev/null +++ b/SecondDimensionWatcherReDive/Repositories/DurableJobRepository.cs @@ -0,0 +1,218 @@ +using Microsoft.EntityFrameworkCore; +using SecondDimensionWatcherReDive.Framework.DataRepository; +using JobEntity = SecondDimensionWatcherReDive.Models.DurableJob; + +namespace SecondDimensionWatcherReDive.Repositories; + +public sealed class DurableJobRepository(Models.ApplicationContext context) + : IDurableJobRepository +{ + public async Task> ClaimDueAsync( + string workerId, + DateTimeOffset now, + DateTimeOffset leaseUntil, + int take, + CancellationToken cancellationToken) + { + var candidateIds = await context.DurableJobs + .AsNoTracking() + .Where(job => + job.NextAttemptAt <= now + && (job.Status == DurableJobStatus.Pending + || (job.Status == DurableJobStatus.Processing + && job.LeaseExpiresAt <= now))) + .OrderBy(job => job.NextAttemptAt) + .ThenBy(job => job.CreatedAt) + .Select(job => job.Id) + .Take(take) + .ToListAsync(cancellationToken); + + var claimedIds = new List(candidateIds.Count); + foreach (var id in candidateIds) + { + var affected = await context.DurableJobs + .Where(job => job.Id == id + && job.NextAttemptAt <= now + && (job.Status == DurableJobStatus.Pending + || (job.Status == DurableJobStatus.Processing + && job.LeaseExpiresAt <= now))) + .ExecuteUpdateAsync(setters => setters + .SetProperty(job => job.Status, DurableJobStatus.Processing) + .SetProperty(job => job.LeaseOwner, workerId) + .SetProperty(job => job.LeaseExpiresAt, leaseUntil) + .SetProperty(job => job.UpdatedAt, now), cancellationToken); + if (affected == 1) + claimedIds.Add(id); + } + + if (claimedIds.Count == 0) + return []; + + return (await context.DurableJobs + .AsNoTracking() + .Where(job => claimedIds.Contains(job.Id)) + .OrderBy(job => job.CreatedAt) + .ToListAsync(cancellationToken)) + .Select(ToRecord) + .ToList(); + } + + public async Task AdvanceStageAsync( + Guid id, + string workerId, + DurableJobStage expectedStage, + DurableJobStage nextStage, + DateTimeOffset now, + CancellationToken cancellationToken) + { + var completed = nextStage == DurableJobStage.Done; + var affected = await context.DurableJobs + .Where(job => job.Id == id + && job.Status == DurableJobStatus.Processing + && job.LeaseOwner == workerId + && job.LeaseExpiresAt > now + && job.Stage == expectedStage) + .ExecuteUpdateAsync(setters => setters + .SetProperty(job => job.Stage, nextStage) + .SetProperty(job => job.Status, + completed ? DurableJobStatus.Completed : DurableJobStatus.Processing) + .SetProperty(job => job.UpdatedAt, now) + .SetProperty(job => job.LastAttemptAt, now) + .SetProperty(job => job.CompletedAt, completed ? now : null) + .SetProperty(job => job.LeaseOwner, completed ? null : workerId) + .SetProperty(job => job.LeaseExpiresAt, + job => completed ? null : job.LeaseExpiresAt) + .SetProperty(job => job.LastError, (string?)null), cancellationToken); + return affected == 1; + } + + public async Task RenewLeaseAsync( + Guid id, + string workerId, + DateTimeOffset now, + DateTimeOffset leaseUntil, + CancellationToken cancellationToken) + { + var affected = await context.DurableJobs + .Where(job => job.Id == id + && job.Status == DurableJobStatus.Processing + && job.LeaseOwner == workerId + && job.LeaseExpiresAt > now) + .ExecuteUpdateAsync(setters => setters + .SetProperty(job => job.LeaseExpiresAt, leaseUntil) + .SetProperty(job => job.UpdatedAt, now), cancellationToken); + return affected == 1; + } + + public Task MarkFailedAsync( + Guid id, + string workerId, + int attemptCount, + DateTimeOffset attemptedAt, + DateTimeOffset? nextAttemptAt, + string error, + CancellationToken cancellationToken) => + context.DurableJobs + .Where(job => job.Id == id + && job.Status == DurableJobStatus.Processing + && job.LeaseOwner == workerId) + .ExecuteUpdateAsync(setters => setters + .SetProperty(job => job.Status, + nextAttemptAt.HasValue + ? DurableJobStatus.Pending + : DurableJobStatus.DeadLetter) + .SetProperty(job => job.AttemptCount, attemptCount) + .SetProperty(job => job.LastAttemptAt, attemptedAt) + .SetProperty(job => job.UpdatedAt, attemptedAt) + .SetProperty(job => job.NextAttemptAt, nextAttemptAt ?? attemptedAt) + .SetProperty(job => job.LeaseOwner, (string?)null) + .SetProperty(job => job.LeaseExpiresAt, (DateTimeOffset?)null) + .SetProperty(job => job.LastError, error), cancellationToken); + + public async Task GetPageAsync( + DurableJobStatus? status, + int skip, + int take, + CancellationToken cancellationToken) + { + var query = context.DurableJobs.AsNoTracking().AsQueryable(); + if (status.HasValue) + query = query.Where(job => job.Status == status.Value); + + var totalCount = await query.CountAsync(cancellationToken); + var jobs = await query + .OrderByDescending(job => job.UpdatedAt) + .ThenByDescending(job => job.CreatedAt) + .Skip(skip) + .Take(take) + .ToListAsync(cancellationToken); + return new DurableJobPage(jobs.Select(ToRecord).ToList(), totalCount); + } + + public Task RetryAsync( + IReadOnlyCollection ids, + DateTimeOffset now, + CancellationToken cancellationToken) => + context.DurableJobs + .Where(job => ids.Contains(job.Id) + && job.Status == DurableJobStatus.DeadLetter) + .ExecuteUpdateAsync(setters => setters + .SetProperty(job => job.Status, DurableJobStatus.Pending) + .SetProperty(job => job.AttemptCount, 0) + .SetProperty(job => job.NextAttemptAt, now) + .SetProperty(job => job.UpdatedAt, now) + .SetProperty(job => job.LastError, (string?)null) + .SetProperty(job => job.LeaseOwner, (string?)null) + .SetProperty(job => job.LeaseExpiresAt, (DateTimeOffset?)null), cancellationToken); + + public Task ResolveAsync( + IReadOnlyCollection ids, + DateTimeOffset now, + CancellationToken cancellationToken) => + context.DurableJobs + .Where(job => ids.Contains(job.Id) + && job.Status == DurableJobStatus.DeadLetter) + .ExecuteUpdateAsync(setters => setters + .SetProperty(job => job.Status, DurableJobStatus.Resolved) + .SetProperty(job => job.CompletedAt, now) + .SetProperty(job => job.UpdatedAt, now) + .SetProperty(job => job.LeaseOwner, (string?)null) + .SetProperty(job => job.LeaseExpiresAt, (DateTimeOffset?)null), cancellationToken); + + public async Task GetStatisticsAsync( + DateTimeOffset now, + CancellationToken cancellationToken) + { + var pendingCount = await context.DurableJobs.CountAsync( + job => job.Status == DurableJobStatus.Pending, cancellationToken); + var processingCount = await context.DurableJobs.CountAsync( + job => job.Status == DurableJobStatus.Processing, cancellationToken); + var deadLetterCount = await context.DurableJobs.CountAsync( + job => job.Status == DurableJobStatus.DeadLetter, cancellationToken); + var oldest = await context.DurableJobs + .Where(job => job.Status == DurableJobStatus.Pending) + .MinAsync(job => (DateTimeOffset?)job.CreatedAt, cancellationToken); + return new DurableJobStatistics( + pendingCount, + processingCount, + deadLetterCount, + oldest.HasValue ? Math.Max(0, (now - oldest.Value).TotalSeconds) : 0); + } + + private static DurableJob ToRecord(JobEntity job) => new( + job.Id, + job.DeduplicationKey, + job.Type, + job.Status, + job.Stage, + job.PayloadJson, + job.AttemptCount, + job.CreatedAt, + job.UpdatedAt, + job.NextAttemptAt, + job.LastAttemptAt, + job.CompletedAt, + job.LeaseOwner, + job.LeaseExpiresAt, + job.LastError); +} diff --git a/SecondDimensionWatcherReDive/Repositories/FileMappingRepositoryPostgreSqlTestFixture.cs b/SecondDimensionWatcherReDive/Repositories/FileMappingRepositoryPostgreSqlTestFixture.cs index 34b17f5..4068035 100644 --- a/SecondDimensionWatcherReDive/Repositories/FileMappingRepositoryPostgreSqlTestFixture.cs +++ b/SecondDimensionWatcherReDive/Repositories/FileMappingRepositoryPostgreSqlTestFixture.cs @@ -24,7 +24,7 @@ public async Task ResetAsync(CancellationToken cancellationToken) { await using var context = new Models.ApplicationContext(_contextOptions); await context.Database.ExecuteSqlRawAsync( - "TRUNCATE TABLE \"FileMappings\", \"AnimationInfo\" RESTART IDENTITY CASCADE", + "TRUNCATE TABLE \"DurableJobs\", \"ScheduledTaskStates\", \"FileMappings\", \"AnimationInfo\" RESTART IDENTITY CASCADE", cancellationToken); } @@ -83,4 +83,200 @@ public async Task GetAnimationInfoStateVersionsAsync(CancellationToken c .Select(info => info.StateVersion) .ToArrayAsync(cancellationToken); } + + public async Task<(Guid ItemId, Guid AttemptId)> SeedTrackedAnimationAsync( + CancellationToken cancellationToken) + { + await using var context = new Models.ApplicationContext(_contextOptions); + var attemptId = Guid.NewGuid(); + var info = new Models.AnimationInfo + { + Id = Guid.NewGuid(), + Title = "tracked integration test", + IsDownloadTracked = true, + DownloadAttemptId = attemptId, + AutomationDisposition = SubscriptionAutomationDisposition.ManualDownloadQueued + }; + context.AnimationInfo.Add(info); + await context.SaveChangesAsync(cancellationToken); + return (info.Id, attemptId); + } + + public async Task CompleteTrackedAnimationAsync( + Guid itemId, + Guid attemptId, + string storePath, + CancellationToken cancellationToken) + { + await using var context = new Models.ApplicationContext(_contextOptions); + var repository = new AnimationInfoRepository(context, _contextOptions); + var result = await repository.TryCompleteDownloadAsync( + itemId, + attemptId, + "local", + storePath, + DateTimeOffset.UtcNow, + cancellationToken); + if (result is null) + throw new InvalidOperationException("The tracked download was not completed."); + } + + public async Task<(bool IsFinished, int JobCount, DownloadCompletionJobPayload Payload)> + GetCompletionStateAsync(Guid itemId, CancellationToken cancellationToken) + { + await using var context = new Models.ApplicationContext(_contextOptions); + var finished = await context.AnimationInfo + .Where(info => info.Id == itemId) + .Select(info => info.IsDownloadFinished) + .SingleAsync(cancellationToken); + var jobs = await context.DurableJobs + .Where(job => job.Type == DurableJobType.DownloadCompletion) + .ToListAsync(cancellationToken); + var payload = System.Text.Json.JsonSerializer + .Deserialize(jobs.Single().PayloadJson)!; + return (finished, jobs.Count, payload); + } + + public async Task SeedDurableJobAsync( + DurableJob job, + CancellationToken cancellationToken) + { + await using var context = new Models.ApplicationContext(_contextOptions); + context.DurableJobs.Add(new Models.DurableJob + { + Id = job.Id, + DeduplicationKey = job.DeduplicationKey, + Type = job.Type, + Status = job.Status, + Stage = job.Stage, + PayloadJson = job.PayloadJson, + AttemptCount = job.AttemptCount, + CreatedAt = job.CreatedAt, + UpdatedAt = job.UpdatedAt, + NextAttemptAt = job.NextAttemptAt, + LastAttemptAt = job.LastAttemptAt, + CompletedAt = job.CompletedAt, + LeaseOwner = job.LeaseOwner, + LeaseExpiresAt = job.LeaseExpiresAt, + LastError = job.LastError + }); + await context.SaveChangesAsync(cancellationToken); + } + + public async Task> ClaimDueJobsAsync( + string ownerId, + DateTimeOffset now, + CancellationToken cancellationToken) + { + await using var context = new Models.ApplicationContext(_contextOptions); + var repository = new DurableJobRepository(context); + return await repository.ClaimDueAsync( + ownerId, + now, + now.AddMinutes(1), + 10, + cancellationToken); + } + + public async Task RenewDurableJobLeaseAsync( + Guid id, + string ownerId, + DateTimeOffset now, + DateTimeOffset leaseUntil, + CancellationToken cancellationToken) + { + await using var context = new Models.ApplicationContext(_contextOptions); + return await new DurableJobRepository(context).RenewLeaseAsync( + id, + ownerId, + now, + leaseUntil, + cancellationToken); + } + + public async Task AdvanceDurableJobAsync( + Guid id, + string ownerId, + DurableJobStage expectedStage, + DurableJobStage nextStage, + DateTimeOffset now, + CancellationToken cancellationToken) + { + await using var context = new Models.ApplicationContext(_contextOptions); + return await new DurableJobRepository(context).AdvanceStageAsync( + id, + ownerId, + expectedStage, + nextStage, + now, + cancellationToken); + } + + public async Task TryAcquireTaskLeaseAsync( + string taskId, + string ownerId, + DateTimeOffset now, + DateTimeOffset leaseUntil, + bool force, + CancellationToken cancellationToken) + { + await using var context = new Models.ApplicationContext(_contextOptions); + var repository = new ScheduledTaskLeaseRepository(context); + return await repository.TryAcquireAsync( + taskId, + ownerId, + now, + leaseUntil, + force, + cancellationToken); + } + + public async Task CompleteTaskLeaseAsync( + string taskId, + string ownerId, + DateTimeOffset completedAt, + DateTimeOffset nextRunAt, + CancellationToken cancellationToken) + { + await using var context = new Models.ApplicationContext(_contextOptions); + await new ScheduledTaskLeaseRepository(context).CompleteAsync( + taskId, + ownerId, + completedAt, + nextRunAt, + true, + null, + cancellationToken); + } + + public async Task RetryJobsAsync( + IReadOnlyCollection ids, + DateTimeOffset now, + CancellationToken cancellationToken) + { + await using var context = new Models.ApplicationContext(_contextOptions); + return await new DurableJobRepository(context) + .RetryAsync(ids, now, cancellationToken); + } + + public async Task ResolveJobsAsync( + IReadOnlyCollection ids, + DateTimeOffset now, + CancellationToken cancellationToken) + { + await using var context = new Models.ApplicationContext(_contextOptions); + return await new DurableJobRepository(context) + .ResolveAsync(ids, now, cancellationToken); + } + + public async Task GetJobStatusAsync( + Guid id, + CancellationToken cancellationToken) + { + await using var context = new Models.ApplicationContext(_contextOptions); + return await context.DurableJobs + .Where(job => job.Id == id) + .Select(job => job.Status) + .SingleAsync(cancellationToken); + } } diff --git a/SecondDimensionWatcherReDive/Repositories/ReadinessRepository.cs b/SecondDimensionWatcherReDive/Repositories/ReadinessRepository.cs new file mode 100644 index 0000000..44c7ddd --- /dev/null +++ b/SecondDimensionWatcherReDive/Repositories/ReadinessRepository.cs @@ -0,0 +1,11 @@ +using Microsoft.EntityFrameworkCore; +using SecondDimensionWatcherReDive.Framework.DataRepository; + +namespace SecondDimensionWatcherReDive.Repositories; + +public sealed class ReadinessRepository(Models.ApplicationContext context) + : IReadinessRepository +{ + public Task CanConnectAsync(CancellationToken cancellationToken) => + context.Database.CanConnectAsync(cancellationToken); +} diff --git a/SecondDimensionWatcherReDive/Repositories/ScheduledTaskLeaseRepository.cs b/SecondDimensionWatcherReDive/Repositories/ScheduledTaskLeaseRepository.cs new file mode 100644 index 0000000..7e1d49f --- /dev/null +++ b/SecondDimensionWatcherReDive/Repositories/ScheduledTaskLeaseRepository.cs @@ -0,0 +1,95 @@ +using Microsoft.EntityFrameworkCore; +using Npgsql; +using SecondDimensionWatcherReDive.Framework.DataRepository; + +namespace SecondDimensionWatcherReDive.Repositories; + +public sealed class ScheduledTaskLeaseRepository(Models.ApplicationContext context) + : IScheduledTaskLeaseRepository +{ + public async Task TryAcquireAsync( + string taskId, + string ownerId, + DateTimeOffset now, + DateTimeOffset leaseUntil, + bool force, + CancellationToken cancellationToken) + { + var affected = await context.ScheduledTaskStates + .Where(state => state.TaskId == taskId + && (state.LeaseOwner == null + || state.LeaseExpiresAt <= now + || state.LeaseOwner == ownerId + || (force + && state.LastCompletedAt != null + && (state.LastStartedAt == null + || state.LastCompletedAt >= state.LastStartedAt)))) + .ExecuteUpdateAsync(setters => setters + .SetProperty(state => state.LeaseOwner, ownerId) + .SetProperty(state => state.LeaseExpiresAt, leaseUntil) + .SetProperty(state => state.LastStartedAt, now) + .SetProperty(state => state.RunCount, state => state.RunCount + 1), + cancellationToken); + if (affected == 1) + return true; + + var state = new Models.ScheduledTaskState + { + TaskId = taskId, + LeaseOwner = ownerId, + LeaseExpiresAt = leaseUntil, + LastStartedAt = now, + RunCount = 1 + }; + await context.ScheduledTaskStates.AddAsync(state, cancellationToken); + try + { + await context.SaveChangesAsync(cancellationToken); + return true; + } + catch (DbUpdateException exception) when ( + exception.InnerException is PostgresException { SqlState: PostgresErrorCodes.UniqueViolation }) + { + context.ChangeTracker.Clear(); + return false; + } + } + + public async Task RenewAsync( + string taskId, + string ownerId, + DateTimeOffset now, + DateTimeOffset leaseUntil, + CancellationToken cancellationToken) + { + var affected = await context.ScheduledTaskStates + .Where(state => state.TaskId == taskId + && state.LeaseOwner == ownerId) + .ExecuteUpdateAsync(setters => setters + .SetProperty(state => state.LeaseExpiresAt, leaseUntil), cancellationToken); + return affected == 1; + } + + public Task CompleteAsync( + string taskId, + string ownerId, + DateTimeOffset completedAt, + DateTimeOffset leaseUntil, + bool succeeded, + string? error, + CancellationToken cancellationToken) => + context.ScheduledTaskStates + .Where(state => state.TaskId == taskId + && state.LeaseOwner == ownerId) + .ExecuteUpdateAsync(setters => setters + // Keep a cooldown lease until the next periodic due time. Other + // instances poll this row and can take over promptly after a crash + // without immediately duplicating a normally completed run. + .SetProperty(state => state.LeaseOwner, ownerId) + .SetProperty(state => state.LeaseExpiresAt, leaseUntil) + .SetProperty(state => state.LastCompletedAt, completedAt) + .SetProperty(state => state.LastSucceededAt, + state => succeeded ? completedAt : state.LastSucceededAt) + .SetProperty(state => state.LastError, + succeeded ? null : error), cancellationToken); +} diff --git a/SecondDimensionWatcherReDive/SecondDimensionWatcherReDive.csproj b/SecondDimensionWatcherReDive/SecondDimensionWatcherReDive.csproj index caf806d..5840222 100644 --- a/SecondDimensionWatcherReDive/SecondDimensionWatcherReDive.csproj +++ b/SecondDimensionWatcherReDive/SecondDimensionWatcherReDive.csproj @@ -31,6 +31,13 @@ + + + + + + + diff --git a/SecondDimensionWatcherReDive/Services/CompleteDownloadBackgroundService.cs b/SecondDimensionWatcherReDive/Services/CompleteDownloadBackgroundService.cs index d6f2eb9..00bafdb 100644 --- a/SecondDimensionWatcherReDive/Services/CompleteDownloadBackgroundService.cs +++ b/SecondDimensionWatcherReDive/Services/CompleteDownloadBackgroundService.cs @@ -1,206 +1,342 @@ +using System.Diagnostics; +using System.Text.Json; using System.Threading.Channels; using SecondDimensionWatcherReDive.Data; -using SecondDimensionWatcherReDive.Framework.PluginParams; using SecondDimensionWatcherReDive.Framework.DataRepository; +using SecondDimensionWatcherReDive.Framework.Notifications; +using SecondDimensionWatcherReDive.Framework.PluginParams; using SecondDimensionWatcherReDive.Plugin; +using SecondDimensionWatcherReDive.Observability; using SecondDimensionWatcherReDive.Utils.FileStore; using SecondDimensionWatcherReDive.Utils.Incidents; namespace SecondDimensionWatcherReDive.Services; +/// +/// Executes persisted download-completion effects. The channel is deliberately +/// only a wake-up hint: polling and expired leases make work recoverable after a +/// process crash or a lost hint. +/// public partial class CompleteDownloadBackgroundService( Channel downloadCompleteRequest, IServiceScopeFactory scopeFactory, ILogger logger, - IIncidentReporter? incidentReporter = null) + IIncidentReporter? incidentReporter = null, + RuntimeTelemetry? telemetry = null) : BackgroundService { + internal const int MaxAttempts = 8; + internal static readonly TimeSpan PollInterval = TimeSpan.FromSeconds(10); + internal static readonly TimeSpan LeaseDuration = TimeSpan.FromMinutes(2); + internal static readonly TimeSpan LeaseRenewInterval = TimeSpan.FromSeconds(30); + + private readonly string _workerId = $"{Environment.MachineName}:{Environment.ProcessId}:{Guid.NewGuid():N}"; + protected override async Task ExecuteAsync(CancellationToken cancellationToken) { - var reader = downloadCompleteRequest.Reader; - while (await reader.WaitToReadAsync(cancellationToken)) + while (!cancellationToken.IsCancellationRequested) { - var request = await reader.ReadAsync(cancellationToken); - for (var attempt = 1; attempt <= 3; attempt++) + var processed = 0; + try { - try - { - await ProcessRequestAsync(request, cancellationToken); - break; - } - catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) - { - throw; - } - catch (Exception ex) when (attempt < 3) - { - LogProcessingRequestRetry(logger, ex, request.ItemId, attempt); - await Task.Delay(TimeSpan.FromMilliseconds(250 * attempt), cancellationToken); - } - catch (Exception ex) - { - LogProcessingRequestFailed(logger, ex, request.ItemId); - await ReportCompletionFailureAsync(request, ex, cancellationToken); - - // The torrent tracker removes finished torrents after handing - // them to this queue. Requeue here so a transient database - // outage cannot permanently lose the completion transition. - await Task.Delay(TimeSpan.FromSeconds(5), cancellationToken); - await downloadCompleteRequest.Writer.WriteAsync(request, cancellationToken); - LogProcessingRequestRequeued(logger, request.ItemId); - } + processed = await ProcessDueJobsAsync(cancellationToken); } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception exception) + { + // A temporary database outage must not terminate the hosted service. + LogPollFailed(logger, exception); + } + + if (processed > 0) + continue; + + await WaitForWakeOrPollAsync(cancellationToken); } } - internal async Task ProcessRequestAsync( - DownloadCompleteRequest request, - CancellationToken cancellationToken) + internal async Task ProcessDueJobsAsync(CancellationToken cancellationToken) { - LogProcessingRequest(logger, request.ItemId, request.StorePath, request.FileStore); - await using var scope = scopeFactory.CreateAsyncScope(); - var animationInfoRepository = scope.ServiceProvider.GetRequiredService(); - - var info = await animationInfoRepository.TryCompleteDownloadAsync( - request.ItemId, - request.DownloadAttemptId, - request.FileStore, - request.StorePath, - DateTimeOffset.Now, + var repository = scope.ServiceProvider.GetRequiredService(); + var now = DateTimeOffset.UtcNow; + var jobs = await repository.ClaimDueAsync( + _workerId, + now, + now + LeaseDuration, + 1, cancellationToken); - if (info is null) - { - LogCompletionIgnored(logger, request.ItemId); - return; - } - LogDownloadMarkedFinished(logger, request.ItemId, info.Title); + foreach (var job in jobs) + await ProcessClaimedJobAsync(scope.ServiceProvider, repository, job, cancellationToken); - if (incidentReporter is not null) - { - await incidentReporter.ResolveAsync( - IncidentType.DownloadStalled, - request.ItemId.ToString(), - cancellationToken); - } + return jobs.Count; + } - // Build virtual-fs mappings for the downloaded files. + internal async Task ProcessClaimedJobAsync( + IServiceProvider serviceProvider, + IDurableJobRepository repository, + DurableJob job, + CancellationToken cancellationToken) + { + var startedAt = Stopwatch.GetTimestamp(); + var currentStage = job.Stage; + using var activity = RuntimeTelemetry.StartDurableJob(job); + using var renewalCancellation = CancellationTokenSource.CreateLinkedTokenSource( + cancellationToken); + using var leaseLost = new CancellationTokenSource(); + using var effectCancellation = CancellationTokenSource.CreateLinkedTokenSource( + cancellationToken, + leaseLost.Token); + var renewalTask = RenewJobLeaseAsync( + job.Id, + leaseLost, + renewalCancellation.Token); + Guid? itemId = null; try { - var fileMapper = scope.ServiceProvider.GetRequiredService(); - if (!await fileMapper.MapDownloadAsync(request.ItemId, cancellationToken)) - throw new InvalidOperationException("No file mapping could be produced."); + if (job.Type != DurableJobType.DownloadCompletion) + throw new NotSupportedException($"Unsupported durable job type: {job.Type}"); + + var payload = JsonSerializer.Deserialize(job.PayloadJson) + ?? throw new JsonException("The durable job payload is empty."); + itemId = payload.ItemId; + var stage = job.Stage; + currentStage = stage; - if (incidentReporter is not null) + if (stage == DurableJobStage.MapFiles) { - await incidentReporter.ResolveAsync( - IncidentType.FileMappingFailure, - request.ItemId.ToString(), - cancellationToken); + var mapper = serviceProvider.GetRequiredService(); + if (!await mapper.MapDownloadAsync(payload.ItemId, effectCancellation.Token)) + throw new InvalidOperationException("No file mapping could be produced."); + + if (incidentReporter is not null) + await incidentReporter.ResolveAsync( + IncidentType.FileMappingFailure, + payload.ItemId.ToString(), + effectCancellation.Token); + + await AdvanceAsync( + repository, job.Id, stage, DurableJobStage.Notify, effectCancellation.Token); + stage = DurableJobStage.Notify; + currentStage = stage; + } + + if (stage == DurableJobStage.Notify) + { + var notifier = serviceProvider.GetRequiredService(); + await notifier.NotifyAsync(job.Id, payload, effectCancellation.Token); + await AdvanceAsync( + repository, job.Id, stage, DurableJobStage.InvokePlugins, effectCancellation.Token); + stage = DurableJobStage.InvokePlugins; + currentStage = stage; } + + if (stage == DurableJobStage.InvokePlugins) + { + var eventTrigger = serviceProvider + .GetRequiredService>(); + await eventTrigger.InvokeAsync( + new FileDownloadCompleteParam( + payload.ItemId, + payload.StorePath, + payload.FileStore, + job.Id), + effectCancellation.Token); + await AdvanceAsync( + repository, job.Id, stage, DurableJobStage.Done, effectCancellation.Token); + } + + LogJobCompleted(logger, job.Id, payload.ItemId); + activity?.SetStatus(ActivityStatusCode.Ok); + telemetry?.RecordJobAttempt( + job.Type, + currentStage, + "completed", + Stopwatch.GetElapsedTime(startedAt)); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (OperationCanceledException) when (leaseLost.IsCancellationRequested) + { + // Another worker may already own the expired lease. Do not mutate the + // job with stale ownership; its persisted stage remains resumable. + LogJobLeaseLost(logger, job.Id); } - catch (Exception ex) + catch (Exception exception) { - LogFileMappingFailed(logger, ex, request.ItemId); - if (incidentReporter is not null) + var attemptCount = job.AttemptCount + 1; + var attemptedAt = DateTimeOffset.UtcNow; + var retryAt = attemptCount >= MaxAttempts + ? (DateTimeOffset?)null + : attemptedAt + RetryDelay(attemptCount); + var error = LimitError(exception); + + await repository.MarkFailedAsync( + job.Id, + _workerId, + attemptCount, + attemptedAt, + retryAt, + error, + cancellationToken); + + if (currentStage == DurableJobStage.MapFiles && incidentReporter is not null) { await incidentReporter.ReportAsync(new IncidentReport( IncidentType.FileMappingFailure, IncidentSeverity.Error, "Downloaded files could not be mapped", - ex.Message, - request.ItemId.ToString()), + error, + (itemId ?? job.Id).ToString()), cancellationToken); } + + if (retryAt.HasValue) + LogJobRetry(logger, exception, job.Id, attemptCount, retryAt.Value); + else + LogJobDeadLettered(logger, exception, job.Id, attemptCount); + activity?.SetStatus(ActivityStatusCode.Error, exception.GetType().Name); + telemetry?.RecordJobAttempt( + job.Type, + currentStage, + retryAt.HasValue ? "retry" : "dead_letter", + Stopwatch.GetElapsedTime(startedAt)); + } + finally + { + await renewalCancellation.CancelAsync(); + await renewalTask; } + } - // Fire plugin event + private async Task RenewJobLeaseAsync( + Guid jobId, + CancellationTokenSource leaseLost, + CancellationToken cancellationToken) + { try { - LogFiringPluginEvent(logger, request.ItemId); - var eventTrigger = scope.ServiceProvider - .GetRequiredService>(); - await eventTrigger.InvokeAsync(new FileDownloadCompleteParam( - request.ItemId, request.StorePath, request.FileStore), cancellationToken); - LogPluginEventCompleted(logger, request.ItemId); + using var timer = new PeriodicTimer(LeaseRenewInterval); + while (await timer.WaitForNextTickAsync(cancellationToken)) + { + var now = DateTimeOffset.UtcNow; + await using var scope = scopeFactory.CreateAsyncScope(); + var repository = scope.ServiceProvider.GetRequiredService(); + if (await repository.RenewLeaseAsync( + jobId, + _workerId, + now, + now + LeaseDuration, + cancellationToken)) + continue; + + leaseLost.Cancel(); + return; + } + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { } - catch (Exception ex) + catch (Exception exception) { - LogDownloadCompletedEventFailed(logger, ex, request.ItemId); + LogJobLeaseRenewalFailed(logger, exception, jobId); + leaseLost.Cancel(); } } - private async Task ReportCompletionFailureAsync( - DownloadCompleteRequest request, - Exception exception, + private async Task AdvanceAsync( + IDurableJobRepository repository, + Guid jobId, + DurableJobStage expectedStage, + DurableJobStage nextStage, CancellationToken cancellationToken) { - if (incidentReporter is null) return; + var advanced = await repository.AdvanceStageAsync( + jobId, + _workerId, + expectedStage, + nextStage, + DateTimeOffset.UtcNow, + cancellationToken); + if (!advanced) + throw new InvalidOperationException("The durable job lease was lost."); + } + private async Task WaitForWakeOrPollAsync(CancellationToken cancellationToken) + { + var reader = downloadCompleteRequest.Reader; + using var waitCancellation = CancellationTokenSource.CreateLinkedTokenSource( + cancellationToken); + waitCancellation.CancelAfter(PollInterval); try { - await incidentReporter.ReportAsync(new IncidentReport( - IncidentType.DownloadStalled, - IncidentSeverity.Error, - "Download completion could not be persisted", - exception.Message, - request.ItemId.ToString()), - cancellationToken); + await reader.WaitToReadAsync(waitCancellation.Token); } - catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + catch (OperationCanceledException) when ( + !cancellationToken.IsCancellationRequested + && waitCancellation.IsCancellationRequested) { - throw; + // The bounded timeout is the polling signal. Cancelling the channel + // wait prevents an abandoned waiter from accumulating every cycle. } - catch (Exception reportException) + + // Coalesce all hints. Their payload is intentionally not processed here; + // every authoritative request is already persisted transactionally. + while (reader.TryRead(out _)) { - // Persistence may be unavailable for both the completion and its - // incident. The queued retry remains the source of eventual recovery. - LogCompletionIncidentFailed(logger, reportException, request.ItemId); } } - [LoggerMessage(Level = LogLevel.Information, Message = "Processing download complete request for {ItemId}, storePath: {StorePath}, fileStore: {FileStore}")] - private static partial void LogProcessingRequest(ILogger logger, Guid itemId, string storePath, string fileStore); + internal static TimeSpan RetryDelay(int attemptCount) + { + var seconds = Math.Min(900, 5 * Math.Pow(2, Math.Max(0, attemptCount - 1))); + return TimeSpan.FromSeconds(seconds); + } - [LoggerMessage(Level = LogLevel.Information, - Message = "Ignoring completion for {ItemId} because it was cancelled, already completed, or removed")] - private static partial void LogCompletionIgnored(ILogger logger, Guid itemId); + private static string LimitError(Exception exception) + { + var value = $"{exception.GetType().Name}: {exception.Message}"; + return value.Length <= 512 ? value : value[..512]; + } - [LoggerMessage(Level = LogLevel.Error, Message = "Failed to process completion for {ItemId}")] - private static partial void LogProcessingRequestFailed(ILogger logger, Exception exception, Guid itemId); + [LoggerMessage(Level = LogLevel.Warning, Message = "Durable completion polling failed; it will retry")] + private static partial void LogPollFailed(ILogger logger, Exception exception); + + [LoggerMessage(Level = LogLevel.Information, + Message = "Durable completion job {JobId} completed for {ItemId}")] + private static partial void LogJobCompleted(ILogger logger, Guid jobId, Guid itemId); [LoggerMessage(Level = LogLevel.Warning, - Message = "Retrying completion for {ItemId} after attempt {Attempt}")] - private static partial void LogProcessingRequestRetry( + Message = "Durable completion job {JobId} failed on attempt {Attempt}; retrying at {RetryAt}")] + private static partial void LogJobRetry( + ILogger logger, + Exception exception, + Guid jobId, + int attempt, + DateTimeOffset retryAt); + + [LoggerMessage(Level = LogLevel.Error, + Message = "Durable completion job {JobId} entered dead-letter after {Attempt} attempts")] + private static partial void LogJobDeadLettered( ILogger logger, Exception exception, - Guid itemId, + Guid jobId, int attempt); [LoggerMessage(Level = LogLevel.Warning, - Message = "Requeued failed completion for {ItemId}")] - private static partial void LogProcessingRequestRequeued(ILogger logger, Guid itemId); + Message = "Durable completion job {JobId} lost its lease; another worker will resume it")] + private static partial void LogJobLeaseLost(ILogger logger, Guid jobId); [LoggerMessage(Level = LogLevel.Warning, - Message = "Failed to record completion incident for {ItemId}")] - private static partial void LogCompletionIncidentFailed( + Message = "Durable completion job {JobId} lease renewal failed")] + private static partial void LogJobLeaseRenewalFailed( ILogger logger, Exception exception, - Guid itemId); - - [LoggerMessage(Level = LogLevel.Information, Message = "Download marked finished for {ItemId}: {Title}")] - private static partial void LogDownloadMarkedFinished(ILogger logger, Guid itemId, string title); - - [LoggerMessage(Level = LogLevel.Debug, Message = "Firing OnFileDownloadCompleted plugin event for {ItemId}")] - private static partial void LogFiringPluginEvent(ILogger logger, Guid itemId); - - [LoggerMessage(Level = LogLevel.Debug, Message = "Plugin event completed for {ItemId}")] - private static partial void LogPluginEventCompleted(ILogger logger, Guid itemId); - - [LoggerMessage(Level = LogLevel.Warning, Message = "OnFileDownloadCompleted event failed for {ItemId}")] - private static partial void LogDownloadCompletedEventFailed(ILogger logger, Exception ex, Guid itemId); - - [LoggerMessage(Level = LogLevel.Warning, Message = "File mapping failed for {ItemId}")] - private static partial void LogFileMappingFailed(ILogger logger, Exception ex, Guid itemId); + Guid jobId); } diff --git a/SecondDimensionWatcherReDive/Services/FetchRemoteTorrentBackgroundService.cs b/SecondDimensionWatcherReDive/Services/FetchRemoteTorrentBackgroundService.cs index d587b9a..cef65f0 100644 --- a/SecondDimensionWatcherReDive/Services/FetchRemoteTorrentBackgroundService.cs +++ b/SecondDimensionWatcherReDive/Services/FetchRemoteTorrentBackgroundService.cs @@ -63,13 +63,31 @@ protected override async Task ExecuteAsync(CancellationToken cancellationToken) var reader = remoteTorrentTrackRequest.Reader; var tracked = new ConcurrentDictionary(); var observations = new ConcurrentDictionary(); - - // Add unfinished to track - await foreach (var request in FetchUnfinishedTaskFromDb(cancellationToken)) - tracked[request.Hash] = request; + var nextDatabaseRefreshAt = DateTimeOffset.MinValue; while (!cancellationToken.IsCancellationRequested) { + if (DateTimeOffset.UtcNow >= nextDatabaseRefreshAt) + { + try + { + // Periodic refresh recovers requests whose initial channel + // binding happened during a temporary database outage. + await foreach (var request in FetchUnfinishedTaskFromDb(cancellationToken)) + tracked[request.Hash] = request; + nextDatabaseRefreshAt = DateTimeOffset.UtcNow.AddSeconds(30); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception exception) + { + nextDatabaseRefreshAt = DateTimeOffset.UtcNow.AddSeconds(5); + LogRefreshTrackedDownloadsFailed(logger, exception); + } + } + // Drain channel messages in the supervised service loop so channel // failures/cancellation cannot disappear in an unobserved Task. while (reader.TryRead(out var request)) @@ -82,9 +100,23 @@ protected override async Task ExecuteAsync(CancellationToken cancellationToken) } else { - var boundRequest = await BindCurrentAttemptAsync(request, cancellationToken); - if (boundRequest is { } currentRequest) - tracked[request.Hash] = currentRequest; + try + { + var boundRequest = await BindCurrentAttemptAsync(request, cancellationToken); + if (boundRequest is { } currentRequest) + tracked[request.Hash] = currentRequest; + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception exception) + { + // The periodic database refresh above will recover this + // unfinished attempt without relying on an unbounded queue. + nextDatabaseRefreshAt = DateTimeOffset.MinValue; + LogBindTrackedDownloadFailed(logger, exception, request.ItemId); + } } } await Task.Delay(500, cancellationToken); @@ -145,14 +177,28 @@ await ObserveHealthAsync( if (state != FileDownloadState.Finished) continue; - //Write complete request and stop tracking. - await downloadCompleteRequest.Writer.WriteAsync( - new DownloadCompleteRequest( - request.ItemId, - torrentInfo.SavePath, - FileStores.LocalDiskStore, - request.DownloadAttemptId), - cancellationToken); + var completion = new DownloadCompleteRequest( + request.ItemId, + torrentInfo.SavePath, + FileStores.LocalDiskStore, + request.DownloadAttemptId); + try + { + // The completion transition and its durable workflow are committed + // together. The channel is only a best-effort wake-up signal; the + // durable worker also polls after restart. + await PersistCompletionAsync(completion, cancellationToken); + downloadCompleteRequest.Writer.TryWrite(completion); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception exception) + { + LogPersistCompletionFailed(logger, exception, request.ItemId); + continue; + } tracked.TryRemove(torrentInfo.Hash, out _); observations.TryRemove(torrentInfo.Hash, out _); } @@ -165,6 +211,21 @@ await ReportMissingAfterThresholdAsync( } } + private async Task PersistCompletionAsync( + DownloadCompleteRequest request, + CancellationToken cancellationToken) + { + await using var scope = scopeFactory.CreateAsyncScope(); + var repository = scope.ServiceProvider.GetRequiredService(); + await repository.TryCompleteDownloadAsync( + request.ItemId, + request.DownloadAttemptId, + request.FileStore, + request.StorePath, + DateTimeOffset.UtcNow, + cancellationToken); + } + private async Task ObserveHealthAsync( RemoteTorrentTrackRequest request, RemoteTorrentInfo torrentInfo, @@ -323,4 +384,24 @@ private bool ShouldResolve(DownloadObservation observation, DateTimeOffset now) [LoggerMessage(Level = LogLevel.Warning, Message = "Failed to fetch torrent status from remote client")] private static partial void LogFetchTorrentStatusFailed(ILogger logger, Exception ex); + + [LoggerMessage(Level = LogLevel.Error, + Message = "Could not persist durable download completion for {ItemId}; tracking will retry")] + private static partial void LogPersistCompletionFailed( + ILogger logger, + Exception exception, + Guid itemId); + + [LoggerMessage(Level = LogLevel.Warning, + Message = "Could not refresh unfinished downloads; retrying")] + private static partial void LogRefreshTrackedDownloadsFailed( + ILogger logger, + Exception exception); + + [LoggerMessage(Level = LogLevel.Warning, + Message = "Could not bind download attempt {ItemId}; the database refresh will retry")] + private static partial void LogBindTrackedDownloadFailed( + ILogger logger, + Exception exception, + Guid itemId); } diff --git a/SecondDimensionWatcherReDive/Services/MediaLibraryScanQueue.cs b/SecondDimensionWatcherReDive/Services/MediaLibraryScanQueue.cs index 4901b70..fc5e1f2 100644 --- a/SecondDimensionWatcherReDive/Services/MediaLibraryScanQueue.cs +++ b/SecondDimensionWatcherReDive/Services/MediaLibraryScanQueue.cs @@ -14,8 +14,14 @@ public interface IMediaLibraryScanQueue public sealed class MediaLibraryScanQueue : IMediaLibraryScanQueue { - private readonly Channel _channel = Channel.CreateUnbounded( - new UnboundedChannelOptions { SingleReader = true, SingleWriter = false }); + internal const int Capacity = 256; + private readonly Channel _channel = Channel.CreateBounded( + new BoundedChannelOptions(Capacity) + { + SingleReader = true, + SingleWriter = false, + FullMode = BoundedChannelFullMode.Wait + }); private readonly ConcurrentDictionary _pending = new(); public bool Enqueue(Guid sourceId) diff --git a/SecondDimensionWatcherReDive/Services/NullDownloadCompletionNotifier.cs b/SecondDimensionWatcherReDive/Services/NullDownloadCompletionNotifier.cs new file mode 100644 index 0000000..73c3c65 --- /dev/null +++ b/SecondDimensionWatcherReDive/Services/NullDownloadCompletionNotifier.cs @@ -0,0 +1,12 @@ +using SecondDimensionWatcherReDive.Framework.DataRepository; +using SecondDimensionWatcherReDive.Framework.Notifications; + +namespace SecondDimensionWatcherReDive.Services; + +internal sealed class NullDownloadCompletionNotifier : IDownloadCompletionNotifier +{ + public Task NotifyAsync( + Guid eventId, + DownloadCompletionJobPayload payload, + CancellationToken cancellationToken) => Task.CompletedTask; +} diff --git a/SecondDimensionWatcherReDive/Services/PostgresScheduledTaskLeaseManager.cs b/SecondDimensionWatcherReDive/Services/PostgresScheduledTaskLeaseManager.cs new file mode 100644 index 0000000..c16108d --- /dev/null +++ b/SecondDimensionWatcherReDive/Services/PostgresScheduledTaskLeaseManager.cs @@ -0,0 +1,146 @@ +using SecondDimensionWatcherReDive.Framework.DataRepository; +using SecondDimensionWatcherReDive.Framework.Tasks; + +namespace SecondDimensionWatcherReDive.Services; + +public sealed partial class PostgresScheduledTaskLeaseManager( + IServiceScopeFactory scopeFactory, + ILogger logger) : IScheduledTaskLeaseManager +{ + internal static readonly TimeSpan LeaseDuration = TimeSpan.FromSeconds(30); + internal static readonly TimeSpan RenewInterval = TimeSpan.FromSeconds(10); + + private readonly string _ownerId = $"{Environment.MachineName}:{Environment.ProcessId}:{Guid.NewGuid():N}"; + + public async Task TryAcquireAsync( + string taskId, + TimeSpan interval, + bool force, + CancellationToken cancellationToken) + { + var now = DateTimeOffset.UtcNow; + await using var scope = scopeFactory.CreateAsyncScope(); + var repository = scope.ServiceProvider.GetRequiredService(); + var acquired = await repository.TryAcquireAsync( + taskId, + _ownerId, + now, + now + LeaseDuration, + force, + cancellationToken); + return acquired + ? new ExecutionLease(taskId, _ownerId, interval, scopeFactory, logger) + : null; + } + + private sealed class ExecutionLease : IScheduledTaskExecutionLease + { + private readonly string _taskId; + private readonly string _ownerId; + private readonly TimeSpan _interval; + private readonly IServiceScopeFactory _scopeFactory; + private readonly ILogger _logger; + private readonly CancellationTokenSource _renewalCancellation = new(); + private readonly CancellationTokenSource _leaseLost = new(); + private readonly Task _renewalTask; + private bool _completed; + + public ExecutionLease( + string taskId, + string ownerId, + TimeSpan interval, + IServiceScopeFactory scopeFactory, + ILogger logger) + { + _taskId = taskId; + _ownerId = ownerId; + _interval = interval; + _scopeFactory = scopeFactory; + _logger = logger; + _renewalTask = RenewLoopAsync(); + } + + public CancellationToken LeaseLostToken => _leaseLost.Token; + + public async Task CompleteAsync( + bool succeeded, + string? error, + CancellationToken cancellationToken) + { + if (_completed) return; + _completed = true; + await StopRenewalAsync(); + + await using var scope = _scopeFactory.CreateAsyncScope(); + var repository = scope.ServiceProvider.GetRequiredService(); + var completedAt = DateTimeOffset.UtcNow; + await repository.CompleteAsync( + _taskId, + _ownerId, + completedAt, + completedAt + _interval, + succeeded, + error, + cancellationToken); + } + + public async ValueTask DisposeAsync() + { + await StopRenewalAsync(); + _renewalCancellation.Dispose(); + _leaseLost.Dispose(); + } + + private async Task RenewLoopAsync() + { + try + { + using var timer = new PeriodicTimer(RenewInterval); + while (await timer.WaitForNextTickAsync(_renewalCancellation.Token)) + { + var now = DateTimeOffset.UtcNow; + await using var scope = _scopeFactory.CreateAsyncScope(); + var repository = scope.ServiceProvider + .GetRequiredService(); + if (await repository.RenewAsync( + _taskId, + _ownerId, + now, + now + LeaseDuration, + _renewalCancellation.Token)) + continue; + + LogLeaseLost(_logger, _taskId); + _leaseLost.Cancel(); + return; + } + } + catch (OperationCanceledException) when (_renewalCancellation.IsCancellationRequested) + { + } + catch (Exception exception) + { + LogLeaseRenewalFailed(_logger, exception, _taskId); + _leaseLost.Cancel(); + } + } + + private async Task StopRenewalAsync() + { + if (!_renewalCancellation.IsCancellationRequested) + await _renewalCancellation.CancelAsync(); + await _renewalTask; + } + } + + [LoggerMessage(Level = LogLevel.Warning, + Message = "Scheduled task {TaskId} lost its execution lease")] + private static partial void LogLeaseLost(ILogger logger, string taskId); + + [LoggerMessage(Level = LogLevel.Warning, + Message = "Scheduled task {TaskId} lease renewal failed")] + private static partial void LogLeaseRenewalFailed( + ILogger logger, + Exception exception, + string taskId); +} diff --git a/SecondDimensionWatcherReDive/Services/ScheduledTaskBackgroundService.cs b/SecondDimensionWatcherReDive/Services/ScheduledTaskBackgroundService.cs index 595c420..4bcfb25 100644 --- a/SecondDimensionWatcherReDive/Services/ScheduledTaskBackgroundService.cs +++ b/SecondDimensionWatcherReDive/Services/ScheduledTaskBackgroundService.cs @@ -1,18 +1,24 @@ +using System.Diagnostics; using SecondDimensionWatcherReDive.Framework.Tasks; +using SecondDimensionWatcherReDive.Observability; namespace SecondDimensionWatcherReDive.Services; public partial class ScheduledTaskBackgroundService( TTask task, + IScheduledTaskLeaseManager leaseManager, + RuntimeTelemetry telemetry, ILogger> logger) : BackgroundService where TTask : ScheduledTaskBase { + private static readonly TimeSpan ContendedLeasePollInterval = TimeSpan.FromSeconds(10); + protected override async Task ExecuteAsync(CancellationToken stoppingToken) { LogStartingScheduledTask(logger, task.Id); await Task.WhenAll( - task.ProcessQueueAsync(stoppingToken), + task.ProcessQueueAsync(leaseManager, stoppingToken), RunTimerLoopAsync(stoppingToken)); } @@ -20,19 +26,49 @@ private async Task RunTimerLoopAsync(CancellationToken stoppingToken) { while (!stoppingToken.IsCancellationRequested) { + var delay = task.Interval; if (task.IsEnabled) { + var startedAt = Stopwatch.GetTimestamp(); try { - await task.RunNowAsync(stoppingToken); + var executed = await task.RunScheduledAsync(stoppingToken); + if (!executed) + delay = ContendedLeasePollInterval; + telemetry.RecordScheduledTask( + task.Id, + executed ? "completed" : "contended", + Stopwatch.GetElapsedTime(startedAt)); + } + catch (ScheduledTaskLeaseUnavailableException ex) + { + delay = ContendedLeasePollInterval; + LogScheduledTaskLeaseUnavailable(logger, ex, task.Id); + telemetry.RecordScheduledTask( + task.Id, + "lease_unavailable", + Stopwatch.GetElapsedTime(startedAt)); + } + catch (OperationCanceledException ex) when (!stoppingToken.IsCancellationRequested) + { + delay = ContendedLeasePollInterval; + LogScheduledTaskLeaseLost(logger, ex, task.Id); + telemetry.RecordScheduledTask( + task.Id, + "lease_lost", + Stopwatch.GetElapsedTime(startedAt)); } catch (Exception ex) when (ex is not OperationCanceledException) { LogScheduledTaskFailed(logger, ex, task.Id); + telemetry.RecordScheduledTask( + task.Id, + "failed", + Stopwatch.GetElapsedTime(startedAt)); } } - await Task.Delay(task.Interval, stoppingToken); + await Task.Delay(delay, stoppingToken); } } @@ -41,4 +77,18 @@ private async Task RunTimerLoopAsync(CancellationToken stoppingToken) [LoggerMessage(Level = LogLevel.Error, Message = "Scheduled task {TaskId} failed during timer execution")] private static partial void LogScheduledTaskFailed(ILogger logger, Exception ex, string taskId); + + [LoggerMessage(Level = LogLevel.Warning, + Message = "Scheduled task {TaskId} lease is unavailable; retrying soon")] + private static partial void LogScheduledTaskLeaseUnavailable( + ILogger logger, + Exception exception, + string taskId); + + [LoggerMessage(Level = LogLevel.Warning, + Message = "Scheduled task {TaskId} execution was cancelled or lost its lease; retrying soon")] + private static partial void LogScheduledTaskLeaseLost( + ILogger logger, + Exception exception, + string taskId); } diff --git a/SecondDimensionWatcherReDive/Utils/FileDownload/RemoteTorrentDownloadClient.cs b/SecondDimensionWatcherReDive/Utils/FileDownload/RemoteTorrentDownloadClient.cs index 9bb0f72..e7746a0 100644 --- a/SecondDimensionWatcherReDive/Utils/FileDownload/RemoteTorrentDownloadClient.cs +++ b/SecondDimensionWatcherReDive/Utils/FileDownload/RemoteTorrentDownloadClient.cs @@ -38,7 +38,8 @@ public override async Task SubmitDownloadTaskAsync( if (response.IsSuccessStatusCode) await remoteTorrentTrackRequest.Writer.WriteAsync( - new(itemId, additionalDownloadInfo)); + new(itemId, additionalDownloadInfo), + cancellationToken); return response.IsSuccessStatusCode; } @@ -51,7 +52,8 @@ public override async Task SubmitQueryDownloadProgressAsync( CancellationToken cancellationToken) { await remoteTorrentTrackRequest.Writer.WriteAsync( - new(itemId, additionalDownloadInfo)); + new(itemId, additionalDownloadInfo), + cancellationToken); } public override async Task PauseDownloadTaskAsync( diff --git a/SecondDimensionWatcherReDive/appsettings.example.json b/SecondDimensionWatcherReDive/appsettings.example.json index da9d5ab..a266ec7 100644 --- a/SecondDimensionWatcherReDive/appsettings.example.json +++ b/SecondDimensionWatcherReDive/appsettings.example.json @@ -92,5 +92,20 @@ "Valkey": { "ConnectionString": "", "InstanceName": "sdw-redive:" + }, + + // /health/live never probes dependencies. /health/ready always probes PostgreSQL; + // these switches control the other deployment-specific readiness dependencies. + "Health": { + "ValkeyRequired": true, + "QbittorrentRequired": true, + "StorageRequired": true, + "AIRequired": false + }, + + // Prometheus metrics are always exposed at /metrics. Set an OTLP endpoint to + // additionally export traces and metrics to an OpenTelemetry collector. + "OpenTelemetry": { + "OtlpEndpoint": "" } } diff --git a/docs/runtime-reliability.md b/docs/runtime-reliability.md new file mode 100644 index 0000000..0ac218f --- /dev/null +++ b/docs/runtime-reliability.md @@ -0,0 +1,66 @@ +# 运行可靠性与可观测性 + +## 持久下载完成流程 + +下载器确认完成后,会在同一个 PostgreSQL 事务中更新下载状态并创建唯一的 +`DownloadCompletion` 持久任务。进程内的完成 Channel 仅用于立即唤醒消费者;即使 +信号丢失或进程在任意阶段退出,消费者也会轮询数据库并在租约到期后继续。 +消费者一次只领取一个任务,2 分钟任务租约每 30 秒续期;慢速映射不会被其他实例并发 +领取,而失联消费者的任务会在租约到期后恢复。 + +每个任务按以下阶段推进,阶段成功后才持久化下一个阶段: + +1. `MapFiles`:以替换方式重建该下载的虚拟文件映射,重复执行不会新增重复映射。 +2. `Notify`:调用可选的通知扩展点。 +3. `InvokePlugins`:触发下载完成插件事件。 +4. `Done`:标记任务完成。 + +通知实现和插件处理器会收到稳定的 `EventId`(持久任务 ID),必须在执行外部副作用前 +把它作为幂等键保存。这样,即使进程恰好在外部调用成功、阶段提交前退出,重新投递也 +不会重复发送通知或执行插件副作用。 + +失败任务采用指数退避(5 秒起步,最长 15 分钟),第 8 次失败后进入死信。登录后打开 +「后台任务」可以查看失败阶段、尝试次数和错误,并选择「重试」或「标记已处理」。对应 +API 为: + +- `GET /api/jobs?status=deadLetter` +- `POST /api/jobs/retry`,请求体 `{"ids":["..."]}` +- `POST /api/jobs/resolve`,请求体 `{"ids":["..."]}` + +API 不返回任务 payload、去重键或租约所有者,避免暴露存储路径和内部拓扑。 + +## 多实例定时任务 + +每个定时任务执行前都会获取 PostgreSQL 租约,并每 10 秒续租一次;默认租约为 30 秒。 +同一时刻只有一个实例执行同名任务。未持有租约的实例每 10 秒检查一次,实例退出或失联后 +可在租约到期时接管;正常完成会把租约冷却期保留到下次应运行时间,避免其他实例立即 +重复运行。手动触发可以越过“已完成”的冷却期,但不能越过仍在执行的租约。同一实例内 +的手动与周期触发会合并为一个容量为 1 的信号,避免积压重复执行。 + +所有生产 Channel 都有明确容量与溢出策略:下载跟踪使用背压;高频进度和已持久化的 +完成唤醒信号丢弃旧值;媒体库扫描在满载时拒绝新请求;聊天 SSE 使用背压。 +Codex app-server 的请求内更新缓冲最多保留 1024 项,超限会终止该轮对话并返回明确错误, +不会继续无界占用内存。 + +## 健康探针 + +- `GET /health/live`:只说明进程可响应,不访问任何外部依赖。 +- `GET /health/ready`:始终检查 PostgreSQL,并按配置检查 Valkey、qBittorrent、下载 + 存储和可选 AI Provider。任何必需依赖失败时返回 `503`。 + +`Health:QbittorrentRequired`、`Health:StorageRequired` 和 +`Health:ValkeyRequired` 默认启用(Valkey 仅在配置连接时注册); +`Health:AIRequired` 默认关闭。探针响应只包含检查名、状态、耗时和异常类型,不包含地址、 +凭据或异常正文。 + +## 指标与链路 + +`GET /metrics` 提供 Prometheus 格式的 ASP.NET Core、HttpClient、运行时和持久任务指标。 +设置 `OpenTelemetry:OtlpEndpoint` 后,应用也会通过 OTLP 导出指标及 ASP.NET Core、 +HttpClient、EF Core 和持久任务链路。 + +HTTP 指标使用显式标签白名单:服务端只保留方法、状态码、路由模板和协议版本;客户端 +只保留方法、状态码、服务地址/端口和协议版本。自定义任务指标只使用固定枚举值 +`job.type`、`job.stage`、`outcome` 和 `status`。原始路径、查询串、动画标题、工具参数、 +SQL 文本和查询参数不会作为指标标签或导出的链路标签。定时任务指标的 `task.id` 也只 +允许四个内置任务值,未知扩展统一归为 `other`。 diff --git a/packaging/appsettings.yml b/packaging/appsettings.yml index 4cf660f..3a1bf21 100644 --- a/packaging/appsettings.yml +++ b/packaging/appsettings.yml @@ -92,3 +92,14 @@ Nfs: # Valkey: # ConnectionString: "localhost:6379" # InstanceName: "sdw-redive:" + +# 健康探针:/health/live 不访问外部依赖;/health/ready 始终检查 PostgreSQL +Health: + ValkeyRequired: true + QbittorrentRequired: true + StorageRequired: true + AIRequired: false + +# /metrics 始终提供 Prometheus 指标;配置地址后同时向 OTLP Collector 导出 +OpenTelemetry: + OtlpEndpoint: "" From ba8b3149cd20b48253860a9df71cfe05d1e39555 Mon Sep 17 00:00:00 2001 From: mahoshojoHCG Date: Sun, 30 Aug 2026 10:50:56 +0800 Subject: [PATCH 05/37] fix: make data restore transitions complete --- .github/workflows/backup-restore.yml | 2 + .../LogicalDataTransferPostgreSqlTests.cs | 251 +++++++++++++++++- .../LogicalDataTransferRepository.cs | 149 ++++++++++- deployments/sdw-backup | 73 ++++- deployments/tests/backup-restore-smoke.sh | 5 + docs/backup-restore.md | 9 +- packaging/backup.env | 4 + 7 files changed, 467 insertions(+), 26 deletions(-) diff --git a/.github/workflows/backup-restore.yml b/.github/workflows/backup-restore.yml index f0c664c..e219226 100644 --- a/.github/workflows/backup-restore.yml +++ b/.github/workflows/backup-restore.yml @@ -141,6 +141,8 @@ jobs: --key-ring-destination "$WORK_DIR/restored/keys" \ --plugin-destination "$WORK_DIR/restored/plugins" \ --safety-directory "$WORK_DIR/backups" + test "$(psql --no-psqlrc --tuples-only --no-align --command \ + "SELECT to_regclass('public.restore_guard') IS NULL")" = "t" test "$(psql --no-psqlrc --tuples-only --no-align --command 'SELECT count(*) FROM "Feeds"')" = "1" cmp "$WORK_DIR/source/password.json" "$WORK_DIR/restored/password.json" cmp "$WORK_DIR/source/appsettings.yml" "$WORK_DIR/restored/appsettings.yml" diff --git a/SecondDimensionWatcherReDive.IntegrationTest/PostgreSql/LogicalDataTransferPostgreSqlTests.cs b/SecondDimensionWatcherReDive.IntegrationTest/PostgreSql/LogicalDataTransferPostgreSqlTests.cs index d39c863..94437f9 100644 --- a/SecondDimensionWatcherReDive.IntegrationTest/PostgreSql/LogicalDataTransferPostgreSqlTests.cs +++ b/SecondDimensionWatcherReDive.IntegrationTest/PostgreSql/LogicalDataTransferPostgreSqlTests.cs @@ -1,6 +1,8 @@ using Microsoft.EntityFrameworkCore; +using Moq; using SecondDimensionWatcherReDive.Framework.DataRepository; using SecondDimensionWatcherReDive.Repositories; +using SecondDimensionWatcherReDive.Utils.FileStore; using Testcontainers.PostgreSql; using Models = SecondDimensionWatcherReDive.Models; @@ -83,7 +85,7 @@ public async Task FeedsPoliciesAndRulesRoundTripWithExplicitConflictStrategies() CreatedAt = DateTimeOffset.UtcNow }); await source.SaveChangesAsync(); - bundle = await new LogicalDataTransferRepository(source).ExportAsync( + bundle = await Repository(source).ExportAsync( LogicalDataCategory.Feeds | LogicalDataCategory.AutomationPolicies | LogicalDataCategory.FileNameRules, @@ -98,7 +100,7 @@ public async Task FeedsPoliciesAndRulesRoundTripWithExplicitConflictStrategies() } await using var target = new Models.ApplicationContext(Options); - var repository = new LogicalDataTransferRepository(target); + var repository = Repository(target); var first = await repository.ImportAsync( bundle, LogicalImportConflictStrategy.Skip, @@ -219,7 +221,7 @@ INSERT INTO "PlaybackPreferences" """); Assert.AreEqual(1, await source.PlaybackPreferences.AsNoTracking().CountAsync()); - bundle = await new LogicalDataTransferRepository(source).ExportAsync( + bundle = await Repository(source).ExportAsync( LogicalDataCategory.MetadataCorrections | LogicalDataCategory.Playback, Guid.Empty, "1.0.0", @@ -244,7 +246,7 @@ INSERT INTO "PlaybackPreferences" target.AddRange(targetInfo, Mapping(targetInfoId, VirtualPath)); await target.SaveChangesAsync(); - var result = await new LogicalDataTransferRepository(target).ImportAsync( + var result = await Repository(target).ImportAsync( bundle, LogicalImportConflictStrategy.Skip, Guid.Empty, @@ -268,6 +270,240 @@ INSERT INTO "PlaybackPreferences" Assert.IsFalse(preferences.AutoPlayNext); } + [TestMethod] + public async Task MetadataImportTransitionsMappingsPlaybackAndRemainsUndoable() + { + var publishedAt = DateTimeOffset.UtcNow.AddDays(-1); + var operationId = Guid.NewGuid(); + var infoId = Guid.NewGuid(); + const string DownloadUrl = "https://example.com/remapped-release.torrent"; + const string PreviousPath = "/Old Show/Old Group/Old Show S01E02.mkv"; + const string ProposedPath = "/Correct Show/Correct Group/Correct Show S02E05.mkv"; + const string PhysicalPath = "/target-media/release.mkv"; + + await using (var seed = new Models.ApplicationContext(Options)) + { + var oldAnimation = Animation("tv:old", "Old Show"); + var oldGroup = new Models.AnimationGroup { Id = Guid.NewGuid(), Name = "Old Group" }; + var info = Release(infoId, DownloadUrl, publishedAt); + info.Animation = oldAnimation; + info.Group = oldGroup; + info.Season = 1; + info.Episode = 2; + info.IsDownloadFinished = true; + info.FileStore = "local"; + info.StorePath = "/target-media"; + info.StateVersion = 7; + seed.AddRange( + oldAnimation, + oldGroup, + info, + new Models.FileMapping + { + Id = Guid.NewGuid(), + AnimationInfoId = infoId, + VirtualPath = PreviousPath, + PhysicalPath = PhysicalPath, + FileStore = "local" + }, + new Models.PlaybackProgress + { + Id = Guid.NewGuid(), + UserId = Guid.Empty, + AnimationInfoId = infoId, + VirtualPath = PreviousPath, + PositionSeconds = 120, + DurationSeconds = 1_440, + UpdatedAt = DateTimeOffset.UtcNow.AddHours(-1) + }); + await seed.SaveChangesAsync(); + } + + var bundle = new LogicalDataBundle( + 1, + DateTimeOffset.UtcNow, + "1.0.0", + LogicalDataCategory.MetadataCorrections | LogicalDataCategory.Playback, + [], + [], + [], + [ + new LogicalMetadataCorrection( + operationId, + DownloadUrl, + "[Group] Example - 02", + publishedAt, + "tv:correct", + "Correct Show", + "Correct Show", + "/correct.jpg", + "corrected description", + 2, + 5, + "Correct Group", + DateTimeOffset.UtcNow) + ], + [ + new LogicalPlaybackProgress( + ProposedPath, + 600, + 1_440, + false, + DateTimeOffset.UtcNow, + null) + ], + null); + var mapper = new Mock(MockBehavior.Strict); + mapper.Setup(candidate => candidate.PreviewDownloadAsync( + It.Is(info => + info.Id == infoId && + info.Animation != null && info.Animation.TmdbId == "tv:correct" && + info.Group != null && info.Group.Name == "Correct Group" && + info.Season == 2 && info.Episode == 5), + CancellationToken.None)) + .ReturnsAsync(new FileMappingPreview( + [ + new FileMapping( + Guid.NewGuid(), + infoId, + ProposedPath, + PhysicalPath, + "local") + ], + [])); + + await using (var importing = new Models.ApplicationContext(Options)) + { + var result = await Repository(importing, mapper.Object).ImportAsync( + bundle, + LogicalImportConflictStrategy.Overwrite, + Guid.Empty, + CancellationToken.None); + Assert.AreEqual(1, result.Added); + Assert.AreEqual(1, result.Updated); + } + + await using (var verification = new Models.ApplicationContext(Options)) + { + var info = await verification.AnimationInfo + .Include(candidate => candidate.Animation) + .Include(candidate => candidate.Group) + .SingleAsync(); + Assert.AreEqual(8, info.StateVersion); + Assert.AreEqual(operationId, info.CurrentMetadataReviewOperationId); + Assert.AreEqual("tv:correct", info.Animation!.TmdbId); + Assert.AreEqual("Correct Group", info.Group!.Name); + + var mapping = await verification.FileMappings.SingleAsync(); + Assert.AreEqual(ProposedPath, mapping.VirtualPath); + Assert.AreEqual(PhysicalPath, mapping.PhysicalPath); + var snapshots = await verification.MetadataReviewMappingSnapshots + .OrderBy(snapshot => snapshot.Kind) + .ToListAsync(); + Assert.AreEqual(2, snapshots.Count); + Assert.AreEqual(PreviousPath, + snapshots.Single(snapshot => snapshot.Kind == MetadataReviewMappingKind.Previous).VirtualPath); + Assert.AreEqual(ProposedPath, + snapshots.Single(snapshot => snapshot.Kind == MetadataReviewMappingKind.Proposed).VirtualPath); + Assert.IsTrue(snapshots.All(snapshot => snapshot.PhysicalPath == PhysicalPath)); + var progress = await verification.PlaybackProgresses.SingleAsync(); + Assert.AreEqual(ProposedPath, progress.VirtualPath); + Assert.AreEqual(600, progress.PositionSeconds); + } + + await using (var undoContext = new Models.ApplicationContext(Options)) + { + var undone = await new MetadataReviewRepository(undoContext, Options).UndoAsync( + operationId, + 8, + CancellationToken.None); + Assert.AreEqual(MetadataReviewMutationOutcome.Success, undone.Outcome); + } + + await using (var verification = new Models.ApplicationContext(Options)) + { + var info = await verification.AnimationInfo + .Include(candidate => candidate.Animation) + .Include(candidate => candidate.Group) + .SingleAsync(); + Assert.AreEqual(9, info.StateVersion); + Assert.AreEqual("tv:old", info.Animation!.TmdbId); + Assert.AreEqual("Old Group", info.Group!.Name); + Assert.AreEqual(PreviousPath, (await verification.FileMappings.SingleAsync()).VirtualPath); + var progress = await verification.PlaybackProgresses.SingleAsync(); + Assert.AreEqual(PreviousPath, progress.VirtualPath); + Assert.AreEqual(600, progress.PositionSeconds); + } + } + + [TestMethod] + public async Task UnavailableMetadataMappingPlanRollsBackWholeImport() + { + var publishedAt = DateTimeOffset.UtcNow.AddDays(-1); + var infoId = Guid.NewGuid(); + const string DownloadUrl = "https://example.com/unavailable-mapping.torrent"; + const string PreviousPath = "/unknown/original.mkv"; + await using (var seed = new Models.ApplicationContext(Options)) + { + var info = Release(infoId, DownloadUrl, publishedAt); + info.IsDownloadFinished = true; + info.FileStore = "local"; + info.StorePath = "/missing-media"; + seed.AddRange(info, Mapping(infoId, PreviousPath)); + await seed.SaveChangesAsync(); + } + + var bundle = new LogicalDataBundle( + 1, + DateTimeOffset.UtcNow, + "1.0.0", + LogicalDataCategory.Feeds | LogicalDataCategory.MetadataCorrections, + [new LogicalFeed(Guid.NewGuid(), "https://example.com/new.xml", "New", DateTimeOffset.UtcNow)], + [], + [], + [ + new LogicalMetadataCorrection( + Guid.NewGuid(), + DownloadUrl, + "[Group] Example - 02", + publishedAt, + "tv:correct", + "Correct Show", + "Correct Show", + null, + "corrected description", + 2, + 5, + "Correct Group", + DateTimeOffset.UtcNow) + ], + [], + null); + var mapper = new Mock(MockBehavior.Strict); + mapper.Setup(candidate => candidate.PreviewDownloadAsync( + It.IsAny(), + CancellationToken.None)) + .ReturnsAsync((FileMappingPreview?)null); + + await using (var importing = new Models.ApplicationContext(Options)) + { + await Assert.ThrowsAsync(() => + Repository(importing, mapper.Object).ImportAsync( + bundle, + LogicalImportConflictStrategy.Overwrite, + Guid.Empty, + CancellationToken.None)); + } + + await using var verification = new Models.ApplicationContext(Options); + Assert.AreEqual(0, await verification.Feeds.CountAsync()); + Assert.AreEqual(0, await verification.MetadataReviewOperations.CountAsync()); + var infoAfter = await verification.AnimationInfo.SingleAsync(); + Assert.AreEqual("uncorrected", infoAfter.Description); + Assert.AreEqual(0, infoAfter.StateVersion); + Assert.AreEqual(PreviousPath, (await verification.FileMappings.SingleAsync()).VirtualPath); + } + [TestMethod] public async Task FailConflictStrategyRollsBackEarlierItems() { @@ -301,7 +537,7 @@ public async Task FailConflictStrategyRollsBackEarlierItems() await using (var importing = new Models.ApplicationContext(Options)) { await Assert.ThrowsAsync(() => - new LogicalDataTransferRepository(importing).ImportAsync( + Repository(importing).ImportAsync( bundle, LogicalImportConflictStrategy.Fail, Guid.Empty, @@ -348,4 +584,9 @@ private static Models.FileMapping Mapping(Guid animationInfoId, string virtualPa PhysicalPath = "/media/example.mkv", FileStore = "local" }; + + private static LogicalDataTransferRepository Repository( + Models.ApplicationContext context, + IFileMapper? fileMapper = null) => + new(context, fileMapper ?? Mock.Of()); } diff --git a/SecondDimensionWatcherReDive/Repositories/LogicalDataTransferRepository.cs b/SecondDimensionWatcherReDive/Repositories/LogicalDataTransferRepository.cs index d9ef62b..fff9139 100644 --- a/SecondDimensionWatcherReDive/Repositories/LogicalDataTransferRepository.cs +++ b/SecondDimensionWatcherReDive/Repositories/LogicalDataTransferRepository.cs @@ -2,10 +2,14 @@ using System.Security.Cryptography; using System.Text; using SecondDimensionWatcherReDive.Framework.DataRepository; +using SecondDimensionWatcherReDive.Utils.FileStore; +using DataFileMapping = SecondDimensionWatcherReDive.Framework.DataRepository.FileMapping; namespace SecondDimensionWatcherReDive.Repositories; -public sealed class LogicalDataTransferRepository(Models.ApplicationContext context) +public sealed class LogicalDataTransferRepository( + Models.ApplicationContext context, + IFileMapper fileMapper) : ILogicalDataTransferRepository { private const int FormatVersion = 1; @@ -43,18 +47,18 @@ public async Task ExportAsync( var rules = categories.HasFlag(LogicalDataCategory.FileNameRules) ? await (from rule in context.FileNameRegexRules.AsNoTracking() - join animation in context.Animations.AsNoTracking() - on rule.AnimationId equals animation.Id - orderby animation.TmdbId, rule.CreatedAt - select new LogicalFileNameRule( - rule.Id, - animation.TmdbId, - animation.Name, - animation.OriginalName, - animation.PosterPath, - rule.Pattern, - rule.Description, - rule.CreatedAt)) + join animation in context.Animations.AsNoTracking() + on rule.AnimationId equals animation.Id + orderby animation.TmdbId, rule.CreatedAt + select new LogicalFileNameRule( + rule.Id, + animation.TmdbId, + animation.Name, + animation.OriginalName, + animation.PosterPath, + rule.Pattern, + rule.Description, + rule.CreatedAt)) .ToListAsync(cancellationToken) : []; @@ -143,7 +147,14 @@ public async Task ImportAsync( await ImportFeedsAsync(bundle, conflictStrategy, feedsByUrl, usedFeedIds, statistics, cancellationToken); await ImportPoliciesAsync(bundle, conflictStrategy, feedsByUrl, statistics, cancellationToken); await ImportRulesAsync(bundle, conflictStrategy, statistics, cancellationToken); + // Mapping previews can consume filename rules and animation rows imported in + // this bundle. Flush them inside the transaction before planning corrections. + await context.SaveChangesAsync(cancellationToken); await ImportMetadataCorrectionsAsync(bundle, conflictStrategy, statistics, cancellationToken); + // Metadata correction imports replace FileMappings in the same transaction. + // Flush them before playback import queries by virtual path; a later failure + // still rolls the entire import back. + await context.SaveChangesAsync(cancellationToken); await ImportPlaybackAsync(bundle, conflictStrategy, userId, statistics, cancellationToken); await context.SaveChangesAsync(cancellationToken); @@ -302,7 +313,19 @@ private async Task ImportMetadataCorrectionsAsync( bundle.MetadataCorrections.Count == 0) return; + // Use the same lock order as metadata review and FileMappingRepository so a + // correction cannot race another virtual-path transition. + await MappingTransactionLock.AcquireAsync(context, cancellationToken); + var downloadUrls = bundle.MetadataCorrections.Select(item => item.ReleaseDownloadUrl).Distinct().ToArray(); + var candidateIds = await context.AnimationInfo.AsNoTracking() + .Where(info => downloadUrls.Contains(info.DownloadUrl)) + .Select(info => info.Id) + .ToListAsync(cancellationToken); + await MappingTransactionLock.LockAnimationInfosAsync( + context, + candidateIds, + cancellationToken); var candidates = await context.AnimationInfo .Include(info => info.Animation) .Include(info => info.Group) @@ -365,6 +388,60 @@ private async Task ImportMetadataCorrectionsAsync( groups.Add(group.Name, group); } + var existingMappings = await context.FileMappings + .AsNoTracking() + .Where(mapping => mapping.AnimationInfoId == info.Id) + .OrderBy(mapping => mapping.VirtualPath) + .ToListAsync(cancellationToken); + IReadOnlyList proposedMappings; + if (info.IsDownloadFinished) + { + var proposedInfo = info.ToRecord() with + { + Animation = animation.ToRecord(), + Group = group?.ToRecord(), + Description = imported.Description, + Season = imported.Season, + Episode = imported.Episode, + MetadataStatus = MetadataReviewStatus.Reviewed, + MetadataConfidence = 1, + MetadataLastError = null, + MetadataReviewedAt = imported.AppliedAt, + IsAiProcessed = true, + AiRetryCount = 0 + }; + var preview = await fileMapper.PreviewDownloadAsync( + proposedInfo, + cancellationToken); + if (preview is null) + throw new LogicalDataImportConflictException( + $"Cannot rebuild mappings for imported metadata:{imported.ReleaseTitle}."); + proposedMappings = preview.Mappings; + } + else + { + // Match the normal review workflow: metadata-only corrections do not + // delete an inconsistent legacy mapping before download completion. + proposedMappings = existingMappings + .Select(mapping => mapping.ToRecord()) + .ToList(); + } + + if (proposedMappings.Any(mapping => mapping.AnimationInfoId != info.Id) || + proposedMappings.Select(mapping => mapping.VirtualPath) + .Distinct(StringComparer.Ordinal).Count() != proposedMappings.Count) + throw new LogicalDataImportConflictException( + $"Invalid mapping plan for imported metadata:{imported.ReleaseTitle}."); + + var proposedPaths = proposedMappings.Select(mapping => mapping.VirtualPath).ToArray(); + if (proposedPaths.Length > 0 && + await context.FileMappings.AsNoTracking().AnyAsync( + mapping => mapping.AnimationInfoId != info.Id && + proposedPaths.Contains(mapping.VirtualPath), + cancellationToken)) + throw new LogicalDataImportConflictException( + $"Mapping conflict for imported metadata:{imported.ReleaseTitle}."); + var nextVersion = checked(info.StateVersion + 1); var operation = new Models.MetadataReviewOperation { @@ -399,7 +476,26 @@ private async Task ImportMetadataCorrectionsAsync( PreviousIsAiProcessed = info.IsAiProcessed, PreviousAiRetryCount = info.AiRetryCount, PreviousReviewedAt = info.MetadataReviewedAt, - PreviousCurrentOperationId = info.CurrentMetadataReviewOperationId + PreviousCurrentOperationId = info.CurrentMetadataReviewOperationId, + MappingSnapshots = existingMappings.Select(mapping => + new Models.MetadataReviewMappingSnapshot + { + Id = Guid.NewGuid(), + OperationId = imported.OperationId, + Kind = MetadataReviewMappingKind.Previous, + VirtualPath = mapping.VirtualPath, + PhysicalPath = mapping.PhysicalPath, + FileStore = mapping.FileStore + }).Concat(proposedMappings.Select(mapping => + new Models.MetadataReviewMappingSnapshot + { + Id = Guid.NewGuid(), + OperationId = imported.OperationId, + Kind = MetadataReviewMappingKind.Proposed, + VirtualPath = mapping.VirtualPath, + PhysicalPath = mapping.PhysicalPath, + FileStore = mapping.FileStore + })).ToList() }; context.MetadataReviewOperations.Add(operation); info.Animation = animation; @@ -415,6 +511,31 @@ private async Task ImportMetadataCorrectionsAsync( info.AiRetryCount = 0; info.StateVersion = nextVersion; info.CurrentMetadataReviewOperationId = operation.Id; + + var replacementMappings = proposedMappings.Select(mapping => + new Models.FileMapping + { + Id = Guid.NewGuid(), + AnimationInfoId = info.Id, + VirtualPath = mapping.VirtualPath, + PhysicalPath = mapping.PhysicalPath, + FileStore = mapping.FileStore + }).ToList(); + await PlaybackProgressMappingMigrator.MigrateAsync( + context, + info.Id, + existingMappings, + replacementMappings, + cancellationToken); + await context.FileMappings + .Where(mapping => mapping.AnimationInfoId == info.Id) + .ExecuteDeleteAsync(cancellationToken); + if (replacementMappings.Count > 0) + await context.FileMappings.AddRangeAsync(replacementMappings, cancellationToken); + + // Make this plan visible to collision checks for subsequent corrections + // in the same bundle. The enclosing transaction still provides atomicity. + await context.SaveChangesAsync(cancellationToken); existingOperationIds.Add(operation.Id); statistics.Add(); } diff --git a/deployments/sdw-backup b/deployments/sdw-backup index 1670cad..1303ae8 100755 --- a/deployments/sdw-backup +++ b/deployments/sdw-backup @@ -66,7 +66,9 @@ Restore options: PostgreSQL is read from PGHOST/PGPORT/PGUSER/PGPASSWORD/PGDATABASE. If those are absent, ConnectionStrings__sdw may contain an ASP.NET semicolon connection -string. Secrets are never printed. +string. PGMAINTENANCEDATABASE defaults to postgres and is used only to replace +the target database during restore. Restore requires a target-owning role with +CREATEDB (or a superuser). Secrets are never printed. EOF } @@ -478,6 +480,71 @@ major_version() { printf '%s' "${value%%.*}" } +prepare_database_replacement() { + local maintenance_database=${PGMAINTENANCEDATABASE:-postgres} + [[ -n "${PGDATABASE}" && -n "${maintenance_database}" ]] || + die "target and maintenance database names are required" + case "${PGDATABASE}" in + postgres|template0|template1) + die "refusing to replace a PostgreSQL system database" + ;; + esac + [[ "${PGDATABASE}" != "${maintenance_database}" ]] || + die "maintenance database must differ from the restore target" + + require_command createdb + require_command dropdb + psql --no-psqlrc --dbname "${maintenance_database}" --tuples-only --no-align \ + --set=ON_ERROR_STOP=1 --command 'SELECT 1' >/dev/null || + die "PostgreSQL maintenance database is unavailable" + + local can_replace + can_replace=$(psql --no-psqlrc --dbname "${PGDATABASE}" \ + --tuples-only --no-align --set=ON_ERROR_STOP=1 --command \ + "SELECT CASE WHEN role.rolsuper OR + (role.rolcreatedb AND pg_has_role(current_user, db.datdba, 'MEMBER')) + THEN 'yes' ELSE 'no' END + FROM pg_database AS db CROSS JOIN pg_roles AS role + WHERE db.datname = current_database() AND role.rolname = current_user" \ + 2>/dev/null) || die "cannot inspect target database ownership" + [[ "${can_replace}" == yes ]] || + die "PostgreSQL role must own the target and have CREATEDB, or be superuser" +} + +replace_database_from_archive() { + local archive=$1 maintenance_database=${PGMAINTENANCEDATABASE:-postgres} + local database_owner database_encoding database_collation database_ctype + database_owner=$(psql --no-psqlrc --dbname "${PGDATABASE}" \ + --tuples-only --no-align --set=ON_ERROR_STOP=1 --command \ + "SELECT pg_get_userbyid(datdba) FROM pg_database WHERE datname = current_database()") + database_encoding=$(psql --no-psqlrc --dbname "${PGDATABASE}" \ + --tuples-only --no-align --set=ON_ERROR_STOP=1 --command \ + "SELECT pg_encoding_to_char(encoding) FROM pg_database WHERE datname = current_database()") + database_collation=$(psql --no-psqlrc --dbname "${PGDATABASE}" \ + --tuples-only --no-align --set=ON_ERROR_STOP=1 --command \ + "SELECT datcollate FROM pg_database WHERE datname = current_database()") + database_ctype=$(psql --no-psqlrc --dbname "${PGDATABASE}" \ + --tuples-only --no-align --set=ON_ERROR_STOP=1 --command \ + "SELECT datctype FROM pg_database WHERE datname = current_database()") + [[ -n "${database_owner}" && -n "${database_encoding}" && + -n "${database_collation}" && -n "${database_ctype}" ]] || + die "target database properties are unavailable" + + # pg_restore --clean only drops objects present in the archive. Recreate the + # database so objects introduced after the backup cannot survive a replacement + # restore and conflict with the restored EF migration history. + dropdb --force --maintenance-db="${maintenance_database}" -- "${PGDATABASE}" + createdb --maintenance-db="${maintenance_database}" \ + --template=template0 \ + --owner="${database_owner}" \ + --encoding="${database_encoding}" \ + --lc-collate="${database_collation}" \ + --lc-ctype="${database_ctype}" \ + -- "${PGDATABASE}" + pg_restore --exit-on-error --no-owner --no-acl \ + --dbname "${PGDATABASE}" "${archive}" +} + restore_backup() { [[ $# -ge 1 ]] || die "restore requires an archive" local archive=$1 @@ -516,6 +583,7 @@ restore_backup() { parse_connection_string require_command psql require_command pg_dump + prepare_database_replacement prepare_archive "${archive}" "${identity}" local extracted="${backup_temp_dir}/extracted" @@ -560,8 +628,7 @@ restore_backup() { chmod 0600 "${safety_dump}.partial" mv "${safety_dump}.partial" "${safety_dump}" - pg_restore --clean --if-exists --exit-on-error --no-owner --no-acl \ - --dbname "${PGDATABASE}" "${extracted}/database.dump" + replace_database_from_archive "${extracted}/database.dump" [[ "$(database_schema_version)" == "${backup_schema}" ]] || die "restored database schema does not match the backup" diff --git a/deployments/tests/backup-restore-smoke.sh b/deployments/tests/backup-restore-smoke.sh index 38f4875..34d7059 100755 --- a/deployments/tests/backup-restore-smoke.sh +++ b/deployments/tests/backup-restore-smoke.sh @@ -65,6 +65,9 @@ fi createdb sdw_restore export PGDATABASE=sdw_restore +psql --no-psqlrc --set=ON_ERROR_STOP=1 --command \ + "CREATE TABLE destination_only (id integer); INSERT INTO destination_only VALUES (99);" \ + >/dev/null "${repo_root}/deployments/sdw-backup" restore "${archive}" \ --confirm-replace \ --expected-version "${app_version}" \ @@ -76,5 +79,7 @@ export PGDATABASE=sdw_restore test "$(psql --no-psqlrc --tuples-only --no-align --command \ 'SELECT value FROM drill_items WHERE id = 1')" = round-trip +test "$(psql --no-psqlrc --tuples-only --no-align --command \ + "SELECT to_regclass('public.destination_only') IS NULL")" = t cmp "${drill_root}/password.json" "${drill_root}/restored/password.json" printf 'backup restore smoke test passed\n' diff --git a/docs/backup-restore.md b/docs/backup-restore.md index 49e2952..3858501 100644 --- a/docs/backup-restore.md +++ b/docs/backup-restore.md @@ -84,14 +84,16 @@ podman-compose exec sdw-redive sdw-backup verify /app/backups/sdw-backup-....tar 恢复是替换操作。先停止所有应用副本和后台任务;仅停止应用,不要停止 PostgreSQL。 -1. 把目标应用安装为与备份相同的 major 版本,并准备空数据库。 +1. 把目标应用安装为与备份相同的 major 版本。恢复脚本会在生成 safety dump 后删除并重建目标数据库,数据库登录角色必须拥有目标数据库并具有 `CREATEDB`(或使用 PostgreSQL 超级用户);不要把 `PGDATABASE` 指向 `postgres`、`template0` 或 `template1`。默认的低权限应用账号不应长期获得 `CREATEDB`,恢复时请临时提供独立的数据库管理员凭据。 2. 预先挂载足够空间;脚本在任何数据库写入前验证路径、链接、所有 SHA-256、`pg_restore --list`、格式、major 版本、可选 schema 版本、临时空间和目标目录可写性。 3. 执行恢复。命令先在 safety directory 创建现有数据库 dump,旧配置、密码和密钥环也会重命名为 `.pre-restore-*`,可人工回退。 4. 修正文件所有者与权限,启动应用,检查 `/api/auth/allowRegister`、登录、订阅和文件浏览。 ```bash sudo systemctl stop sdw-redive -sudo -u sdw-redive --preserve-env=PGHOST,PGPORT,PGUSER,PGPASSWORD,PGDATABASE \ +# /etc/sdw-redive 由 root 管理,因此完整恢复必须以 root 写入配置; +# 下列 PostgreSQL 变量应由 root 可读的凭据文件或当前管理会话提供。 +sudo --preserve-env=PGHOST,PGPORT,PGUSER,PGPASSWORD,PGDATABASE,PGMAINTENANCEDATABASE \ sdw-backup restore /var/lib/sdw-redive/backups/sdw-backup-....tar.gz \ --confirm-replace \ --expected-version 2.3.0 \ @@ -101,8 +103,7 @@ sudo -u sdw-redive --preserve-env=PGHOST,PGPORT,PGUSER,PGPASSWORD,PGDATABASE \ --key-ring-destination /var/lib/sdw-redive/data-protection-keys sudo chown root:sdw-redive /etc/sdw-redive/appsettings.yml sudo chmod 0640 /etc/sdw-redive/appsettings.yml -sudo chown -R sdw-redive:sdw-redive /var/lib/sdw-redive/data-protection-keys \ - /var/lib/sdw-redive/password.json +sudo chown -R sdw-redive:sdw-redive /var/lib/sdw-redive sudo systemctl start sdw-redive curl --fail http://127.0.0.1:5097/api/auth/allowRegister ``` diff --git a/packaging/backup.env b/packaging/backup.env index 4b8684b..400ebdf 100644 --- a/packaging/backup.env +++ b/packaging/backup.env @@ -4,6 +4,10 @@ PGPORT=5432 PGUSER=sdw PGDATABASE=sdw PGPASSWORD=CHANGE_ME +# Replacement restore additionally requires a temporary database administrator +# that owns PGDATABASE and has CREATEDB. Do not grant that privilege permanently +# to this runtime/backup account; override PGUSER/PGPASSWORD for the manual restore. +# PGMAINTENANCEDATABASE=postgres SDW_BACKUP_DIRECTORY=/var/lib/sdw-redive/backups SDW_BACKUP_RETENTION_DAYS=14 From e0fc4c24bfe3d5af013f9f3daf511eb93ccf54e8 Mon Sep 17 00:00:00 2001 From: mahoshojoHCG Date: Sun, 30 Aug 2026 11:13:56 +0800 Subject: [PATCH 06/37] fix: harden progressive transcoding --- .../FfmpegProcessRunnerTests.cs | 15 +- .../HlsTranscodingServiceTests.cs | 211 ++++++++++++++++-- .../TranscodingPlannerTests.cs | 38 +++- .../Transcoding/FfmpegProcessRunner.cs | 72 +++--- .../Transcoding/HlsTranscodingService.cs | 172 ++++++++++---- .../Services/Transcoding/TranscodingModels.cs | 4 +- .../Transcoding/TranscodingPlanner.cs | 34 ++- 7 files changed, 430 insertions(+), 116 deletions(-) diff --git a/SecondDimensionWatcherReDive.Test/FfmpegProcessRunnerTests.cs b/SecondDimensionWatcherReDive.Test/FfmpegProcessRunnerTests.cs index c1a5114..6aaaee2 100644 --- a/SecondDimensionWatcherReDive.Test/FfmpegProcessRunnerTests.cs +++ b/SecondDimensionWatcherReDive.Test/FfmpegProcessRunnerTests.cs @@ -32,6 +32,8 @@ public async Task ProbeAndGenerateHlsAsync_ProducesProgressivePlaylistFromPipeIn MediaProbe probe; await using (var source = File.OpenRead(sourcePath)) probe = await runner.ProbeAsync(source, CancellationToken.None); + Assert.IsTrue(probe.Video?.Profile is "Baseline" or "Constrained Baseline" or "Main" or "High"); + Assert.AreEqual("yuv420p", probe.Video?.PixelFormat); var sourceInfo = new FileInfo(sourcePath); var sourceModel = new TranscodingSource( Guid.NewGuid(), @@ -67,13 +69,18 @@ public async Task ProbeAndGenerateHlsAsync_ProducesProgressivePlaylistFromPipeIn StringAssert.Contains( await File.ReadAllTextAsync(Path.Combine(output, "media.m3u8")), "#EXT-X-ENDLIST"); - IReadOnlyList subtitles; - await using (var source = File.OpenRead(sourcePath)) - subtitles = await runner.ExtractTextSubtitlesAsync( + var subtitles = new List(); + for (var index = 0; index < plan.TextSubtitles.Count; index++) + { + await using var source = File.OpenRead(sourcePath); + var subtitle = await runner.ExtractTextSubtitleAsync( source, - plan, + plan.TextSubtitles[index], + index + 1, output, CancellationToken.None); + if (subtitle is not null) subtitles.Add(subtitle); + } Assert.AreEqual(1, subtitles.Count); StringAssert.StartsWith( await File.ReadAllTextAsync(Path.Combine(output, subtitles[0].FileName)), diff --git a/SecondDimensionWatcherReDive.Test/HlsTranscodingServiceTests.cs b/SecondDimensionWatcherReDive.Test/HlsTranscodingServiceTests.cs index e77c747..f46da09 100644 --- a/SecondDimensionWatcherReDive.Test/HlsTranscodingServiceTests.cs +++ b/SecondDimensionWatcherReDive.Test/HlsTranscodingServiceTests.cs @@ -1,3 +1,4 @@ +using System.Reflection; using Microsoft.AspNetCore.StaticFiles; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging.Abstractions; @@ -171,6 +172,99 @@ public async Task FailedJobDeletesPartialOutputAndReportsFailureRate() Assert.AreEqual(1, metrics.FailureRate); } + [TestMethod] + public async Task EmbeddedSubtitleIsPublishedWhileHlsGenerationIsStillRunning() + { + var runner = new ProgressiveSubtitleRunner(failFirstSubtitle: false); + await using var fixture = await TranscodingFixture.CreateAsync(runner); + var initial = await fixture.Service.PrepareAsync( + fixture.AnimationInfoId, + "episode.mkv", + TranscodingSelection.Create("auto", null, null, null, null), + CancellationToken.None); + + try + { + await runner.GenerationStarted.Task.WaitAsync(TimeSpan.FromSeconds(3)); + await runner.ExtractionCompleted.Task.WaitAsync(TimeSpan.FromSeconds(3)); + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(3)); + TranscodingSessionStatus status; + do + { + status = await fixture.Service.GetStatusAsync( + initial.SessionId, + initial.AccessToken, + timeout.Token) + ?? throw new AssertFailedException("The transcoding session disappeared."); + if (status.Subtitles.Count == 1) break; + await Task.Delay(10, timeout.Token); + } while (true); + + Assert.AreEqual(TranscodingJobState.Transcoding, status.State); + Assert.IsTrue(status.IsPlayable); + Assert.AreEqual("subtitle-2.vtt", status.Subtitles[0].FileName); + } + finally + { + runner.ReleaseGeneration.TrySetResult(); + } + + await WaitForStateAsync(fixture.Service, initial, TranscodingJobState.Ready); + } + + [TestMethod] + public async Task FailedEmbeddedSubtitleDoesNotSuppressValidTrack() + { + var runner = new ProgressiveSubtitleRunner(failFirstSubtitle: true); + runner.ReleaseGeneration.TrySetResult(); + await using var fixture = await TranscodingFixture.CreateAsync(runner); + var initial = await fixture.Service.PrepareAsync( + fixture.AnimationInfoId, + "episode.mkv", + TranscodingSelection.Create("auto", null, null, null, null), + CancellationToken.None); + + var ready = await WaitForStateAsync(fixture.Service, initial, TranscodingJobState.Ready); + + CollectionAssert.AreEqual(new[] { 2, 3 }, runner.ExtractedStreamIndices.ToArray()); + Assert.AreEqual(1, ready.Subtitles.Count); + Assert.AreEqual("subtitle-3.vtt", ready.Subtitles[0].FileName); + } + + [TestMethod] + public async Task CacheCleanup_WaitsForSessionCreationCriticalSection() + { + await using var fixture = await TranscodingFixture.CreateAsync(new CompletingRunner()); + var serviceType = typeof(HlsTranscodingService); + var creationGate = (SemaphoreSlim)(serviceType.GetField( + "_creationGate", + BindingFlags.Instance | BindingFlags.NonPublic)?.GetValue(fixture.Service) + ?? throw new AssertFailedException("The cache creation gate was not found.")); + var cleanupMethod = serviceType.GetMethod( + "CleanupCacheAsync", + BindingFlags.Instance | BindingFlags.NonPublic) + ?? throw new AssertFailedException("The cache cleanup method was not found."); + + await creationGate.WaitAsync(CancellationToken.None); + Task cleanupTask; + try + { + cleanupTask = (Task)(cleanupMethod.Invoke( + fixture.Service, + [false, CancellationToken.None]) + ?? throw new AssertFailedException("Cache cleanup did not return a task.")); + Assert.IsFalse( + cleanupTask.IsCompleted, + "Cleanup must not inspect or evict cache entries while PrepareAsync can attach a session."); + } + finally + { + creationGate.Release(); + } + + await cleanupTask.WaitAsync(TimeSpan.FromSeconds(3)); + } + private static async Task WaitForStateAsync( IHlsTranscodingService service, TranscodingSessionStatus session, @@ -201,9 +295,9 @@ public Task ProbeAsync(Stream source, CancellationToken cancellation "matroska", TimeSpan.FromSeconds(30), [ - new MediaStreamProbe(0, "video", "h264", null, null, true, false, false), - new MediaStreamProbe(1, "audio", "aac", "jpn", "Japanese", true, false, false), - new MediaStreamProbe(2, "subtitle", "ass", "eng", "English", true, false, false) + new MediaStreamProbe(0, "video", "h264", null, null, true, false, false, "High", "yuv420p"), + new MediaStreamProbe(1, "audio", "aac", "jpn", "Japanese", true, false, false, null, null), + new MediaStreamProbe(2, "subtitle", "ass", "eng", "English", true, false, false, null, null) ])); public async Task GenerateHlsAsync( @@ -228,18 +322,19 @@ await File.WriteAllTextAsync( return new FfmpegRunResult(0, string.Empty); } - public async Task> ExtractTextSubtitlesAsync( + public async Task ExtractTextSubtitleAsync( Stream source, - TranscodingPlan plan, + MediaStreamProbe subtitle, + int ordinal, string outputDirectory, CancellationToken cancellationToken) { - const string name = "subtitle-2.vtt"; + var name = $"subtitle-{subtitle.Index}.vtt"; await File.WriteAllTextAsync( Path.Combine(outputDirectory, name), "WEBVTT\n", cancellationToken); - return [new TranscodingSubtitle(name, "English", "eng", "vtt")]; + return new TranscodingSubtitle(name, "English", "eng", "vtt"); } } @@ -252,8 +347,8 @@ public Task ProbeAsync(Stream source, CancellationToken cancellation "matroska", TimeSpan.FromSeconds(30), [ - new MediaStreamProbe(0, "video", "h264", null, null, true, false, false), - new MediaStreamProbe(1, "audio", "aac", null, null, true, false, false) + new MediaStreamProbe(0, "video", "h264", null, null, true, false, false, "High", "yuv420p"), + new MediaStreamProbe(1, "audio", "aac", null, null, true, false, false, null, null) ])); public async Task GenerateHlsAsync( @@ -274,12 +369,13 @@ await File.WriteAllTextAsync( return new FfmpegRunResult(0, string.Empty); } - public Task> ExtractTextSubtitlesAsync( + public Task ExtractTextSubtitleAsync( Stream source, - TranscodingPlan plan, + MediaStreamProbe subtitle, + int ordinal, string outputDirectory, CancellationToken cancellationToken) - => Task.FromResult>([]); + => Task.FromResult(null); } private sealed class FailingRunner : IFfmpegProcessRunner @@ -289,8 +385,8 @@ public Task ProbeAsync(Stream source, CancellationToken cancellation "matroska", TimeSpan.FromSeconds(30), [ - new MediaStreamProbe(0, "video", "h264", null, null, true, false, false), - new MediaStreamProbe(1, "audio", "aac", null, null, true, false, false) + new MediaStreamProbe(0, "video", "h264", null, null, true, false, false, "High", "yuv420p"), + new MediaStreamProbe(1, "audio", "aac", null, null, true, false, false, null, null) ])); public async Task GenerateHlsAsync( @@ -309,12 +405,95 @@ await File.WriteAllTextAsync( return new FfmpegRunResult(1, "fixture FFmpeg failure"); } - public Task> ExtractTextSubtitlesAsync( + public Task ExtractTextSubtitleAsync( + Stream source, + MediaStreamProbe subtitle, + int ordinal, + string outputDirectory, + CancellationToken cancellationToken) + => Task.FromResult(null); + } + + private sealed class ProgressiveSubtitleRunner(bool failFirstSubtitle) : IFfmpegProcessRunner + { + private readonly object _gate = new(); + private readonly List _extractedStreamIndices = []; + + public TaskCompletionSource GenerationStarted { get; } = + new(TaskCreationOptions.RunContinuationsAsynchronously); + public TaskCompletionSource ReleaseGeneration { get; } = + new(TaskCreationOptions.RunContinuationsAsynchronously); + public TaskCompletionSource ExtractionCompleted { get; } = + new(TaskCreationOptions.RunContinuationsAsynchronously); + public IReadOnlyList ExtractedStreamIndices + { + get { lock (_gate) return _extractedStreamIndices.ToArray(); } + } + + public Task ProbeAsync(Stream source, CancellationToken cancellationToken) + { + var streams = new List + { + new(0, "video", "h264", null, null, true, false, false, "High", "yuv420p"), + new(1, "audio", "aac", null, null, true, false, false, null, null), + new(2, "subtitle", "ass", "eng", failFirstSubtitle ? "Broken" : "English", false, false, false, null, null) + }; + if (failFirstSubtitle) + streams.Add(new MediaStreamProbe( + 3, + "subtitle", + "subrip", + "jpn", + "Japanese", + true, + false, + false, + null, + null)); + return Task.FromResult(new MediaProbe("matroska", TimeSpan.FromSeconds(30), streams)); + } + + public async Task GenerateHlsAsync( Stream source, TranscodingPlan plan, + TranscodingSelection selection, string outputDirectory, + bool useHardwareEncoder, + Action onProgress, CancellationToken cancellationToken) - => Task.FromResult>([]); + { + await File.WriteAllBytesAsync( + Path.Combine(outputDirectory, "segment-000000.ts"), + [1, 2, 3], + cancellationToken); + await File.WriteAllTextAsync( + Path.Combine(outputDirectory, "media.m3u8"), + "#EXTM3U\n#EXTINF:6,\nsegment-000000.ts\n#EXT-X-ENDLIST\n", + cancellationToken); + onProgress(new FfmpegProgress(1, 1, true)); + GenerationStarted.TrySetResult(); + await ReleaseGeneration.Task.WaitAsync(cancellationToken); + return new FfmpegRunResult(0, string.Empty); + } + + public async Task ExtractTextSubtitleAsync( + Stream source, + MediaStreamProbe subtitle, + int ordinal, + string outputDirectory, + CancellationToken cancellationToken) + { + lock (_gate) _extractedStreamIndices.Add(subtitle.Index); + if (failFirstSubtitle && subtitle.Index == 2) return null; + + var name = $"subtitle-{subtitle.Index}.vtt"; + await File.WriteAllTextAsync( + Path.Combine(outputDirectory, name), + "WEBVTT\n", + cancellationToken); + if (subtitle.Index == 3 || !failFirstSubtitle) ExtractionCompleted.TrySetResult(); + return new TranscodingSubtitle(name, subtitle.Title ?? $"Subtitle {ordinal}", subtitle.Language, "vtt"); + } } private sealed class TranscodingFixture : IAsyncDisposable diff --git a/SecondDimensionWatcherReDive.Test/TranscodingPlannerTests.cs b/SecondDimensionWatcherReDive.Test/TranscodingPlannerTests.cs index a696c89..d14d145 100644 --- a/SecondDimensionWatcherReDive.Test/TranscodingPlannerTests.cs +++ b/SecondDimensionWatcherReDive.Test/TranscodingPlannerTests.cs @@ -33,6 +33,33 @@ public void CreatePlan_CompatibleTracksInMkv_UsesLosslessRemux() Assert.IsTrue(plan.CopyAudio); } + [TestMethod] + public void CreatePlan_Hi10pH264_TranscodesToBrowserCompatibleVideo() + { + var plan = TranscodingPlanner.CreatePlan( + CreateSource("episode.mkv"), + CreateProbe(Video("h264", "High 10", "yuv420p10le"), Audio("aac")), + TranscodingSelection.Create("auto", null, null, null, null), + burnBitmapSubtitles: false); + + Assert.AreEqual(TranscodingStrategy.Transcode, plan.Strategy); + Assert.IsFalse(plan.CopyVideo); + Assert.IsTrue(plan.CopyAudio); + } + + [TestMethod] + public void CreatePlan_H264WithoutCompatibilityMetadata_FailsClosedToTranscode() + { + var plan = TranscodingPlanner.CreatePlan( + CreateSource("episode.mp4"), + CreateProbe(Video("h264", null, null), Audio("aac")), + TranscodingSelection.Create("auto", null, null, null, null), + burnBitmapSubtitles: false); + + Assert.AreEqual(TranscodingStrategy.Transcode, plan.Strategy); + Assert.IsFalse(plan.CopyVideo); + } + [TestMethod] public void CreatePlan_UnsupportedCodecs_TranscodesAndSelectsPreferredAudio() { @@ -125,8 +152,11 @@ private static TranscodingSource CreateSource(string fileName) private static MediaProbe CreateProbe(params MediaStreamProbe[] streams) => new("matroska", TimeSpan.FromMinutes(24), streams); - private static MediaStreamProbe Video(string codec) - => new(0, "video", codec, null, null, true, false, false); + private static MediaStreamProbe Video( + string codec, + string? profile = "High", + string? pixelFormat = "yuv420p") + => new(0, "video", codec, null, null, true, false, false, profile, pixelFormat); private static MediaStreamProbe Audio( string codec, @@ -134,12 +164,12 @@ private static MediaStreamProbe Audio( string? language = null, string? title = null, bool isDefault = false) - => new(index, "audio", codec, language, title, isDefault, false, false); + => new(index, "audio", codec, language, title, isDefault, false, false, null, null); private static MediaStreamProbe Subtitle( string codec, int index, string? language, string? title) - => new(index, "subtitle", codec, language, title, false, false, false); + => new(index, "subtitle", codec, language, title, false, false, false, null, null); } diff --git a/SecondDimensionWatcherReDive/Services/Transcoding/FfmpegProcessRunner.cs b/SecondDimensionWatcherReDive/Services/Transcoding/FfmpegProcessRunner.cs index c5189a2..9d46544 100644 --- a/SecondDimensionWatcherReDive/Services/Transcoding/FfmpegProcessRunner.cs +++ b/SecondDimensionWatcherReDive/Services/Transcoding/FfmpegProcessRunner.cs @@ -24,9 +24,10 @@ Task GenerateHlsAsync( Action onProgress, CancellationToken cancellationToken); - Task> ExtractTextSubtitlesAsync( + Task ExtractTextSubtitleAsync( Stream source, - TranscodingPlan plan, + MediaStreamProbe subtitle, + int ordinal, string outputDirectory, CancellationToken cancellationToken); } @@ -89,7 +90,9 @@ public async Task ProbeAsync(Stream source, CancellationToken cancel stream.Tags?.Title, stream.Disposition?.Default == 1, stream.Disposition?.Forced == 1, - stream.Disposition?.AttachedPic == 1)) + stream.Disposition?.AttachedPic == 1, + stream.Profile, + stream.PixelFormat)) .ToArray(); var duration = ParseDuration(document.Format?.Duration) ?? (document.Streams ?? []).Select(stream => ParseDuration(stream.Duration)).FirstOrDefault(value => value is not null); @@ -185,32 +188,26 @@ public async Task GenerateHlsAsync( cancellationToken); } - public async Task> ExtractTextSubtitlesAsync( + public async Task ExtractTextSubtitleAsync( Stream source, - TranscodingPlan plan, + MediaStreamProbe subtitle, + int ordinal, string outputDirectory, CancellationToken cancellationToken) { - if (plan.TextSubtitles.Count == 0) return []; - + var finalPath = Path.Combine(outputDirectory, $"subtitle-{subtitle.Index}.vtt"); + var temporaryPath = $"{finalPath}.tmp"; + TryDelete(temporaryPath); var startInfo = CreateStartInfo(_options.FfmpegPath, redirectOutput: false); AddArguments(startInfo, "-hide_banner", "-y", "-i", "pipe:0", "-threads", _options.MaxThreadsPerJob.ToString(CultureInfo.InvariantCulture), - "-nostats"); - var pending = new List<(MediaStreamProbe Stream, string TemporaryPath, string FinalPath)>(); - foreach (var stream in plan.TextSubtitles) - { - var finalPath = Path.Combine(outputDirectory, $"subtitle-{stream.Index}.vtt"); - var temporaryPath = $"{finalPath}.tmp"; - pending.Add((stream, temporaryPath, finalPath)); - AddArguments(startInfo, - "-map", $"0:{stream.Index}", - "-c:s", "webvtt", - "-f", "webvtt", - temporaryPath); - } + "-nostats", + "-map", $"0:{subtitle.Index}", + "-c:s", "webvtt", + "-f", "webvtt", + temporaryPath); var result = await RunFfmpegAsync( startInfo, source, @@ -220,23 +217,18 @@ public async Task> ExtractTextSubtitlesAsync( detectFirstSegment: false); if (result.ExitCode != 0) { - LogSubtitleExtractionFailed(logger, result.ExitCode, result.ErrorOutput); - foreach (var item in pending) TryDelete(item.TemporaryPath); - return []; + LogSubtitleExtractionFailed(logger, subtitle.Index, result.ExitCode, result.ErrorOutput); + TryDelete(temporaryPath); + return null; } - var subtitles = new List(); - foreach (var item in pending) - { - if (!File.Exists(item.TemporaryPath)) continue; - File.Move(item.TemporaryPath, item.FinalPath, overwrite: true); - subtitles.Add(new TranscodingSubtitle( - Path.GetFileName(item.FinalPath), - BuildSubtitleLabel(item.Stream, subtitles.Count + 1), - item.Stream.Language, - "vtt")); - } - return subtitles; + if (!File.Exists(temporaryPath)) return null; + File.Move(temporaryPath, finalPath, overwrite: true); + return new TranscodingSubtitle( + Path.GetFileName(finalPath), + BuildSubtitleLabel(subtitle, ordinal), + subtitle.Language, + "vtt"); } private async Task RunFfmpegAsync( @@ -515,8 +507,12 @@ private static void TryDelete(string path) catch (IOException) { } } - [LoggerMessage(Level = LogLevel.Warning, Message = "FFmpeg subtitle extraction exited with code {ExitCode}: {Error}")] - private static partial void LogSubtitleExtractionFailed(ILogger logger, int exitCode, string error); + [LoggerMessage(Level = LogLevel.Warning, Message = "FFmpeg subtitle stream {StreamIndex} extraction exited with code {ExitCode}: {Error}")] + private static partial void LogSubtitleExtractionFailed( + ILogger logger, + int streamIndex, + int exitCode, + string error); private sealed record FfprobeDocument( [property: JsonPropertyName("streams")] FfprobeStream[]? Streams, @@ -526,6 +522,8 @@ private sealed record FfprobeStream( [property: JsonPropertyName("index")] int? Index, [property: JsonPropertyName("codec_name")] string? CodecName, [property: JsonPropertyName("codec_type")] string? CodecType, + [property: JsonPropertyName("profile")] string? Profile, + [property: JsonPropertyName("pix_fmt")] string? PixelFormat, [property: JsonPropertyName("duration")] string? Duration, [property: JsonPropertyName("disposition")] FfprobeDisposition? Disposition, [property: JsonPropertyName("tags")] FfprobeTags? Tags); diff --git a/SecondDimensionWatcherReDive/Services/Transcoding/HlsTranscodingService.cs b/SecondDimensionWatcherReDive/Services/Transcoding/HlsTranscodingService.cs index dcf6537..6837408 100644 --- a/SecondDimensionWatcherReDive/Services/Transcoding/HlsTranscodingService.cs +++ b/SecondDimensionWatcherReDive/Services/Transcoding/HlsTranscodingService.cs @@ -13,7 +13,7 @@ namespace SecondDimensionWatcherReDive.Services.Transcoding; internal sealed partial class HlsTranscodingService : BackgroundService, IHlsTranscodingService { - private const int CacheManifestVersion = 1; + private const int CacheManifestVersion = 2; private const string CacheOwnershipMarker = ".sdw-transcode-cache"; private readonly IServiceScopeFactory _scopeFactory; private readonly IFfmpegProcessRunner _processRunner; @@ -305,6 +305,9 @@ private async Task ProcessJobAsync(TranscodingJob job, CancellationToken stoppin RecreateJobDirectory(job.CacheDirectory); job.SetState(TranscodingJobState.Transcoding); UpdateJobGauges(); + using var subtitleCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + var subtitleTask = ExtractTextSubtitlesAsync(job, plan, subtitleCancellation.Token); + var subtitleTaskObserved = false; var firstSegmentRecorded = 0; void OnProgress(FfmpegProgress update) { @@ -318,49 +321,59 @@ void OnProgress(FfmpegProgress update) } } - var useHardware = !plan.CopyVideo && !string.IsNullOrWhiteSpace(_options.HardwareVideoEncoder); - FfmpegRunResult result; - await using (var source = await OpenSourceStreamAsync(job.Source, cancellationToken)) - result = await _processRunner.GenerateHlsAsync( - source, - plan, - job.Selection, - job.CacheDirectory, - useHardware, - OnProgress, - cancellationToken); - if (result.ExitCode != 0 && useHardware) + try { - LogHardwareFallback(_logger, _options.HardwareVideoEncoder!, result.ErrorOutput); - DeleteGeneratedFiles(job.CacheDirectory); - await using var source = await OpenSourceStreamAsync(job.Source, cancellationToken); - result = await _processRunner.GenerateHlsAsync( - source, - plan, - job.Selection, - job.CacheDirectory, - useHardwareEncoder: false, - OnProgress, - cancellationToken); - } - if (result.ExitCode != 0) - throw new InvalidOperationException($"FFmpeg exited with code {result.ExitCode}: {result.ErrorOutput}"); + var useHardware = !plan.CopyVideo && !string.IsNullOrWhiteSpace(_options.HardwareVideoEncoder); + FfmpegRunResult result; + await using (var source = await OpenSourceStreamAsync(job.Source, cancellationToken)) + result = await _processRunner.GenerateHlsAsync( + source, + plan, + job.Selection, + job.CacheDirectory, + useHardware, + OnProgress, + cancellationToken); + if (result.ExitCode != 0 && useHardware) + { + LogHardwareFallback(_logger, _options.HardwareVideoEncoder!, result.ErrorOutput); + DeleteGeneratedHlsFiles(job.CacheDirectory); + await using var source = await OpenSourceStreamAsync(job.Source, cancellationToken); + result = await _processRunner.GenerateHlsAsync( + source, + plan, + job.Selection, + job.CacheDirectory, + useHardwareEncoder: false, + OnProgress, + cancellationToken); + } + if (result.ExitCode != 0) + throw new InvalidOperationException($"FFmpeg exited with code {result.ExitCode}: {result.ErrorOutput}"); - IReadOnlyList subtitles; - await using (var source = await OpenSourceStreamAsync(job.Source, cancellationToken)) - subtitles = await _processRunner.ExtractTextSubtitlesAsync( - source, - plan, - job.CacheDirectory, - cancellationToken); - job.SetSubtitles(subtitles); - await WriteManifestAsync(job, cancellationToken); - _metrics.RecordCompleted(); - if (job.GetSpeed() is { } speed) _metrics.RecordSpeed(speed); - UpdateCacheBytes(); - job.SetReady(subtitles); - UpdateJobGauges(); - await CleanupCacheAsync(removeIncomplete: false, cancellationToken); + var subtitles = await subtitleTask; + subtitleTaskObserved = true; + await WriteManifestAsync(job, cancellationToken); + _metrics.RecordCompleted(); + if (job.GetSpeed() is { } speed) _metrics.RecordSpeed(speed); + UpdateCacheBytes(); + job.SetReady(subtitles); + UpdateJobGauges(); + await CleanupCacheAsync(removeIncomplete: false, cancellationToken); + } + finally + { + if (!subtitleTaskObserved) + { + try + { + await subtitleCancellation.CancelAsync(); + await subtitleTask; + } + catch (OperationCanceledException) when (subtitleCancellation.IsCancellationRequested) { } + catch (Exception exception) { LogSubtitleCleanupFailed(_logger, exception); } + } + } } catch (OperationCanceledException) when (job.Cancellation.IsCancellationRequested) { @@ -441,6 +454,32 @@ private async Task OpenSourceStreamAsync( } } + private async Task> ExtractTextSubtitlesAsync( + TranscodingJob job, + TranscodingPlan plan, + CancellationToken cancellationToken) + { + var subtitles = new List(); + for (var index = 0; index < plan.TextSubtitles.Count; index++) + { + cancellationToken.ThrowIfCancellationRequested(); + var track = plan.TextSubtitles[index]; + TranscodingSubtitle? subtitle; + await using (var source = await OpenSourceStreamAsync(job.Source, cancellationToken)) + subtitle = await _processRunner.ExtractTextSubtitleAsync( + source, + track, + index + 1, + job.CacheDirectory, + cancellationToken); + if (subtitle is null) continue; + + subtitles.Add(subtitle); + job.SetSubtitles(subtitles.ToArray()); + } + return subtitles; + } + private TranscodingSession? FindSession(Guid id, string token, bool touch = true) { if (!_sessions.TryGetValue(id, out var session) || !TokensEqual(session.AccessToken, token)) @@ -506,6 +545,19 @@ private async Task WriteManifestAsync(TranscodingJob job, CancellationToken canc } private async Task CleanupCacheAsync(bool removeIncomplete, CancellationToken cancellationToken) + { + await _creationGate.WaitAsync(cancellationToken); + try + { + CleanupCacheCore(removeIncomplete, cancellationToken); + } + finally + { + _creationGate.Release(); + } + } + + private void CleanupCacheCore(bool removeIncomplete, CancellationToken cancellationToken) { CleanupExpiredSessions(); if (!Directory.Exists(_options.CachePath)) return; @@ -549,7 +601,6 @@ private async Task CleanupCacheAsync(bool removeIncomplete, CancellationToken ca total -= candidate.Size; } _metrics.SetCacheBytes(Math.Max(0, total)); - await Task.CompletedTask; } private void CleanupExpiredSessions() @@ -677,12 +728,16 @@ private static void RecreateJobDirectory(string path) File.WriteAllText(Path.Combine(path, CacheOwnershipMarker), string.Empty); } - private static void DeleteGeneratedFiles(string directory) + private static void DeleteGeneratedHlsFiles(string directory) { foreach (var path in Directory.EnumerateFiles(directory)) - if (Path.GetFileName(path) is not (".access" or CacheOwnershipMarker)) + { + var fileName = Path.GetFileName(path); + if (fileName is "media.m3u8" or "media.m3u8.tmp" + || fileName.StartsWith("segment-", StringComparison.Ordinal)) try { File.Delete(path); } catch (IOException) { } + } } private static void TryDeleteDirectory(string path) @@ -741,6 +796,9 @@ private static bool IsCacheKey(string name) [LoggerMessage(Level = LogLevel.Warning, Message = "Ignoring invalid transcoding cache manifest {Path}")] private static partial void LogInvalidCacheManifest(ILogger logger, string path, Exception exception); + [LoggerMessage(Level = LogLevel.Warning, Message = "Failed while stopping background subtitle extraction")] + private static partial void LogSubtitleCleanupFailed(ILogger logger, Exception exception); + private sealed record CacheDirectory(string Key, string Path, DateTimeOffset LastAccess, long Size); private sealed record CacheManifest( @@ -828,10 +886,30 @@ public static TranscodingJob FromManifest( _progress = 1, _subtitles = manifest.Subtitles }; - var video = new MediaStreamProbe(0, "video", manifest.VideoCodec, null, null, true, false, false); + var video = new MediaStreamProbe( + 0, + "video", + manifest.VideoCodec, + null, + null, + true, + false, + false, + null, + null); var audio = manifest.AudioCodec is null ? null - : new MediaStreamProbe(1, "audio", manifest.AudioCodec, null, null, true, false, false); + : new MediaStreamProbe( + 1, + "audio", + manifest.AudioCodec, + null, + null, + true, + false, + false, + null, + null); job._plan = new TranscodingPlan( manifest.Strategy, video, diff --git a/SecondDimensionWatcherReDive/Services/Transcoding/TranscodingModels.cs b/SecondDimensionWatcherReDive/Services/Transcoding/TranscodingModels.cs index 860130d..ca0e599 100644 --- a/SecondDimensionWatcherReDive/Services/Transcoding/TranscodingModels.cs +++ b/SecondDimensionWatcherReDive/Services/Transcoding/TranscodingModels.cs @@ -89,7 +89,9 @@ internal sealed record MediaStreamProbe( string? Title, bool IsDefault, bool IsForced, - bool IsAttachedPicture); + bool IsAttachedPicture, + string? Profile, + string? PixelFormat); internal sealed record MediaProbe( string Container, diff --git a/SecondDimensionWatcherReDive/Services/Transcoding/TranscodingPlanner.cs b/SecondDimensionWatcherReDive/Services/Transcoding/TranscodingPlanner.cs index ef4a2db..9d28b58 100644 --- a/SecondDimensionWatcherReDive/Services/Transcoding/TranscodingPlanner.cs +++ b/SecondDimensionWatcherReDive/Services/Transcoding/TranscodingPlanner.cs @@ -40,11 +40,11 @@ public static TranscodingPlan CreatePlan( : null; var extension = Path.GetExtension(source.FileName); - var copyVideo = video.CodecName.Equals("h264", StringComparison.OrdinalIgnoreCase) + var copyVideo = IsBrowserCompatibleH264(video) && selection.Quality == "auto" && bitmapToBurn is null; var copyAudio = audio is null || HlsAudioCodecs.Contains(audio.CodecName); - var direct = IsDirectPlayContainer(extension, video.CodecName, audio?.CodecName) + var direct = IsDirectPlayContainer(extension, video, audio?.CodecName) && selection.Quality == "auto" && bitmapToBurn is null; var strategy = direct @@ -107,20 +107,40 @@ private static string NormalizeLanguage(string language) return separator < 0 ? normalized : normalized[..separator]; } - private static bool IsDirectPlayContainer(string extension, string videoCodec, string? audioCodec) + private static bool IsDirectPlayContainer( + string extension, + MediaStreamProbe video, + string? audioCodec) { if (extension.Equals(".mp4", StringComparison.OrdinalIgnoreCase) || extension.Equals(".m4v", StringComparison.OrdinalIgnoreCase)) - return videoCodec.Equals("h264", StringComparison.OrdinalIgnoreCase) + return IsBrowserCompatibleH264(video) && (audioCodec is null || audioCodec.Equals("aac", StringComparison.OrdinalIgnoreCase)); if (!extension.Equals(".webm", StringComparison.OrdinalIgnoreCase)) return false; - var supportedVideo = videoCodec.Equals("vp8", StringComparison.OrdinalIgnoreCase) - || videoCodec.Equals("vp9", StringComparison.OrdinalIgnoreCase) - || videoCodec.Equals("av1", StringComparison.OrdinalIgnoreCase); + var supportedVideo = video.CodecName.Equals("vp8", StringComparison.OrdinalIgnoreCase) + || video.CodecName.Equals("vp9", StringComparison.OrdinalIgnoreCase) + || video.CodecName.Equals("av1", StringComparison.OrdinalIgnoreCase); var supportedAudio = audioCodec is null || audioCodec.Equals("opus", StringComparison.OrdinalIgnoreCase) || audioCodec.Equals("vorbis", StringComparison.OrdinalIgnoreCase); return supportedVideo && supportedAudio; } + + private static bool IsBrowserCompatibleH264(MediaStreamProbe video) + { + if (!video.CodecName.Equals("h264", StringComparison.OrdinalIgnoreCase)) return false; + + var profile = video.Profile?.Trim(); + var compatibleProfile = profile is not null + && (profile.Equals("Baseline", StringComparison.OrdinalIgnoreCase) + || profile.Equals("Constrained Baseline", StringComparison.OrdinalIgnoreCase) + || profile.Equals("Main", StringComparison.OrdinalIgnoreCase) + || profile.Equals("High", StringComparison.OrdinalIgnoreCase)); + var pixelFormat = video.PixelFormat?.Trim(); + var compatiblePixelFormat = pixelFormat is not null + && (pixelFormat.Equals("yuv420p", StringComparison.OrdinalIgnoreCase) + || pixelFormat.Equals("yuvj420p", StringComparison.OrdinalIgnoreCase)); + return compatibleProfile && compatiblePixelFormat; + } } From f32939d62f5c8267273f05000448aeba6ba5ebf5 Mon Sep 17 00:00:00 2001 From: mahoshojoHCG Date: Sun, 30 Aug 2026 11:15:29 +0800 Subject: [PATCH 07/37] fix: harden streaming and distributed task state --- .../ChatController.cs | 22 +-- .../Tools/ManageTasksTool.cs | 25 +++- .../IScheduledTaskLeaseRepository.cs | 4 + .../DataRepository/ScheduledTaskLeaseState.cs | 8 ++ .../Tasks/IScheduledTaskLeaseManager.cs | 8 ++ .../Tasks/ScheduledTaskBase.cs | 94 +++++++++---- .../FileMappingRepositoryPostgreSqlTests.cs | 37 +++++ .../ChatControllerStreamingTests.cs | 128 ++++++++++++++++++ .../ManageTasksToolTests.cs | 42 ++++++ .../PostgresScheduledTaskLeaseManagerTests.cs | 70 ++++++++++ .../ScheduledTaskBaseTests.cs | 64 +++++++++ .../TasksControllerTests.cs | 39 ++++++ .../Controllers/TasksController.cs | 27 ++-- ...eMappingRepositoryPostgreSqlTestFixture.cs | 10 ++ .../ScheduledTaskLeaseRepository.cs | 19 +++ .../PostgresScheduledTaskLeaseManager.cs | 35 +++++ 16 files changed, 581 insertions(+), 51 deletions(-) create mode 100644 SecondDimensionWatcherReDive.Framework/DataRepository/ScheduledTaskLeaseState.cs create mode 100644 SecondDimensionWatcherReDive.Test/ChatControllerStreamingTests.cs create mode 100644 SecondDimensionWatcherReDive.Test/ManageTasksToolTests.cs create mode 100644 SecondDimensionWatcherReDive.Test/PostgresScheduledTaskLeaseManagerTests.cs create mode 100644 SecondDimensionWatcherReDive.Test/TasksControllerTests.cs diff --git a/Plugins/SecondDimensionWatcherReDive.Chat/ChatController.cs b/Plugins/SecondDimensionWatcherReDive.Chat/ChatController.cs index 7420380..48ef392 100644 --- a/Plugins/SecondDimensionWatcherReDive.Chat/ChatController.cs +++ b/Plugins/SecondDimensionWatcherReDive.Chat/ChatController.cs @@ -179,7 +179,7 @@ public async Task SendMessage( cancellationToken)); } - private async IAsyncEnumerable> StreamChatEvents( + internal async IAsyncEnumerable> StreamChatEvents( IAIEngine aiEngine, List messages, ChatOptions chatOptions, @@ -197,6 +197,8 @@ private async IAsyncEnumerable> StreamChatEvents( SingleWriter = true, FullMode = BoundedChannelFullMode.Wait }); + using var producerCancellation = CancellationTokenSource.CreateLinkedTokenSource( + cancellationToken); // Producer: runs AI chat streaming in background, writes SSE items to channel. // Keep the task and await it during iterator disposal so a disconnected request cannot @@ -204,7 +206,7 @@ private async IAsyncEnumerable> StreamChatEvents( var producer = ProduceChatEventsAsync( aiEngine, messages, chatOptions, conversationId, messageOrder, firstUserMessage, autoTitleEligible, model, - channel.Writer, cancellationToken); + channel.Writer, producerCancellation.Token); try { @@ -214,13 +216,15 @@ private async IAsyncEnumerable> StreamChatEvents( } finally { - // Do not use the canceled request token here. The producer receives it directly, - // performs bounded engine cleanup, and persists accumulated messages before returning. + // The reader can be disposed independently of RequestAborted (for example when + // response-body I/O fails). Explicitly stop the producer, then let it persist + // accumulated messages before this request scope is released. + await producerCancellation.CancelAsync(); await producer; } } - private async Task ProduceChatEventsAsync( + internal async Task ProduceChatEventsAsync( IAIEngine aiEngine, List messages, ChatOptions chatOptions, @@ -334,12 +338,14 @@ await writer.WriteAsync( catch (Exception ex) { LogStreamingError(ex, conversationId); - await writer.WriteAsync( + // A disconnected/failed reader may leave this bounded channel full. + // Terminal error delivery is best-effort so persistence and request + // scope disposal can never wait forever for a reader that is gone. + writer.TryWrite( new SseItem( JsonSerializer.Serialize(new SseError(ex.Message), ChatJsonSerializerContext.Default.SseError), - "error"), - CancellationToken.None); + "error")); } // Save all accumulated segments to DB diff --git a/Plugins/SecondDimensionWatcherReDive.Chat/Tools/ManageTasksTool.cs b/Plugins/SecondDimensionWatcherReDive.Chat/Tools/ManageTasksTool.cs index ae9875f..5fc6330 100644 --- a/Plugins/SecondDimensionWatcherReDive.Chat/Tools/ManageTasksTool.cs +++ b/Plugins/SecondDimensionWatcherReDive.Chat/Tools/ManageTasksTool.cs @@ -9,9 +9,10 @@ namespace SecondDimensionWatcherReDive.Chat.Tools; "manage_tasks", "Manage background scheduled tasks. List all task statuses or manually trigger a specific task to run.")] internal sealed partial class ManageTasksTool( - IEnumerable scheduledTasks) : ITool + IEnumerable scheduledTasks, + IScheduledTaskLeaseManager leaseManager) : ITool { - private Task ExecuteCoreAsync( + private async Task ExecuteCoreAsync( ManageTasksParams param, CancellationToken cancellationToken) { var taskList = scheduledTasks.ToList(); @@ -20,10 +21,24 @@ private Task ExecuteCoreAsync( switch (param.Action) { case ManageTasksAction.List: + { + var statuses = await leaseManager.GetStatusesAsync( + taskList.Select(task => task.Id).ToArray(), + cancellationToken); result = new ToolSuccessResult(new TaskListResult( - taskList.Select(t => new TaskSummary( - t.Id, t.Interval.ToString(), t.IsEnabled, t.LastRunAt, t.IsRunning)))); + taskList.Select(task => + { + var status = statuses.GetValueOrDefault(task.Id) + ?? new ScheduledTaskStatus(null, false); + return new TaskSummary( + task.Id, + task.Interval.ToString(), + task.IsEnabled, + status.LastRunAt, + status.IsRunning); + }))); break; + } case ManageTasksAction.Run: { @@ -52,7 +67,7 @@ private Task ExecuteCoreAsync( break; } - return Task.FromResult(result); + return result; } } diff --git a/SecondDimensionWatcherReDive.Framework/DataRepository/IScheduledTaskLeaseRepository.cs b/SecondDimensionWatcherReDive.Framework/DataRepository/IScheduledTaskLeaseRepository.cs index 34b6424..684afa3 100644 --- a/SecondDimensionWatcherReDive.Framework/DataRepository/IScheduledTaskLeaseRepository.cs +++ b/SecondDimensionWatcherReDive.Framework/DataRepository/IScheduledTaskLeaseRepository.cs @@ -25,4 +25,8 @@ Task CompleteAsync( bool succeeded, string? error, CancellationToken cancellationToken); + + Task> GetStatesAsync( + IReadOnlyCollection taskIds, + CancellationToken cancellationToken); } diff --git a/SecondDimensionWatcherReDive.Framework/DataRepository/ScheduledTaskLeaseState.cs b/SecondDimensionWatcherReDive.Framework/DataRepository/ScheduledTaskLeaseState.cs new file mode 100644 index 0000000..c2d9f65 --- /dev/null +++ b/SecondDimensionWatcherReDive.Framework/DataRepository/ScheduledTaskLeaseState.cs @@ -0,0 +1,8 @@ +namespace SecondDimensionWatcherReDive.Framework.DataRepository; + +public sealed record ScheduledTaskLeaseState( + string TaskId, + string? LeaseOwner, + DateTimeOffset? LeaseExpiresAt, + DateTimeOffset? LastStartedAt, + DateTimeOffset? LastCompletedAt); diff --git a/SecondDimensionWatcherReDive.Framework/Tasks/IScheduledTaskLeaseManager.cs b/SecondDimensionWatcherReDive.Framework/Tasks/IScheduledTaskLeaseManager.cs index 2a0f1bd..eede259 100644 --- a/SecondDimensionWatcherReDive.Framework/Tasks/IScheduledTaskLeaseManager.cs +++ b/SecondDimensionWatcherReDive.Framework/Tasks/IScheduledTaskLeaseManager.cs @@ -3,6 +3,10 @@ namespace SecondDimensionWatcherReDive.Framework.Tasks; public sealed class ScheduledTaskLeaseUnavailableException(Exception innerException) : Exception("The scheduled-task lease store is unavailable.", innerException); +public sealed record ScheduledTaskStatus( + DateTimeOffset? LastRunAt, + bool IsRunning); + public interface IScheduledTaskExecutionLease : IAsyncDisposable { CancellationToken LeaseLostToken { get; } @@ -20,4 +24,8 @@ public interface IScheduledTaskLeaseManager TimeSpan interval, bool force, CancellationToken cancellationToken); + + Task> GetStatusesAsync( + IReadOnlyCollection taskIds, + CancellationToken cancellationToken); } diff --git a/SecondDimensionWatcherReDive.Framework/Tasks/ScheduledTaskBase.cs b/SecondDimensionWatcherReDive.Framework/Tasks/ScheduledTaskBase.cs index fa025c7..e8945e9 100644 --- a/SecondDimensionWatcherReDive.Framework/Tasks/ScheduledTaskBase.cs +++ b/SecondDimensionWatcherReDive.Framework/Tasks/ScheduledTaskBase.cs @@ -56,45 +56,49 @@ public async Task ProcessQueueAsync( await foreach (var _ in _runQueue.Reader.ReadAllAsync(cancellationToken)) { TaskCompletionSource? completion; - bool force; lock (_sync) { completion = _pendingRun; - force = _pendingForce; } if (completion is null) continue; - IScheduledTaskExecutionLease? lease; - try + IScheduledTaskExecutionLease? lease = null; + while (lease is null) { - lease = await leaseManager.TryAcquireAsync( - Id, - Interval, - force, - cancellationToken); - } - catch (OperationCanceledException exception) when (cancellationToken.IsCancellationRequested) - { - completion.TrySetCanceled(exception.CancellationToken); - FinishRun(completion); - throw; - } - catch (Exception exception) - { - completion.TrySetException( - new ScheduledTaskLeaseUnavailableException(exception)); - FinishRun(completion); - continue; - } + var force = TakePendingForce(completion); + try + { + lease = await leaseManager.TryAcquireAsync( + Id, + Interval, + force, + cancellationToken); + } + catch (OperationCanceledException exception) when (cancellationToken.IsCancellationRequested) + { + completion.TrySetCanceled(exception.CancellationToken); + FinishRun(completion); + throw; + } + catch (Exception exception) + { + completion.TrySetException( + new ScheduledTaskLeaseUnavailableException(exception)); + FinishRun(completion); + break; + } - if (lease is null) - { - // Another instance owns the same periodic task. Its local timer - // will drive the execution; this duplicate signal is complete. - completion.TrySetResult(false); - FinishRun(completion); - continue; + if (lease is not null) + break; + + // A manual request can upgrade a periodic acquisition while the + // database call is in flight. Retry that upgrade before completing + // the shared signal so a completed cooldown cannot swallow it. + if (CompleteLeaseDenialOrRetryForce(completion, force)) + continue; + break; } + if (lease is null) continue; await using (lease) { @@ -167,6 +171,36 @@ private Task QueueRun(bool force) } } + private bool TakePendingForce(TaskCompletionSource completion) + { + lock (_sync) + { + if (!ReferenceEquals(_pendingRun, completion)) + return false; + var force = _pendingForce; + _pendingForce = false; + return force; + } + } + + private bool CompleteLeaseDenialOrRetryForce( + TaskCompletionSource completion, + bool attemptedForce) + { + lock (_sync) + { + if (!ReferenceEquals(_pendingRun, completion)) + return false; + if (!attemptedForce && _pendingForce) + return true; + + _pendingRun = null; + _pendingForce = false; + completion.TrySetResult(false); + return false; + } + } + private void FinishRun(TaskCompletionSource completion) { lock (_sync) diff --git a/SecondDimensionWatcherReDive.IntegrationTest/PostgreSql/FileMappingRepositoryPostgreSqlTests.cs b/SecondDimensionWatcherReDive.IntegrationTest/PostgreSql/FileMappingRepositoryPostgreSqlTests.cs index 3e53a88..2611336 100644 --- a/SecondDimensionWatcherReDive.IntegrationTest/PostgreSql/FileMappingRepositoryPostgreSqlTests.cs +++ b/SecondDimensionWatcherReDive.IntegrationTest/PostgreSql/FileMappingRepositoryPostgreSqlTests.cs @@ -240,6 +240,43 @@ await Fixture.CompleteTaskLeaseAsync( CancellationToken.None)); } + [TestMethod] + public async Task ScheduledTaskLease_StatusProjectionReadsSharedState() + { + var now = DateTimeOffset.FromUnixTimeMilliseconds( + DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()); + var leaseUntil = now.AddSeconds(30); + Assert.IsTrue(await Fixture.TryAcquireTaskLeaseAsync( + "StatusTask", + "instance-a", + now, + leaseUntil, + false, + CancellationToken.None)); + + var running = await Fixture.GetTaskLeaseStatesAsync( + ["StatusTask", "missing"], + CancellationToken.None); + + Assert.HasCount(1, running); + Assert.AreEqual("instance-a", running[0].LeaseOwner); + Assert.AreEqual(now, running[0].LastStartedAt); + Assert.AreEqual(leaseUntil, running[0].LeaseExpiresAt); + Assert.IsNull(running[0].LastCompletedAt); + + var completedAt = now.AddSeconds(5); + await Fixture.CompleteTaskLeaseAsync( + "StatusTask", + "instance-a", + completedAt, + now.AddMinutes(10), + CancellationToken.None); + var completed = await Fixture.GetTaskLeaseStatesAsync( + ["StatusTask"], + CancellationToken.None); + Assert.AreEqual(completedAt, completed.Single().LastCompletedAt); + } + [TestMethod] public async Task DeadLetterJobs_CanBeRetriedOrMarkedHandled() { diff --git a/SecondDimensionWatcherReDive.Test/ChatControllerStreamingTests.cs b/SecondDimensionWatcherReDive.Test/ChatControllerStreamingTests.cs new file mode 100644 index 0000000..a5def04 --- /dev/null +++ b/SecondDimensionWatcherReDive.Test/ChatControllerStreamingTests.cs @@ -0,0 +1,128 @@ +using System.Net.ServerSentEvents; +using System.Runtime.CompilerServices; +using System.Threading.Channels; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using SecondDimensionWatcherReDive.AI.Abstractions; +using SecondDimensionWatcherReDive.AI.Models; +using SecondDimensionWatcherReDive.Chat; +using SecondDimensionWatcherReDive.Framework.DataRepository; + +namespace SecondDimensionWatcherReDive.Test; + +[TestClass] +public sealed class ChatControllerStreamingTests +{ + [TestMethod] + public async Task ProduceChatEventsAsync_ErrorWithFullChannel_DoesNotBlock() + { + var channel = Channel.CreateBounded>(1); + Assert.IsTrue(channel.Writer.TryWrite(new SseItem("buffered", "text_delta"))); + var controller = CreateController(new Mock(MockBehavior.Strict).Object); + + await controller.ProduceChatEventsAsync( + new ThrowingEngine(), + [], + new ChatOptions(), + Guid.NewGuid(), + 0, + "question", + false, + null, + channel.Writer, + CancellationToken.None) + .WaitAsync(TimeSpan.FromSeconds(2)); + + Assert.IsTrue(channel.Reader.TryRead(out var buffered)); + Assert.AreEqual("buffered", buffered.Data); + await channel.Reader.Completion.WaitAsync(TimeSpan.FromSeconds(2)); + Assert.IsFalse(channel.Reader.TryRead(out _)); + } + + [TestMethod] + public async Task StreamChatEvents_EarlyReaderExit_CancelsAndJoinsProducer() + { + var conversationId = Guid.NewGuid(); + var repository = new Mock(MockBehavior.Strict); + repository.Setup(candidate => candidate.AddMessagesAsync( + conversationId, + It.Is>(messages => + messages.Any(message => message.Role == "assistant" + && message.Content == "partial")), + CancellationToken.None)) + .Returns(Task.CompletedTask); + var engine = new CancellationAwareEngine(); + var controller = CreateController(repository.Object); + var enumerator = controller.StreamChatEvents( + engine, + [], + new ChatOptions(), + conversationId, + 0, + "question", + false, + null, + CancellationToken.None) + .GetAsyncEnumerator(); + + Assert.IsTrue(await enumerator.MoveNextAsync()); + await enumerator.DisposeAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(2)); + + await engine.CancellationObserved.Task.WaitAsync(TimeSpan.FromSeconds(2)); + repository.VerifyAll(); + } + + private static ChatController CreateController(IChatRepository repository) => + new( + repository, + Mock.Of(), + Mock.Of(), + NullLogger.Instance); + + private sealed class ThrowingEngine : IAIEngine + { + public Task> GetAvailableModelsAsync( + CancellationToken cancellationToken) => + Task.FromResult>([]); + + public async IAsyncEnumerable ChatAsync( + IReadOnlyList messages, + ChatOptions? options, + [EnumeratorCancellation] CancellationToken cancellationToken) + { + await Task.Yield(); + if (!cancellationToken.IsCancellationRequested) + throw new InvalidOperationException("provider failed"); + cancellationToken.ThrowIfCancellationRequested(); + yield break; + } + } + + private sealed class CancellationAwareEngine : IAIEngine + { + public TaskCompletionSource CancellationObserved { get; } = new( + TaskCreationOptions.RunContinuationsAsynchronously); + + public Task> GetAvailableModelsAsync( + CancellationToken cancellationToken) => + Task.FromResult>([]); + + public async IAsyncEnumerable ChatAsync( + IReadOnlyList messages, + ChatOptions? options, + [EnumeratorCancellation] CancellationToken cancellationToken) + { + yield return new TextDelta("partial"); + try + { + await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + CancellationObserved.TrySetResult(); + throw; + } + } + } +} diff --git a/SecondDimensionWatcherReDive.Test/ManageTasksToolTests.cs b/SecondDimensionWatcherReDive.Test/ManageTasksToolTests.cs new file mode 100644 index 0000000..fc6702c --- /dev/null +++ b/SecondDimensionWatcherReDive.Test/ManageTasksToolTests.cs @@ -0,0 +1,42 @@ +using System.Text.Json; +using Moq; +using SecondDimensionWatcherReDive.AI.Models; +using SecondDimensionWatcherReDive.Chat.Tools; +using SecondDimensionWatcherReDive.Framework.Tasks; + +namespace SecondDimensionWatcherReDive.Test; + +[TestClass] +public sealed class ManageTasksToolTests +{ + [TestMethod] + public async Task List_UsesSharedLeaseStatuses() + { + var task = new Mock(MockBehavior.Strict); + task.SetupGet(candidate => candidate.Id).Returns("remote-task"); + task.SetupGet(candidate => candidate.Interval).Returns(TimeSpan.FromMinutes(10)); + task.SetupGet(candidate => candidate.IsEnabled).Returns(true); + var lastRunAt = DateTimeOffset.UtcNow.AddMinutes(-4); + var leaseManager = new Mock(MockBehavior.Strict); + leaseManager.Setup(candidate => candidate.GetStatusesAsync( + It.Is>(ids => ids.SequenceEqual(new[] { "remote-task" })), + CancellationToken.None)) + .ReturnsAsync(new Dictionary + { + ["remote-task"] = new(lastRunAt, true) + }); + var tool = new ManageTasksTool([task.Object], leaseManager.Object); + + var result = await tool.ExecuteAsync( + JsonSerializer.SerializeToElement( + new ManageTasksParams(ManageTasksAction.List), + ToolJsonOptions.Options), + CancellationToken.None); + + var success = result as ToolSuccessResult; + Assert.IsNotNull(success); + var status = success.Result.Tasks.Single(); + Assert.AreEqual(lastRunAt, status.LastRunAt); + Assert.IsTrue(status.IsRunning); + } +} diff --git a/SecondDimensionWatcherReDive.Test/PostgresScheduledTaskLeaseManagerTests.cs b/SecondDimensionWatcherReDive.Test/PostgresScheduledTaskLeaseManagerTests.cs new file mode 100644 index 0000000..b269f9f --- /dev/null +++ b/SecondDimensionWatcherReDive.Test/PostgresScheduledTaskLeaseManagerTests.cs @@ -0,0 +1,70 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using SecondDimensionWatcherReDive.Framework.DataRepository; +using SecondDimensionWatcherReDive.Framework.Tasks; +using SecondDimensionWatcherReDive.Services; + +namespace SecondDimensionWatcherReDive.Test; + +[TestClass] +public sealed class PostgresScheduledTaskLeaseManagerTests +{ + [TestMethod] + public async Task GetStatusesAsync_DerivesCrossReplicaStateFromPersistedLease() + { + var now = DateTimeOffset.UtcNow; + var lastCompleted = now.AddMinutes(-2); + var repository = new Mock(MockBehavior.Strict); + repository.Setup(candidate => candidate.GetStatesAsync( + It.IsAny>(), + CancellationToken.None)) + .ReturnsAsync( + [ + new ScheduledTaskLeaseState( + "remote-running", + "instance-b", + now.AddMinutes(5), + now.AddMinutes(-1), + lastCompleted), + new ScheduledTaskLeaseState( + "cooldown", + "instance-b", + now.AddMinutes(5), + now.AddMinutes(-2), + now.AddMinutes(-1)), + new ScheduledTaskLeaseState( + "expired-incomplete", + "instance-b", + now.AddMinutes(-1), + now.AddMinutes(-2), + null), + new ScheduledTaskLeaseState( + "ownerless", + null, + now.AddMinutes(5), + now.AddMinutes(-1), + null) + ]); + using var services = new ServiceCollection() + .AddScoped(_ => repository.Object) + .BuildServiceProvider(); + var manager = new PostgresScheduledTaskLeaseManager( + services.GetRequiredService(), + NullLogger.Instance); + + var statuses = await manager.GetStatusesAsync( + ["remote-running", "cooldown", "expired-incomplete", "ownerless", "not-started"], + CancellationToken.None); + + Assert.IsTrue(statuses["remote-running"].IsRunning); + Assert.AreEqual(lastCompleted, statuses["remote-running"].LastRunAt); + Assert.IsFalse(statuses["cooldown"].IsRunning); + Assert.AreEqual(now.AddMinutes(-1), statuses["cooldown"].LastRunAt); + Assert.IsFalse(statuses["expired-incomplete"].IsRunning); + Assert.IsNull(statuses["expired-incomplete"].LastRunAt); + Assert.IsFalse(statuses["ownerless"].IsRunning); + Assert.IsFalse(statuses["not-started"].IsRunning); + Assert.IsNull(statuses["not-started"].LastRunAt); + } +} diff --git a/SecondDimensionWatcherReDive.Test/ScheduledTaskBaseTests.cs b/SecondDimensionWatcherReDive.Test/ScheduledTaskBaseTests.cs index 94b5105..9f4c715 100644 --- a/SecondDimensionWatcherReDive.Test/ScheduledTaskBaseTests.cs +++ b/SecondDimensionWatcherReDive.Test/ScheduledTaskBaseTests.cs @@ -63,6 +63,29 @@ public async Task RunScheduledAsync_ContentionReportsSkippedWithoutForcingCooldo await AssertCanceledAsync(processor); } + [TestMethod] + public async Task RunScheduledAsync_ForceArrivingDuringAcquisitionRetriesAsForced() + { + var task = new BlockingTask(); + var leaseManager = new ForceUpgradeLeaseManager(); + using var cancellation = new CancellationTokenSource(); + var processor = task.ProcessQueueAsync(leaseManager, cancellation.Token); + + var scheduled = task.RunScheduledAsync(CancellationToken.None); + await leaseManager.FirstAcquireStarted.Task.WaitAsync(TimeSpan.FromSeconds(2)); + task.Enqueue(); + leaseManager.ReleaseFirstAcquire.TrySetResult(); + await task.Started.Task.WaitAsync(TimeSpan.FromSeconds(2)); + task.Release.TrySetResult(); + + Assert.IsTrue(await scheduled.WaitAsync(TimeSpan.FromSeconds(2))); + CollectionAssert.AreEqual(new[] { false, true }, leaseManager.Forces); + Assert.AreEqual(1, task.ExecutionCount); + + await cancellation.CancelAsync(); + await AssertCanceledAsync(processor); + } + [TestMethod] public async Task RunScheduledAsync_TemporaryLeaseStoreFailureDoesNotStopQueue() { @@ -147,6 +170,47 @@ private sealed class FakeLeaseManager : IScheduledTaskLeaseManager return Task.FromException(AcquireException); return Task.FromResult(Deny ? null : Lease); } + + public Task> GetStatusesAsync( + IReadOnlyCollection taskIds, + CancellationToken cancellationToken) => + Task.FromResult>( + new Dictionary()); + } + + private sealed class ForceUpgradeLeaseManager : IScheduledTaskLeaseManager + { + public TaskCompletionSource FirstAcquireStarted { get; } = new( + TaskCreationOptions.RunContinuationsAsynchronously); + public TaskCompletionSource ReleaseFirstAcquire { get; } = new( + TaskCreationOptions.RunContinuationsAsynchronously); + public bool[] Forces => _forces.ToArray(); + + private readonly List _forces = []; + private readonly FakeLease _lease = new(); + + public async Task TryAcquireAsync( + string taskId, + TimeSpan interval, + bool force, + CancellationToken cancellationToken) + { + _forces.Add(force); + if (_forces.Count == 1) + { + FirstAcquireStarted.TrySetResult(); + await ReleaseFirstAcquire.Task.WaitAsync(cancellationToken); + return null; + } + + return _lease; + } + + public Task> GetStatusesAsync( + IReadOnlyCollection taskIds, + CancellationToken cancellationToken) => + Task.FromResult>( + new Dictionary()); } private sealed class FakeLease : IScheduledTaskExecutionLease diff --git a/SecondDimensionWatcherReDive.Test/TasksControllerTests.cs b/SecondDimensionWatcherReDive.Test/TasksControllerTests.cs new file mode 100644 index 0000000..89cb9c3 --- /dev/null +++ b/SecondDimensionWatcherReDive.Test/TasksControllerTests.cs @@ -0,0 +1,39 @@ +using Microsoft.AspNetCore.Mvc; +using Moq; +using SecondDimensionWatcherReDive.Controllers; +using SecondDimensionWatcherReDive.Framework.Tasks; + +namespace SecondDimensionWatcherReDive.Test; + +[TestClass] +public sealed class TasksControllerTests +{ + [TestMethod] + public async Task GetTasksAsync_UsesSharedLeaseStatuses() + { + var task = new Mock(MockBehavior.Strict); + task.SetupGet(candidate => candidate.Id).Returns("remote-task"); + task.SetupGet(candidate => candidate.Interval).Returns(TimeSpan.FromMinutes(10)); + task.SetupGet(candidate => candidate.IsEnabled).Returns(true); + var lastRunAt = DateTimeOffset.UtcNow.AddMinutes(-3); + var leaseManager = new Mock(MockBehavior.Strict); + leaseManager.Setup(candidate => candidate.GetStatusesAsync( + It.Is>(ids => ids.SequenceEqual(new[] { "remote-task" })), + CancellationToken.None)) + .ReturnsAsync(new Dictionary + { + ["remote-task"] = new(lastRunAt, true) + }); + var controller = new TasksController([task.Object], leaseManager.Object); + + var result = await controller.GetTasksAsync(CancellationToken.None); + + var ok = result as OkObjectResult; + Assert.IsNotNull(ok); + var response = ok.Value as IReadOnlyList; + Assert.IsNotNull(response); + Assert.HasCount(1, response); + Assert.AreEqual(lastRunAt, response[0].LastRunAt); + Assert.IsTrue(response[0].IsRunning); + } +} diff --git a/SecondDimensionWatcherReDive/Controllers/TasksController.cs b/SecondDimensionWatcherReDive/Controllers/TasksController.cs index 50dd947..4cec76d 100644 --- a/SecondDimensionWatcherReDive/Controllers/TasksController.cs +++ b/SecondDimensionWatcherReDive/Controllers/TasksController.cs @@ -8,17 +8,28 @@ namespace SecondDimensionWatcherReDive.Controllers; [ApiController] [Route("api/[controller]")] [Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)] -internal class TasksController(IEnumerable scheduledTasks) : ControllerBase +internal class TasksController( + IEnumerable scheduledTasks, + IScheduledTaskLeaseManager leaseManager) : ControllerBase { [HttpGet] - public IActionResult GetTasks() + public async Task GetTasksAsync(CancellationToken cancellationToken) { - var tasks = scheduledTasks.Select(t => new External.ScheduledTask( - t.Id, - t.Interval.ToString(), - t.IsEnabled, - t.LastRunAt, - t.IsRunning)).ToList(); + var taskList = scheduledTasks.ToList(); + var statuses = await leaseManager.GetStatusesAsync( + taskList.Select(task => task.Id).ToArray(), + cancellationToken); + var tasks = taskList.Select(task => + { + var status = statuses.GetValueOrDefault(task.Id) + ?? new ScheduledTaskStatus(null, false); + return new External.ScheduledTask( + task.Id, + task.Interval.ToString(), + task.IsEnabled, + status.LastRunAt, + status.IsRunning); + }).ToList(); return Ok(tasks); } diff --git a/SecondDimensionWatcherReDive/Repositories/FileMappingRepositoryPostgreSqlTestFixture.cs b/SecondDimensionWatcherReDive/Repositories/FileMappingRepositoryPostgreSqlTestFixture.cs index 4068035..b9b2987 100644 --- a/SecondDimensionWatcherReDive/Repositories/FileMappingRepositoryPostgreSqlTestFixture.cs +++ b/SecondDimensionWatcherReDive/Repositories/FileMappingRepositoryPostgreSqlTestFixture.cs @@ -249,6 +249,16 @@ public async Task CompleteTaskLeaseAsync( cancellationToken); } + public async Task> GetTaskLeaseStatesAsync( + IReadOnlyCollection taskIds, + CancellationToken cancellationToken) + { + await using var context = new Models.ApplicationContext(_contextOptions); + return await new ScheduledTaskLeaseRepository(context).GetStatesAsync( + taskIds, + cancellationToken); + } + public async Task RetryJobsAsync( IReadOnlyCollection ids, DateTimeOffset now, diff --git a/SecondDimensionWatcherReDive/Repositories/ScheduledTaskLeaseRepository.cs b/SecondDimensionWatcherReDive/Repositories/ScheduledTaskLeaseRepository.cs index 7e1d49f..32d6f5f 100644 --- a/SecondDimensionWatcherReDive/Repositories/ScheduledTaskLeaseRepository.cs +++ b/SecondDimensionWatcherReDive/Repositories/ScheduledTaskLeaseRepository.cs @@ -92,4 +92,23 @@ public Task CompleteAsync( state => succeeded ? completedAt : state.LastSucceededAt) .SetProperty(state => state.LastError, succeeded ? null : error), cancellationToken); + + public async Task> GetStatesAsync( + IReadOnlyCollection taskIds, + CancellationToken cancellationToken) + { + if (taskIds.Count == 0) return []; + + var ids = taskIds.Distinct(StringComparer.Ordinal).ToArray(); + return await context.ScheduledTaskStates + .AsNoTracking() + .Where(state => ids.Contains(state.TaskId)) + .Select(state => new ScheduledTaskLeaseState( + state.TaskId, + state.LeaseOwner, + state.LeaseExpiresAt, + state.LastStartedAt, + state.LastCompletedAt)) + .ToListAsync(cancellationToken); + } } diff --git a/SecondDimensionWatcherReDive/Services/PostgresScheduledTaskLeaseManager.cs b/SecondDimensionWatcherReDive/Services/PostgresScheduledTaskLeaseManager.cs index c16108d..ebafb6c 100644 --- a/SecondDimensionWatcherReDive/Services/PostgresScheduledTaskLeaseManager.cs +++ b/SecondDimensionWatcherReDive/Services/PostgresScheduledTaskLeaseManager.cs @@ -33,6 +33,41 @@ public sealed partial class PostgresScheduledTaskLeaseManager( : null; } + public async Task> GetStatusesAsync( + IReadOnlyCollection taskIds, + CancellationToken cancellationToken) + { + var ids = taskIds.Distinct(StringComparer.Ordinal).ToArray(); + if (ids.Length == 0) + return new Dictionary(StringComparer.Ordinal); + + await using var scope = scopeFactory.CreateAsyncScope(); + var repository = scope.ServiceProvider.GetRequiredService(); + var persistedStates = await repository.GetStatesAsync(ids, cancellationToken); + var statesById = persistedStates.ToDictionary( + state => state.TaskId, + StringComparer.Ordinal); + var now = DateTimeOffset.UtcNow; + return ids.ToDictionary( + taskId => taskId, + taskId => statesById.TryGetValue(taskId, out var state) + ? ToStatus(state, now) + : new ScheduledTaskStatus(null, false), + StringComparer.Ordinal); + } + + private static ScheduledTaskStatus ToStatus( + ScheduledTaskLeaseState state, + DateTimeOffset now) + { + var isRunning = state.LeaseOwner is not null + && state.LeaseExpiresAt > now + && state.LastStartedAt is { } startedAt + && (state.LastCompletedAt is null + || startedAt > state.LastCompletedAt); + return new ScheduledTaskStatus(state.LastCompletedAt, isRunning); + } + private sealed class ExecutionLease : IScheduledTaskExecutionLease { private readonly string _taskId; From 23198d0e139232fa5dd1ca714e7ebc07065325d0 Mon Sep 17 00:00:00 2001 From: mahoshojoHCG Date: Sun, 30 Aug 2026 11:21:22 +0800 Subject: [PATCH 08/37] ci: initialize backup drill workspace at runtime --- .github/workflows/backup-restore.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/backup-restore.yml b/.github/workflows/backup-restore.yml index e219226..67dc844 100644 --- a/.github/workflows/backup-restore.yml +++ b/.github/workflows/backup-restore.yml @@ -41,7 +41,6 @@ jobs: ConnectionStrings__sdw: Host=127.0.0.1;Port=5432;Username=postgres;Password=postgres;Database=sdw_source JwtSecret: backup-restore-drill-jwt-secret-with-at-least-32-bytes ASPNETCORE_ENVIRONMENT: Production - WORK_DIR: ${{ runner.temp }}/sdw-backup-drill steps: - uses: actions/checkout@v7 @@ -49,6 +48,9 @@ jobs: with: dotnet-version: "10.0.x" + - name: Configure drill workspace + run: printf 'WORK_DIR=%s\n' "$RUNNER_TEMP/sdw-backup-drill" >> "$GITHUB_ENV" + - name: Install PostgreSQL client run: sudo apt-get update && sudo apt-get install -y postgresql-client From f6fda4e09f6f74dcff987e88a0e26a5514e3450d Mon Sep 17 00:00:00 2001 From: mahoshojoHCG Date: Sun, 30 Aug 2026 11:47:06 +0800 Subject: [PATCH 09/37] fix: make release upgrades atomic and recoverable --- .../Tools/ManageDownloadsTool.cs | 1 + .../DataRepository/AnimationInfo.cs | 2 +- .../DataRepository/IFileMappingRepository.cs | 1 + .../DataRepository/ReleaseUpgrade.cs | 2 + .../Helpers/Fakes.cs | 1 + .../FileMappingRepositoryPostgreSqlTests.cs | 366 ++++- .../AnimationInfoControllerTests.cs | 6 + .../ReleaseUpgradeCoordinatorTests.cs | 421 +++++- .../Controllers/AnimationInfoController.cs | 1 + ...orceSingleActiveEpisodeRelease.Designer.cs | 1199 +++++++++++++++++ ...30724_EnforceSingleActiveEpisodeRelease.cs | 89 ++ .../ApplicationContextModelSnapshot.cs | 12 +- .../Models/AnimationInfo.cs | 2 +- .../Models/ApplicationContext.cs | 15 +- .../Repositories/AnimationInfoRepository.cs | 272 +++- .../Repositories/FileMappingRepository.cs | 27 +- ...eMappingRepositoryPostgreSqlTestFixture.cs | 542 +++++++- .../Repositories/MetadataReviewRepository.cs | 42 + .../Repositories/ReleaseUpgradeRepository.cs | 355 ++++- .../ReleaseUpgradeCoordinator.cs | 249 +++- 20 files changed, 3435 insertions(+), 170 deletions(-) create mode 100644 SecondDimensionWatcherReDive/Migrations/20260830030724_EnforceSingleActiveEpisodeRelease.Designer.cs create mode 100644 SecondDimensionWatcherReDive/Migrations/20260830030724_EnforceSingleActiveEpisodeRelease.cs diff --git a/Plugins/SecondDimensionWatcherReDive.Chat/Tools/ManageDownloadsTool.cs b/Plugins/SecondDimensionWatcherReDive.Chat/Tools/ManageDownloadsTool.cs index 831ec4b..f28acd2 100644 --- a/Plugins/SecondDimensionWatcherReDive.Chat/Tools/ManageDownloadsTool.cs +++ b/Plugins/SecondDimensionWatcherReDive.Chat/Tools/ManageDownloadsTool.cs @@ -141,6 +141,7 @@ private async Task CancelDownloadAsync( info.Id, info.DownloadAttemptId, cancellationAttemptId, + terminalDisposition: null, finalizeCancellation.Token); if (!cancelled) return new ToolFailureResult("Download state changed during cancellation"); diff --git a/SecondDimensionWatcherReDive.Framework/DataRepository/AnimationInfo.cs b/SecondDimensionWatcherReDive.Framework/DataRepository/AnimationInfo.cs index be05aaa..d15d21c 100644 --- a/SecondDimensionWatcherReDive.Framework/DataRepository/AnimationInfo.cs +++ b/SecondDimensionWatcherReDive.Framework/DataRepository/AnimationInfo.cs @@ -47,4 +47,4 @@ public sealed record AnimationInfo( string? ReleaseScoreReasonsJson = null, int? ExpectedEpisodeCount = null, DateTimeOffset? IngestedAt = null, - bool IsActiveRelease = true); + bool IsActiveRelease = false); diff --git a/SecondDimensionWatcherReDive.Framework/DataRepository/IFileMappingRepository.cs b/SecondDimensionWatcherReDive.Framework/DataRepository/IFileMappingRepository.cs index e3b800e..0d7cd2e 100644 --- a/SecondDimensionWatcherReDive.Framework/DataRepository/IFileMappingRepository.cs +++ b/SecondDimensionWatcherReDive.Framework/DataRepository/IFileMappingRepository.cs @@ -46,6 +46,7 @@ Task TryFinalizeDownloadCancellationAsync( Guid animationInfoId, Guid? downloadAttemptId, Guid cancellationAttemptId, + SubscriptionAutomationDisposition? terminalDisposition, CancellationToken cancellationToken); Task RemoveByAnimationInfoAsync(Guid animationInfoId, CancellationToken cancellationToken); diff --git a/SecondDimensionWatcherReDive.Framework/DataRepository/ReleaseUpgrade.cs b/SecondDimensionWatcherReDive.Framework/DataRepository/ReleaseUpgrade.cs index 547cd5d..0c2e280 100644 --- a/SecondDimensionWatcherReDive.Framework/DataRepository/ReleaseUpgrade.cs +++ b/SecondDimensionWatcherReDive.Framework/DataRepository/ReleaseUpgrade.cs @@ -76,6 +76,8 @@ Task> GetReadyCandidateIdsAsync( Task ActivateAsync( Guid operationId, + IReadOnlyList expectedPreviousMappings, + IReadOnlyList expectedCandidateMappings, DateTimeOffset verifiedAt, DateTimeOffset rollbackUntil, CancellationToken cancellationToken); diff --git a/SecondDimensionWatcherReDive.IntegrationTest/Helpers/Fakes.cs b/SecondDimensionWatcherReDive.IntegrationTest/Helpers/Fakes.cs index 62034f0..1be2230 100644 --- a/SecondDimensionWatcherReDive.IntegrationTest/Helpers/Fakes.cs +++ b/SecondDimensionWatcherReDive.IntegrationTest/Helpers/Fakes.cs @@ -79,6 +79,7 @@ public Task TryFinalizeDownloadCancellationAsync( Guid animationInfoId, Guid? downloadAttemptId, Guid cancellationAttemptId, + SubscriptionAutomationDisposition? terminalDisposition, CancellationToken cancellationToken) { _mappings.RemoveAll(mapping => mapping.AnimationInfoId == animationInfoId); diff --git a/SecondDimensionWatcherReDive.IntegrationTest/PostgreSql/FileMappingRepositoryPostgreSqlTests.cs b/SecondDimensionWatcherReDive.IntegrationTest/PostgreSql/FileMappingRepositoryPostgreSqlTests.cs index 91313ad..2bd4534 100644 --- a/SecondDimensionWatcherReDive.IntegrationTest/PostgreSql/FileMappingRepositoryPostgreSqlTests.cs +++ b/SecondDimensionWatcherReDive.IntegrationTest/PostgreSql/FileMappingRepositoryPostgreSqlTests.cs @@ -165,12 +165,36 @@ public async Task Migration_CreatesReleaseUniquenessAndSearchIndexes() "IX_Animations_OriginalName_Trgm", "IX_AnimationGroups_Name_Trgm", "IX_FileMappings_VirtualPath_Trgm", - "IX_AnimationInfo_ReleaseLanguages_Gin" + "IX_AnimationInfo_ReleaseLanguages_Gin", + "IX_AnimationInfo_AnimationId", + "UX_AnimationInfo_ActiveEpisodeRelease" }; Assert.IsTrue(expected.All(indexes.Contains), $"Missing indexes: {string.Join(", ", expected.Except(indexes))}"); } + [TestMethod] + public async Task Migration_DeduplicatesActiveReleasesBeforeCreatingUniqueIndex() + { + await using var migrationDatabase = new PostgreSqlBuilder("postgres:17-alpine") + .WithDatabase("sdw_migration_tests") + .WithUsername("postgres") + .WithPassword("postgres") + .Build(); + await migrationDatabase.StartAsync(); + var fixture = new FileMappingRepositoryPostgreSqlTestFixture( + migrationDatabase.GetConnectionString()); + + var result = await fixture.MigrateDuplicateActiveReleasesAsync(CancellationToken.None); + var indexes = await fixture.GetLibraryIndexNamesAsync(CancellationToken.None); + + Assert.HasCount(1, result.ActiveIds); + Assert.AreEqual(result.ExpectedActiveId, result.ActiveIds[0]); + Assert.AreEqual(1, result.DowngradedCandidateOperationCount); + CollectionAssert.Contains(indexes.ToArray(), "IX_AnimationInfo_AnimationId"); + CollectionAssert.Contains(indexes.ToArray(), "UX_AnimationInfo_ActiveEpisodeRelease"); + } + [TestMethod] public async Task UpgradeRace_ClaimsOnce_AtomicallySwapsMappings_AndRollsBack() { @@ -184,8 +208,13 @@ public async Task UpgradeRace_ClaimsOnce_AtomicallySwapsMappings_AndRollsBack() var beforeCurrent = await Fixture.GetMappingsAsync( scenario.Candidate.CurrentReleaseId, CancellationToken.None); - Assert.HasCount(1, beforeCurrent); - Assert.AreEqual(scenario.CanonicalPath, beforeCurrent[0].VirtualPath); + Assert.HasCount(2, beforeCurrent); + CollectionAssert.Contains( + beforeCurrent.Select(mapping => mapping.VirtualPath).ToArray(), + scenario.CanonicalPath); + CollectionAssert.Contains( + beforeCurrent.Select(mapping => mapping.VirtualPath).ToArray(), + scenario.CanonicalSubtitlePath); var applied = await Fixture.ActivateUpgradeAsync(operation.Id, CancellationToken.None); Assert.IsTrue(applied.IsSuccess); @@ -194,18 +223,341 @@ public async Task UpgradeRace_ClaimsOnce_AtomicallySwapsMappings_AndRollsBack() scenario.Candidate.CurrentReleaseId, CancellationToken.None)); var activeCandidate = await Fixture.GetMappingsAsync( scenario.Candidate.CandidateReleaseId, CancellationToken.None); - Assert.HasCount(1, activeCandidate); - Assert.AreEqual(scenario.CanonicalPath, activeCandidate[0].VirtualPath); + Assert.HasCount(2, activeCandidate); + var activeVideo = activeCandidate.Single(mapping => mapping.VirtualPath == scenario.CanonicalPath); + var activeSubtitle = activeCandidate.Single(mapping => + mapping.VirtualPath == scenario.CanonicalSubtitlePath); + Assert.AreEqual("/store/new.mkv", activeVideo.PhysicalPath); + Assert.AreEqual("/store/new.en.srt", activeSubtitle.PhysicalPath); + var activeProgress = await Fixture.GetPlaybackProgressesAsync( + scenario.UserId, + CancellationToken.None); + Assert.HasCount(1, activeProgress); + Assert.AreEqual(scenario.Candidate.CandidateReleaseId, activeProgress[0].AnimationInfoId); + Assert.AreEqual(scenario.CanonicalPath, activeProgress[0].VirtualPath); + Assert.AreEqual(321d, activeProgress[0].PositionSeconds); + var lateFailure = await Fixture.MarkUpgradeFailedAsync(operation.Id, CancellationToken.None); + Assert.IsFalse(lateFailure.IsSuccess); + Assert.AreEqual("invalid_state", lateFailure.Outcome); + Assert.AreEqual(ReleaseUpgradeStatus.Applied, lateFailure.Operation!.Status); var rolledBack = await Fixture.RollbackUpgradeAsync(operation.Id, CancellationToken.None); Assert.IsTrue(rolledBack.IsSuccess); Assert.AreEqual(ReleaseUpgradeStatus.RolledBack, rolledBack.Operation!.Status); var restored = await Fixture.GetMappingsAsync( scenario.Candidate.CurrentReleaseId, CancellationToken.None); - Assert.HasCount(1, restored); - Assert.AreEqual(scenario.CanonicalPath, restored[0].VirtualPath); + Assert.HasCount(2, restored); + CollectionAssert.Contains( + restored.Select(mapping => mapping.VirtualPath).ToArray(), + scenario.CanonicalPath); + CollectionAssert.Contains( + restored.Select(mapping => mapping.VirtualPath).ToArray(), + scenario.CanonicalSubtitlePath); Assert.IsEmpty(await Fixture.GetMappingsAsync( scenario.Candidate.CandidateReleaseId, CancellationToken.None)); + var restoredProgress = await Fixture.GetPlaybackProgressesAsync( + scenario.UserId, + CancellationToken.None); + Assert.HasCount(1, restoredProgress); + Assert.AreEqual(scenario.Candidate.CurrentReleaseId, restoredProgress[0].AnimationInfoId); + Assert.AreEqual(scenario.CanonicalPath, restoredProgress[0].VirtualPath); + Assert.AreEqual(321d, restoredProgress[0].PositionSeconds); + } + + [TestMethod] + public async Task IdentifiedAlternatives_KeepExactlyOneActiveRelease() + { + var activities = await Fixture.IdentifyCompetingReleasesAsync(CancellationToken.None); + + Assert.IsTrue(activities.FirstActive); + Assert.IsFalse(activities.SecondActive); + } + + [TestMethod] + public async Task MovingActiveRelease_PromotesPreviousEpisodeSuccessor() + { + var activities = await Fixture.IdentifyCompetingReleasesAsync( + CancellationToken.None, + moveFirst: true); + + Assert.IsTrue(activities.FirstActive); + Assert.AreEqual(2, activities.FirstEpisode); + Assert.IsTrue(activities.SecondActive); + } + + [TestMethod] + public async Task DeidentifyingActiveRelease_PromotesPreviousEpisodeSuccessor() + { + var activities = await Fixture.IdentifyCompetingReleasesAsync( + CancellationToken.None, + deidentifyFirst: true); + + Assert.IsFalse(activities.FirstActive); + Assert.IsTrue(activities.SecondActive); + } + + [TestMethod] + public async Task ConcurrentIdentification_ActivatesExactlyOneRelease() + { + var activities = await Fixture.IdentifyCompetingReleasesAsync( + CancellationToken.None, + concurrent: true); + + Assert.AreEqual(1, new[] { activities.FirstActive, activities.SecondActive }.Count(active => active)); + } + + [TestMethod] + public async Task ReleaseUpgrade_RejectsMetadataDriftAfterOperationBegins() + { + var scenario = await Fixture.SeedUpgradeScenarioAsync(CancellationToken.None); + var operation = await Fixture.BeginUpgradeAsync(scenario.Candidate, CancellationToken.None); + Assert.IsNotNull(operation); + await Fixture.ChangeReleaseEpisodeAsync( + scenario.Candidate.CandidateReleaseId, + 2, + CancellationToken.None); + + var applied = await Fixture.ActivateUpgradeAsync(operation.Id, CancellationToken.None); + + Assert.IsFalse(applied.IsSuccess); + Assert.AreEqual("release_changed", applied.Outcome); + Assert.HasCount(2, await Fixture.GetMappingsAsync( + scenario.Candidate.CurrentReleaseId, CancellationToken.None)); + Assert.HasCount(2, await Fixture.GetMappingsAsync( + scenario.Candidate.CandidateReleaseId, CancellationToken.None)); + } + + [TestMethod] + public async Task ReleaseUpgrade_RollbackRejectsInterveningMappingDrift() + { + var scenario = await Fixture.SeedUpgradeScenarioAsync(CancellationToken.None); + var operation = await Fixture.BeginUpgradeAsync(scenario.Candidate, CancellationToken.None); + Assert.IsNotNull(operation); + var applied = await Fixture.ActivateUpgradeAsync(operation.Id, CancellationToken.None); + Assert.IsTrue(applied.IsSuccess); + var remappedPath = "/Upgrade Show/Reviewed/Upgrade Show S01E01.MKV"; + await Fixture.RemapCandidatePlaybackAsync( + scenario.Candidate.CandidateReleaseId, + scenario.UserId, + scenario.CanonicalPath, + remappedPath, + CancellationToken.None); + + var rolledBack = await Fixture.RollbackUpgradeAsync(operation.Id, CancellationToken.None); + + Assert.IsFalse(rolledBack.IsSuccess); + Assert.AreEqual("mapping_changed", rolledBack.Outcome); + var candidateMappings = await Fixture.GetMappingsAsync( + scenario.Candidate.CandidateReleaseId, CancellationToken.None); + CollectionAssert.Contains( + candidateMappings.Select(mapping => mapping.VirtualPath).ToArray(), + remappedPath); + var progress = await Fixture.GetPlaybackProgressesAsync( + scenario.UserId, + CancellationToken.None); + Assert.HasCount(1, progress); + Assert.AreEqual(scenario.Candidate.CandidateReleaseId, progress[0].AnimationInfoId); + Assert.AreEqual(remappedPath, progress[0].VirtualPath); + } + + [TestMethod] + public async Task ReleaseUpgrade_RejectsMappingsChangedAfterFileValidation() + { + var scenario = await Fixture.SeedUpgradeScenarioAsync(CancellationToken.None); + var operation = await Fixture.BeginUpgradeAsync(scenario.Candidate, CancellationToken.None); + Assert.IsNotNull(operation); + var expected = await Fixture.GetUpgradeActivationAsync( + scenario.Candidate.CandidateReleaseId, + CancellationToken.None); + Assert.IsNotNull(expected); + var candidateVideo = expected.CandidateMappings.Single(mapping => + mapping.PhysicalPath == "/store/new.mkv"); + await Fixture.ChangeMappingPhysicalPathAsync( + scenario.Candidate.CandidateReleaseId, + candidateVideo.VirtualPath, + "/store/unvalidated.mkv", + CancellationToken.None); + + var applied = await Fixture.ActivateUpgradeAsync( + operation.Id, + expected, + CancellationToken.None); + + Assert.IsFalse(applied.IsSuccess); + Assert.AreEqual("mapping_changed", applied.Outcome); + Assert.HasCount(2, await Fixture.GetMappingsAsync( + scenario.Candidate.CurrentReleaseId, CancellationToken.None)); + var candidateMappings = await Fixture.GetMappingsAsync( + scenario.Candidate.CandidateReleaseId, CancellationToken.None); + CollectionAssert.Contains( + candidateMappings.Select(mapping => mapping.PhysicalPath).ToArray(), + "/store/unvalidated.mkv"); + } + + [TestMethod] + public async Task ReleaseUpgrade_DuplicateFileRolesRollbackDeterministically() + { + var scenario = await Fixture.SeedUpgradeScenarioAsync( + CancellationToken.None, + includeDuplicateVideoRole: true); + var operation = await Fixture.BeginUpgradeAsync(scenario.Candidate, CancellationToken.None); + Assert.IsNotNull(operation); + + var applied = await Fixture.ActivateUpgradeAsync(operation.Id, CancellationToken.None); + Assert.IsTrue(applied.IsSuccess); + var activeMappings = await Fixture.GetMappingsAsync( + scenario.Candidate.CandidateReleaseId, + CancellationToken.None); + Assert.AreEqual( + "/store/new.mkv", + activeMappings.Single(mapping => mapping.VirtualPath == scenario.CanonicalPath).PhysicalPath); + + var rolledBack = await Fixture.RollbackUpgradeAsync(operation.Id, CancellationToken.None); + Assert.IsTrue(rolledBack.IsSuccess); + Assert.HasCount(2, await Fixture.GetMappingsAsync( + scenario.Candidate.CurrentReleaseId, + CancellationToken.None)); + Assert.IsEmpty(await Fixture.GetMappingsAsync( + scenario.Candidate.CandidateReleaseId, + CancellationToken.None)); + } + + [TestMethod] + public async Task FailedReleaseUpgrade_CanBeClaimedAgain() + { + var scenario = await Fixture.SeedUpgradeScenarioAsync(CancellationToken.None); + var first = await Fixture.BeginUpgradeAsync(scenario.Candidate, CancellationToken.None); + Assert.IsNotNull(first); + var failed = await Fixture.MarkUpgradeFailedAsync(first.Id, CancellationToken.None); + Assert.IsTrue(failed.IsSuccess); + + var retry = await Fixture.BeginUpgradeAsync(scenario.Candidate, CancellationToken.None); + + Assert.IsNotNull(retry); + Assert.AreNotEqual(first.Id, retry.Id); + } + + [TestMethod] + public async Task CancellingTrackedUpgrade_TerminatesOperationAndAllowsRetry() + { + var scenario = await Fixture.SeedUpgradeScenarioAsync(CancellationToken.None); + var downloadAttemptId = await Fixture.SetCandidateDownloadInProgressAsync( + scenario.Candidate.CandidateReleaseId, + CancellationToken.None); + var first = await Fixture.BeginUpgradeAsync(scenario.Candidate, CancellationToken.None); + Assert.IsNotNull(first); + Assert.AreEqual(ReleaseUpgradeStatus.Downloading, first.Status); + + var cancelled = await Fixture.CancelUpgradeCandidateAsync( + scenario.Candidate.CandidateReleaseId, + downloadAttemptId, + CancellationToken.None); + Assert.IsNotNull(cancelled); + Assert.IsFalse(cancelled.IsDownloadTracked); + var retry = await Fixture.BeginUpgradeAsync(scenario.Candidate, CancellationToken.None); + + Assert.IsNotNull(retry); + Assert.AreNotEqual(first.Id, retry.Id); + } + + [TestMethod] + public async Task UpgradeCancellationIntent_PreventsActivationBeforeRemoteFinalize() + { + var scenario = await Fixture.SeedUpgradeScenarioAsync(CancellationToken.None); + var operation = await Fixture.BeginUpgradeAsync(scenario.Candidate, CancellationToken.None); + Assert.IsNotNull(operation); + Assert.AreEqual(ReleaseUpgradeStatus.Verifying, operation.Status); + var expected = await Fixture.GetUpgradeActivationAsync( + scenario.Candidate.CandidateReleaseId, + CancellationToken.None); + Assert.IsNotNull(expected); + var cancellationAttemptId = Guid.NewGuid(); + + var beganCancellation = await Fixture.BeginUpgradeCandidateCancellationAsync( + scenario.Candidate.CandidateReleaseId, + downloadAttemptId: null, + cancellationAttemptId, + CancellationToken.None); + var applied = await Fixture.ActivateUpgradeAsync( + operation.Id, + expected, + CancellationToken.None); + var finalized = await Fixture.FinalizeUpgradeCandidateCancellationAsync( + scenario.Candidate.CandidateReleaseId, + downloadAttemptId: null, + cancellationAttemptId, + CancellationToken.None); + var persisted = await Fixture.GetUpgradeOperationAsync( + operation.Id, + CancellationToken.None); + + Assert.IsTrue(beganCancellation); + Assert.IsFalse(applied.IsSuccess); + Assert.AreEqual("invalid_state", applied.Outcome); + Assert.IsTrue(finalized); + Assert.AreEqual(ReleaseUpgradeStatus.Failed, persisted.Status); + Assert.IsEmpty(await Fixture.GetMappingsAsync( + scenario.Candidate.CandidateReleaseId, + CancellationToken.None)); + Assert.HasCount(2, await Fixture.GetMappingsAsync( + scenario.Candidate.CurrentReleaseId, + CancellationToken.None)); + } + + [TestMethod] + public async Task AppliedUpgrade_RejectsStaleCancellationBeforeRemoteDeletion() + { + var scenario = await Fixture.SeedUpgradeScenarioAsync(CancellationToken.None); + var operation = await Fixture.BeginUpgradeAsync(scenario.Candidate, CancellationToken.None); + Assert.IsNotNull(operation); + var applied = await Fixture.ActivateUpgradeAsync(operation.Id, CancellationToken.None); + Assert.IsTrue(applied.IsSuccess); + + var beganCancellation = await Fixture.BeginUpgradeCandidateCancellationAsync( + scenario.Candidate.CandidateReleaseId, + downloadAttemptId: null, + cancellationAttemptId: Guid.NewGuid(), + CancellationToken.None); + + Assert.IsFalse(beganCancellation); + Assert.HasCount(2, await Fixture.GetMappingsAsync( + scenario.Candidate.CandidateReleaseId, + CancellationToken.None)); + Assert.IsEmpty(await Fixture.GetMappingsAsync( + scenario.Candidate.CurrentReleaseId, + CancellationToken.None)); + } + + [TestMethod] + public async Task ReleaseUpgrade_MergesNewestPlaybackStateAcrossActivationAndRollback() + { + var scenario = await Fixture.SeedUpgradeScenarioAsync( + CancellationToken.None, + includeCandidateProgress: true); + var operation = await Fixture.BeginUpgradeAsync(scenario.Candidate, CancellationToken.None); + Assert.IsNotNull(operation); + + var applied = await Fixture.ActivateUpgradeAsync(operation.Id, CancellationToken.None); + Assert.IsTrue(applied.IsSuccess); + var activeProgress = await Fixture.GetPlaybackProgressesAsync( + scenario.UserId, + CancellationToken.None); + Assert.HasCount(1, activeProgress); + Assert.AreEqual(scenario.Candidate.CandidateReleaseId, activeProgress[0].AnimationInfoId); + Assert.IsTrue(activeProgress[0].IsWatched); + Assert.IsNotNull(activeProgress[0].WatchedAt); + Assert.AreEqual(1200d, activeProgress[0].PositionSeconds); + + var rolledBack = await Fixture.RollbackUpgradeAsync(operation.Id, CancellationToken.None); + Assert.IsTrue(rolledBack.IsSuccess); + var restoredProgress = await Fixture.GetPlaybackProgressesAsync( + scenario.UserId, + CancellationToken.None); + Assert.HasCount(1, restoredProgress); + Assert.AreEqual(scenario.Candidate.CurrentReleaseId, restoredProgress[0].AnimationInfoId); + Assert.IsTrue(restoredProgress[0].IsWatched); + Assert.IsNotNull(restoredProgress[0].WatchedAt); + Assert.AreEqual(1200d, restoredProgress[0].PositionSeconds); } private static FileMapping Mapping(Guid animationInfoId, string virtualPath) => diff --git a/SecondDimensionWatcherReDive.Test/AnimationInfoControllerTests.cs b/SecondDimensionWatcherReDive.Test/AnimationInfoControllerTests.cs index 1cf8e25..f80d5e0 100644 --- a/SecondDimensionWatcherReDive.Test/AnimationInfoControllerTests.cs +++ b/SecondDimensionWatcherReDive.Test/AnimationInfoControllerTests.cs @@ -327,6 +327,7 @@ public async Task CancelDownload_Success_SetsIsDownloadTrackedFalseAndUpdates() id, info.DownloadAttemptId, It.IsAny(), + null, It.Is(token => token.CanBeCanceled && !token.IsCancellationRequested))) .ReturnsAsync(true); @@ -339,6 +340,7 @@ public async Task CancelDownload_Success_SetsIsDownloadTrackedFalseAndUpdates() id, info.DownloadAttemptId, cancellationAttemptId.Value, + null, It.Is(token => token.CanBeCanceled && !token.IsCancellationRequested)), Times.Once); } @@ -370,6 +372,7 @@ public async Task CancelDownload_AutomaticDownload_MarksDispositionCancelled() id, info.DownloadAttemptId, It.IsAny(), + null, It.IsAny())) .ReturnsAsync(true); @@ -380,6 +383,7 @@ public async Task CancelDownload_AutomaticDownload_MarksDispositionCancelled() id, info.DownloadAttemptId, It.IsAny(), + null, It.IsAny()), Times.Once); } @@ -413,6 +417,7 @@ public async Task CancelDownload_PendingCancellation_ReusesIdAndFinalizes() id, info.DownloadAttemptId, cancellationAttemptId, + null, It.IsAny())) .ReturnsAsync(true); @@ -428,6 +433,7 @@ public async Task CancelDownload_PendingCancellation_ReusesIdAndFinalizes() id, info.DownloadAttemptId, cancellationAttemptId, + null, It.IsAny()), Times.Once); } diff --git a/SecondDimensionWatcherReDive.Test/ReleaseUpgradeCoordinatorTests.cs b/SecondDimensionWatcherReDive.Test/ReleaseUpgradeCoordinatorTests.cs index e048ecb..ff75110 100644 --- a/SecondDimensionWatcherReDive.Test/ReleaseUpgradeCoordinatorTests.cs +++ b/SecondDimensionWatcherReDive.Test/ReleaseUpgradeCoordinatorTests.cs @@ -23,7 +23,8 @@ public async Task CandidateValidationFailure_DoesNotInvokeAtomicSwap_AndRecordsF Assert.IsNotNull(result); Assert.IsFalse(result.IsSuccess); fixture.UpgradeRepository.Verify(repository => repository.ActivateAsync( - It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny>(), + It.IsAny>(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); fixture.UpgradeRepository.Verify(repository => repository.MarkFailedAsync( fixture.Operation.Id, @@ -50,26 +51,363 @@ public async Task ValidCandidate_InvokesAtomicSwapOnlyAfterEveryFilePasses() fixture.FileStore.Verify(store => store.FileInfoAsync( "/store/new.mkv", It.IsAny()), Times.Once); fixture.UpgradeRepository.Verify(repository => repository.ActivateAsync( - fixture.Operation.Id, + fixture.Operation.Id, It.IsAny>(), + It.IsAny>(), It.IsAny(), It.Is(until => until > DateTimeOffset.UtcNow.AddHours(71)), It.IsAny()), Times.Once); } + [TestMethod] + public async Task CandidateCompletedDuringClaim_ActivatesWithoutStartingAnotherDownload() + { + var fixture = new CoordinatorFixture( + fileExists: true, + candidateDownloaded: false, + operationStatus: ReleaseUpgradeStatus.Verifying); + + var result = await fixture.Coordinator.ExecuteAsync( + fixture.Candidate, + dryRun: false, + CancellationToken.None); + + Assert.IsTrue(result.IsSuccess); + fixture.AnimationRepository.Verify(repository => repository.TryStartDownloadAsync( + It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task CandidateCompletedBeforeMapping_KeepsVerifyingOperationPending() + { + var fixture = new CoordinatorFixture( + fileExists: true, + candidateDownloaded: false, + operationStatus: ReleaseUpgradeStatus.Verifying, + candidateMapped: false); + + var result = await fixture.Coordinator.ExecuteAsync( + fixture.Candidate, + dryRun: false, + CancellationToken.None); + + Assert.IsTrue(result.IsSuccess); + Assert.AreEqual("mapping_pending", result.Outcome); + fixture.UpgradeRepository.Verify(repository => repository.MarkFailedAsync( + It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ExistingTrackedCandidate_IsFollowedWithoutReplacingItsAttempt() + { + var fixture = new CoordinatorFixture( + fileExists: true, + candidateDownloaded: false, + candidateTracked: true); + + var result = await fixture.Coordinator.ExecuteAsync( + fixture.Candidate, + dryRun: false, + CancellationToken.None); + + Assert.IsTrue(result.IsSuccess); + Assert.AreEqual("download_in_progress", result.Outcome); + fixture.AnimationRepository.Verify(repository => repository.TryStartDownloadAsync( + It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny()), Times.Never); + fixture.UpgradeRepository.Verify(repository => repository.MarkFailedAsync( + It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task DownloadClientResolutionFailure_RestoresClaimedLocalState() + { + var fixture = new CoordinatorFixture(fileExists: true, candidateDownloaded: false); + fixture.DownloadClientProvider + .Setup(provider => provider.GetRequiredClient(fixture.CandidateInfo.DownloadType)) + .Throws(new InvalidOperationException("client unavailable")); + + var result = await fixture.Coordinator.ExecuteAsync( + fixture.Candidate, + dryRun: false, + CancellationToken.None); + + Assert.IsFalse(result.IsSuccess); + fixture.AnimationRepository.Verify(repository => repository.TryBeginCancelDownloadAsync( + fixture.CandidateInfo.Id, + It.IsAny(), + It.IsAny(), + It.IsAny()), Times.Once); + fixture.FileMappingRepository.Verify(repository => repository.TryFinalizeDownloadCancellationAsync( + fixture.CandidateInfo.Id, + It.IsAny(), + It.IsAny(), + SubscriptionAutomationDisposition.AutoDownloadFailed, + It.IsAny()), Times.Once); + fixture.DownloadClient.Verify(client => client.CancelDownloadTaskAsync( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny()), Times.Never); + fixture.UpgradeRepository.Verify(repository => repository.MarkFailedAsync( + fixture.Operation.Id, + It.IsAny(), + It.IsAny()), Times.Once); + } + + [TestMethod] + public async Task DownloadSubmissionFailure_CancelsRemoteThenRestoresLocalState() + { + var fixture = new CoordinatorFixture(fileExists: true, candidateDownloaded: false); + var cancellationRegistered = false; + var remoteCancelled = false; + fixture.AnimationRepository.Setup(repository => repository.TryBeginCancelDownloadAsync( + fixture.CandidateInfo.Id, + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Callback(() => cancellationRegistered = true) + .ReturnsAsync(true); + fixture.DownloadClient.Setup(client => client.SubmitDownloadTaskAsync( + fixture.CandidateInfo.Id, + fixture.CandidateInfo.DownloadUrl, + fixture.CandidateInfo.CachedDownloadData, + fixture.CandidateInfo.AdditionalDownloadInfo, + It.IsAny())) + .ThrowsAsync(new IOException("submission interrupted")); + fixture.DownloadClient.Setup(client => client.CancelDownloadTaskAsync( + fixture.CandidateInfo.Id, + fixture.CandidateInfo.DownloadUrl, + fixture.CandidateInfo.CachedDownloadData, + fixture.CandidateInfo.AdditionalDownloadInfo, + false, + It.IsAny())) + .Callback(() => + { + Assert.IsTrue(cancellationRegistered); + remoteCancelled = true; + }) + .ReturnsAsync(new CancelDownloadResult(true, false)); + fixture.FileMappingRepository.Setup(repository => repository.TryFinalizeDownloadCancellationAsync( + fixture.CandidateInfo.Id, + It.IsAny(), + It.IsAny(), + SubscriptionAutomationDisposition.AutoDownloadFailed, + It.IsAny())) + .Callback(() => Assert.IsTrue(remoteCancelled)) + .ReturnsAsync(true); + + var result = await fixture.Coordinator.ExecuteAsync( + fixture.Candidate, + dryRun: false, + CancellationToken.None); + + Assert.IsFalse(result.IsSuccess); + fixture.DownloadClient.Verify(client => client.CancelDownloadTaskAsync( + fixture.CandidateInfo.Id, + fixture.CandidateInfo.DownloadUrl, + fixture.CandidateInfo.CachedDownloadData, + fixture.CandidateInfo.AdditionalDownloadInfo, + false, + It.IsAny()), Times.Once); + fixture.AnimationRepository.Verify(repository => repository.TryBeginCancelDownloadAsync( + fixture.CandidateInfo.Id, + It.IsAny(), + It.IsAny(), + It.IsAny()), Times.Once); + fixture.FileMappingRepository.Verify(repository => repository.TryFinalizeDownloadCancellationAsync( + fixture.CandidateInfo.Id, + It.IsAny(), + It.IsAny(), + SubscriptionAutomationDisposition.AutoDownloadFailed, + It.IsAny()), Times.Once); + fixture.UpgradeRepository.Verify(repository => repository.MarkFailedAsync( + fixture.Operation.Id, + It.IsAny(), + It.IsAny()), Times.Once); + } + + [TestMethod] + public async Task UnconfirmedRemoteCancellation_LeavesOperationRecoverable() + { + var fixture = new CoordinatorFixture(fileExists: true, candidateDownloaded: false); + fixture.DownloadClient.Setup(client => client.SubmitDownloadTaskAsync( + fixture.CandidateInfo.Id, + fixture.CandidateInfo.DownloadUrl, + fixture.CandidateInfo.CachedDownloadData, + fixture.CandidateInfo.AdditionalDownloadInfo, + It.IsAny())) + .ThrowsAsync(new IOException("submission outcome unknown")); + fixture.DownloadClient.Setup(client => client.CancelDownloadTaskAsync( + fixture.CandidateInfo.Id, + fixture.CandidateInfo.DownloadUrl, + fixture.CandidateInfo.CachedDownloadData, + fixture.CandidateInfo.AdditionalDownloadInfo, + false, + It.IsAny())) + .ReturnsAsync(new CancelDownloadResult(false, false)); + + var result = await fixture.Coordinator.ExecuteAsync( + fixture.Candidate, + dryRun: false, + CancellationToken.None); + + Assert.IsFalse(result.IsSuccess); + Assert.AreEqual("recovery_pending", result.Outcome); + Assert.IsTrue(result.RequiresDownload); + fixture.AnimationRepository.Verify(repository => repository.TryBeginCancelDownloadAsync( + fixture.CandidateInfo.Id, It.IsAny(), It.IsAny(), + It.IsAny()), Times.Once); + fixture.FileMappingRepository.Verify(repository => repository.TryFinalizeDownloadCancellationAsync( + It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny()), Times.Never); + fixture.UpgradeRepository.Verify(repository => repository.MarkFailedAsync( + It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task LocalCancellationConflict_LeavesOperationRecoverable() + { + var fixture = new CoordinatorFixture(fileExists: true, candidateDownloaded: false); + fixture.DownloadClientProvider + .Setup(provider => provider.GetRequiredClient(fixture.CandidateInfo.DownloadType)) + .Throws(new InvalidOperationException("client unavailable")); + fixture.AnimationRepository.Setup(repository => repository.TryBeginCancelDownloadAsync( + fixture.CandidateInfo.Id, + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(false); + fixture.AnimationRepository.SetupSequence(repository => repository.FindByIdAsync( + fixture.CandidateInfo.Id, + It.IsAny())) + .ReturnsAsync(fixture.CandidateInfo) + .ReturnsAsync(fixture.CandidateInfo) + .ReturnsAsync(fixture.CandidateInfo with { IsDownloadTracked = true }); + + var result = await fixture.Coordinator.ExecuteAsync( + fixture.Candidate, + dryRun: false, + CancellationToken.None); + + Assert.AreEqual("recovery_pending", result.Outcome); + fixture.UpgradeRepository.Verify(repository => repository.MarkFailedAsync( + It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ActivationWinningBeforeCompensation_DoesNotDeleteRemoteCandidate() + { + var fixture = new CoordinatorFixture(fileExists: true, candidateDownloaded: false); + fixture.DownloadClient.Setup(client => client.SubmitDownloadTaskAsync( + fixture.CandidateInfo.Id, + fixture.CandidateInfo.DownloadUrl, + fixture.CandidateInfo.CachedDownloadData, + fixture.CandidateInfo.AdditionalDownloadInfo, + It.IsAny())) + .ThrowsAsync(new IOException("submission outcome unknown")); + fixture.AnimationRepository.Setup(repository => repository.TryBeginCancelDownloadAsync( + fixture.CandidateInfo.Id, + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(false); + fixture.AnimationRepository.SetupSequence(repository => repository.FindByIdAsync( + fixture.CandidateInfo.Id, + It.IsAny())) + .ReturnsAsync(fixture.CandidateInfo) + .ReturnsAsync(fixture.CandidateInfo) + .ReturnsAsync(fixture.CandidateInfo with { IsDownloadTracked = true }); + + var result = await fixture.Coordinator.ExecuteAsync( + fixture.Candidate, + dryRun: false, + CancellationToken.None); + + Assert.IsFalse(result.IsSuccess); + Assert.AreEqual("recovery_pending", result.Outcome); + fixture.DownloadClient.Verify(client => client.CancelDownloadTaskAsync( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny()), Times.Never); + fixture.FileMappingRepository.Verify(repository => repository.TryFinalizeDownloadCancellationAsync( + It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task RequestCancellation_AfterCompensationTerminatesOperation() + { + var fixture = new CoordinatorFixture(fileExists: true, candidateDownloaded: false); + fixture.DownloadClient.Setup(client => client.SubmitDownloadTaskAsync( + fixture.CandidateInfo.Id, + fixture.CandidateInfo.DownloadUrl, + fixture.CandidateInfo.CachedDownloadData, + fixture.CandidateInfo.AdditionalDownloadInfo, + It.IsAny())) + .ThrowsAsync(new OperationCanceledException()); + using var cancellation = new CancellationTokenSource(); + cancellation.Cancel(); + + await Assert.ThrowsExactlyAsync(() => + fixture.Coordinator.ExecuteAsync( + fixture.Candidate, + dryRun: false, + cancellation.Token)); + + fixture.AnimationRepository.Verify(repository => repository.TryBeginCancelDownloadAsync( + fixture.CandidateInfo.Id, + It.IsAny(), + It.IsAny(), + It.IsAny()), Times.Once); + fixture.FileMappingRepository.Verify(repository => repository.TryFinalizeDownloadCancellationAsync( + fixture.CandidateInfo.Id, + It.IsAny(), + It.IsAny(), + SubscriptionAutomationDisposition.AutoDownloadFailed, + It.IsAny()), Times.Once); + fixture.UpgradeRepository.Verify(repository => repository.MarkFailedAsync( + fixture.Operation.Id, + It.IsAny(), + It.IsAny()), Times.Once); + } + private sealed class CoordinatorFixture { public Mock UpgradeRepository { get; } = new(); + public Mock AnimationRepository { get; } = new(); + public Mock FileMappingRepository { get; } = new(); + public Mock DownloadClient { get; } = new(); + public Mock DownloadClientProvider { get; } = new(); public Mock FileStore { get; } = new(); public Mock IncidentReporter { get; } = new(); public ReleaseUpgradeOperation Operation { get; } + public ReleaseUpgradeCandidate Candidate { get; } + public AnimationInfo CandidateInfo { get; } public IReleaseUpgradeCoordinator Coordinator { get; } - public CoordinatorFixture(bool fileExists) + public CoordinatorFixture( + bool fileExists, + bool candidateDownloaded = true, + bool? candidateTracked = null, + ReleaseUpgradeStatus? operationStatus = null, + bool candidateMapped = true) { + var isCandidateTracked = candidateTracked ?? candidateDownloaded; Operation = new ReleaseUpgradeOperation( Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid(), - ReleaseUpgradeStatus.Verifying, 200, 500, + operationStatus ?? (candidateDownloaded + ? ReleaseUpgradeStatus.Verifying + : ReleaseUpgradeStatus.Downloading), + 200, 500, DateTimeOffset.UtcNow, null, null, null, null, null); + Candidate = new ReleaseUpgradeCandidate( + Operation.CurrentReleaseId, + Operation.CandidateReleaseId, + "candidate show", + 1, + 1, + 200, + 500, + [], + true); var previous = new FileMapping( Guid.NewGuid(), Operation.CurrentReleaseId, "/show/e01.mkv", "/store/old.mkv", "local"); var candidate = new FileMapping( @@ -79,15 +417,25 @@ public CoordinatorFixture(bool fileExists) .ReturnsAsync(Operation); UpgradeRepository.Setup(repository => repository.GetActivationAsync( Operation.CandidateReleaseId, It.IsAny())) - .ReturnsAsync(new ReleaseUpgradeActivation(Operation, [previous], [candidate])); + .ReturnsAsync(new ReleaseUpgradeActivation( + Operation, + [previous], + candidateMapped ? [candidate] : [])); UpgradeRepository.Setup(repository => repository.MarkFailedAsync( Operation.Id, It.IsAny(), It.IsAny())) .ReturnsAsync(new ReleaseUpgradeMutationResult(true, "failed", Operation)); UpgradeRepository.Setup(repository => repository.ActivateAsync( - Operation.Id, It.IsAny(), It.IsAny(), + Operation.Id, It.IsAny>(), + It.IsAny>(), + It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(new ReleaseUpgradeMutationResult(true, "applied", Operation with { Status = ReleaseUpgradeStatus.Applied })); + UpgradeRepository.Setup(repository => repository.TryBeginAsync( + Candidate, + It.IsAny(), + It.IsAny())) + .ReturnsAsync(Operation); FileStore.Setup(store => store.ExistAsync( candidate.PhysicalPath, It.IsAny())) @@ -99,21 +447,62 @@ public CoordinatorFixture(bool fileExists) storeProvider.Setup(provider => provider.GetRequiredClient("local")) .Returns(FileStore.Object); - var animationRepository = new Mock(); - animationRepository.Setup(repository => repository.FindByIdAsync( + CandidateInfo = new AnimationInfo( + Operation.CandidateReleaseId, "candidate", "", DateTimeOffset.UtcNow, + "https://example.test/new", FileDownloadTypes.TorrentDownload, [], "", + isCandidateTracked, default, default, candidateDownloaded, + candidateDownloaded ? "local" : null, + candidateDownloaded ? "/store/new" : null, + 1, 1, null, null, true, 0); + AnimationRepository.Setup(repository => repository.FindByIdAsync( Operation.CandidateReleaseId, It.IsAny())) - .ReturnsAsync(new AnimationInfo( - Operation.CandidateReleaseId, "candidate", "", DateTimeOffset.UtcNow, - "https://example.test/new", FileDownloadTypes.TorrentDownload, [], "", - true, default, default, true, "local", "/store/new", 1, 1, - null, null, true, 0)); + .ReturnsAsync(CandidateInfo); + AnimationRepository.Setup(repository => repository.TryStartDownloadAsync( + CandidateInfo.Id, + It.IsAny(), + It.IsAny(), + SubscriptionAutomationDisposition.AutoDownloadQueued, + It.IsAny())) + .ReturnsAsync(true); + AnimationRepository.Setup(repository => repository.TryBeginCancelDownloadAsync( + CandidateInfo.Id, + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(true); + FileMappingRepository.Setup(repository => repository.TryFinalizeDownloadCancellationAsync( + CandidateInfo.Id, + It.IsAny(), + It.IsAny(), + SubscriptionAutomationDisposition.AutoDownloadFailed, + It.IsAny())) + .ReturnsAsync(true); + + DownloadClientProvider + .Setup(provider => provider.GetRequiredClient(CandidateInfo.DownloadType)) + .Returns(DownloadClient.Object); + DownloadClient.Setup(client => client.SubmitDownloadTaskAsync( + CandidateInfo.Id, + CandidateInfo.DownloadUrl, + CandidateInfo.CachedDownloadData, + CandidateInfo.AdditionalDownloadInfo, + It.IsAny())) + .ReturnsAsync(true); + DownloadClient.Setup(client => client.CancelDownloadTaskAsync( + CandidateInfo.Id, + CandidateInfo.DownloadUrl, + CandidateInfo.CachedDownloadData, + CandidateInfo.AdditionalDownloadInfo, + false, + It.IsAny())) + .ReturnsAsync(new CancelDownloadResult(true, false)); Coordinator = new ReleaseUpgradeCoordinator( UpgradeRepository.Object, - animationRepository.Object, + AnimationRepository.Object, Mock.Of(), - Mock.Of(), - Mock.Of(), + FileMappingRepository.Object, + DownloadClientProvider.Object, storeProvider.Object, IncidentReporter.Object, Mock.Of>()); diff --git a/SecondDimensionWatcherReDive/Controllers/AnimationInfoController.cs b/SecondDimensionWatcherReDive/Controllers/AnimationInfoController.cs index 8e3ee5b..d601896 100644 --- a/SecondDimensionWatcherReDive/Controllers/AnimationInfoController.cs +++ b/SecondDimensionWatcherReDive/Controllers/AnimationInfoController.cs @@ -230,6 +230,7 @@ public async Task CancelDownload([FromRoute] Guid id, [FromQuery] id, info.DownloadAttemptId, cancellationAttemptId, + terminalDisposition: null, finalizeCancellation.Token); if (!cancelled) return Conflict(); diff --git a/SecondDimensionWatcherReDive/Migrations/20260830030724_EnforceSingleActiveEpisodeRelease.Designer.cs b/SecondDimensionWatcherReDive/Migrations/20260830030724_EnforceSingleActiveEpisodeRelease.Designer.cs new file mode 100644 index 0000000..24c1773 --- /dev/null +++ b/SecondDimensionWatcherReDive/Migrations/20260830030724_EnforceSingleActiveEpisodeRelease.Designer.cs @@ -0,0 +1,1199 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using SecondDimensionWatcherReDive.Models; + +#nullable disable + +namespace SecondDimensionWatcherReDive.Migrations +{ + [DbContext(typeof(ApplicationContext))] + [Migration("20260830030724_EnforceSingleActiveEpisodeRelease")] + partial class EnforceSingleActiveEpisodeRelease + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.11") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.Animation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("OriginalName") + .IsRequired() + .HasColumnType("text"); + + b.Property("PosterPath") + .HasColumnType("text"); + + b.Property("TmdbId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("TmdbId") + .IsUnique(); + + b.ToTable("Animations"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.AnimationGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("AnimationGroups"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.AnimationInfo", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AdditionalDownloadInfo") + .IsRequired() + .HasColumnType("text"); + + b.Property("AiRetryCount") + .HasColumnType("integer"); + + b.Property("AnimationId") + .HasColumnType("uuid"); + + b.Property("AutomationDisposition") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("AutomationExplanationJson") + .HasColumnType("text"); + + b.Property("CachedDownloadData") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("CurrentMetadataReviewOperationId") + .HasColumnType("uuid"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("DownloadAttemptId") + .HasColumnType("uuid"); + + b.Property("DownloadCancellationId") + .HasColumnType("uuid"); + + b.Property("DownloadEndTime") + .HasColumnType("timestamp with time zone"); + + b.Property("DownloadStartTime") + .HasColumnType("timestamp with time zone"); + + b.Property("DownloadType") + .IsRequired() + .HasColumnType("text"); + + b.Property("DownloadUrl") + .IsRequired() + .HasColumnType("text"); + + b.Property("EnclosureId") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("Episode") + .HasColumnType("integer"); + + b.Property("ExpectedEpisodeCount") + .HasColumnType("integer"); + + b.Property("FeedItemGuid") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("FileStore") + .HasColumnType("text"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("IngestedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("IsActiveRelease") + .HasColumnType("boolean"); + + b.Property("IsAiProcessed") + .HasColumnType("boolean"); + + b.Property("IsDownloadFinished") + .HasColumnType("boolean"); + + b.Property("IsDownloadTracked") + .HasColumnType("boolean"); + + b.Property("MediaLibraryMissingSince") + .HasColumnType("timestamp with time zone"); + + b.Property("MediaLibrarySourceId") + .HasColumnType("uuid"); + + b.Property("MetadataConfidence") + .HasColumnType("double precision"); + + b.Property("MetadataLastError") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("MetadataReviewedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("MetadataStatus") + .HasColumnType("integer"); + + b.Property("PublishTime") + .HasColumnType("timestamp with time zone"); + + b.Property("ReleaseCodec") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("ReleaseIdentity") + .HasMaxLength(192) + .HasColumnType("character varying(192)"); + + b.PrimitiveCollection("ReleaseLanguages") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("ReleaseResolution") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("ReleaseScore") + .HasColumnType("integer"); + + b.Property("ReleaseScoreReasonsJson") + .HasColumnType("text"); + + b.Property("ReleaseSizeBytes") + .HasColumnType("bigint"); + + b.Property("ReleaseSubtitleGroup") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Season") + .HasColumnType("integer"); + + b.Property("SourceFeedId") + .HasColumnType("uuid"); + + b.Property("StateVersion") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.Property("StorePath") + .HasColumnType("text"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.Property("TorrentInfoHash") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.HasKey("Id"); + + b.HasIndex("CurrentMetadataReviewOperationId") + .IsUnique(); + + b.HasIndex("GroupId"); + + b.HasIndex("MediaLibrarySourceId"); + + b.HasIndex("ReleaseIdentity") + .IsUnique() + .HasDatabaseName("UX_AnimationInfo_ReleaseIdentity") + .HasFilter("\"ReleaseIdentity\" IS NOT NULL"); + + b.HasIndex("SourceFeedId"); + + b.HasIndex("AnimationId"); + + b.HasIndex("FileStore", "StorePath") + .IsUnique() + .HasFilter("\"DownloadType\" = 'http://schemas.hcgstudio.com/ws/2023/06/sdw/downloadtype/media-library-import'"); + + b.HasIndex("MetadataStatus", "PublishTime"); + + b.HasIndex("AnimationId", "Season", "Episode") + .IsUnique() + .HasDatabaseName("UX_AnimationInfo_ActiveEpisodeRelease") + .HasFilter("\"IsActiveRelease\" = TRUE AND \"AnimationId\" IS NOT NULL AND \"Season\" IS NOT NULL AND \"Episode\" IS NOT NULL"); + + b.HasIndex("Season", "Episode", "ReleaseScore"); + + b.ToTable("AnimationInfo", t => + { + t.HasCheckConstraint("CK_AnimationInfo_ExpectedEpisodeCount_Positive", "\"ExpectedEpisodeCount\" IS NULL OR \"ExpectedEpisodeCount\" > 0"); + + t.HasCheckConstraint("CK_AnimationInfo_MetadataConfidence_Range", "\"MetadataConfidence\" IS NULL OR (\"MetadataConfidence\" >= 0 AND \"MetadataConfidence\" <= 1)"); + + t.HasCheckConstraint("CK_AnimationInfo_ReleaseScore_NonNegative", "\"ReleaseScore\" >= 0"); + }); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.ApplicationSettings", b => + { + b.Property("Id") + .HasColumnType("integer"); + + b.Property("ProtectedSecrets") + .HasColumnType("text"); + + b.Property("Revision") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ValuesJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.HasKey("Id"); + + b.ToTable("ApplicationSettings", t => + { + t.HasCheckConstraint("CK_ApplicationSettings_Revision_Positive", "\"Revision\" > 0"); + + t.HasCheckConstraint("CK_ApplicationSettings_Singleton", "\"Id\" = 1"); + }); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.BangumiSubgroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("MikanSubgroupId") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("ScrapedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SeasonBangumiId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("SeasonBangumiId", "MikanSubgroupId") + .IsUnique(); + + b.ToTable("BangumiSubgroups"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.ChatConversation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Title") + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("ChatConversations"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.ChatMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Content") + .HasColumnType("text"); + + b.Property("ConversationId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Order") + .HasColumnType("integer"); + + b.Property("Role") + .IsRequired() + .HasColumnType("text"); + + b.Property("ToolCallId") + .HasColumnType("text"); + + b.Property("ToolCallsJson") + .HasColumnType("text"); + + b.Property("ToolName") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ConversationId"); + + b.ToTable("ChatMessages"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.Feed", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Url") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Feeds"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.FileMapping", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AnimationInfoId") + .HasColumnType("uuid"); + + b.Property("FileStore") + .IsRequired() + .HasColumnType("text"); + + b.Property("PhysicalPath") + .IsRequired() + .HasColumnType("text"); + + b.Property("VirtualPath") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("AnimationInfoId"); + + b.HasIndex("VirtualPath") + .IsUnique(); + + b.ToTable("FileMappings"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.FileNameRegexRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AnimationId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Pattern") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.HasKey("Id"); + + b.HasIndex("AnimationId", "CreatedAt"); + + b.HasIndex("AnimationId", "Pattern") + .IsUnique(); + + b.ToTable("FileNameRegexRules"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.Incident", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Detail") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("DetectedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Fingerprint") + .IsRequired() + .HasMaxLength(96) + .HasColumnType("character varying(96)"); + + b.Property("LastRetryAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastRetryError") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("ResolvedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RetryCount") + .HasColumnType("integer"); + + b.Property("Severity") + .HasColumnType("integer"); + + b.Property("SourceId") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("Fingerprint") + .IsUnique(); + + b.HasIndex("ResolvedAt", "Type", "UpdatedAt"); + + b.ToTable("Incidents"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.MediaLibrarySource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsMonitoring") + .HasColumnType("boolean"); + + b.Property("LastError") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("LastImportedCount") + .HasColumnType("integer"); + + b.Property("LastRemovedCount") + .HasColumnType("integer"); + + b.Property("LastScanAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSkippedCount") + .HasColumnType("integer"); + + b.Property("LastUpdatedCount") + .HasColumnType("integer"); + + b.Property("Path") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Path") + .IsUnique(); + + b.ToTable("MediaLibrarySources"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.MetadataReviewMappingSnapshot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("FileStore") + .IsRequired() + .HasColumnType("text"); + + b.Property("Kind") + .HasColumnType("integer"); + + b.Property("OperationId") + .HasColumnType("uuid"); + + b.Property("PhysicalPath") + .IsRequired() + .HasColumnType("text"); + + b.Property("VirtualPath") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("OperationId", "Kind", "VirtualPath") + .IsUnique(); + + b.ToTable("MetadataReviewMappingSnapshots"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.MetadataReviewOperation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AnimationInfoId") + .HasColumnType("uuid"); + + b.Property("AppliedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("AppliedVersion") + .HasColumnType("bigint"); + + b.Property("BaseFileStore") + .HasColumnType("text"); + + b.Property("BaseIsDownloadFinished") + .HasColumnType("boolean"); + + b.Property("BaseStorePath") + .HasColumnType("text"); + + b.Property("BaseVersion") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PreviousAiRetryCount") + .HasColumnType("integer"); + + b.Property("PreviousAnimationId") + .HasColumnType("uuid"); + + b.Property("PreviousConfidence") + .HasColumnType("double precision"); + + b.Property("PreviousCurrentOperationId") + .HasColumnType("uuid"); + + b.Property("PreviousDescription") + .HasColumnType("text"); + + b.Property("PreviousEpisode") + .HasColumnType("integer"); + + b.Property("PreviousGroupId") + .HasColumnType("uuid"); + + b.Property("PreviousIsAiProcessed") + .HasColumnType("boolean"); + + b.Property("PreviousLastError") + .HasColumnType("text"); + + b.Property("PreviousMetadataStatus") + .HasColumnType("integer"); + + b.Property("PreviousReviewedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PreviousSeason") + .HasColumnType("integer"); + + b.Property("ProposedAnimationName") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProposedAnimationOriginalName") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProposedAnimationPosterPath") + .HasColumnType("text"); + + b.Property("ProposedAnimationTmdbId") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProposedDescription") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProposedEpisode") + .HasColumnType("integer"); + + b.Property("ProposedGroupName") + .HasColumnType("text"); + + b.Property("ProposedSeason") + .HasColumnType("integer"); + + b.Property("State") + .HasColumnType("integer"); + + b.Property("UndoneAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("AnimationInfoId", "AppliedVersion") + .IsUnique(); + + b.HasIndex("AnimationInfoId", "State"); + + b.HasIndex("State", "ExpiresAt"); + + b.ToTable("MetadataReviewOperations", t => + { + t.HasCheckConstraint("CK_MetadataReviewOperations_Expiry", "\"ExpiresAt\" > \"CreatedAt\""); + }); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.MigrationMarker", b => + { + b.Property("Key") + .HasColumnType("text"); + + b.Property("AppliedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Key"); + + b.ToTable("MigrationMarkers"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.PlaybackPreference", b => + { + b.Property("UserId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AudioLanguage") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("AudioTrackLabel") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("AutoPlayNext") + .HasColumnType("boolean"); + + b.Property("SubtitleLanguage") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("SubtitleTrackLabel") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("UserId"); + + b.ToTable("PlaybackPreferences"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.PlaybackProgress", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AnimationInfoId") + .HasColumnType("uuid"); + + b.Property("DurationSeconds") + .HasColumnType("double precision"); + + b.Property("IsWatched") + .HasColumnType("boolean"); + + b.Property("PositionSeconds") + .HasColumnType("double precision"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("VirtualPath") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("WatchedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("AnimationInfoId"); + + b.HasIndex("UserId", "AnimationInfoId", "VirtualPath") + .IsUnique(); + + b.HasIndex("UserId", "IsWatched", "UpdatedAt"); + + b.ToTable("PlaybackProgresses", t => + { + t.HasCheckConstraint("CK_PlaybackProgresses_Duration_NonNegative", "\"DurationSeconds\" >= 0"); + + t.HasCheckConstraint("CK_PlaybackProgresses_Position_NonNegative", "\"PositionSeconds\" >= 0"); + }); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.ReleaseUpgradeMappingSnapshot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AnimationInfoId") + .HasColumnType("uuid"); + + b.Property("FileStore") + .IsRequired() + .HasColumnType("text"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("OperationId") + .HasColumnType("uuid"); + + b.Property("OriginalMappingId") + .HasColumnType("uuid"); + + b.Property("PhysicalPath") + .IsRequired() + .HasColumnType("text"); + + b.Property("VirtualPath") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.HasKey("Id"); + + b.HasIndex("OperationId", "Kind", "OriginalMappingId") + .IsUnique(); + + b.ToTable("ReleaseUpgradeMappingSnapshots"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.ReleaseUpgradeOperation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AppliedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CandidateReleaseId") + .HasColumnType("uuid"); + + b.Property("CandidateScore") + .HasColumnType("integer"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrentReleaseId") + .HasColumnType("uuid"); + + b.Property("CurrentScore") + .HasColumnType("integer"); + + b.Property("FailureSummary") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("RollbackUntil") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("VerifiedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CandidateReleaseId") + .IsUnique() + .HasFilter("\"Status\" <> 'Failed'"); + + b.HasIndex("CurrentReleaseId") + .IsUnique() + .HasDatabaseName("UX_ReleaseUpgradeOperations_ActiveCurrentRelease") + .HasFilter("\"Status\" IN ('Downloading', 'Verifying', 'Applied')"); + + b.HasIndex("Status", "CreatedAt"); + + b.ToTable("ReleaseUpgradeOperations", t => + { + t.HasCheckConstraint("CK_ReleaseUpgradeOperations_ScoreIncrease", "\"CandidateScore\" > \"CurrentScore\""); + }); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.SeasonBangumi", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("DayOfWeek") + .HasColumnType("integer"); + + b.Property("ImageUrl") + .HasColumnType("text"); + + b.Property("MikanId") + .HasColumnType("integer"); + + b.Property("ScrapedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("MikanId") + .IsUnique(); + + b.ToTable("SeasonBangumis"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.SubscriptionAutomationPolicy", b => + { + b.Property("FeedId") + .HasColumnType("uuid"); + + b.PrimitiveCollection("Codecs") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EnableVersionUpgrade") + .HasColumnType("boolean"); + + b.PrimitiveCollection("ExcludedKeywords") + .IsRequired() + .HasColumnType("text[]"); + + b.PrimitiveCollection("Languages") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("MaxSizeBytes") + .HasColumnType("bigint"); + + b.Property("MinSizeBytes") + .HasColumnType("bigint"); + + b.Property("MinimumUpgradeScore") + .HasColumnType("integer"); + + b.Property("Mode") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.PrimitiveCollection("Resolutions") + .IsRequired() + .HasColumnType("text[]"); + + b.PrimitiveCollection("SubtitleGroups") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpgradeRollbackHours") + .HasColumnType("integer"); + + b.HasKey("FeedId"); + + b.HasIndex("UpdatedAt"); + + b.ToTable("SubscriptionAutomationPolicies", t => + { + t.HasCheckConstraint("CK_SubscriptionAutomationPolicies_MinimumUpgradeScore", "\"MinimumUpgradeScore\" >= 1 AND \"MinimumUpgradeScore\" <= 1000"); + + t.HasCheckConstraint("CK_SubscriptionAutomationPolicies_UpgradeRollbackHours", "\"UpgradeRollbackHours\" >= 1 AND \"UpgradeRollbackHours\" <= 720"); + }); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.WebDavToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("TokenHash") + .IsRequired() + .HasColumnType("text"); + + b.Property("Username") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("WebDavTokens"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.AnimationInfo", b => + { + b.HasOne("SecondDimensionWatcherReDive.Models.Animation", "Animation") + .WithMany() + .HasForeignKey("AnimationId"); + + b.HasOne("SecondDimensionWatcherReDive.Models.AnimationGroup", "Group") + .WithMany() + .HasForeignKey("GroupId"); + + b.HasOne("SecondDimensionWatcherReDive.Models.MediaLibrarySource", null) + .WithMany() + .HasForeignKey("MediaLibrarySourceId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("SecondDimensionWatcherReDive.Models.Feed", null) + .WithMany() + .HasForeignKey("SourceFeedId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Animation"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.BangumiSubgroup", b => + { + b.HasOne("SecondDimensionWatcherReDive.Models.SeasonBangumi", "SeasonBangumi") + .WithMany("Subgroups") + .HasForeignKey("SeasonBangumiId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SeasonBangumi"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.ChatMessage", b => + { + b.HasOne("SecondDimensionWatcherReDive.Models.ChatConversation", "Conversation") + .WithMany("Messages") + .HasForeignKey("ConversationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Conversation"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.FileNameRegexRule", b => + { + b.HasOne("SecondDimensionWatcherReDive.Models.Animation", null) + .WithMany() + .HasForeignKey("AnimationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.MetadataReviewMappingSnapshot", b => + { + b.HasOne("SecondDimensionWatcherReDive.Models.MetadataReviewOperation", "Operation") + .WithMany("MappingSnapshots") + .HasForeignKey("OperationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Operation"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.MetadataReviewOperation", b => + { + b.HasOne("SecondDimensionWatcherReDive.Models.AnimationInfo", "AnimationInfo") + .WithMany() + .HasForeignKey("AnimationInfoId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AnimationInfo"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.PlaybackProgress", b => + { + b.HasOne("SecondDimensionWatcherReDive.Models.AnimationInfo", "AnimationInfo") + .WithMany() + .HasForeignKey("AnimationInfoId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AnimationInfo"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.ReleaseUpgradeMappingSnapshot", b => + { + b.HasOne("SecondDimensionWatcherReDive.Models.ReleaseUpgradeOperation", "Operation") + .WithMany("MappingSnapshots") + .HasForeignKey("OperationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Operation"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.ReleaseUpgradeOperation", b => + { + b.HasOne("SecondDimensionWatcherReDive.Models.AnimationInfo", "CandidateRelease") + .WithMany() + .HasForeignKey("CandidateReleaseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SecondDimensionWatcherReDive.Models.AnimationInfo", "CurrentRelease") + .WithMany() + .HasForeignKey("CurrentReleaseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("CandidateRelease"); + + b.Navigation("CurrentRelease"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.SubscriptionAutomationPolicy", b => + { + b.HasOne("SecondDimensionWatcherReDive.Models.Feed", "Feed") + .WithOne() + .HasForeignKey("SecondDimensionWatcherReDive.Models.SubscriptionAutomationPolicy", "FeedId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Feed"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.ChatConversation", b => + { + b.Navigation("Messages"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.MetadataReviewOperation", b => + { + b.Navigation("MappingSnapshots"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.ReleaseUpgradeOperation", b => + { + b.Navigation("MappingSnapshots"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.SeasonBangumi", b => + { + b.Navigation("Subgroups"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/SecondDimensionWatcherReDive/Migrations/20260830030724_EnforceSingleActiveEpisodeRelease.cs b/SecondDimensionWatcherReDive/Migrations/20260830030724_EnforceSingleActiveEpisodeRelease.cs new file mode 100644 index 0000000..90005a1 --- /dev/null +++ b/SecondDimensionWatcherReDive/Migrations/20260830030724_EnforceSingleActiveEpisodeRelease.cs @@ -0,0 +1,89 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace SecondDimensionWatcherReDive.Migrations +{ + /// + public partial class EnforceSingleActiveEpisodeRelease : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_ReleaseUpgradeOperations_CandidateReleaseId", + table: "ReleaseUpgradeOperations"); + + migrationBuilder.Sql( + """ + WITH ranked_active_releases AS ( + SELECT info."Id", + row_number() OVER ( + PARTITION BY info."AnimationId", info."Season", info."Episode" + ORDER BY info."IngestedAt", info."PublishTime", info."Id" + ) AS rank + FROM "AnimationInfo" info + WHERE info."IsActiveRelease" = TRUE + AND info."AnimationId" IS NOT NULL + AND info."Season" IS NOT NULL + AND info."Episode" IS NOT NULL + ) + UPDATE "AnimationInfo" info + SET "IsActiveRelease" = FALSE + FROM ranked_active_releases ranked + WHERE info."Id" = ranked."Id" + AND ranked.rank > 1; + """); + + migrationBuilder.CreateIndex( + name: "UX_AnimationInfo_ActiveEpisodeRelease", + table: "AnimationInfo", + columns: new[] { "AnimationId", "Season", "Episode" }, + unique: true, + filter: "\"IsActiveRelease\" = TRUE AND \"AnimationId\" IS NOT NULL AND \"Season\" IS NOT NULL AND \"Episode\" IS NOT NULL"); + + migrationBuilder.CreateIndex( + name: "IX_ReleaseUpgradeOperations_CandidateReleaseId", + table: "ReleaseUpgradeOperations", + column: "CandidateReleaseId", + unique: true, + filter: "\"Status\" <> 'Failed'"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "UX_AnimationInfo_ActiveEpisodeRelease", + table: "AnimationInfo"); + + migrationBuilder.DropIndex( + name: "IX_ReleaseUpgradeOperations_CandidateReleaseId", + table: "ReleaseUpgradeOperations"); + + migrationBuilder.Sql( + """ + WITH ranked_candidate_operations AS ( + SELECT operation."Id", + row_number() OVER ( + PARTITION BY operation."CandidateReleaseId" + ORDER BY (operation."Status" <> 'Failed') DESC, + operation."CreatedAt" DESC, + operation."Id" + ) AS rank + FROM "ReleaseUpgradeOperations" operation + ) + DELETE FROM "ReleaseUpgradeOperations" operation + USING ranked_candidate_operations ranked + WHERE operation."Id" = ranked."Id" + AND ranked.rank > 1; + """); + + migrationBuilder.CreateIndex( + name: "IX_ReleaseUpgradeOperations_CandidateReleaseId", + table: "ReleaseUpgradeOperations", + column: "CandidateReleaseId", + unique: true); + } + } +} diff --git a/SecondDimensionWatcherReDive/Migrations/ApplicationContextModelSnapshot.cs b/SecondDimensionWatcherReDive/Migrations/ApplicationContextModelSnapshot.cs index 78612ac..7ea80e1 100644 --- a/SecondDimensionWatcherReDive/Migrations/ApplicationContextModelSnapshot.cs +++ b/SecondDimensionWatcherReDive/Migrations/ApplicationContextModelSnapshot.cs @@ -234,8 +234,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasKey("Id"); - b.HasIndex("AnimationId"); - b.HasIndex("CurrentMetadataReviewOperationId") .IsUnique(); @@ -250,12 +248,19 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("SourceFeedId"); + b.HasIndex("AnimationId"); + b.HasIndex("FileStore", "StorePath") .IsUnique() .HasFilter("\"DownloadType\" = 'http://schemas.hcgstudio.com/ws/2023/06/sdw/downloadtype/media-library-import'"); b.HasIndex("MetadataStatus", "PublishTime"); + b.HasIndex("AnimationId", "Season", "Episode") + .IsUnique() + .HasDatabaseName("UX_AnimationInfo_ActiveEpisodeRelease") + .HasFilter("\"IsActiveRelease\" = TRUE AND \"AnimationId\" IS NOT NULL AND \"Season\" IS NOT NULL AND \"Episode\" IS NOT NULL"); + b.HasIndex("Season", "Episode", "ReleaseScore"); b.ToTable("AnimationInfo", t => @@ -899,7 +904,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasKey("Id"); b.HasIndex("CandidateReleaseId") - .IsUnique(); + .IsUnique() + .HasFilter("\"Status\" <> 'Failed'"); b.HasIndex("CurrentReleaseId") .IsUnique() diff --git a/SecondDimensionWatcherReDive/Models/AnimationInfo.cs b/SecondDimensionWatcherReDive/Models/AnimationInfo.cs index f929279..dc4b7ef 100644 --- a/SecondDimensionWatcherReDive/Models/AnimationInfo.cs +++ b/SecondDimensionWatcherReDive/Models/AnimationInfo.cs @@ -93,5 +93,5 @@ public class AnimationInfo public int? ExpectedEpisodeCount { get; set; } - public bool IsActiveRelease { get; set; } = true; + public bool IsActiveRelease { get; set; } } diff --git a/SecondDimensionWatcherReDive/Models/ApplicationContext.cs b/SecondDimensionWatcherReDive/Models/ApplicationContext.cs index dab05a9..30b6ea0 100644 --- a/SecondDimensionWatcherReDive/Models/ApplicationContext.cs +++ b/SecondDimensionWatcherReDive/Models/ApplicationContext.cs @@ -319,6 +319,18 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) modelBuilder.Entity() .HasIndex(info => info.SourceFeedId); + modelBuilder.Entity() + .HasIndex("AnimationId") + .HasDatabaseName("IX_AnimationInfo_AnimationId"); + + modelBuilder.Entity() + .HasIndex("AnimationId", "Season", "Episode") + .IsUnique() + .HasFilter( + "\"IsActiveRelease\" = TRUE AND \"AnimationId\" IS NOT NULL " + + "AND \"Season\" IS NOT NULL AND \"Episode\" IS NOT NULL") + .HasDatabaseName("UX_AnimationInfo_ActiveEpisodeRelease"); + modelBuilder.Entity() .HasKey(policy => policy.FeedId); @@ -370,7 +382,8 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) modelBuilder.Entity() .HasIndex(operation => operation.CandidateReleaseId) - .IsUnique(); + .IsUnique() + .HasFilter("\"Status\" <> 'Failed'"); modelBuilder.Entity() .HasIndex(operation => operation.CurrentReleaseId) diff --git a/SecondDimensionWatcherReDive/Repositories/AnimationInfoRepository.cs b/SecondDimensionWatcherReDive/Repositories/AnimationInfoRepository.cs index 2bfa7e0..771c0fc 100644 --- a/SecondDimensionWatcherReDive/Repositories/AnimationInfoRepository.cs +++ b/SecondDimensionWatcherReDive/Repositories/AnimationInfoRepository.cs @@ -241,12 +241,21 @@ public async Task RemoveMediaLibraryEntryAsync( || entity.MediaLibrarySourceId != expectedSourceId || entity.DownloadType != FileDownloadTypes.MediaLibraryImport) return false; + var previousEpisodeIdentity = GetEpisodeIdentity(writeContext, entity); + var wasActiveRelease = entity.IsActiveRelease; await writeContext.FileMappings .Where(mapping => mapping.AnimationInfoId == id) .ExecuteDeleteAsync(cancellationToken); writeContext.AnimationInfo.Remove(entity); await writeContext.SaveChangesAsync(cancellationToken); + await PromotePreviousEpisodeSuccessorAsync( + writeContext, + entity.Id, + wasActiveRelease, + previousEpisodeIdentity, + currentIdentity: null, + cancellationToken); await transaction.CommitAsync(cancellationToken); return true; }); @@ -338,10 +347,10 @@ public async Task TryAddReleaseAsync( return true; } catch (DbUpdateException exception) when (exception.InnerException is PostgresException - { - SqlState: PostgresErrorCodes.UniqueViolation, - ConstraintName: "UX_AnimationInfo_ReleaseIdentity" - }) + { + SqlState: PostgresErrorCodes.UniqueViolation, + ConstraintName: "UX_AnimationInfo_ReleaseIdentity" + }) { context.Entry(entity).State = EntityState.Detached; return false; @@ -350,24 +359,48 @@ public async Task TryAddReleaseAsync( public async Task UpdateAsync(AnimationInfo info, CancellationToken cancellationToken) { - var entity = await context.AnimationInfo.FindAsync([info.Id], cancellationToken) - ?? throw new InvalidOperationException($"AnimationInfo {info.Id} not found"); - var currentStateVersion = entity.StateVersion; - if (currentStateVersion != info.StateVersion) - throw new DbUpdateConcurrencyException( - $"AnimationInfo {info.Id} changed from revision {info.StateVersion} to {currentStateVersion}."); - - info.ApplyTo(entity); - - entity.Animation = info.Animation is null - ? null - : await context.Animations.FindAsync([info.Animation.Id], cancellationToken); - entity.Group = info.Group is null - ? null - : await context.AnimationGroups.FindAsync([info.Group.Id], cancellationToken); - entity.StateVersion = checked(currentStateVersion + 1); + var strategy = context.Database.CreateExecutionStrategy(); + await strategy.ExecuteAsync(async () => + { + await using var writeContext = new Models.ApplicationContext(contextOptions); + await using var transaction = await writeContext.Database + .BeginTransactionAsync(cancellationToken); + await MappingTransactionLock.AcquireAsync(writeContext, cancellationToken); + var entity = await MappingTransactionLock.LockAnimationInfoAsync( + writeContext, + info.Id, + cancellationToken) + ?? throw new InvalidOperationException($"AnimationInfo {info.Id} not found"); + var currentStateVersion = entity.StateVersion; + if (currentStateVersion != info.StateVersion) + throw new DbUpdateConcurrencyException( + $"AnimationInfo {info.Id} changed from revision {info.StateVersion} to {currentStateVersion}."); + var previousEpisodeIdentity = GetEpisodeIdentity(writeContext, entity); + var wasActiveRelease = entity.IsActiveRelease; + + info.ApplyTo(entity); + entity.Animation = info.Animation is null + ? null + : await writeContext.Animations.FindAsync([info.Animation.Id], cancellationToken); + entity.Group = info.Group is null + ? null + : await writeContext.AnimationGroups.FindAsync([info.Group.Id], cancellationToken); + writeContext.Entry(entity).Property("AnimationId").CurrentValue = info.Animation?.Id; + writeContext.Entry(entity).Property("GroupId").CurrentValue = info.Group?.Id; + await SetEpisodeReleaseActivityAsync(writeContext, entity, cancellationToken); + var currentEpisodeIdentity = GetEpisodeIdentity(writeContext, entity); + entity.StateVersion = checked(currentStateVersion + 1); - await context.SaveChangesAsync(cancellationToken); + await writeContext.SaveChangesAsync(cancellationToken); + await PromotePreviousEpisodeSuccessorAsync( + writeContext, + entity.Id, + wasActiveRelease, + previousEpisodeIdentity, + currentEpisodeIdentity, + cancellationToken); + await transaction.CommitAsync(cancellationToken); + }); } public async Task TryStartDownloadAsync( @@ -383,6 +416,7 @@ public async Task TryStartDownloadAsync( await using var writeContext = new Models.ApplicationContext(contextOptions); await using var transaction = await writeContext.Database .BeginTransactionAsync(cancellationToken); + await MappingTransactionLock.AcquireAsync(writeContext, cancellationToken); var entity = await MappingTransactionLock.LockAnimationInfoAsync( writeContext, id, @@ -436,6 +470,7 @@ public async Task TryBeginCancelDownloadAsync( await using var writeContext = new Models.ApplicationContext(contextOptions); await using var transaction = await writeContext.Database .BeginTransactionAsync(cancellationToken); + await MappingTransactionLock.AcquireAsync(writeContext, cancellationToken); var entity = await MappingTransactionLock.LockAnimationInfoAsync( writeContext, id, @@ -445,16 +480,41 @@ public async Task TryBeginCancelDownloadAsync( || entity.DownloadAttemptId != downloadAttemptId) return false; - if (entity.DownloadCancellationId == cancellationAttemptId) + if (entity.DownloadCancellationId is not null && + entity.DownloadCancellationId != cancellationAttemptId) + return false; + + // If activation acquired the shared mapping lock first, this is a + // stale cancellation request for a release that is now live. Do + // not let the caller delete its remote files after activation. + if (await writeContext.ReleaseUpgradeOperations.AnyAsync( + operation => operation.CandidateReleaseId == entity.Id && + operation.Status == ReleaseUpgradeStatus.Applied, + cancellationToken)) + return false; + + if (entity.DownloadCancellationId is null) { - await transaction.CommitAsync(cancellationToken); - return true; + entity.DownloadCancellationId = cancellationAttemptId; + entity.StateVersion = checked(entity.StateVersion + 1); } - if (entity.DownloadCancellationId is not null) - return false; - entity.DownloadCancellationId = cancellationAttemptId; - entity.StateVersion = checked(entity.StateVersion + 1); + // Persist the cancellation intent and terminate the pending + // upgrade atomically. Activation uses the same global lock, so it + // can no longer commit between remote deletion and local finalize. + var cancelledAt = DateTimeOffset.UtcNow; + await writeContext.ReleaseUpgradeOperations + .Where(operation => operation.CandidateReleaseId == entity.Id && + (operation.Status == ReleaseUpgradeStatus.Downloading || + operation.Status == ReleaseUpgradeStatus.Verifying)) + .ExecuteUpdateAsync( + setters => setters + .SetProperty(operation => operation.Status, ReleaseUpgradeStatus.Failed) + .SetProperty( + operation => operation.FailureSummary, + "Candidate download cancellation was requested before upgrade activation.") + .SetProperty(operation => operation.CompletedAt, cancelledAt), + cancellationToken); await writeContext.SaveChangesAsync(cancellationToken); await transaction.CommitAsync(cancellationToken); return true; @@ -542,6 +602,7 @@ await writeContext.Entry(entity) await using var writeContext = new Models.ApplicationContext(contextOptions); await using var transaction = await writeContext.Database .BeginTransactionAsync(cancellationToken); + await MappingTransactionLock.AcquireAsync(writeContext, cancellationToken); var entity = await MappingTransactionLock.LockAnimationInfoAsync( writeContext, id, @@ -567,6 +628,19 @@ SubscriptionAutomationDisposition.ManualDownloadQueued or ? SubscriptionAutomationDisposition.DownloadCancelled : entity.AutomationDisposition); entity.StateVersion = checked(entity.StateVersion + 1); + var cancelledAt = DateTimeOffset.UtcNow; + await writeContext.ReleaseUpgradeOperations + .Where(operation => operation.CandidateReleaseId == entity.Id && + (operation.Status == ReleaseUpgradeStatus.Downloading || + operation.Status == ReleaseUpgradeStatus.Verifying)) + .ExecuteUpdateAsync( + setters => setters + .SetProperty(operation => operation.Status, ReleaseUpgradeStatus.Failed) + .SetProperty( + operation => operation.FailureSummary, + "Candidate download was cancelled before upgrade activation.") + .SetProperty(operation => operation.CompletedAt, cancelledAt), + cancellationToken); await writeContext.SaveChangesAsync(cancellationToken); } else if (entity.DownloadAttemptId is not null @@ -591,30 +665,130 @@ public async Task TryUpdateAsync( long expectedStateVersion, CancellationToken cancellationToken) { - var entity = await context.AnimationInfo - .FirstOrDefaultAsync(candidate => candidate.Id == info.Id, cancellationToken); - if (entity is null || entity.StateVersion != expectedStateVersion) - return false; - - info.ApplyTo(entity); - entity.Animation = info.Animation is null - ? null - : await context.Animations.FindAsync([info.Animation.Id], cancellationToken); - entity.Group = info.Group is null - ? null - : await context.AnimationGroups.FindAsync([info.Group.Id], cancellationToken); - entity.StateVersion = checked(expectedStateVersion + 1); - context.Entry(entity).Property(candidate => candidate.StateVersion).OriginalValue = expectedStateVersion; - - try + var strategy = context.Database.CreateExecutionStrategy(); + return await strategy.ExecuteAsync(async () => { - await context.SaveChangesAsync(cancellationToken); + await using var writeContext = new Models.ApplicationContext(contextOptions); + await using var transaction = await writeContext.Database + .BeginTransactionAsync(cancellationToken); + await MappingTransactionLock.AcquireAsync(writeContext, cancellationToken); + var entity = await MappingTransactionLock.LockAnimationInfoAsync( + writeContext, + info.Id, + cancellationToken); + if (entity is null || entity.StateVersion != expectedStateVersion) + return false; + var previousEpisodeIdentity = GetEpisodeIdentity(writeContext, entity); + var wasActiveRelease = entity.IsActiveRelease; + + info.ApplyTo(entity); + entity.Animation = info.Animation is null + ? null + : await writeContext.Animations.FindAsync([info.Animation.Id], cancellationToken); + entity.Group = info.Group is null + ? null + : await writeContext.AnimationGroups.FindAsync([info.Group.Id], cancellationToken); + writeContext.Entry(entity).Property("AnimationId").CurrentValue = info.Animation?.Id; + writeContext.Entry(entity).Property("GroupId").CurrentValue = info.Group?.Id; + await SetEpisodeReleaseActivityAsync(writeContext, entity, cancellationToken); + var currentEpisodeIdentity = GetEpisodeIdentity(writeContext, entity); + entity.StateVersion = checked(expectedStateVersion + 1); + writeContext.Entry(entity).Property(candidate => candidate.StateVersion).OriginalValue = + expectedStateVersion; + + await writeContext.SaveChangesAsync(cancellationToken); + await PromotePreviousEpisodeSuccessorAsync( + writeContext, + entity.Id, + wasActiveRelease, + previousEpisodeIdentity, + currentEpisodeIdentity, + cancellationToken); + await transaction.CommitAsync(cancellationToken); return true; - } - catch (DbUpdateConcurrencyException) + }); + } + + internal static async Task SetEpisodeReleaseActivityAsync( + Models.ApplicationContext writeContext, + Models.AnimationInfo entity, + CancellationToken cancellationToken) + { + var identity = GetEpisodeIdentity(writeContext, entity); + if (identity is null) { - context.Entry(entity).State = EntityState.Detached; - return false; + entity.IsActiveRelease = false; + return; } + + var value = identity.Value; + entity.IsActiveRelease = !await writeContext.AnimationInfo + .AsNoTracking() + .AnyAsync(other => other.Id != entity.Id && + other.IsActiveRelease && + EF.Property(other, "AnimationId") == value.AnimationId && + other.Season == value.Season && + other.Episode == value.Episode, + cancellationToken); + } + + internal static EpisodeReleaseIdentity? GetEpisodeIdentity( + Models.ApplicationContext writeContext, + Models.AnimationInfo entity) + { + writeContext.ChangeTracker.DetectChanges(); + var animationId = entity.Animation?.Id ?? writeContext.Entry(entity) + .Property("AnimationId") + .CurrentValue; + return animationId is { } id && entity.Season is { } season && entity.Episode is { } episode + ? new EpisodeReleaseIdentity(id, season, episode) + : null; + } + + internal static async Task PromotePreviousEpisodeSuccessorAsync( + Models.ApplicationContext writeContext, + Guid changedReleaseId, + bool wasActiveRelease, + EpisodeReleaseIdentity? previousIdentity, + EpisodeReleaseIdentity? currentIdentity, + CancellationToken cancellationToken) + { + if (!wasActiveRelease || previousIdentity is not { } previous || previous == currentIdentity) + return; + if (await writeContext.AnimationInfo.AsNoTracking().AnyAsync( + info => info.Id != changedReleaseId && + info.IsActiveRelease && + EF.Property(info, "AnimationId") == previous.AnimationId && + info.Season == previous.Season && + info.Episode == previous.Episode, + cancellationToken)) + return; + + var successorId = await writeContext.AnimationInfo + .AsNoTracking() + .Where(info => info.Id != changedReleaseId && + info.MediaLibraryMissingSince == null && + EF.Property(info, "AnimationId") == previous.AnimationId && + info.Season == previous.Season && + info.Episode == previous.Episode) + .OrderByDescending(info => info.IsDownloadFinished && + writeContext.FileMappings.Any(mapping => + mapping.AnimationInfoId == info.Id)) + .ThenByDescending(info => info.ReleaseScore) + .ThenByDescending(info => info.PublishTime) + .ThenBy(info => info.Id) + .Select(info => (Guid?)info.Id) + .FirstOrDefaultAsync(cancellationToken); + if (successorId is null) return; + + await writeContext.AnimationInfo + .Where(info => info.Id == successorId.Value) + .ExecuteUpdateAsync( + setters => setters + .SetProperty(info => info.IsActiveRelease, true) + .SetProperty(info => info.StateVersion, info => info.StateVersion + 1), + cancellationToken); } + + internal readonly record struct EpisodeReleaseIdentity(Guid AnimationId, int Season, int Episode); } diff --git a/SecondDimensionWatcherReDive/Repositories/FileMappingRepository.cs b/SecondDimensionWatcherReDive/Repositories/FileMappingRepository.cs index 0404fac..c168010 100644 --- a/SecondDimensionWatcherReDive/Repositories/FileMappingRepository.cs +++ b/SecondDimensionWatcherReDive/Repositories/FileMappingRepository.cs @@ -208,6 +208,7 @@ public async Task TryFinalizeDownloadCancellationAsync( Guid animationInfoId, Guid? downloadAttemptId, Guid cancellationAttemptId, + SubscriptionAutomationDisposition? terminalDisposition, CancellationToken cancellationToken) { var strategy = context.Database.CreateExecutionStrategy(); @@ -249,13 +250,27 @@ await finalizeContext.FileMappings // Retain the completed cancellation id until the next Start so a // lost commit acknowledgement can be retried idempotently. animationInfo.DownloadCancellationId = cancellationAttemptId; - animationInfo.AutomationDisposition = animationInfo.AutomationDisposition is - SubscriptionAutomationDisposition.AutoDownloadQueued or - SubscriptionAutomationDisposition.ManualDownloadQueued or - SubscriptionAutomationDisposition.DownloadCompleted - ? SubscriptionAutomationDisposition.DownloadCancelled - : animationInfo.AutomationDisposition; + animationInfo.AutomationDisposition = terminalDisposition + ?? (animationInfo.AutomationDisposition is + SubscriptionAutomationDisposition.AutoDownloadQueued or + SubscriptionAutomationDisposition.ManualDownloadQueued or + SubscriptionAutomationDisposition.DownloadCompleted + ? SubscriptionAutomationDisposition.DownloadCancelled + : animationInfo.AutomationDisposition); animationInfo.StateVersion = checked(animationInfo.StateVersion + 1); + var cancelledAt = DateTimeOffset.UtcNow; + await finalizeContext.ReleaseUpgradeOperations + .Where(operation => operation.CandidateReleaseId == animationInfoId && + (operation.Status == ReleaseUpgradeStatus.Downloading || + operation.Status == ReleaseUpgradeStatus.Verifying)) + .ExecuteUpdateAsync( + setters => setters + .SetProperty(operation => operation.Status, ReleaseUpgradeStatus.Failed) + .SetProperty( + operation => operation.FailureSummary, + "Candidate download was cancelled before upgrade activation.") + .SetProperty(operation => operation.CompletedAt, cancelledAt), + cancellationToken); await finalizeContext.SaveChangesAsync(cancellationToken); await transaction.CommitAsync(cancellationToken); return true; diff --git a/SecondDimensionWatcherReDive/Repositories/FileMappingRepositoryPostgreSqlTestFixture.cs b/SecondDimensionWatcherReDive/Repositories/FileMappingRepositoryPostgreSqlTestFixture.cs index dd21bf3..b1581e5 100644 --- a/SecondDimensionWatcherReDive/Repositories/FileMappingRepositoryPostgreSqlTestFixture.cs +++ b/SecondDimensionWatcherReDive/Repositories/FileMappingRepositoryPostgreSqlTestFixture.cs @@ -1,4 +1,6 @@ using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; using SecondDimensionWatcherReDive.Framework.DataRepository; using SecondDimensionWatcherReDive.Framework.FileDownload; @@ -29,6 +31,92 @@ await context.Database.ExecuteSqlRawAsync( cancellationToken); } + public async Task<( + Guid ExpectedActiveId, + IReadOnlyList ActiveIds, + int DowngradedCandidateOperationCount)> + MigrateDuplicateActiveReleasesAsync(CancellationToken cancellationToken) + { + await using var context = new Models.ApplicationContext(_contextOptions); + var migrator = context.GetService(); + await migrator.MigrateAsync( + "20260829151303_AddLibrarySearchAndReleaseUpgrades", + cancellationToken); + var animation = new Models.Animation + { + Id = Guid.NewGuid(), + TmdbId = "migration-active-show", + Name = "Migration Active Show", + OriginalName = "Migration Active Show" + }; + var earlier = Release( + animation, + null, + 1, + 1, + 100, + DateTimeOffset.UtcNow.AddMinutes(-2), + FileDownloadTypes.TorrentDownload, + false, + "migration:earlier-" + Guid.NewGuid().ToString("N"), + 1); + var later = Release( + animation, + null, + 1, + 1, + 200, + DateTimeOffset.UtcNow.AddMinutes(-1), + FileDownloadTypes.TorrentDownload, + false, + "migration:later-" + Guid.NewGuid().ToString("N"), + 1); + context.AnimationInfo.AddRange(earlier, later); + await context.SaveChangesAsync(cancellationToken); + context.ChangeTracker.Clear(); + + await migrator.MigrateAsync(null, cancellationToken); + var activeIds = await context.AnimationInfo + .AsNoTracking() + .Where(info => info.IsActiveRelease) + .Select(info => info.Id) + .ToListAsync(cancellationToken); + context.ReleaseUpgradeOperations.AddRange( + new Models.ReleaseUpgradeOperation + { + Id = Guid.NewGuid(), + CurrentReleaseId = earlier.Id, + CandidateReleaseId = later.Id, + Status = ReleaseUpgradeStatus.Failed, + CurrentScore = earlier.ReleaseScore, + CandidateScore = later.ReleaseScore, + CreatedAt = DateTimeOffset.UtcNow.AddMinutes(-1), + CompletedAt = DateTimeOffset.UtcNow.AddMinutes(-1) + }, + new Models.ReleaseUpgradeOperation + { + Id = Guid.NewGuid(), + CurrentReleaseId = earlier.Id, + CandidateReleaseId = later.Id, + Status = ReleaseUpgradeStatus.Failed, + CurrentScore = earlier.ReleaseScore, + CandidateScore = later.ReleaseScore, + CreatedAt = DateTimeOffset.UtcNow, + CompletedAt = DateTimeOffset.UtcNow + }); + await context.SaveChangesAsync(cancellationToken); + context.ChangeTracker.Clear(); + await migrator.MigrateAsync( + "20260829151303_AddLibrarySearchAndReleaseUpgrades", + cancellationToken); + var downgradedCandidateOperationCount = await context.ReleaseUpgradeOperations + .CountAsync( + operation => operation.CandidateReleaseId == later.Id, + cancellationToken); + await migrator.MigrateAsync(null, cancellationToken); + return (earlier.Id, activeIds, downgradedCandidateOperationCount); + } + public async Task SeedDownloadedAnimationAsync(CancellationToken cancellationToken) { await using var context = new Models.ApplicationContext(_contextOptions); @@ -226,43 +314,113 @@ FROM pg_indexes WHERE schemaname = 'public' AND (indexname LIKE 'IX_%_Trgm' OR indexname = 'IX_AnimationInfo_ReleaseLanguages_Gin' - OR indexname = 'UX_AnimationInfo_ReleaseIdentity') + OR indexname = 'IX_AnimationInfo_AnimationId' + OR indexname IN ('UX_AnimationInfo_ReleaseIdentity', + 'UX_AnimationInfo_ActiveEpisodeRelease')) ORDER BY indexname """) .ToListAsync(cancellationToken); } - public async Task SeedUpgradeScenarioAsync(CancellationToken cancellationToken) + public async Task SeedUpgradeScenarioAsync( + CancellationToken cancellationToken, + bool includeDuplicateVideoRole = false, + bool includeCandidateProgress = false) { await using var context = new Models.ApplicationContext(_contextOptions); var animation = new Models.Animation { - Id = Guid.NewGuid(), TmdbId = "upgrade-show", Name = "Upgrade Show", OriginalName = "Upgrade Show" + Id = Guid.NewGuid(), + TmdbId = "upgrade-show", + Name = "Upgrade Show", + OriginalName = "Upgrade Show" }; var current = Release(animation, null, 1, 1, 200, DateTimeOffset.UtcNow.AddMinutes(-2), FileDownloadTypes.TorrentDownload, true, "torrent:old-" + Guid.NewGuid().ToString("N"), 1); var candidate = Release(animation, null, 1, 1, 500, DateTimeOffset.UtcNow.AddMinutes(-1), FileDownloadTypes.TorrentDownload, true, "torrent:new-" + Guid.NewGuid().ToString("N"), 1); candidate.IsActiveRelease = false; + var canonicalPath = "/Upgrade Show/Old/Upgrade Show S01E01.MKV"; + var canonicalSubtitlePath = "/Upgrade Show/Old/Upgrade Show S01E01.EN.srt"; context.AnimationInfo.AddRange(current, candidate); context.FileMappings.AddRange( new Models.FileMapping { - Id = Guid.NewGuid(), AnimationInfoId = current.Id, - VirtualPath = "/Upgrade Show/Old/Upgrade Show S01E01.mkv", - PhysicalPath = "/store/old.mkv", FileStore = "local" + Id = Guid.NewGuid(), + AnimationInfoId = current.Id, + VirtualPath = canonicalPath, + PhysicalPath = "/store/old.mkv", + FileStore = "local" }, new Models.FileMapping { - Id = Guid.NewGuid(), AnimationInfoId = candidate.Id, + Id = Guid.NewGuid(), + AnimationInfoId = current.Id, + VirtualPath = canonicalSubtitlePath, + PhysicalPath = "/store/old.en.srt", + FileStore = "local" + }, + new Models.FileMapping + { + Id = Guid.NewGuid(), + AnimationInfoId = candidate.Id, + VirtualPath = "/Upgrade Show/New/Upgrade Show S01E01 (2).mkv", + PhysicalPath = "/store/new.mkv", + FileStore = "local" + }, + new Models.FileMapping + { + Id = Guid.NewGuid(), + AnimationInfoId = candidate.Id, + VirtualPath = "/Upgrade Show/New/Upgrade Show S01E01.en (2).srt", + PhysicalPath = "/store/new.en.srt", + FileStore = "local" + }); + if (includeDuplicateVideoRole) + { + context.FileMappings.Add(new Models.FileMapping + { + Id = Guid.NewGuid(), + AnimationInfoId = candidate.Id, + VirtualPath = "/Upgrade Show/New/Upgrade Show S01E01 (3).mkv", + PhysicalPath = "/store/new-alternate.mkv", + FileStore = "local" + }); + } + var userId = Guid.NewGuid(); + context.PlaybackProgresses.Add(new Models.PlaybackProgress + { + Id = Guid.NewGuid(), + UserId = userId, + AnimationInfoId = current.Id, + VirtualPath = canonicalPath, + PositionSeconds = 321, + DurationSeconds = 1200, + IsWatched = false, + UpdatedAt = DateTimeOffset.UtcNow + }); + if (includeCandidateProgress) + { + context.PlaybackProgresses.Add(new Models.PlaybackProgress + { + Id = Guid.NewGuid(), + UserId = userId, + AnimationInfoId = candidate.Id, VirtualPath = "/Upgrade Show/New/Upgrade Show S01E01 (2).mkv", - PhysicalPath = "/store/new.mkv", FileStore = "local" + PositionSeconds = 1200, + DurationSeconds = 1200, + IsWatched = true, + UpdatedAt = DateTimeOffset.UtcNow.AddMinutes(1), + WatchedAt = DateTimeOffset.UtcNow.AddMinutes(1) }); + } await context.SaveChangesAsync(cancellationToken); return new UpgradeScenario(new ReleaseUpgradeCandidate( current.Id, candidate.Id, animation.Name, 1, 1, 200, 500, ["resolution:2160p:+400"], false), - "/Upgrade Show/Old/Upgrade Show S01E01.mkv"); + canonicalPath, + canonicalSubtitlePath, + userId); } public async Task BeginUpgradeAsync( @@ -277,10 +435,42 @@ public async Task SeedUpgradeScenarioAsync(CancellationToken ca public async Task ActivateUpgradeAsync( Guid operationId, CancellationToken cancellationToken) + { + await using var context = new Models.ApplicationContext(_contextOptions); + var operation = await context.ReleaseUpgradeOperations + .AsNoTracking() + .SingleAsync(item => item.Id == operationId, cancellationToken); + var repository = new ReleaseUpgradeRepository(context, _contextOptions); + var activation = await repository.GetActivationAsync( + operation.CandidateReleaseId, + cancellationToken) + ?? throw new InvalidOperationException("Upgrade activation was not found."); + return await ActivateUpgradeAsync(operationId, activation, cancellationToken); + } + + public async Task GetUpgradeActivationAsync( + Guid candidateReleaseId, + CancellationToken cancellationToken) { await using var context = new Models.ApplicationContext(_contextOptions); return await new ReleaseUpgradeRepository(context, _contextOptions) - .ActivateAsync(operationId, DateTimeOffset.UtcNow, DateTimeOffset.UtcNow.AddHours(24), cancellationToken); + .GetActivationAsync(candidateReleaseId, cancellationToken); + } + + public async Task ActivateUpgradeAsync( + Guid operationId, + ReleaseUpgradeActivation expected, + CancellationToken cancellationToken) + { + await using var context = new Models.ApplicationContext(_contextOptions); + return await new ReleaseUpgradeRepository(context, _contextOptions) + .ActivateAsync( + operationId, + expected.PreviousMappings, + expected.CandidateMappings, + DateTimeOffset.UtcNow, + DateTimeOffset.UtcNow.AddHours(24), + cancellationToken); } public async Task> GetReadyUpgradeCandidateIdsAsync( @@ -300,6 +490,57 @@ public async Task RollbackUpgradeAsync( .RollbackAsync(operationId, DateTimeOffset.UtcNow, cancellationToken); } + public async Task MarkUpgradeFailedAsync( + Guid operationId, + CancellationToken cancellationToken) + { + await using var context = new Models.ApplicationContext(_contextOptions); + return await new ReleaseUpgradeRepository(context, _contextOptions) + .MarkFailedAsync(operationId, "late failure", cancellationToken); + } + + public async Task BeginUpgradeCandidateCancellationAsync( + Guid animationInfoId, + Guid? downloadAttemptId, + Guid cancellationAttemptId, + CancellationToken cancellationToken) + { + await using var context = new Models.ApplicationContext(_contextOptions); + return await new AnimationInfoRepository(context, _contextOptions) + .TryBeginCancelDownloadAsync( + animationInfoId, + downloadAttemptId, + cancellationAttemptId, + cancellationToken); + } + + public async Task FinalizeUpgradeCandidateCancellationAsync( + Guid animationInfoId, + Guid? downloadAttemptId, + Guid cancellationAttemptId, + CancellationToken cancellationToken) + { + await using var context = new Models.ApplicationContext(_contextOptions); + return await new FileMappingRepository(context, _contextOptions) + .TryFinalizeDownloadCancellationAsync( + animationInfoId, + downloadAttemptId, + cancellationAttemptId, + terminalDisposition: null, + cancellationToken); + } + + public async Task GetUpgradeOperationAsync( + Guid operationId, + CancellationToken cancellationToken) + { + await using var context = new Models.ApplicationContext(_contextOptions); + return (await context.ReleaseUpgradeOperations + .AsNoTracking() + .SingleAsync(operation => operation.Id == operationId, cancellationToken)) + .ToRecord(); + } + public async Task> GetMappingsAsync( Guid animationInfoId, CancellationToken cancellationToken) @@ -309,6 +550,238 @@ public async Task> GetMappingsAsync( .GetForAnimationInfoAsync(animationInfoId, cancellationToken); } + public async Task> GetPlaybackProgressesAsync( + Guid userId, + CancellationToken cancellationToken) + { + await using var context = new Models.ApplicationContext(_contextOptions); + return (await context.PlaybackProgresses + .AsNoTracking() + .Where(progress => progress.UserId == userId) + .OrderBy(progress => progress.VirtualPath) + .ToListAsync(cancellationToken)) + .Select(progress => progress.ToRecord()) + .ToList(); + } + + public async Task ChangeReleaseEpisodeAsync( + Guid animationInfoId, + int episode, + CancellationToken cancellationToken) + { + await using var context = new Models.ApplicationContext(_contextOptions); + await context.AnimationInfo + .Where(info => info.Id == animationInfoId) + .ExecuteUpdateAsync( + setters => setters.SetProperty(info => info.Episode, episode), + cancellationToken); + } + + public async Task SetCandidateDownloadInProgressAsync( + Guid animationInfoId, + CancellationToken cancellationToken) + { + var downloadAttemptId = Guid.NewGuid(); + await using var context = new Models.ApplicationContext(_contextOptions); + await context.AnimationInfo + .Where(info => info.Id == animationInfoId) + .ExecuteUpdateAsync( + setters => setters + .SetProperty(info => info.IsDownloadTracked, true) + .SetProperty(info => info.IsDownloadFinished, false) + .SetProperty(info => info.DownloadAttemptId, downloadAttemptId) + .SetProperty(info => info.FileStore, (string?)null) + .SetProperty(info => info.StorePath, (string?)null), + cancellationToken); + return downloadAttemptId; + } + + public async Task CancelUpgradeCandidateAsync( + Guid animationInfoId, + Guid downloadAttemptId, + CancellationToken cancellationToken) + { + await using var context = new Models.ApplicationContext(_contextOptions); + var animationRepository = new AnimationInfoRepository(context, _contextOptions); + var cancellationAttemptId = Guid.NewGuid(); + if (!await animationRepository.TryBeginCancelDownloadAsync( + animationInfoId, + downloadAttemptId, + cancellationAttemptId, + cancellationToken)) + return null; + var mappingRepository = new FileMappingRepository(context, _contextOptions); + if (!await mappingRepository.TryFinalizeDownloadCancellationAsync( + animationInfoId, + downloadAttemptId, + cancellationAttemptId, + terminalDisposition: null, + cancellationToken)) + return null; + return await animationRepository.FindByIdAsync(animationInfoId, cancellationToken); + } + + public async Task ChangeMappingPhysicalPathAsync( + Guid animationInfoId, + string virtualPath, + string physicalPath, + CancellationToken cancellationToken) + { + await using var context = new Models.ApplicationContext(_contextOptions); + await context.FileMappings + .Where(mapping => mapping.AnimationInfoId == animationInfoId && + mapping.VirtualPath == virtualPath) + .ExecuteUpdateAsync( + setters => setters.SetProperty(mapping => mapping.PhysicalPath, physicalPath), + cancellationToken); + } + + public async Task RemapCandidatePlaybackAsync( + Guid animationInfoId, + Guid userId, + string currentPath, + string replacementPath, + CancellationToken cancellationToken) + { + await using var context = new Models.ApplicationContext(_contextOptions); + await using var transaction = await context.Database.BeginTransactionAsync(cancellationToken); + await MappingTransactionLock.AcquireAsync(context, cancellationToken); + await context.FileMappings + .Where(mapping => mapping.AnimationInfoId == animationInfoId && + mapping.VirtualPath == currentPath) + .ExecuteUpdateAsync( + setters => setters.SetProperty(mapping => mapping.VirtualPath, replacementPath), + cancellationToken); + await context.PlaybackProgresses + .Where(progress => progress.AnimationInfoId == animationInfoId && + progress.UserId == userId && + progress.VirtualPath == currentPath) + .ExecuteUpdateAsync( + setters => setters.SetProperty(progress => progress.VirtualPath, replacementPath), + cancellationToken); + await transaction.CommitAsync(cancellationToken); + } + + public async Task<(bool FirstActive, bool SecondActive, int FirstEpisode)> + IdentifyCompetingReleasesAsync( + CancellationToken cancellationToken, + bool moveFirst = false, + bool deidentifyFirst = false, + bool concurrent = false) + { + var animation = new Models.Animation + { + Id = Guid.NewGuid(), + TmdbId = "single-active-show", + Name = "Single Active Show", + OriginalName = "Single Active Show" + }; + var first = Release( + animation, + null, + 1, + null, + 100, + DateTimeOffset.UtcNow.AddMinutes(-2), + FileDownloadTypes.TorrentDownload, + false, + "torrent:first-" + Guid.NewGuid().ToString("N"), + 12); + var second = Release( + animation, + null, + 1, + null, + 200, + DateTimeOffset.UtcNow.AddMinutes(-1), + FileDownloadTypes.TorrentDownload, + false, + "torrent:second-" + Guid.NewGuid().ToString("N"), + 12); + first.IsActiveRelease = false; + second.IsActiveRelease = false; + await using (var seedContext = new Models.ApplicationContext(_contextOptions)) + { + seedContext.AnimationInfo.AddRange(first, second); + await seedContext.SaveChangesAsync(cancellationToken); + } + + if (concurrent) + { + async Task IdentifyAsync(Guid releaseId) + { + await using var updateContext = new Models.ApplicationContext(_contextOptions); + var updateRepository = new AnimationInfoRepository(updateContext, _contextOptions); + var record = await updateRepository.FindByIdAsync(releaseId, cancellationToken) + ?? throw new InvalidOperationException("Seeded release could not be reloaded."); + if (!await updateRepository.TryUpdateAsync( + record with + { + Animation = animation.ToRecord(), + Season = 1, + Episode = 1 + }, + record.StateVersion, + cancellationToken)) + throw new InvalidOperationException("Seeded release could not be identified."); + } + + await Task.WhenAll(IdentifyAsync(first.Id), IdentifyAsync(second.Id)); + } + else + { + await using var repositoryContext = new Models.ApplicationContext(_contextOptions); + var repository = new AnimationInfoRepository(repositoryContext, _contextOptions); + var firstRecord = await repository.FindByIdAsync(first.Id, cancellationToken); + var secondRecord = await repository.FindByIdAsync(second.Id, cancellationToken); + if (firstRecord is null || secondRecord is null) + throw new InvalidOperationException("Seeded releases could not be reloaded."); + + var animationRecord = animation.ToRecord(); + if (!await repository.TryUpdateAsync( + firstRecord with { Animation = animationRecord, Season = 1, Episode = 1 }, + firstRecord.StateVersion, + cancellationToken) || + !await repository.TryUpdateAsync( + secondRecord with { Animation = animationRecord, Season = 1, Episode = 1 }, + secondRecord.StateVersion, + cancellationToken)) + throw new InvalidOperationException("Seeded releases could not be identified."); + + if (moveFirst) + { + var identifiedFirst = await repository.FindByIdAsync(first.Id, cancellationToken) + ?? throw new InvalidOperationException( + "Active release could not be reloaded."); + if (!await repository.TryUpdateAsync( + identifiedFirst with { Episode = 2 }, + identifiedFirst.StateVersion, + cancellationToken)) + throw new InvalidOperationException("Active release could not be moved."); + } + else if (deidentifyFirst) + { + var identifiedFirst = await repository.FindByIdAsync(first.Id, cancellationToken) + ?? throw new InvalidOperationException( + "Active release could not be reloaded."); + if (!await repository.TryUpdateAsync( + identifiedFirst with { Animation = null }, + identifiedFirst.StateVersion, + cancellationToken)) + throw new InvalidOperationException("Active release could not be de-identified."); + } + } + + await using var readContext = new Models.ApplicationContext(_contextOptions); + var releases = await readContext.AnimationInfo + .Where(info => info.Id == first.Id || info.Id == second.Id) + .ToDictionaryAsync(info => info.Id, cancellationToken); + return ( + releases[first.Id].IsActiveRelease, + releases[second.Id].IsActiveRelease, + releases[first.Id].Episode!.Value); + } + private static Models.AnimationInfo Release( Models.Animation animation, Models.AnimationGroup? group, @@ -320,28 +793,29 @@ private static Models.AnimationInfo Release( bool downloaded, string identity, int expected) => new() - { - Id = Guid.NewGuid(), - Animation = animation, - Group = group, - Title = $"{animation.Name} S{season:D2}E{episode:D2}", - Description = animation.OriginalName, - PublishTime = ingestedAt, - IngestedAt = ingestedAt, - DownloadUrl = "https://example.test/" + Guid.NewGuid().ToString("N"), - DownloadType = downloadType, - IsDownloadTracked = downloaded, - IsDownloadFinished = downloaded, - FileStore = downloaded ? "local" : null, - StorePath = downloaded ? "/store/" + Guid.NewGuid().ToString("N") : null, - Season = season, - Episode = episode, - ReleaseIdentity = identity, - ReleaseSubtitleGroup = group?.Name, - ReleaseScore = score, - ExpectedEpisodeCount = expected, - IsAiProcessed = true - }; + { + Id = Guid.NewGuid(), + Animation = animation, + Group = group, + Title = $"{animation.Name} S{season:D2}E{episode:D2}", + Description = animation.OriginalName, + PublishTime = ingestedAt, + IngestedAt = ingestedAt, + DownloadUrl = "https://example.test/" + Guid.NewGuid().ToString("N"), + DownloadType = downloadType, + IsDownloadTracked = downloaded, + IsDownloadFinished = downloaded, + FileStore = downloaded ? "local" : null, + StorePath = downloaded ? "/store/" + Guid.NewGuid().ToString("N") : null, + Season = season, + Episode = episode, + ReleaseIdentity = identity, + ReleaseSubtitleGroup = group?.Name, + ReleaseScore = score, + ExpectedEpisodeCount = expected, + IsAiProcessed = true, + IsActiveRelease = true + }; private static Models.FileMapping MappingEntity(Guid animationInfoId, string path) => new() { @@ -361,4 +835,6 @@ internal sealed record LibraryScenario( internal sealed record UpgradeScenario( ReleaseUpgradeCandidate Candidate, - string CanonicalPath); + string CanonicalPath, + string CanonicalSubtitlePath, + Guid UserId); diff --git a/SecondDimensionWatcherReDive/Repositories/MetadataReviewRepository.cs b/SecondDimensionWatcherReDive/Repositories/MetadataReviewRepository.cs index 233c45d..decee7c 100644 --- a/SecondDimensionWatcherReDive/Repositories/MetadataReviewRepository.cs +++ b/SecondDimensionWatcherReDive/Repositories/MetadataReviewRepository.cs @@ -290,6 +290,10 @@ public async Task ApplyPreviewAsync( var mappingsBefore = existingMappings.Select(mapping => mapping.ToRecord()).ToList(); var animationInfoEntry = applyContext.Entry(animationInfo); + var previousEpisodeIdentity = AnimationInfoRepository.GetEpisodeIdentity( + applyContext, + animationInfo); + var wasActiveRelease = animationInfo.IsActiveRelease; operation.PreviousDescription = animationInfo.Description; operation.PreviousAnimationId = animationInfoEntry .Property("AnimationId") @@ -335,6 +339,8 @@ await applyContext.MetadataReviewMappingSnapshots.AddRangeAsync( animationInfo.Description = operation.ProposedDescription; animationInfo.Animation = animation; animationInfo.Group = group; + animationInfoEntry.Property("AnimationId").CurrentValue = animation.Id; + animationInfoEntry.Property("GroupId").CurrentValue = group?.Id; animationInfo.Season = operation.ProposedSeason; animationInfo.Episode = operation.ProposedEpisode; animationInfo.MetadataStatus = MetadataReviewStatus.Reviewed; @@ -344,6 +350,13 @@ await applyContext.MetadataReviewMappingSnapshots.AddRangeAsync( animationInfo.AiRetryCount = 0; animationInfo.MetadataReviewedAt = appliedAt; animationInfo.CurrentMetadataReviewOperationId = operation.Id; + await AnimationInfoRepository.SetEpisodeReleaseActivityAsync( + applyContext, + animationInfo, + cancellationToken); + var currentEpisodeIdentity = AnimationInfoRepository.GetEpisodeIdentity( + applyContext, + animationInfo); animationInfo.StateVersion = checked(animationInfo.StateVersion + 1); var replacementMappings = proposedSnapshots @@ -374,6 +387,13 @@ await applyContext.FileMappings operation.AppliedVersion = animationInfo.StateVersion; await applyContext.SaveChangesAsync(cancellationToken); + await AnimationInfoRepository.PromotePreviousEpisodeSuccessorAsync( + applyContext, + animationInfo.Id, + wasActiveRelease, + previousEpisodeIdentity, + currentEpisodeIdentity, + cancellationToken); await transaction.CommitAsync(cancellationToken); return new MetadataReviewMutationResult( MetadataReviewMutationOutcome.Success, @@ -547,9 +567,17 @@ public async Task UndoAsync( animationInfo.Id); } + var previousEpisodeIdentity = AnimationInfoRepository.GetEpisodeIdentity( + undoContext, + animationInfo); + var wasActiveRelease = animationInfo.IsActiveRelease; animationInfo.Description = operation.PreviousDescription; animationInfo.Animation = previousAnimation; animationInfo.Group = previousGroup; + undoContext.Entry(animationInfo).Property("AnimationId").CurrentValue = + previousAnimation?.Id; + undoContext.Entry(animationInfo).Property("GroupId").CurrentValue = + previousGroup?.Id; animationInfo.Season = operation.PreviousSeason; animationInfo.Episode = operation.PreviousEpisode; animationInfo.MetadataStatus = operation.PreviousMetadataStatus.Value; @@ -559,6 +587,13 @@ public async Task UndoAsync( animationInfo.AiRetryCount = operation.PreviousAiRetryCount.Value; animationInfo.MetadataReviewedAt = operation.PreviousReviewedAt; animationInfo.CurrentMetadataReviewOperationId = operation.PreviousCurrentOperationId; + await AnimationInfoRepository.SetEpisodeReleaseActivityAsync( + undoContext, + animationInfo, + cancellationToken); + var currentEpisodeIdentity = AnimationInfoRepository.GetEpisodeIdentity( + undoContext, + animationInfo); animationInfo.StateVersion = checked(animationInfo.StateVersion + 1); var restoredMappings = previousSnapshots @@ -594,6 +629,13 @@ await undoContext.FileMappings previousOperation.AppliedVersion = animationInfo.StateVersion; await undoContext.SaveChangesAsync(cancellationToken); + await AnimationInfoRepository.PromotePreviousEpisodeSuccessorAsync( + undoContext, + animationInfo.Id, + wasActiveRelease, + previousEpisodeIdentity, + currentEpisodeIdentity, + cancellationToken); await transaction.CommitAsync(cancellationToken); return new MetadataReviewMutationResult( MetadataReviewMutationOutcome.Success, diff --git a/SecondDimensionWatcherReDive/Repositories/ReleaseUpgradeRepository.cs b/SecondDimensionWatcherReDive/Repositories/ReleaseUpgradeRepository.cs index cff38da..e69543c 100644 --- a/SecondDimensionWatcherReDive/Repositories/ReleaseUpgradeRepository.cs +++ b/SecondDimensionWatcherReDive/Repositories/ReleaseUpgradeRepository.cs @@ -1,10 +1,11 @@ +using System.Text.RegularExpressions; using Microsoft.EntityFrameworkCore; using Npgsql; using SecondDimensionWatcherReDive.Framework.DataRepository; namespace SecondDimensionWatcherReDive.Repositories; -public sealed class ReleaseUpgradeRepository( +public sealed partial class ReleaseUpgradeRepository( Models.ApplicationContext context, DbContextOptions contextOptions) : IReleaseUpgradeRepository { @@ -30,16 +31,17 @@ public async Task> GetCandidatesAsync( var policies = await context.SubscriptionAutomationPolicies.AsNoTracking() .ToDictionaryAsync(policy => policy.FeedId, cancellationToken); var attempted = await context.ReleaseUpgradeOperations.AsNoTracking() + .Where(operation => automaticOnly || operation.Status != ReleaseUpgradeStatus.Failed) .Select(operation => operation.CandidateReleaseId) .ToHashSetAsync(cancellationToken); var candidates = new List(); foreach (var episode in releases.GroupBy(info => new - { - AnimationId = info.Animation!.Id, - info.Season, - info.Episode - })) + { + AnimationId = info.Animation!.Id, + info.Season, + info.Episode + })) { var current = episode .Where(info => info.IsActiveRelease && info.IsDownloadFinished && mapped.Contains(info.Id)) @@ -109,6 +111,9 @@ public async Task> GetCandidatesAsync( current.Season != next.Season || current.Episode != next.Episode || next.ReleaseScore <= current.ReleaseScore || + !current.IsActiveRelease || + next.IsActiveRelease || + (next.IsDownloadTracked && next.DownloadCancellationId is not null) || !current.IsDownloadFinished || !await writeContext.FileMappings.AnyAsync( mapping => mapping.AnimationInfoId == current.Id, @@ -116,7 +121,8 @@ public async Task> GetCandidatesAsync( return null; if (await writeContext.ReleaseUpgradeOperations.AnyAsync( - operation => operation.CandidateReleaseId == next.Id || + operation => (operation.CandidateReleaseId == next.Id && + operation.Status != ReleaseUpgradeStatus.Failed) || (operation.CurrentReleaseId == current.Id && (operation.Status == ReleaseUpgradeStatus.Downloading || operation.Status == ReleaseUpgradeStatus.Verifying || @@ -144,7 +150,7 @@ public async Task> GetCandidatesAsync( return entity.ToRecord(); } catch (DbUpdateException exception) when (exception.InnerException is PostgresException - { SqlState: PostgresErrorCodes.UniqueViolation }) + { SqlState: PostgresErrorCodes.UniqueViolation }) { return null; } @@ -207,6 +213,8 @@ public async Task> GetReadyCandidateIdsAsync( public async Task ActivateAsync( Guid operationId, + IReadOnlyList expectedPreviousMappings, + IReadOnlyList expectedCandidateMappings, DateTimeOffset verifiedAt, DateTimeOffset rollbackUntil, CancellationToken cancellationToken) @@ -235,6 +243,14 @@ public async Task ActivateAsync( !infos.TryGetValue(operation.CandidateReleaseId, out var candidate) || !candidate.IsDownloadFinished) return new ReleaseUpgradeMutationResult(false, "candidate_not_ready", operation.ToRecord()); + if (current.DownloadCancellationId is not null || + candidate.DownloadCancellationId is not null) + return new ReleaseUpgradeMutationResult(false, "download_cancelling", operation.ToRecord()); + if (!AreSameEpisode(writeContext, current, candidate) || + !current.IsActiveRelease || + candidate.IsActiveRelease || + candidate.ReleaseScore <= current.ReleaseScore) + return new ReleaseUpgradeMutationResult(false, "release_changed", operation.ToRecord()); var mappings = await writeContext.FileMappings .Where(mapping => mapping.AnimationInfoId == current.Id || @@ -245,6 +261,9 @@ public async Task ActivateAsync( var next = mappings.Where(mapping => mapping.AnimationInfoId == candidate.Id).ToList(); if (previous.Count == 0 || next.Count == 0) return new ReleaseUpgradeMutationResult(false, "mapping_missing", operation.ToRecord()); + if (!MappingSetsMatch(previous, expectedPreviousMappings) || + !MappingSetsMatch(next, expectedCandidateMappings)) + return new ReleaseUpgradeMutationResult(false, "mapping_changed", operation.ToRecord()); var snapshots = previous .Select(mapping => ToSnapshot( @@ -262,7 +281,11 @@ await writeContext.ReleaseUpgradeMappingSnapshots.AddRangeAsync( writeContext.FileMappings.RemoveRange(mappings); var replacement = BuildCandidateReplacement(previous, next, candidate.Id); - await writeContext.FileMappings.AddRangeAsync(replacement, cancellationToken); + await writeContext.FileMappings.AddRangeAsync(replacement.Mappings, cancellationToken); + await TransferPlaybackProgressAsync( + writeContext, + BuildActivationPlaybackTransfers(current.Id, candidate.Id, replacement), + cancellationToken); await writeContext.AnimationInfo .Where(info => info.Id == current.Id) .ExecuteUpdateAsync(setters => setters @@ -290,19 +313,33 @@ public async Task MarkFailedAsync( string failureSummary, CancellationToken cancellationToken) { - var operation = await context.ReleaseUpgradeOperations - .SingleOrDefaultAsync(item => item.Id == operationId, cancellationToken); - if (operation is null) - return new ReleaseUpgradeMutationResult(false, "not_found", null); - if (operation.Status is ReleaseUpgradeStatus.Completed or ReleaseUpgradeStatus.RolledBack) - return new ReleaseUpgradeMutationResult(false, "invalid_state", operation.ToRecord()); - operation.Status = ReleaseUpgradeStatus.Failed; - operation.FailureSummary = failureSummary.Length <= 2048 - ? failureSummary - : failureSummary[..2048]; - operation.CompletedAt = DateTimeOffset.UtcNow; - await context.SaveChangesAsync(cancellationToken); - return new ReleaseUpgradeMutationResult(true, "failed", operation.ToRecord()); + var strategy = context.Database.CreateExecutionStrategy(); + return await strategy.ExecuteAsync(async () => + { + await using var writeContext = new Models.ApplicationContext(contextOptions); + await using var transaction = await writeContext.Database.BeginTransactionAsync(cancellationToken); + await MappingTransactionLock.AcquireAsync(writeContext, cancellationToken); + await writeContext.Database.ExecuteSqlInterpolatedAsync( + $"SELECT 1 FROM \"ReleaseUpgradeOperations\" WHERE \"Id\" = {operationId} FOR UPDATE", + cancellationToken); + var operation = await writeContext.ReleaseUpgradeOperations + .SingleOrDefaultAsync(item => item.Id == operationId, cancellationToken); + if (operation is null) + return new ReleaseUpgradeMutationResult(false, "not_found", null); + if (operation.Status == ReleaseUpgradeStatus.Failed) + return new ReleaseUpgradeMutationResult(true, "already_failed", operation.ToRecord()); + if (operation.Status is not (ReleaseUpgradeStatus.Downloading or ReleaseUpgradeStatus.Verifying)) + return new ReleaseUpgradeMutationResult(false, "invalid_state", operation.ToRecord()); + + operation.Status = ReleaseUpgradeStatus.Failed; + operation.FailureSummary = failureSummary.Length <= 2048 + ? failureSummary + : failureSummary[..2048]; + operation.CompletedAt = DateTimeOffset.UtcNow; + await writeContext.SaveChangesAsync(cancellationToken); + await transaction.CommitAsync(cancellationToken); + return new ReleaseUpgradeMutationResult(true, "failed", operation.ToRecord()); + }); } public async Task RollbackAsync( @@ -330,12 +367,39 @@ public async Task RollbackAsync( writeContext, [operation.CurrentReleaseId, operation.CandidateReleaseId], cancellationToken); + if (!infos.TryGetValue(operation.CurrentReleaseId, out var current) || + !infos.TryGetValue(operation.CandidateReleaseId, out var activeCandidate) || + !AreSameEpisode(writeContext, current, activeCandidate) || + current.IsActiveRelease || + !activeCandidate.IsActiveRelease) + return new ReleaseUpgradeMutationResult(false, "release_changed", operation.ToRecord()); var previous = operation.MappingSnapshots .Where(snapshot => snapshot.Kind == ReleaseUpgradeMappingKind.Previous) .ToList(); - if (previous.Count == 0) + var candidate = operation.MappingSnapshots + .Where(snapshot => snapshot.Kind == ReleaseUpgradeMappingKind.Candidate) + .ToList(); + if (previous.Count == 0 || candidate.Count == 0) return new ReleaseUpgradeMutationResult(false, "snapshot_missing", operation.ToRecord()); + var replacement = BuildCandidateReplacement( + previous.Select(FromSnapshot).ToList(), + candidate.Select(FromSnapshot).ToList(), + operation.CandidateReleaseId); + var currentCandidateMappings = await writeContext.FileMappings + .AsNoTracking() + .Where(mapping => mapping.AnimationInfoId == operation.CandidateReleaseId) + .ToListAsync(cancellationToken); + if (!MappingSetsMatch(currentCandidateMappings, replacement.Mappings)) + return new ReleaseUpgradeMutationResult(false, "mapping_changed", operation.ToRecord()); + await TransferPlaybackProgressAsync( + writeContext, + BuildRollbackPlaybackTransfers( + operation.CurrentReleaseId, + operation.CandidateReleaseId, + replacement), + cancellationToken); + await writeContext.FileMappings .Where(mapping => mapping.AnimationInfoId == operation.CandidateReleaseId) .ExecuteDeleteAsync(cancellationToken); @@ -348,16 +412,16 @@ await writeContext.FileMappings FileStore = snapshot.FileStore }), cancellationToken); await writeContext.AnimationInfo - .Where(info => info.Id == operation.CurrentReleaseId) + .Where(info => info.Id == operation.CandidateReleaseId) .ExecuteUpdateAsync(setters => setters .SetProperty(info => info.StateVersion, info => info.StateVersion + 1) - .SetProperty(info => info.IsActiveRelease, true), + .SetProperty(info => info.IsActiveRelease, false), cancellationToken); await writeContext.AnimationInfo - .Where(info => info.Id == operation.CandidateReleaseId) + .Where(info => info.Id == operation.CurrentReleaseId) .ExecuteUpdateAsync(setters => setters .SetProperty(info => info.StateVersion, info => info.StateVersion + 1) - .SetProperty(info => info.IsActiveRelease, false), + .SetProperty(info => info.IsActiveRelease, true), cancellationToken); operation.Status = ReleaseUpgradeStatus.RolledBack; operation.CompletedAt = rolledBackAt; @@ -395,34 +459,50 @@ private static Models.ReleaseUpgradeMappingSnapshot ToSnapshot( Guid operationId, Models.FileMapping mapping, ReleaseUpgradeMappingKind kind) => new() - { - Id = Guid.NewGuid(), - OperationId = operationId, - Kind = kind, - OriginalMappingId = mapping.Id, - AnimationInfoId = mapping.AnimationInfoId, - VirtualPath = mapping.VirtualPath, - PhysicalPath = mapping.PhysicalPath, - FileStore = mapping.FileStore - }; + { + Id = Guid.NewGuid(), + OperationId = operationId, + Kind = kind, + OriginalMappingId = mapping.Id, + AnimationInfoId = mapping.AnimationInfoId, + VirtualPath = mapping.VirtualPath, + PhysicalPath = mapping.PhysicalPath, + FileStore = mapping.FileStore + }; - private static IReadOnlyList BuildCandidateReplacement( + private static CandidateReplacementPlan BuildCandidateReplacement( IReadOnlyList previous, IReadOnlyList candidate, Guid candidateReleaseId) { - var remaining = new HashSet(candidate.Select(item => item.VirtualPath), StringComparer.Ordinal); + var previousByRole = previous + .GroupBy(mapping => GetStableFileRole(mapping.VirtualPath), StringComparer.OrdinalIgnoreCase) + .Where(group => group.Count() == 1) + .ToDictionary(group => group.Key, group => group.Single(), StringComparer.OrdinalIgnoreCase); + var matchedPreviousIds = new HashSet(); var used = new HashSet(StringComparer.Ordinal); var result = new List(candidate.Count); - for (var index = 0; index < candidate.Count; index++) + var candidatePathReplacements = new Dictionary(StringComparer.Ordinal); + var previousPathReplacements = new Dictionary(StringComparer.Ordinal); + foreach (var mapping in candidate + .OrderBy(item => item.VirtualPath, StringComparer.Ordinal) + .ThenBy(item => item.PhysicalPath, StringComparer.Ordinal) + .ThenBy(item => item.Id)) { - var mapping = candidate[index]; - remaining.Remove(mapping.VirtualPath); - var preferred = index < previous.Count ? previous[index].VirtualPath : mapping.VirtualPath; - var virtualPath = !used.Contains(preferred) && !remaining.Contains(preferred) - ? preferred + var role = GetStableFileRole(mapping.VirtualPath); + var matchedPrevious = previousByRole.GetValueOrDefault(role); + var usesPreviousPath = matchedPrevious is not null && + matchedPreviousIds.Add(matchedPrevious.Id); + var virtualPath = usesPreviousPath + ? matchedPrevious!.VirtualPath : mapping.VirtualPath; - used.Add(virtualPath); + if (!used.Add(virtualPath)) + throw new InvalidOperationException( + $"Candidate replacement produced duplicate virtual path '{virtualPath}'."); + + candidatePathReplacements.Add(mapping.VirtualPath, virtualPath); + if (usesPreviousPath) + previousPathReplacements.TryAdd(matchedPrevious!.VirtualPath, virtualPath); result.Add(new Models.FileMapping { Id = Guid.NewGuid(), @@ -433,9 +513,190 @@ private static Models.ReleaseUpgradeMappingSnapshot ToSnapshot( }); } - return result; + return new CandidateReplacementPlan( + result, + candidatePathReplacements, + previousPathReplacements); + } + + private static string GetStableFileRole(string virtualPath) + { + var fileName = virtualPath[(virtualPath.LastIndexOf('/') + 1)..]; + var extension = Path.GetExtension(fileName); + var stem = extension.Length == 0 ? fileName : fileName[..^extension.Length]; + return CollisionSuffixRegex().Replace(stem, string.Empty) + extension; + } + + private static Models.FileMapping FromSnapshot(Models.ReleaseUpgradeMappingSnapshot snapshot) => new() + { + Id = snapshot.OriginalMappingId, + AnimationInfoId = snapshot.AnimationInfoId, + VirtualPath = snapshot.VirtualPath, + PhysicalPath = snapshot.PhysicalPath, + FileStore = snapshot.FileStore + }; + + private static bool AreSameEpisode( + Models.ApplicationContext writeContext, + Models.AnimationInfo current, + Models.AnimationInfo candidate) + { + var currentAnimationId = writeContext.Entry(current) + .Property("AnimationId") + .CurrentValue; + var candidateAnimationId = writeContext.Entry(candidate) + .Property("AnimationId") + .CurrentValue; + return currentAnimationId is not null && + currentAnimationId == candidateAnimationId && + current.Season is not null && + current.Season == candidate.Season && + current.Episode is not null && + current.Episode == candidate.Episode; + } + + private static bool MappingSetsMatch( + IReadOnlyCollection actual, + IReadOnlyCollection expected) + { + if (actual.Count != expected.Count) return false; + var expectedMappings = expected + .Select(mapping => (mapping.VirtualPath, mapping.PhysicalPath, mapping.FileStore)) + .ToHashSet(); + return actual.All(mapping => expectedMappings.Contains( + (mapping.VirtualPath, mapping.PhysicalPath, mapping.FileStore))); + } + + private static bool MappingSetsMatch( + IReadOnlyCollection actual, + IReadOnlyCollection expected) + { + if (actual.Count != expected.Count) return false; + var expectedMappings = expected + .Select(mapping => (mapping.VirtualPath, mapping.PhysicalPath, mapping.FileStore)) + .ToHashSet(); + return actual.All(mapping => expectedMappings.Contains( + (mapping.VirtualPath, mapping.PhysicalPath, mapping.FileStore))); + } + + private static IReadOnlyDictionary + BuildActivationPlaybackTransfers( + Guid currentReleaseId, + Guid candidateReleaseId, + CandidateReplacementPlan replacement) + { + var transfers = replacement.CandidatePathReplacements.ToDictionary( + pair => new PlaybackLocation(candidateReleaseId, pair.Key), + pair => new PlaybackLocation(candidateReleaseId, pair.Value)); + foreach (var pair in replacement.PreviousPathReplacements) + { + transfers[new PlaybackLocation(currentReleaseId, pair.Key)] = + new PlaybackLocation(candidateReleaseId, pair.Value); + } + + return transfers; + } + + private static IReadOnlyDictionary + BuildRollbackPlaybackTransfers( + Guid currentReleaseId, + Guid candidateReleaseId, + CandidateReplacementPlan replacement) + { + var transfers = new Dictionary(); + foreach (var pair in replacement.PreviousPathReplacements) + { + transfers[new PlaybackLocation(candidateReleaseId, pair.Value)] = + new PlaybackLocation(currentReleaseId, pair.Key); + } + + return transfers; } + private static async Task TransferPlaybackProgressAsync( + Models.ApplicationContext writeContext, + IReadOnlyDictionary transfers, + CancellationToken cancellationToken) + { + if (transfers.Count == 0) return; + + var ownerIds = transfers.Keys + .Select(location => location.AnimationInfoId) + .Concat(transfers.Values.Select(location => location.AnimationInfoId)) + .Distinct() + .ToArray(); + var rows = await writeContext.PlaybackProgresses + .AsNoTracking() + .Where(progress => ownerIds.Contains(progress.AnimationInfoId)) + .ToListAsync(cancellationToken); + var targets = transfers.Values.ToHashSet(); + var affected = rows + .Where(progress => + { + var location = new PlaybackLocation( + progress.AnimationInfoId, + progress.VirtualPath); + return transfers.ContainsKey(location) || targets.Contains(location); + }) + .ToList(); + if (affected.Count == 0) return; + + var affectedIds = affected.Select(progress => progress.Id).ToArray(); + await writeContext.PlaybackProgresses + .Where(progress => affectedIds.Contains(progress.Id)) + .ExecuteDeleteAsync(cancellationToken); + + var merged = affected + .Select(progress => + { + var source = new PlaybackLocation( + progress.AnimationInfoId, + progress.VirtualPath); + var target = transfers.GetValueOrDefault(source, source); + return (Progress: progress, Target: target); + }) + .GroupBy(item => new + { + item.Progress.UserId, + item.Target.AnimationInfoId, + item.Target.VirtualPath + }) + .Select(group => + { + // If both releases have progress for the same user/file role, + // retain the last user action rather than reviving stale state. + var winner = group + .OrderByDescending(item => item.Progress.UpdatedAt) + .ThenByDescending(item => item.Progress.Id) + .First() + .Progress; + return new Models.PlaybackProgress + { + Id = winner.Id, + UserId = group.Key.UserId, + AnimationInfoId = group.Key.AnimationInfoId, + VirtualPath = group.Key.VirtualPath, + PositionSeconds = winner.PositionSeconds, + DurationSeconds = winner.DurationSeconds, + IsWatched = winner.IsWatched, + UpdatedAt = winner.UpdatedAt, + WatchedAt = winner.WatchedAt + }; + }) + .ToList(); + await writeContext.PlaybackProgresses.AddRangeAsync(merged, cancellationToken); + } + + [GeneratedRegex(@" \(\d+\)$", RegexOptions.CultureInvariant)] + private static partial Regex CollisionSuffixRegex(); + + private readonly record struct PlaybackLocation(Guid AnimationInfoId, string VirtualPath); + + private sealed record CandidateReplacementPlan( + IReadOnlyList Mappings, + IReadOnlyDictionary CandidatePathReplacements, + IReadOnlyDictionary PreviousPathReplacements); + private static IReadOnlyList ParseReasons(string? json) { if (string.IsNullOrWhiteSpace(json)) return []; diff --git a/SecondDimensionWatcherReDive/Utils/ReleaseUpgrades/ReleaseUpgradeCoordinator.cs b/SecondDimensionWatcherReDive/Utils/ReleaseUpgrades/ReleaseUpgradeCoordinator.cs index 2424349..61fc6a0 100644 --- a/SecondDimensionWatcherReDive/Utils/ReleaseUpgrades/ReleaseUpgradeCoordinator.cs +++ b/SecondDimensionWatcherReDive/Utils/ReleaseUpgrades/ReleaseUpgradeCoordinator.cs @@ -48,21 +48,45 @@ public async Task ExecuteAsync( return Result(false, "upgrade_already_started", false, requiresDownload, null, ["Another worker already claimed this upgrade."]); - if (!requiresDownload) + if (operation.Status == ReleaseUpgradeStatus.Verifying) return await ActivateAsync(operation.CandidateReleaseId, cancellationToken); + next = await animationInfoRepository.FindByIdAsync( + candidate.CandidateReleaseId, + cancellationToken); + if (next is null) + return await FailAsync(operation, "Candidate release disappeared after claim.", cancellationToken); + if (next.IsDownloadFinished) + return await ActivateAsync(operation.CandidateReleaseId, cancellationToken); + if (next.IsDownloadTracked) + return Result(true, "download_in_progress", false, true, operation, []); + var downloadAttemptId = Guid.NewGuid(); + IFileDownloadClient? client = null; + var downloadStartAttempted = false; + var submissionAttempted = false; try { + downloadStartAttempted = true; if (!await animationInfoRepository.TryStartDownloadAsync( next.Id, downloadAttemptId, DateTimeOffset.UtcNow, SubscriptionAutomationDisposition.AutoDownloadQueued, cancellationToken)) + { + var racedCandidate = await animationInfoRepository.FindByIdAsync( + next.Id, + cancellationToken); + if (racedCandidate?.IsDownloadFinished == true) + return await ActivateAsync(operation.CandidateReleaseId, cancellationToken); + if (racedCandidate?.IsDownloadTracked == true) + return Result(true, "download_in_progress", false, true, operation, []); return await FailAsync(operation, "Candidate download state changed.", cancellationToken); + } - var client = downloadClientProvider.GetRequiredClient(next.DownloadType); + client = downloadClientProvider.GetRequiredClient(next.DownloadType); + submissionAttempted = true; if (!await client.SubmitDownloadTaskAsync( next.Id, next.DownloadUrl, @@ -70,11 +94,16 @@ public async Task ExecuteAsync( next.AdditionalDownloadInfo, cancellationToken)) { - await animationInfoRepository.TryCancelDownloadAsync( - next.Id, + var compensation = await CompensateDownloadStartAsync( + next, + client, downloadAttemptId, - SubscriptionAutomationDisposition.AutoDownloadFailed, - cancellationToken); + remoteMayHaveAccepted: false); + if (compensation == DownloadCompensationOutcome.RetainedForRecovery) + return await RecoveryPendingAsync( + operation, + "Download client rejected the candidate, but local state could not be restored.", + cancellationToken); return await FailAsync(operation, "Download client rejected the candidate.", cancellationToken); } @@ -82,11 +111,33 @@ await animationInfoRepository.TryCancelDownloadAsync( } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { + var compensation = downloadStartAttempted + ? await CompensateDownloadStartAsync( + next, + client, + downloadAttemptId, + submissionAttempted) + : DownloadCompensationOutcome.LocalStateRestored; + await FinalizeCancelledOperationAsync(operation, compensation); + throw; } catch (Exception exception) { + var compensation = downloadStartAttempted + ? await CompensateDownloadStartAsync( + next, + client, + downloadAttemptId, + submissionAttempted) + : DownloadCompensationOutcome.LocalStateRestored; + logger.LogWarning(exception, "Failed to queue release upgrade {OperationId}", operation.Id); + if (compensation == DownloadCompensationOutcome.RetainedForRecovery) + return await RecoveryPendingAsync( + operation, + $"{exception.Message} Download tracking was retained because cancellation could not be confirmed.", + cancellationToken); return await FailAsync(operation, exception.Message, cancellationToken); } } @@ -132,6 +183,8 @@ private async Task ActivateAsync( if (activation is null) return Result(false, "operation_not_found", false, false, null, ["No active upgrade was found for this candidate."]); + if (activation.CandidateMappings.Count == 0) + return Result(true, "mapping_pending", false, false, activation.Operation, []); // Validation is deliberately external to the mapping transaction. Until every // candidate file passes, the old virtual paths and physical files remain untouched. @@ -151,6 +204,8 @@ private async Task ActivateAsync( var now = DateTimeOffset.UtcNow; var mutation = await upgradeRepository.ActivateAsync( activation.Operation.Id, + activation.PreviousMappings, + activation.CandidateMappings, now, now.AddHours(rollbackHours), cancellationToken); @@ -238,6 +293,21 @@ private async Task FailAsync( operation.Id, summary, cancellationToken); + if (!mutation.IsSuccess) + { + var settled = mutation.Operation?.Status is + ReleaseUpgradeStatus.Applied or + ReleaseUpgradeStatus.Completed or + ReleaseUpgradeStatus.RolledBack; + return Result( + settled, + settled ? "already_settled" : mutation.Outcome, + false, + false, + mutation.Operation ?? operation, + errors ?? [summary]); + } + await incidentReporter.ReportAsync(new IncidentReport( IncidentType.FileMappingFailure, IncidentSeverity.Error, @@ -249,8 +319,175 @@ await incidentReporter.ReportAsync(new IncidentReport( errors ?? [summary]); } + private async Task RecoveryPendingAsync( + ReleaseUpgradeOperation operation, + string summary, + CancellationToken cancellationToken) + { + await incidentReporter.ReportAsync(new IncidentReport( + IncidentType.FileMappingFailure, + IncidentSeverity.Error, + "Release upgrade download recovery pending", + summary, + UpgradeSource(operation.Id)), + cancellationToken); + return Result(false, "recovery_pending", false, true, operation, [summary]); + } + + private async Task FinalizeCancelledOperationAsync( + ReleaseUpgradeOperation operation, + DownloadCompensationOutcome compensation) + { + using var cleanup = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var summary = compensation == DownloadCompensationOutcome.LocalStateRestored + ? "Release upgrade request was cancelled and its download state was restored." + : "Release upgrade request was cancelled, but download tracking was retained for recovery."; + try + { + if (compensation == DownloadCompensationOutcome.LocalStateRestored) + await upgradeRepository.MarkFailedAsync(operation.Id, summary, cleanup.Token); + await incidentReporter.ReportAsync(new IncidentReport( + IncidentType.FileMappingFailure, + IncidentSeverity.Error, + compensation == DownloadCompensationOutcome.LocalStateRestored + ? "Release upgrade cancelled" + : "Release upgrade download recovery pending", + summary, + UpgradeSource(operation.Id)), + cleanup.Token); + } + catch (Exception exception) + { + logger.LogWarning( + exception, + "Could not finalize cancelled release upgrade {OperationId}", + operation.Id); + } + } + + private async Task CompensateDownloadStartAsync( + AnimationInfo info, + IFileDownloadClient? downloadClient, + Guid downloadAttemptId, + bool remoteMayHaveAccepted) + { + using var cleanup = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var cancellationAttemptId = Guid.NewGuid(); + try + { + // Register cancellation before any remote I/O. Activation and the + // cancellation saga share the mapping lock, so an activation that + // already committed makes this return false and its live files are + // never deleted by stale compensation. + if (!await animationInfoRepository.TryBeginCancelDownloadAsync( + info.Id, + downloadAttemptId, + cancellationAttemptId, + cleanup.Token)) + { + var current = await animationInfoRepository.FindByIdAsync( + info.Id, + cleanup.Token); + return current is null || !current.IsDownloadTracked + ? DownloadCompensationOutcome.LocalStateRestored + : DownloadCompensationOutcome.RetainedForRecovery; + } + } + catch (Exception exception) + { + logger.LogWarning( + exception, + "Could not register compensation for release upgrade download {AnimationInfoId}", + info.Id); + return DownloadCompensationOutcome.RetainedForRecovery; + } + + if (remoteMayHaveAccepted && downloadClient is not null) + { + try + { + var cancellation = await downloadClient.CancelDownloadTaskAsync( + info.Id, + info.DownloadUrl, + info.CachedDownloadData, + info.AdditionalDownloadInfo, + removeFile: false, + cleanup.Token); + if (!cancellation.IsSuccess) + { + await QueryDownloadProgressSafelyAsync(downloadClient, info, cleanup.Token); + return DownloadCompensationOutcome.RetainedForRecovery; + } + } + catch (Exception exception) + { + logger.LogWarning( + exception, + "Could not confirm compensation for release upgrade download {AnimationInfoId}", + info.Id); + await QueryDownloadProgressSafelyAsync(downloadClient, info, cleanup.Token); + return DownloadCompensationOutcome.RetainedForRecovery; + } + } + else if (remoteMayHaveAccepted) + { + return DownloadCompensationOutcome.RetainedForRecovery; + } + + try + { + var restored = await fileMappingRepository.TryFinalizeDownloadCancellationAsync( + info.Id, + downloadAttemptId, + cancellationAttemptId, + SubscriptionAutomationDisposition.AutoDownloadFailed, + cleanup.Token); + if (restored) + return DownloadCompensationOutcome.LocalStateRestored; + + var current = await animationInfoRepository.FindByIdAsync(info.Id, cleanup.Token); + return current is null || !current.IsDownloadTracked + ? DownloadCompensationOutcome.LocalStateRestored + : DownloadCompensationOutcome.RetainedForRecovery; + } + catch (Exception exception) + { + logger.LogWarning( + exception, + "Could not restore local download state for release upgrade {AnimationInfoId}", + info.Id); + return DownloadCompensationOutcome.RetainedForRecovery; + } + } + + private static async Task QueryDownloadProgressSafelyAsync( + IFileDownloadClient downloadClient, + AnimationInfo info, + CancellationToken cancellationToken) + { + try + { + await downloadClient.SubmitQueryDownloadProgressAsync( + info.Id, + info.DownloadUrl, + info.CachedDownloadData, + info.AdditionalDownloadInfo, + cancellationToken); + } + catch + { + // Startup recovery can rediscover the persisted attempt. + } + } + private static string UpgradeSource(Guid operationId) => $"release-upgrade:{operationId:N}"; + private enum DownloadCompensationOutcome + { + LocalStateRestored, + RetainedForRecovery + } + private static ReleaseUpgradeExecutionResult Result( bool success, string outcome, From ec4ba1be6ff6e1b19e93315422c258e7edaef87f Mon Sep 17 00:00:00 2001 From: mahoshojoHCG Date: Mon, 31 Aug 2026 10:27:35 +0800 Subject: [PATCH 10/37] fix: harden backup and logical data recovery --- .containerignore | 3 +- .github/workflows/backup-restore.yml | 182 +++++-- .github/workflows/build.yml | 25 +- .github/workflows/container.yml | 2 + Containerfile | 16 +- .../DataRepository/LogicalDataTransfer.cs | 8 + .../LogicalDataTransferPostgreSqlTests.cs | 293 ++++++++++- .../LogicalDataTransferControllerTests.cs | 49 ++ .../LogicalDataTransferController.cs | 49 +- .../ApplicationContextModelSnapshot.cs | 1 + SecondDimensionWatcherReDive/Program.cs | 1 + .../LogicalDataTransferRepository.cs | 82 ++- deployments/sdw-backup | 489 +++++++++++++++--- deployments/tests/backup-restore-smoke.sh | 147 +++++- docs/backup-restore.md | 28 +- packaging/backup.env | 9 +- packaging/nfpm.yaml | 12 +- packaging/postinstall.sh | 7 +- packaging/preremove.sh | 19 +- 19 files changed, 1232 insertions(+), 190 deletions(-) diff --git a/.containerignore b/.containerignore index 6259054..f7e1b63 100644 --- a/.containerignore +++ b/.containerignore @@ -8,7 +8,8 @@ **/obj/ SecondDimensionWatcherReDive.Test/ packaging/ -deployments/ +deployments/* +!deployments/sdw-backup docs/ SecondDimensionWatcherReDive.Client/dist/ SecondDimensionWatcherReDive.Client/.parcel-cache/ diff --git a/.github/workflows/backup-restore.yml b/.github/workflows/backup-restore.yml index 67dc844..b3fd59d 100644 --- a/.github/workflows/backup-restore.yml +++ b/.github/workflows/backup-restore.yml @@ -1,46 +1,40 @@ name: Backup Restore Drill on: - push: - branches: [ "main" ] - pull_request: - branches: [ "main" ] + workflow_call: workflow_dispatch: permissions: contents: read -concurrency: - group: backup-restore-${{ github.ref }} - cancel-in-progress: true - jobs: drill: + name: PostgreSQL ${{ matrix.postgres }} rollback-safe drill runs-on: ubuntu-latest - timeout-minutes: 15 + timeout-minutes: 25 + strategy: + fail-fast: false + matrix: + postgres: ["16", "17"] services: postgres: - image: postgres:16-alpine + image: postgres:${{ matrix.postgres }}-alpine env: POSTGRES_USER: postgres POSTGRES_PASSWORD: postgres - POSTGRES_DB: sdw_source + POSTGRES_DB: postgres ports: - 5432:5432 options: >- - --health-cmd "pg_isready -U postgres -d sdw_source" + --health-cmd "pg_isready -U postgres -d postgres" --health-interval 5s --health-timeout 5s --health-retries 12 env: PGHOST: 127.0.0.1 PGPORT: 5432 - PGUSER: postgres - PGPASSWORD: postgres - PGDATABASE: sdw_source - ConnectionStrings__sdw: Host=127.0.0.1;Port=5432;Username=postgres;Password=postgres;Database=sdw_source - JwtSecret: backup-restore-drill-jwt-secret-with-at-least-32-bytes ASPNETCORE_ENVIRONMENT: Production + JwtSecret: backup-restore-drill-jwt-secret-with-at-least-32-bytes steps: - uses: actions/checkout@v7 @@ -48,25 +42,42 @@ jobs: with: dotnet-version: "10.0.x" - - name: Configure drill workspace - run: printf 'WORK_DIR=%s\n' "$RUNNER_TEMP/sdw-backup-drill" >> "$GITHUB_ENV" - - name: Install PostgreSQL client run: sudo apt-get update && sudo apt-get install -y postgresql-client + - name: Configure distinct database roles and measured capacity + shell: bash + env: + SERVICE_CONTAINER: ${{ job.services.postgres.id }} + run: | + set -Eeuo pipefail + export PGUSER=postgres PGPASSWORD=postgres PGDATABASE=postgres + psql --no-psqlrc --set=ON_ERROR_STOP=1 <<'SQL' + CREATE ROLE sdw_app LOGIN PASSWORD 'app-password'; + CREATE ROLE sdw_restore_admin LOGIN CREATEDB PASSWORD 'restore-password'; + GRANT sdw_app TO sdw_restore_admin; + CREATE DATABASE sdw_source OWNER sdw_app; + SQL + available_kib=$(docker exec "$SERVICE_CONTAINER" \ + df -Pk /var/lib/postgresql/data | awk 'NR == 2 {print $4}') + [[ "$available_kib" =~ ^[0-9]+$ ]] + printf 'POSTGRES_AVAILABLE_BYTES=%s\n' "$((available_kib * 1024))" >> "$GITHUB_ENV" + printf 'WORK_DIR=%s\n' "$RUNNER_TEMP/sdw-backup-drill-${{ matrix.postgres }}" >> "$GITHUB_ENV" + - name: Build backend run: >- dotnet build SecondDimensionWatcherReDive/SecondDimensionWatcherReDive.csproj -c Release /p:Version="$(tr -d '[:space:]' < VERSION)" - - name: Migrate and health-check source instance + - name: Migrate, register, and persist an encrypted runtime secret shell: bash run: | set -Eeuo pipefail mkdir -p "$WORK_DIR/source/keys" "$WORK_DIR/plugins/example" "$WORK_DIR/backups" - printf '{}\n' > "$WORK_DIR/source/password.json" printf 'DisableCors: true\n' > "$WORK_DIR/source/appsettings.yml" printf '{"name":"example","version":"1"}\n' > "$WORK_DIR/plugins/example/manifest.json" + export PGUSER=sdw_app PGPASSWORD=app-password PGDATABASE=sdw_source + export ConnectionStrings__sdw='Host=127.0.0.1;Port=5432;Username=sdw_app;Password=app-password;Database=sdw_source' export PasswordFile="$WORK_DIR/source/password.json" export DataProtection__KeyRingPath="$WORK_DIR/source/keys" export Config="$WORK_DIR/source/appsettings.yml" @@ -75,22 +86,43 @@ jobs: app_pid=$! trap 'kill "$app_pid" 2>/dev/null || true' EXIT for attempt in {1..60}; do - if curl --silent --fail http://127.0.0.1:5097/api/auth/allowRegister >/dev/null; then - break - fi + curl --silent --fail http://127.0.0.1:5097/api/auth/allowRegister >/dev/null && break sleep 1 done - curl --fail http://127.0.0.1:5097/api/auth/allowRegister >/dev/null + registration=$(curl --silent --show-error --fail \ + -H 'Content-Type: application/json' \ + -d '{"password":"drill-password"}' \ + http://127.0.0.1:5097/api/auth/register) + token=$(jq -er .token <<<"$registration") + curl --silent --show-error --fail \ + -H 'Content-Type: application/json' \ + -d '{"password":"drill-password"}' \ + http://127.0.0.1:5097/api/auth/login >/dev/null + revision=$(curl --silent --show-error --fail \ + -H "Authorization: Bearer $token" \ + http://127.0.0.1:5097/api/settings | jq -er .revision) + jq -n --argjson revision "$revision" \ + '{expectedRevision:$revision,tmdb:{apiKey:{operation:"set",value:"encrypted-drill-secret"}}}' | + curl --silent --show-error --fail -X PATCH \ + -H "Authorization: Bearer $token" -H 'Content-Type: application/json' \ + --data-binary @- http://127.0.0.1:5097/api/settings >/dev/null kill "$app_pid" wait "$app_pid" || true trap - EXIT - psql --no-psqlrc --command \ + psql --no-psqlrc --set=ON_ERROR_STOP=1 --command \ "INSERT INTO \"Feeds\" (\"Id\", \"Url\", \"Name\", \"CreatedAt\") VALUES ('11111111-1111-1111-1111-111111111111', 'https://example.com/feed', 'drill', now())" + schema=$(psql --no-psqlrc -Atc \ + 'SELECT "MigrationId" FROM "__EFMigrationsHistory" ORDER BY "MigrationId" DESC LIMIT 1') + [[ "$schema" =~ ^[A-Za-z0-9._:+/@-]+$ ]] + printf 'BACKUP_SCHEMA=%s\n' "$schema" >> "$GITHUB_ENV" + test -s "$WORK_DIR/source/password.json" + test "$(find "$WORK_DIR/source/keys" -type f | wc -l)" -gt 0 - - name: Create, verify, and reject corruption + - name: Create, verify, and reject archive corruption shell: bash run: | set -Eeuo pipefail + export PGUSER=sdw_app PGPASSWORD=app-password PGDATABASE=sdw_source archive=$(deployments/sdw-backup create \ --output "$WORK_DIR/backups" \ --config "$WORK_DIR/source/appsettings.yml" \ @@ -102,7 +134,7 @@ jobs: deployments/sdw-backup verify "$archive" cp "$archive" "$WORK_DIR/corrupt.tar.gz" archive_size=$(stat --format=%s "$WORK_DIR/corrupt.tar.gz") - printf 'CORRUPT' | dd of="$WORK_DIR/corrupt.tar.gz" bs=1 \ + printf CORRUPT | dd of="$WORK_DIR/corrupt.tar.gz" bs=1 \ seek=$((archive_size / 2)) conv=notrunc status=none if deployments/sdw-backup verify "$WORK_DIR/corrupt.tar.gz"; then echo "corrupted archive unexpectedly verified" >&2 @@ -110,46 +142,88 @@ jobs: fi printf '%s\n' "$archive" > "$WORK_DIR/archive-path" - - name: Restore into a fresh database and health-check it + - name: Reject failures without changing the target database shell: bash run: | set -Eeuo pipefail - createdb sdw_restore - export PGDATABASE=sdw_restore - export ConnectionStrings__sdw='Host=127.0.0.1;Port=5432;Username=postgres;Password=postgres;Database=sdw_restore' - archive=$(cat "$WORK_DIR/archive-path") - mkdir -p "$WORK_DIR/restored" - psql --no-psqlrc --command \ + export PGUSER=postgres PGPASSWORD=postgres PGDATABASE=postgres + createdb --template=sdw_source --owner=sdw_app sdw_restore + export PGUSER=sdw_restore_admin PGPASSWORD=restore-password PGDATABASE=sdw_restore + export PGMAINTENANCEDATABASE=postgres + psql --no-psqlrc --set=ON_ERROR_STOP=1 --command \ 'CREATE TABLE restore_guard (value integer NOT NULL); INSERT INTO restore_guard VALUES (1)' - if deployments/sdw-backup restore "$WORK_DIR/corrupt.tar.gz" \ - --confirm-replace --expected-version "$(tr -d '[:space:]' < VERSION)"; then + archive=$(cat "$WORK_DIR/archive-path") + mkdir -p "$WORK_DIR/failure-state" + common=(--confirm-replace --expected-version "$(tr -d '[:space:]' < VERSION)" \ + --expected-schema "$BACKUP_SCHEMA" \ + --postgres-available-bytes "$POSTGRES_AVAILABLE_BYTES" \ + --config-destination "$WORK_DIR/failure-state/appsettings.yml" \ + --password-destination "$WORK_DIR/failure-state/password.json" \ + --key-ring-destination "$WORK_DIR/failure-state/keys" \ + --plugin-destination "$WORK_DIR/failure-state/plugins" \ + --safety-directory "$WORK_DIR/backups") + if deployments/sdw-backup restore "$WORK_DIR/corrupt.tar.gz" "${common[@]}"; then echo "corrupted restore unexpectedly started" >&2 exit 1 fi - test "$(psql --no-psqlrc --tuples-only --no-align --command \ - 'SELECT value FROM restore_guard')" = "1" - if deployments/sdw-backup restore "$archive" \ - --confirm-replace --expected-version 999.0.0; then - echo "incompatible restore unexpectedly started" >&2 + test "$(psql --no-psqlrc -Atc 'SELECT value FROM restore_guard')" = 1 + test "$(psql --no-psqlrc -Atc \ + "SELECT count(*) FROM pg_database WHERE datname LIKE 'sdw_restore_%'")" = 0 + if deployments/sdw-backup restore "$archive" "${common[@]}" \ + --expected-schema definitely-not-this-schema; then + echo "incompatible schema unexpectedly restored" >&2 + exit 1 + fi + test "$(psql --no-psqlrc -Atc 'SELECT value FROM restore_guard')" = 1 + + mkdir -p "$WORK_DIR/fault-bin" + real_pg_restore=$(command -v pg_restore) + cat > "$WORK_DIR/fault-bin/pg_restore" <&2 exit 1 fi - test "$(psql --no-psqlrc --tuples-only --no-align --command \ - 'SELECT value FROM restore_guard')" = "1" + test "$(psql --no-psqlrc -Atc 'SELECT value FROM restore_guard')" = 1 + test "$(psql --no-psqlrc -Atc \ + "SELECT count(*) FROM pg_database WHERE datname LIKE 'sdw_restore_%'")" = 0 + + - name: Restore, verify owners, and start as the application role + shell: bash + run: | + set -Eeuo pipefail + export PGUSER=sdw_restore_admin PGPASSWORD=restore-password PGDATABASE=sdw_restore + export PGMAINTENANCEDATABASE=postgres + archive=$(cat "$WORK_DIR/archive-path") + mkdir -p "$WORK_DIR/restored" deployments/sdw-backup restore "$archive" \ --confirm-replace \ --expected-version "$(tr -d '[:space:]' < VERSION)" \ + --expected-schema "$BACKUP_SCHEMA" \ + --postgres-available-bytes "$POSTGRES_AVAILABLE_BYTES" \ --config-destination "$WORK_DIR/restored/appsettings.yml" \ --password-destination "$WORK_DIR/restored/password.json" \ --key-ring-destination "$WORK_DIR/restored/keys" \ --plugin-destination "$WORK_DIR/restored/plugins" \ --safety-directory "$WORK_DIR/backups" - test "$(psql --no-psqlrc --tuples-only --no-align --command \ - "SELECT to_regclass('public.restore_guard') IS NULL")" = "t" - test "$(psql --no-psqlrc --tuples-only --no-align --command 'SELECT count(*) FROM "Feeds"')" = "1" + test "$(psql --no-psqlrc -Atc "SELECT to_regclass('public.restore_guard') IS NULL")" = t + test "$(psql --no-psqlrc -Atc 'SELECT count(*) FROM "Feeds"')" = 1 + test "$(psql --no-psqlrc -Atc \ + "SELECT count(*) FROM pg_class c JOIN pg_namespace n ON n.oid=c.relnamespace WHERE n.nspname='public' AND c.relkind IN ('r','p','v','m','S','f') AND pg_get_userbyid(c.relowner) <> 'sdw_app'")" = 0 cmp "$WORK_DIR/source/password.json" "$WORK_DIR/restored/password.json" cmp "$WORK_DIR/source/appsettings.yml" "$WORK_DIR/restored/appsettings.yml" cmp "$WORK_DIR/plugins/example/manifest.json" \ "$WORK_DIR/restored/plugins/example/manifest.json" + + export PGUSER=sdw_app PGPASSWORD=app-password PGDATABASE=sdw_restore + export ConnectionStrings__sdw='Host=127.0.0.1;Port=5432;Username=sdw_app;Password=app-password;Database=sdw_restore' export PasswordFile="$WORK_DIR/restored/password.json" export DataProtection__KeyRingPath="$WORK_DIR/restored/keys" export Config="$WORK_DIR/restored/appsettings.yml" @@ -158,12 +232,16 @@ jobs: app_pid=$! trap 'kill "$app_pid" 2>/dev/null || true' EXIT for attempt in {1..60}; do - if curl --silent --fail http://127.0.0.1:5098/api/auth/allowRegister >/dev/null; then - break - fi + curl --silent --fail http://127.0.0.1:5098/api/auth/allowRegister >/dev/null && break sleep 1 done - curl --fail http://127.0.0.1:5098/api/auth/allowRegister >/dev/null + login=$(curl --silent --show-error --fail \ + -H 'Content-Type: application/json' -d '{"password":"drill-password"}' \ + http://127.0.0.1:5098/api/auth/login) + token=$(jq -er .token <<<"$login") + test "$(curl --silent --show-error --fail \ + -H "Authorization: Bearer $token" http://127.0.0.1:5098/api/settings | + jq -r '.tmdb.apiKey.isConfigured')" = true kill "$app_pid" wait "$app_pid" || true trap - EXIT diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 98cb3e1..17f5c6d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -16,6 +16,9 @@ permissions: contents: read jobs: + backup-restore: + uses: ./.github/workflows/backup-restore.yml + test: runs-on: ubuntu-latest steps: @@ -42,9 +45,24 @@ jobs: **/TestResults/*.trx **/TestResults/**/coverage.cobertura.xml + quality_gate: + name: Required quality gate + if: always() + needs: [test, backup-restore] + runs-on: ubuntu-latest + steps: + - name: Require tests and rollback-safe backup drill + env: + BACKUP_RESULT: ${{ needs.backup-restore.result }} + TEST_RESULT: ${{ needs.test.result }} + run: | + set -euo pipefail + test "$TEST_RESULT" = success + test "$BACKUP_RESULT" = success + build-frontend: if: github.event_name != 'pull_request' - needs: test + needs: quality_gate runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 @@ -110,6 +128,7 @@ jobs: --self-contained false \ /p:Version=${VERSION} \ -o publish + printf '%s\n' "$VERSION" > publish/VERSION - name: Install nfpm run: | @@ -257,8 +276,8 @@ jobs: # NativeAOT cross-compile is not supported, so each architecture builds on a # native runner. ubuntu-24.04-arm is GA for public repos; private repos pay # for arm minutes — switch to a self-hosted arm runner if that becomes an - # issue. The job depends on `test` only (no frontend artifact needed). - needs: test + # issue. The job needs the quality gate but no frontend artifact. + needs: quality_gate strategy: fail-fast: false matrix: diff --git a/.github/workflows/container.yml b/.github/workflows/container.yml index cc30ca5..3e6e9d4 100644 --- a/.github/workflows/container.yml +++ b/.github/workflows/container.yml @@ -67,3 +67,5 @@ jobs: ${{ env.TAG2 }} cache-from: type=gha cache-to: type=gha,mode=max + build-args: | + VERSION=${{ env.VERSION }} diff --git a/Containerfile b/Containerfile index 4bc08c8..d7ce455 100644 --- a/Containerfile +++ b/Containerfile @@ -1,3 +1,5 @@ +ARG VERSION + # Stage 1: Build frontend FROM node:24 AS frontend-build WORKDIR /app @@ -8,6 +10,7 @@ RUN yarn build # Stage 2: Build backend FROM mcr.microsoft.com/dotnet/sdk:10.0 AS backend-build +ARG VERSION WORKDIR /src COPY SecondDimensionWatcherReDive.slnx . COPY VERSION . @@ -17,19 +20,22 @@ COPY Plugins/ Plugins/ COPY Share/ Share/ COPY --from=frontend-build /app/dist SecondDimensionWatcherReDive/wwwroot/ RUN dotnet restore SecondDimensionWatcherReDive/SecondDimensionWatcherReDive.csproj -RUN dotnet publish SecondDimensionWatcherReDive/SecondDimensionWatcherReDive.csproj \ - -c Release -o /app --no-restore \ - /p:Version="$(tr -d '[:space:]' < VERSION)" +RUN effective_version="${VERSION:-$(tr -d '[:space:]' < VERSION)}" \ + && dotnet publish SecondDimensionWatcherReDive/SecondDimensionWatcherReDive.csproj \ + -c Release -o /app --no-restore \ + /p:Version="${effective_version}" \ + && printf '%s\n' "${effective_version}" > /app/VERSION # Stage 3: Runtime FROM mcr.microsoft.com/dotnet/aspnet:10.0 WORKDIR /app RUN apt-get update \ - && apt-get install -y --no-install-recommends postgresql-client \ + && apt-get install -y --no-install-recommends postgresql-client curl \ && rm -rf /var/lib/apt/lists/* COPY --from=backend-build /app . COPY deployments/sdw-backup /usr/local/bin/sdw-backup -COPY VERSION /usr/lib/sdw-redive/VERSION +RUN mkdir -p /usr/lib/sdw-redive \ + && install -m 0644 /app/VERSION /usr/lib/sdw-redive/VERSION EXPOSE 8080 # Optional: read-only NFSv4 export (set Nfs:Enabled=true to activate; publish port at run time). EXPOSE 2049 diff --git a/SecondDimensionWatcherReDive.Framework/DataRepository/LogicalDataTransfer.cs b/SecondDimensionWatcherReDive.Framework/DataRepository/LogicalDataTransfer.cs index d8d4028..7ab2049 100644 --- a/SecondDimensionWatcherReDive.Framework/DataRepository/LogicalDataTransfer.cs +++ b/SecondDimensionWatcherReDive.Framework/DataRepository/LogicalDataTransfer.cs @@ -2,6 +2,12 @@ namespace SecondDimensionWatcherReDive.Framework.DataRepository; +public static class LogicalDataTransferLimits +{ + public const int MaximumItemsPerCategory = 10_000; + public const int MaximumPayloadBytes = 10 * 1024 * 1024; +} + [Flags] [JsonConverter(typeof(JsonStringEnumConverter))] public enum LogicalDataCategory @@ -103,3 +109,5 @@ public sealed record LogicalImportResult( IReadOnlyList Messages); public sealed class LogicalDataImportConflictException(string message) : InvalidOperationException(message); + +public sealed class LogicalDataExportLimitException(string message) : InvalidOperationException(message); diff --git a/SecondDimensionWatcherReDive.IntegrationTest/PostgreSql/LogicalDataTransferPostgreSqlTests.cs b/SecondDimensionWatcherReDive.IntegrationTest/PostgreSql/LogicalDataTransferPostgreSqlTests.cs index 94437f9..bfaa3e4 100644 --- a/SecondDimensionWatcherReDive.IntegrationTest/PostgreSql/LogicalDataTransferPostgreSqlTests.cs +++ b/SecondDimensionWatcherReDive.IntegrationTest/PostgreSql/LogicalDataTransferPostgreSqlTests.cs @@ -1,5 +1,9 @@ +using System.Data.Common; using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Microsoft.Extensions.DependencyInjection; using Moq; +using Npgsql; using SecondDimensionWatcherReDive.Framework.DataRepository; using SecondDimensionWatcherReDive.Repositories; using SecondDimensionWatcherReDive.Utils.FileStore; @@ -549,6 +553,245 @@ await Assert.ThrowsAsync(() => Assert.AreEqual("Existing", (await verification.Feeds.SingleAsync()).Name); } + [TestMethod] + public async Task MetadataImportReusesSharedAnimationWithoutMutatingGlobalFieldsAndUndoIsLossless() + { + var animation = Animation("tv:shared", "Target Canonical Name"); + animation.OriginalName = "Target Original"; + animation.PosterPath = "/target.jpg"; + var targetId = Guid.NewGuid(); + var siblingId = Guid.NewGuid(); + var publishedAt = DateTimeOffset.UtcNow.AddDays(-1); + const string TargetUrl = "https://example.com/shared-target.torrent"; + const string TargetPath = "/Target Canonical Name/Group/target.mkv"; + const string SiblingPath = "/Target Canonical Name/Group/sibling.mkv"; + + await using (var seed = new Models.ApplicationContext(Options)) + { + var target = Release(targetId, TargetUrl, publishedAt); + target.Animation = animation; + var sibling = Release( + siblingId, + "https://example.com/shared-sibling.torrent", + publishedAt.AddMinutes(1)); + sibling.Animation = animation; + seed.AddRange( + animation, + target, + sibling, + Mapping(targetId, TargetPath), + Mapping(siblingId, SiblingPath)); + await seed.SaveChangesAsync(); + } + + var operationId = Guid.NewGuid(); + var bundle = new LogicalDataBundle( + 1, + DateTimeOffset.UtcNow, + "1.0.0", + LogicalDataCategory.MetadataCorrections, + [], + [], + [], + [ + new LogicalMetadataCorrection( + operationId, + TargetUrl, + "[Group] Example - 02", + publishedAt, + animation.TmdbId, + "Foreign Backup Name", + "Foreign Original", + "/foreign.jpg", + "imported correction", + 1, + 2, + null, + DateTimeOffset.UtcNow) + ], + [], + null); + + await using (var importing = new Models.ApplicationContext(Options)) + { + var result = await Repository(importing).ImportAsync( + bundle, + LogicalImportConflictStrategy.Overwrite, + Guid.Empty, + CancellationToken.None); + Assert.AreEqual(1, result.Added); + } + + await using (var verification = new Models.ApplicationContext(Options)) + { + var shared = await verification.Animations.SingleAsync(); + Assert.AreEqual("Target Canonical Name", shared.Name); + Assert.AreEqual("Target Original", shared.OriginalName); + Assert.AreEqual("/target.jpg", shared.PosterPath); + Assert.AreEqual(2, await verification.AnimationInfo.CountAsync(info => info.Animation == shared)); + Assert.AreEqual( + SiblingPath, + (await verification.FileMappings.SingleAsync(mapping => + mapping.AnimationInfoId == siblingId)).VirtualPath); + } + + await using (var undoContext = new Models.ApplicationContext(Options)) + { + var undone = await new MetadataReviewRepository(undoContext, Options).UndoAsync( + operationId, + 1, + CancellationToken.None); + Assert.AreEqual(MetadataReviewMutationOutcome.Success, undone.Outcome); + } + + await using var afterUndo = new Models.ApplicationContext(Options); + var canonical = await afterUndo.Animations.SingleAsync(); + Assert.AreEqual("Target Canonical Name", canonical.Name); + Assert.AreEqual("Target Original", canonical.OriginalName); + Assert.AreEqual("/target.jpg", canonical.PosterPath); + Assert.AreEqual( + SiblingPath, + (await afterUndo.FileMappings.SingleAsync(mapping => + mapping.AnimationInfoId == siblingId)).VirtualPath); + } + + [TestMethod] + public async Task ProductionRetryStrategyRetriesWithFreshScopedStateAndCommitsOnce() + { + var transientFailure = new FailFirstConnectionInterceptor(); + var mapperScopes = 0; + var services = new ServiceCollection(); + services.AddDbContext(options => + options.UseNpgsql( + Database.GetConnectionString(), + npgsql => npgsql.EnableRetryOnFailure( + 2, + TimeSpan.Zero, + null)) + .AddInterceptors(transientFailure)); + services.AddScoped(_ => + { + Interlocked.Increment(ref mapperScopes); + return Mock.Of(); + }); + services.AddScoped(); + services.AddScoped(); + await using var provider = services.BuildServiceProvider(); + await using var requestScope = provider.CreateAsyncScope(); + var repository = requestScope.ServiceProvider + .GetRequiredService(); + var bundle = new LogicalDataBundle( + 1, + DateTimeOffset.UtcNow, + "1.0.0", + LogicalDataCategory.Feeds, + [new LogicalFeed( + Guid.NewGuid(), + "https://example.com/retried.xml", + "Retried", + DateTimeOffset.UtcNow)], + [], + [], + [], + [], + null); + + var result = await repository.ImportAsync( + bundle, + LogicalImportConflictStrategy.Fail, + Guid.Empty, + CancellationToken.None); + + Assert.AreEqual(1, result.Added); + Assert.IsGreaterThanOrEqualTo(2, transientFailure.Attempts); + Assert.IsGreaterThanOrEqualTo(2, mapperScopes, + "Every execution-strategy retry must resolve a fresh scoped worker graph."); + await using var verification = new Models.ApplicationContext(Options); + Assert.AreEqual(1, await verification.Feeds.CountAsync(feed => + feed.Url == "https://example.com/retried.xml")); + } + + [TestMethod] + public async Task ExportUsesOneRepeatableReadSnapshotAcrossCategories() + { + var feed = new Models.Feed + { + Id = Guid.NewGuid(), + Url = "https://example.com/snapshot.xml", + Name = "Snapshot", + CreatedAt = DateTimeOffset.UtcNow + }; + await using (var seed = new Models.ApplicationContext(Options)) + { + seed.Feeds.Add(feed); + await seed.SaveChangesAsync(); + } + + var barrier = new BlockAfterFeedReadInterceptor(); + var exportOptions = new DbContextOptionsBuilder() + .UseNpgsql(Database.GetConnectionString()) + .AddInterceptors(barrier) + .Options; + await using var exporting = new Models.ApplicationContext(exportOptions); + var exportTask = Repository(exporting).ExportAsync( + LogicalDataCategory.Feeds | LogicalDataCategory.AutomationPolicies, + Guid.Empty, + "1.0.0", + CancellationToken.None); + + await barrier.FeedRead.WaitAsync(TimeSpan.FromSeconds(10)); + await using (var writer = new Models.ApplicationContext(Options)) + { + writer.SubscriptionAutomationPolicies.Add(new Models.SubscriptionAutomationPolicy + { + FeedId = feed.Id, + SubtitleGroups = [], + Resolutions = [], + Codecs = [], + Languages = [], + ExcludedKeywords = [], + Mode = SubscriptionAutomationMode.ManualConfirm, + CreatedAt = DateTimeOffset.UtcNow, + UpdatedAt = DateTimeOffset.UtcNow + }); + await writer.SaveChangesAsync(); + } + barrier.Release(); + + var bundle = await exportTask; + Assert.HasCount(1, bundle.Feeds); + Assert.HasCount(0, bundle.AutomationPolicies, + "A policy committed after the first export query must not appear in the same bundle."); + } + + [TestMethod] + public async Task ExportRefusesMoreItemsThanTheImporterAccepts() + { + await using (var seed = new Models.ApplicationContext(Options)) + { + var createdAt = DateTimeOffset.UtcNow; + seed.Feeds.AddRange(Enumerable.Range( + 0, + LogicalDataTransferLimits.MaximumItemsPerCategory + 1) + .Select(index => new Models.Feed + { + Id = Guid.NewGuid(), + Url = $"https://example.com/limit/{index}", + Name = $"Feed {index}", + CreatedAt = createdAt.AddTicks(index) + })); + await seed.SaveChangesAsync(); + } + + await using var exporting = new Models.ApplicationContext(Options); + await Assert.ThrowsAsync(() => + Repository(exporting).ExportAsync( + LogicalDataCategory.Feeds, + Guid.Empty, + "1.0.0", + CancellationToken.None)); + } + private static Models.Animation Animation(string tmdbId, string name) => new() { @@ -585,8 +828,56 @@ private static Models.FileMapping Mapping(Guid animationInfoId, string virtualPa FileStore = "local" }; - private static LogicalDataTransferRepository Repository( + private static LogicalDataTransferWorker Repository( Models.ApplicationContext context, IFileMapper? fileMapper = null) => new(context, fileMapper ?? Mock.Of()); + + private sealed class FailFirstConnectionInterceptor : DbConnectionInterceptor + { + private int _attempts; + + public int Attempts => Volatile.Read(ref _attempts); + + public override ValueTask ConnectionOpeningAsync( + DbConnection connection, + ConnectionEventData eventData, + InterceptionResult result, + CancellationToken cancellationToken = default) + { + if (Interlocked.Increment(ref _attempts) == 1) + throw new NpgsqlException( + "Synthetic transient connection failure.", + new TimeoutException()); + return ValueTask.FromResult(result); + } + } + + private sealed class BlockAfterFeedReadInterceptor : DbCommandInterceptor + { + private readonly TaskCompletionSource _feedRead = new( + TaskCreationOptions.RunContinuationsAsynchronously); + private readonly TaskCompletionSource _release = new( + TaskCreationOptions.RunContinuationsAsynchronously); + private int _blocked; + + public Task FeedRead => _feedRead.Task; + + public void Release() => _release.TrySetResult(); + + public override async ValueTask ReaderExecutedAsync( + DbCommand command, + CommandExecutedEventData eventData, + DbDataReader result, + CancellationToken cancellationToken = default) + { + if (command.CommandText.Contains("FROM \"Feeds\"", StringComparison.Ordinal) && + Interlocked.Exchange(ref _blocked, 1) == 0) + { + _feedRead.TrySetResult(); + await _release.Task.WaitAsync(cancellationToken); + } + return result; + } + } } diff --git a/SecondDimensionWatcherReDive.Test/LogicalDataTransferControllerTests.cs b/SecondDimensionWatcherReDive.Test/LogicalDataTransferControllerTests.cs index 56c5744..44e7996 100644 --- a/SecondDimensionWatcherReDive.Test/LogicalDataTransferControllerTests.cs +++ b/SecondDimensionWatcherReDive.Test/LogicalDataTransferControllerTests.cs @@ -111,6 +111,55 @@ public async Task ImportPassesValidatedBundleAndConflictStrategy() Assert.AreEqual(1, result.Added); } + [TestMethod] + public async Task ExportRejectsARepositoryCategoryOverflow() + { + var repository = new Mock(); + repository.Setup(item => item.ExportAsync( + It.IsAny(), + Guid.Empty, + It.IsAny(), + It.IsAny())) + .ThrowsAsync(new LogicalDataExportLimitException("too many items")); + + var action = await Controller(repository.Object) + .ExportAsync("feeds", CancellationToken.None); + + var result = Assert.IsInstanceOfType(action); + Assert.AreEqual(StatusCodes.Status413PayloadTooLarge, result.StatusCode); + } + + [TestMethod] + public async Task ExportRejectsABundleThatCannotFitTheImportRequestLimit() + { + var repository = new Mock(); + repository.Setup(item => item.ExportAsync( + LogicalDataCategory.Feeds, + Guid.Empty, + It.IsAny(), + It.IsAny())) + .ReturnsAsync((LogicalDataCategory categories, Guid _, string version, CancellationToken _) => + { + var bundle = Bundle(version, categories); + return bundle with + { + Feeds = + [ + bundle.Feeds[0] with + { + Name = new string('x', LogicalDataTransferLimits.MaximumPayloadBytes) + } + ] + }; + }); + + var action = await Controller(repository.Object) + .ExportAsync("feeds", CancellationToken.None); + + var result = Assert.IsInstanceOfType(action); + Assert.AreEqual(StatusCodes.Status413PayloadTooLarge, result.StatusCode); + } + private static LogicalDataTransferController Controller( ILogicalDataTransferRepository repository) => new(repository) diff --git a/SecondDimensionWatcherReDive/Controllers/LogicalDataTransferController.cs b/SecondDimensionWatcherReDive/Controllers/LogicalDataTransferController.cs index 3a6f7fc..9e9f0a9 100644 --- a/SecondDimensionWatcherReDive/Controllers/LogicalDataTransferController.cs +++ b/SecondDimensionWatcherReDive/Controllers/LogicalDataTransferController.cs @@ -18,7 +18,6 @@ internal sealed class LogicalDataTransferController( ILogicalDataTransferRepository repository) : ControllerBase { private const int SupportedFormatVersion = 1; - private const int MaximumItemsPerCategory = 10_000; private static readonly Guid CurrentUserId = Guid.Empty; private static readonly string ApplicationVersion = typeof(LogicalDataTransferController).Assembly @@ -34,12 +33,34 @@ public async Task ExportAsync( if (!TryParseCategories(categories, out var selected)) return BadRequest(new { error = "Unknown data category." }); - var bundle = await repository.ExportAsync( - selected, - CurrentUserId, - ApplicationVersion, - cancellationToken); - var envelope = new External.LogicalDataExportEnvelope(bundle, Digest(bundle)); + LogicalDataBundle bundle; + try + { + bundle = await repository.ExportAsync( + selected, + CurrentUserId, + ApplicationVersion, + cancellationToken); + } + catch (LogicalDataExportLimitException exception) + { + return StatusCode(StatusCodes.Status413PayloadTooLarge, new { error = exception.Message }); + } + + var digest = Digest(bundle); + var envelope = new External.LogicalDataExportEnvelope(bundle, digest); + // Validate the larger import representation, including the longest conflict + // strategy name, so every successful export fits the import request limit. + var importBytes = JsonSerializer.SerializeToUtf8Bytes( + new External.LogicalDataImportRequest( + bundle, + digest, + LogicalImportConflictStrategy.Overwrite), + External.AppJsonSerializerContext.Default.LogicalDataImportRequest); + if (importBytes.Length > LogicalDataTransferLimits.MaximumPayloadBytes) + return StatusCode( + StatusCodes.Status413PayloadTooLarge, + new { error = $"Logical export exceeds {LogicalDataTransferLimits.MaximumPayloadBytes} bytes." }); var bytes = JsonSerializer.SerializeToUtf8Bytes( envelope, External.AppJsonSerializerContext.Default.LogicalDataExportEnvelope); @@ -49,7 +70,7 @@ public async Task ExportAsync( } [HttpPost("import")] - [RequestSizeLimit(10 * 1024 * 1024)] + [RequestSizeLimit(LogicalDataTransferLimits.MaximumPayloadBytes)] public async Task ImportAsync( [FromBody] External.LogicalDataImportRequest request, CancellationToken cancellationToken) @@ -133,13 +154,13 @@ bundle.FileNameRules is null || bundle.MetadataCorrections is null || error = "Logical export was created by an incompatible application major version."; return false; } - if (bundle.Feeds.Count > MaximumItemsPerCategory || - bundle.AutomationPolicies.Count > MaximumItemsPerCategory || - bundle.FileNameRules.Count > MaximumItemsPerCategory || - bundle.MetadataCorrections.Count > MaximumItemsPerCategory || - bundle.PlaybackProgress.Count > MaximumItemsPerCategory) + if (bundle.Feeds.Count > LogicalDataTransferLimits.MaximumItemsPerCategory || + bundle.AutomationPolicies.Count > LogicalDataTransferLimits.MaximumItemsPerCategory || + bundle.FileNameRules.Count > LogicalDataTransferLimits.MaximumItemsPerCategory || + bundle.MetadataCorrections.Count > LogicalDataTransferLimits.MaximumItemsPerCategory || + bundle.PlaybackProgress.Count > LogicalDataTransferLimits.MaximumItemsPerCategory) { - error = $"A logical export category exceeds {MaximumItemsPerCategory} items."; + error = $"A logical export category exceeds {LogicalDataTransferLimits.MaximumItemsPerCategory} items."; return false; } if ((!bundle.Categories.HasFlag(LogicalDataCategory.Feeds) && bundle.Feeds.Count > 0) || diff --git a/SecondDimensionWatcherReDive/Migrations/ApplicationContextModelSnapshot.cs b/SecondDimensionWatcherReDive/Migrations/ApplicationContextModelSnapshot.cs index 7364757..e9074c0 100644 --- a/SecondDimensionWatcherReDive/Migrations/ApplicationContextModelSnapshot.cs +++ b/SecondDimensionWatcherReDive/Migrations/ApplicationContextModelSnapshot.cs @@ -674,6 +674,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) modelBuilder.Entity("SecondDimensionWatcherReDive.Models.PlaybackPreference", b => { b.Property("UserId") + .ValueGeneratedNever() .HasColumnType("uuid"); b.Property("AudioLanguage") diff --git a/SecondDimensionWatcherReDive/Program.cs b/SecondDimensionWatcherReDive/Program.cs index 00c04c7..690cef1 100644 --- a/SecondDimensionWatcherReDive/Program.cs +++ b/SecondDimensionWatcherReDive/Program.cs @@ -278,6 +278,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddSingleton(); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/SecondDimensionWatcherReDive/Repositories/LogicalDataTransferRepository.cs b/SecondDimensionWatcherReDive/Repositories/LogicalDataTransferRepository.cs index fff9139..2c57b89 100644 --- a/SecondDimensionWatcherReDive/Repositories/LogicalDataTransferRepository.cs +++ b/SecondDimensionWatcherReDive/Repositories/LogicalDataTransferRepository.cs @@ -1,4 +1,6 @@ using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using System.Data; using System.Security.Cryptography; using System.Text; using SecondDimensionWatcherReDive.Framework.DataRepository; @@ -7,10 +9,53 @@ namespace SecondDimensionWatcherReDive.Repositories; -public sealed class LogicalDataTransferRepository( +public sealed class LogicalDataTransferRepository(IServiceScopeFactory scopeFactory) + : ILogicalDataTransferRepository +{ + public Task ExportAsync( + LogicalDataCategory categories, + Guid userId, + string applicationVersion, + CancellationToken cancellationToken) => + ExecuteWithFreshScopeAsync( + (worker, token) => worker.ExportAsync(categories, userId, applicationVersion, token), + cancellationToken); + + public Task ImportAsync( + LogicalDataBundle bundle, + LogicalImportConflictStrategy conflictStrategy, + Guid userId, + CancellationToken cancellationToken) => + ExecuteWithFreshScopeAsync( + (worker, token) => worker.ImportAsync(bundle, conflictStrategy, userId, token), + cancellationToken); + + private async Task ExecuteWithFreshScopeAsync( + Func> operation, + CancellationToken cancellationToken) + { + await using var strategyScope = scopeFactory.CreateAsyncScope(); + var strategyContext = strategyScope.ServiceProvider + .GetRequiredService(); + var strategy = strategyContext.Database.CreateExecutionStrategy(); + + return await strategy.ExecuteAsync( + async token => + { + // A failed Npgsql attempt can leave both EF tracking state and scoped + // collaborators unusable. Resolve the complete attempt graph again. + await using var attemptScope = scopeFactory.CreateAsyncScope(); + var worker = attemptScope.ServiceProvider + .GetRequiredService(); + return await operation(worker, token); + }, + cancellationToken); + } +} + +internal sealed class LogicalDataTransferWorker( Models.ApplicationContext context, IFileMapper fileMapper) - : ILogicalDataTransferRepository { private const int FormatVersion = 1; @@ -20,10 +65,15 @@ public async Task ExportAsync( string applicationVersion, CancellationToken cancellationToken) { + await using var transaction = await context.Database.BeginTransactionAsync( + IsolationLevel.RepeatableRead, + cancellationToken); + var feeds = categories.HasFlag(LogicalDataCategory.Feeds) ? await context.Feeds.AsNoTracking() .OrderBy(feed => feed.CreatedAt) .Select(feed => new LogicalFeed(feed.Id, feed.Url, feed.Name, feed.CreatedAt)) + .Take(LogicalDataTransferLimits.MaximumItemsPerCategory + 1) .ToListAsync(cancellationToken) : []; @@ -42,6 +92,7 @@ public async Task ExportAsync( policy.Mode, policy.CreatedAt, policy.UpdatedAt)) + .Take(LogicalDataTransferLimits.MaximumItemsPerCategory + 1) .ToListAsync(cancellationToken) : []; @@ -59,6 +110,7 @@ on rule.AnimationId equals animation.Id rule.Pattern, rule.Description, rule.CreatedAt)) + .Take(LogicalDataTransferLimits.MaximumItemsPerCategory + 1) .ToListAsync(cancellationToken) : []; @@ -82,6 +134,7 @@ on rule.AnimationId equals animation.Id operation.ProposedEpisode, operation.ProposedGroupName, operation.AppliedAt!.Value)) + .Take(LogicalDataTransferLimits.MaximumItemsPerCategory + 1) .ToListAsync(cancellationToken) : []; @@ -96,6 +149,7 @@ on rule.AnimationId equals animation.Id item.IsWatched, item.UpdatedAt, item.WatchedAt)) + .Take(LogicalDataTransferLimits.MaximumItemsPerCategory + 1) .ToListAsync(cancellationToken) : []; @@ -114,7 +168,7 @@ on rule.AnimationId equals animation.Id .FirstOrDefaultAsync(cancellationToken); } - return new LogicalDataBundle( + var result = new LogicalDataBundle( FormatVersion, DateTimeOffset.UtcNow, applicationVersion, @@ -125,6 +179,9 @@ on rule.AnimationId equals animation.Id corrections, progress, preferences); + EnsureExportCountLimits(result); + await transaction.CommitAsync(cancellationToken); + return result; } public async Task ImportAsync( @@ -370,14 +427,14 @@ await MappingTransactionLock.LockAnimationInfosAsync( animation = new Models.Animation { Id = Guid.NewGuid(), - TmdbId = imported.AnimationTmdbId + TmdbId = imported.AnimationTmdbId, + Name = imported.AnimationName, + OriginalName = imported.AnimationOriginalName, + PosterPath = imported.AnimationPosterPath }; context.Animations.Add(animation); animations.Add(animation.TmdbId, animation); } - animation.Name = imported.AnimationName; - animation.OriginalName = imported.AnimationOriginalName; - animation.PosterPath = imported.AnimationPosterPath; Models.AnimationGroup? group = null; if (!string.IsNullOrWhiteSpace(imported.GroupName) && @@ -624,6 +681,17 @@ private static string Identifier(string kind, string value) return $"{kind}:{Convert.ToHexString(digest.AsSpan(0, 8))}"; } + private static void EnsureExportCountLimits(LogicalDataBundle bundle) + { + if (bundle.Feeds.Count > LogicalDataTransferLimits.MaximumItemsPerCategory || + bundle.AutomationPolicies.Count > LogicalDataTransferLimits.MaximumItemsPerCategory || + bundle.FileNameRules.Count > LogicalDataTransferLimits.MaximumItemsPerCategory || + bundle.MetadataCorrections.Count > LogicalDataTransferLimits.MaximumItemsPerCategory || + bundle.PlaybackProgress.Count > LogicalDataTransferLimits.MaximumItemsPerCategory) + throw new LogicalDataExportLimitException( + $"A logical export category exceeds {LogicalDataTransferLimits.MaximumItemsPerCategory} items."); + } + private static void ApplyPolicy( LogicalAutomationPolicy source, Models.SubscriptionAutomationPolicy target) diff --git a/deployments/sdw-backup b/deployments/sdw-backup index 1303ae8..086f579 100755 --- a/deployments/sdw-backup +++ b/deployments/sdw-backup @@ -3,12 +3,73 @@ set -Eeuo pipefail umask 077 -readonly BACKUP_FORMAT_VERSION=1 +readonly BACKUP_FORMAT_VERSION=2 +readonly MIGRATION_LOCK_KEY=6000016593017852465 backup_temp_dir="" backup_partial_archive="" backup_partial_checksum="" +restore_candidate_database="" +migration_lease_active=false +restore_staging_paths=() +restore_state_rollback_active=false +restore_config_destination="" +restore_password_destination="" +restore_key_ring_destination="" +restore_plugin_destination="" +restore_state_timestamp="" +restore_config_had_original=false +restore_password_had_original=false +restore_key_ring_had_original=false +restore_plugin_had_original=false + +release_migration_lease() { + if [[ "${migration_lease_active}" == true ]]; then + printf '\\q\n' >&"${SDW_MIGRATION_LEASE[1]}" 2>/dev/null || true + wait "${SDW_MIGRATION_LEASE_PID}" 2>/dev/null || true + migration_lease_active=false + fi +} cleanup() { + release_migration_lease + if [[ -n "${restore_candidate_database}" ]] && command -v dropdb >/dev/null 2>&1; then + dropdb --if-exists --force \ + --maintenance-db="${PGMAINTENANCEDATABASE:-postgres}" \ + -- "${restore_candidate_database}" >/dev/null 2>&1 || true + restore_candidate_database="" + fi + if [[ "${restore_state_rollback_active}" == true ]]; then + rm -f -- "${restore_config_destination}" "${restore_password_destination}" 2>/dev/null || true + if [[ "${restore_config_had_original}" == true ]]; then + install -m 0600 \ + "${restore_config_destination}.pre-restore-${restore_state_timestamp}" \ + "${restore_config_destination}" 2>/dev/null || true + fi + if [[ "${restore_password_had_original}" == true ]]; then + install -m 0600 \ + "${restore_password_destination}.pre-restore-${restore_state_timestamp}" \ + "${restore_password_destination}" 2>/dev/null || true + fi + rm -rf -- "${restore_key_ring_destination}" "${restore_plugin_destination}" \ + 2>/dev/null || true + if [[ "${restore_key_ring_had_original}" == true ]]; then + mv -- "${restore_key_ring_destination}.pre-restore-${restore_state_timestamp}" \ + "${restore_key_ring_destination}" 2>/dev/null || true + fi + if [[ "${restore_plugin_had_original}" == true ]]; then + mv -- "${restore_plugin_destination}.pre-restore-${restore_state_timestamp}" \ + "${restore_plugin_destination}" 2>/dev/null || true + fi + restore_state_rollback_active=false + fi + local staging_path + for staging_path in "${restore_staging_paths[@]}"; do + case "${staging_path}" in + *.sdw-restore-[0-9]*.partial) + rm -rf -- "${staging_path}" 2>/dev/null || true + ;; + esac + done case "${backup_temp_dir}" in "${TMPDIR:-/tmp}"/sdw-backup.*) [[ -d "${backup_temp_dir}" ]] && rm -rf -- "${backup_temp_dir}" @@ -22,19 +83,24 @@ cleanup() { fi } -notify_failure() { +finish() { local status=$? + trap - EXIT + cleanup if [[ ${status} -ne 0 && -n "${SDW_BACKUP_FAILURE_WEBHOOK:-}" ]] && command -v curl >/dev/null 2>&1; then - curl --silent --show-error --fail --max-time 10 \ - --header 'Content-Type: application/json' \ - --data '{"event":"sdw_backup_failed"}' \ - "${SDW_BACKUP_FAILURE_WEBHOOK}" >/dev/null 2>&1 || true + local webhook_url=${SDW_BACKUP_FAILURE_WEBHOOK//\\/\\\\} + webhook_url=${webhook_url//\"/\\\"} + # Feed the secret-bearing URL through stdin so it is absent from process + # arguments and diagnostics. Only the fixed event body is on argv. + printf 'url = "%s"\n' "${webhook_url}" | + curl --config - --silent --show-error --fail --max-time 10 \ + --header 'Content-Type: application/json' \ + --data '{"event":"sdw_backup_failed"}' >/dev/null 2>&1 || true fi - return "${status}" + exit "${status}" } -trap notify_failure ERR -trap cleanup EXIT +trap finish EXIT usage() { cat <<'EOF' @@ -53,6 +119,7 @@ Create options: --retention-days DAYS Delete completed backups older than DAYS --app-version VERSION Application version written to non-secret metadata --age-recipient RECIPIENT Encrypt the final archive with age + --migration-lock-held Pre-migration hook already holds the SDWMIGR1 lease Restore options: --config-destination FILE @@ -63,12 +130,14 @@ Restore options: --expected-schema MIGRATION --age-identity FILE --safety-directory DIR Write a pre-restore database dump here + --postgres-available-bytes BYTES + Free bytes measured on the PostgreSQL default tablespace PostgreSQL is read from PGHOST/PGPORT/PGUSER/PGPASSWORD/PGDATABASE. If those are absent, ConnectionStrings__sdw may contain an ASP.NET semicolon connection string. PGMAINTENANCEDATABASE defaults to postgres and is used only to replace -the target database during restore. Restore requires a target-owning role with -CREATEDB (or a superuser). Secrets are never printed. +the target database during restore. Restore requires a superuser, or a role +with CREATEDB plus membership in the target owner role. Secrets are never printed. EOF } @@ -77,6 +146,18 @@ die() { exit 1 } +validate_failure_webhook() { + if [[ -n "${SDW_BACKUP_FAILURE_WEBHOOK:-}" ]]; then + command -v curl >/dev/null 2>&1 || + die "failure webhook is configured but curl is unavailable" + [[ ${#SDW_BACKUP_FAILURE_WEBHOOK} -le 2048 && + "${SDW_BACKUP_FAILURE_WEBHOOK}" =~ ^https?:// && + "${SDW_BACKUP_FAILURE_WEBHOOK}" != *$'\n'* && + "${SDW_BACKUP_FAILURE_WEBHOOK}" != *$'\r'* ]] || + die "failure webhook URL is invalid" + fi +} + require_command() { command -v "$1" >/dev/null 2>&1 || die "required command is missing: $1" } @@ -114,6 +195,39 @@ parse_connection_string() { export PGHOST PGPORT="${PGPORT:-5432}" PGUSER PGPASSWORD="${PGPASSWORD:-}" PGDATABASE } +acquire_migration_lease() { + local database=$1 already_held=$2 line acquired=false + if [[ "${already_held}" == true ]]; then + return + fi + [[ "${already_held}" == false ]] || die "invalid migration-lock-held state" + + coproc SDW_MIGRATION_LEASE { + psql --no-psqlrc --quiet --tuples-only --no-align \ + --set=ON_ERROR_STOP=1 --dbname "${database}" + } + migration_lease_active=true + printf 'SELECT pg_advisory_lock(%s);\n\\echo SDW_MIGRATION_LEASE_ACQUIRED\n' \ + "${MIGRATION_LOCK_KEY}" >&"${SDW_MIGRATION_LEASE[1]}" + while IFS= read -r line <&"${SDW_MIGRATION_LEASE[0]}"; do + if [[ "${line}" == SDW_MIGRATION_LEASE_ACQUIRED ]]; then + acquired=true + break + fi + done + [[ "${acquired}" == true ]] || die "could not acquire the PostgreSQL migration lease" +} + +assert_migration_lease_held() { + local database=$1 held + held=$(psql --no-psqlrc --quiet --tuples-only --no-align \ + --set=ON_ERROR_STOP=1 --dbname "${database}" --command \ + "SELECT NOT pg_try_advisory_lock(${MIGRATION_LOCK_KEY})") || + die "cannot verify the PostgreSQL migration lease" + held=${held//$'\n'/} + [[ "${held}" == t ]] || die "PostgreSQL migration lease was not held" +} + validate_output_directory() { local directory=$1 [[ -n "${directory}" && "${directory}" != "/" ]] || die "refusing unsafe backup output directory" @@ -145,10 +259,21 @@ safe_token() { } database_schema_version() { - local schema - schema=$(psql --no-psqlrc --tuples-only --no-align --command \ - 'SELECT "MigrationId" FROM "__EFMigrationsHistory" ORDER BY "MigrationId" DESC LIMIT 1' \ - 2>/dev/null || true) + local database=${1:-${PGDATABASE}} history schema + history=$(psql --no-psqlrc --tuples-only --no-align --set=ON_ERROR_STOP=1 \ + --dbname "${database}" --command \ + "SELECT to_regclass('\"__EFMigrationsHistory\"') IS NOT NULL") || + die "cannot inspect database migration history" + history=${history//$'\n'/} + case "${history}" in + f) printf 'uninitialized'; return ;; + t) ;; + *) die "database returned an invalid migration-history state" ;; + esac + schema=$(psql --no-psqlrc --tuples-only --no-align --set=ON_ERROR_STOP=1 \ + --dbname "${database}" --command \ + 'SELECT "MigrationId" FROM "__EFMigrationsHistory" ORDER BY "MigrationId" DESC LIMIT 1') || + die "cannot read database schema version" schema=${schema//$'\n'/} if [[ -z "${schema}" ]]; then printf 'uninitialized' @@ -158,6 +283,17 @@ database_schema_version() { fi } +database_size_bytes() { + local database=${1:-${PGDATABASE}} size + size=$(psql --no-psqlrc --tuples-only --no-align --set=ON_ERROR_STOP=1 \ + --dbname "${database}" --command 'SELECT pg_database_size(current_database())') || + die "cannot inspect database size" + size=${size//$'\n'/} + [[ "${size}" =~ ^[0-9]+$ && ${#size} -le 18 ]] || + die "database returned an invalid size" + printf '%s' "${size}" +} + copy_plugin_manifests() { local plugin_dir=$1 destination=$2 mkdir -p "${destination}" @@ -202,6 +338,7 @@ create_backup() { local retention_days="${SDW_BACKUP_RETENTION_DAYS:-14}" local app_version="${SDW_APP_VERSION:-}" local age_recipient="${SDW_BACKUP_AGE_RECIPIENT:-}" + local migration_lock_held="${SDW_MIGRATION_LOCK_HELD:-false}" while [[ $# -gt 0 ]]; do case "$1" in @@ -213,6 +350,7 @@ create_backup() { --retention-days) retention_days=$2; shift 2 ;; --app-version) app_version=$2; shift 2 ;; --age-recipient) age_recipient=$2; shift 2 ;; + --migration-lock-held) migration_lock_held=true; shift ;; *) die "unknown create option: $1" ;; esac done @@ -238,19 +376,37 @@ create_backup() { local stage="${backup_temp_dir}/stage" mkdir -p "${stage}/state/config" "${stage}/state/data-protection-keys" + + # Serialize schema migrations with the same cluster-wide lease as application + # startup. A pre-migration hook explicitly opts out because its parent process + # already owns this lease on a dedicated session. + acquire_migration_lease "${PGDATABASE}" "${migration_lock_held}" + assert_migration_lease_held "${PGDATABASE}" + local schema schema_after database_bytes + schema=$(database_schema_version "${PGDATABASE}") + pg_dump --format=custom --compress=6 --no-owner --no-acl --no-tablespaces \ + --file "${stage}/database.dump" + pg_restore --list "${stage}/database.dump" >/dev/null + assert_migration_lease_held "${PGDATABASE}" + schema_after=$(database_schema_version "${PGDATABASE}") + [[ "${schema_after}" == "${schema}" ]] || + die "database schema changed during backup" + database_bytes=$(database_size_bytes "${PGDATABASE}") + + # Data Protection key files are append-only. Copying them after the database + # snapshot produces a safe superset for every encrypted value in the dump. install -m 0600 -- "${config}" "${stage}/state/config/appsettings.yml" install -m 0600 -- "${password_file}" "${stage}/state/password.json" cp -a -- "${key_ring}/." "${stage}/state/data-protection-keys/" find "${stage}/state/data-protection-keys" -type d -exec chmod 0700 {} + find "${stage}/state/data-protection-keys" -type f -exec chmod 0600 {} + copy_plugin_manifests "${plugin_dir}" "${stage}/state/plugin-manifests" + assert_migration_lease_held "${PGDATABASE}" + release_migration_lease validate_stage_paths "${stage}" - pg_dump --format=custom --compress=6 --no-owner --no-acl --file "${stage}/database.dump" - pg_restore --list "${stage}/database.dump" >/dev/null - local created schema stage_bytes required_bytes + local created stage_bytes required_bytes created=$(date -u +%Y-%m-%dT%H:%M:%SZ) - schema=$(database_schema_version) stage_bytes=$(du -sb "${stage}" | awk '{print $1}') required_bytes=$((stage_bytes * 2 + 67108864)) { @@ -259,6 +415,7 @@ create_backup() { printf 'application_version=%s\n' "${app_version}" printf 'schema_version=%s\n' "${schema}" printf 'required_free_bytes=%s\n' "${required_bytes}" + printf 'database_bytes=%s\n' "${database_bytes}" printf 'media_files_included=false\n' } >"${stage}/manifest.env" ( @@ -435,16 +592,19 @@ prepare_archive() { ) || die "backup checksum verification failed" pg_restore --list "${backup_temp_dir}/extracted/database.dump" >/dev/null || die "PostgreSQL dump verification failed" - local format required created application schema media_scope + local format required database_bytes created application schema media_scope format=$(manifest_value "${backup_temp_dir}/extracted/manifest.env" format_version) [[ "${format}" == "${BACKUP_FORMAT_VERSION}" ]] || die "unsupported backup format" required=$(manifest_value "${backup_temp_dir}/extracted/manifest.env" required_free_bytes) + database_bytes=$(manifest_value "${backup_temp_dir}/extracted/manifest.env" database_bytes) created=$(manifest_value "${backup_temp_dir}/extracted/manifest.env" created_utc) application=$(manifest_value "${backup_temp_dir}/extracted/manifest.env" application_version) schema=$(manifest_value "${backup_temp_dir}/extracted/manifest.env" schema_version) media_scope=$(manifest_value "${backup_temp_dir}/extracted/manifest.env" media_files_included) [[ "${required}" =~ ^[0-9]+$ && ${#required} -le 18 ]] || die "backup has invalid space metadata" + [[ "${database_bytes}" =~ ^[0-9]+$ && ${#database_bytes} -le 18 ]] || + die "backup has invalid database size metadata" [[ "${created}" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$ ]] || die "backup has invalid timestamp metadata" safe_token "${application}" && safe_token "${schema}" || @@ -511,38 +671,197 @@ prepare_database_replacement() { die "PostgreSQL role must own the target and have CREATEDB, or be superuser" } -replace_database_from_archive() { - local archive=$1 maintenance_database=${PGMAINTENANCEDATABASE:-postgres} - local database_owner database_encoding database_collation database_ctype +postgresql_available_bytes() { + local database=$1 override=$2 path value + if [[ -n "${override}" ]]; then + [[ "${override}" =~ ^[0-9]+$ && ${#override} -le 18 ]] || + die "PostgreSQL available bytes must be a non-negative integer" + printf '%s' "${override}" + return + fi + + path=$(psql --no-psqlrc --dbname "${database}" --tuples-only --no-align \ + --set=ON_ERROR_STOP=1 --command \ + "SELECT CASE WHEN tablespace.spcname = 'pg_default' + THEN current_setting('data_directory') || '/base' + ELSE pg_tablespace_location(tablespace.oid) + END + FROM pg_database AS database + JOIN pg_tablespace AS tablespace ON tablespace.oid = database.dattablespace + WHERE database.datname = current_database()") || + die "cannot inspect the PostgreSQL default tablespace" + path=${path//$'\n'/} + [[ -n "${path}" ]] || die "PostgreSQL returned an empty tablespace path" + [[ -d "${path}" ]] || + die "PostgreSQL storage is remote; provide --postgres-available-bytes from database-host monitoring" + value=$(available_bytes "${path}") || die "cannot inspect PostgreSQL storage capacity" + [[ "${value}" =~ ^[0-9]+$ ]] || die "PostgreSQL storage returned invalid capacity" + printf '%s' "${value}" +} + +restore_candidate_from_archive() { + local archive=$1 backup_schema=$2 postgres_available=$3 + local maintenance_database=${PGMAINTENANCEDATABASE:-postgres} + local database_owner database_encoding database_collation database_ctype database_tablespace database_owner=$(psql --no-psqlrc --dbname "${PGDATABASE}" \ --tuples-only --no-align --set=ON_ERROR_STOP=1 --command \ - "SELECT pg_get_userbyid(datdba) FROM pg_database WHERE datname = current_database()") + "SELECT pg_get_userbyid(datdba) FROM pg_database WHERE datname = current_database()") || + die "cannot inspect target database owner" database_encoding=$(psql --no-psqlrc --dbname "${PGDATABASE}" \ --tuples-only --no-align --set=ON_ERROR_STOP=1 --command \ - "SELECT pg_encoding_to_char(encoding) FROM pg_database WHERE datname = current_database()") + "SELECT pg_encoding_to_char(encoding) FROM pg_database WHERE datname = current_database()") || + die "cannot inspect target database encoding" database_collation=$(psql --no-psqlrc --dbname "${PGDATABASE}" \ --tuples-only --no-align --set=ON_ERROR_STOP=1 --command \ - "SELECT datcollate FROM pg_database WHERE datname = current_database()") + "SELECT datcollate FROM pg_database WHERE datname = current_database()") || + die "cannot inspect target database collation" database_ctype=$(psql --no-psqlrc --dbname "${PGDATABASE}" \ --tuples-only --no-align --set=ON_ERROR_STOP=1 --command \ - "SELECT datctype FROM pg_database WHERE datname = current_database()") + "SELECT datctype FROM pg_database WHERE datname = current_database()") || + die "cannot inspect target database character classification" + database_tablespace=$(psql --no-psqlrc --dbname "${PGDATABASE}" \ + --tuples-only --no-align --set=ON_ERROR_STOP=1 --command \ + "SELECT tablespace.spcname + FROM pg_database AS database + JOIN pg_tablespace AS tablespace ON tablespace.oid = database.dattablespace + WHERE database.datname = current_database()") || + die "cannot inspect target database tablespace" [[ -n "${database_owner}" && -n "${database_encoding}" && - -n "${database_collation}" && -n "${database_ctype}" ]] || + -n "${database_collation}" && -n "${database_ctype}" && + -n "${database_tablespace}" ]] || die "target database properties are unavailable" - # pg_restore --clean only drops objects present in the archive. Recreate the - # database so objects introduced after the backup cannot survive a replacement - # restore and conflict with the restored EF migration history. - dropdb --force --maintenance-db="${maintenance_database}" -- "${PGDATABASE}" - createdb --maintenance-db="${maintenance_database}" \ - --template=template0 \ - --owner="${database_owner}" \ - --encoding="${database_encoding}" \ - --lc-collate="${database_collation}" \ - --lc-ctype="${database_ctype}" \ - -- "${PGDATABASE}" - pg_restore --exit-on-error --no-owner --no-acl \ - --dbname "${PGDATABASE}" "${archive}" + local manifest_database_bytes required_database_bytes + manifest_database_bytes=$(manifest_value "${backup_temp_dir}/extracted/manifest.env" database_bytes) + required_database_bytes=$((manifest_database_bytes + manifest_database_bytes / 4 + 67108864)) + [[ "${postgres_available}" -gt "${required_database_bytes}" ]] || + die "insufficient PostgreSQL tablespace capacity for a rollback-safe candidate restore" + + restore_candidate_database="sdw_restore_$(date -u +%Y%m%d%H%M%S)_$$" + local candidate_exists + candidate_exists=$(psql --no-psqlrc --dbname "${maintenance_database}" \ + --tuples-only --no-align --set=ON_ERROR_STOP=1 \ + --set=candidate="${restore_candidate_database}" <<'SQL' +SELECT EXISTS (SELECT 1 FROM pg_database WHERE datname = :'candidate'); +SQL + ) || + die "cannot inspect candidate database name" + [[ "${candidate_exists}" == f ]] || die "generated candidate database already exists" + + local -a create_options=( + --maintenance-db="${maintenance_database}" + --template=template0 + --owner="${database_owner}" + --encoding="${database_encoding}" + --lc-collate="${database_collation}" + --lc-ctype="${database_ctype}") + # Explicitly naming pg_default unnecessarily requires CREATE privilege on + # that tablespace. Custom target tablespaces do need to be preserved. + if [[ "${database_tablespace}" != pg_default ]]; then + create_options+=(--tablespace="${database_tablespace}") + fi + createdb "${create_options[@]}" -- "${restore_candidate_database}" || + die "cannot create restore candidate database" + pg_restore --exit-on-error --single-transaction --no-owner --no-acl \ + --role="${database_owner}" --dbname "${restore_candidate_database}" "${archive}" || + die "candidate database restore failed" + + [[ "$(database_schema_version "${restore_candidate_database}")" == "${backup_schema}" ]] || + die "candidate database schema does not match the backup" + + local wrong_owners invalid_indexes app_access + wrong_owners=$(psql --no-psqlrc --dbname "${restore_candidate_database}" \ + --tuples-only --no-align --set=ON_ERROR_STOP=1 --set=owner="${database_owner}" <<'SQL' + SELECT + (SELECT count(*) FROM pg_class AS object + JOIN pg_namespace AS namespace ON namespace.oid = object.relnamespace + WHERE namespace.nspname NOT IN ('pg_catalog', 'information_schema') + AND namespace.nspname !~ '^pg_toast' + AND object.relkind IN ('r','p','v','m','S','f') + AND pg_get_userbyid(object.relowner) <> :'owner') + + (SELECT count(*) FROM pg_proc AS object + JOIN pg_namespace AS namespace ON namespace.oid = object.pronamespace + WHERE namespace.nspname NOT IN ('pg_catalog', 'information_schema') + AND namespace.nspname !~ '^pg_toast' + AND pg_get_userbyid(object.proowner) <> :'owner') + + (SELECT count(*) FROM pg_type AS object + JOIN pg_namespace AS namespace ON namespace.oid = object.typnamespace + WHERE namespace.nspname NOT IN ('pg_catalog', 'information_schema') + AND namespace.nspname !~ '^pg_toast' + AND pg_get_userbyid(object.typowner) <> :'owner') + + (SELECT count(*) FROM pg_namespace AS object + WHERE object.nspname NOT IN ('public', 'pg_catalog', 'information_schema') + AND object.nspname !~ '^pg_toast' + AND pg_get_userbyid(object.nspowner) <> :'owner') + + (SELECT count(*) FROM pg_extension AS object + WHERE object.extname <> 'plpgsql' + AND pg_get_userbyid(object.extowner) <> :'owner'); +SQL + ) || + die "cannot validate restored object ownership" + [[ "${wrong_owners}" == 0 ]] || die "candidate database contains objects not owned by the application role" + + invalid_indexes=$(psql --no-psqlrc --dbname "${restore_candidate_database}" \ + --tuples-only --no-align --set=ON_ERROR_STOP=1 --command \ + 'SELECT count(*) FROM pg_index WHERE NOT indisvalid OR NOT indisready') || + die "cannot validate candidate indexes" + [[ "${invalid_indexes}" == 0 ]] || die "candidate database contains invalid indexes" + app_access=$(psql --no-psqlrc --dbname "${restore_candidate_database}" \ + --tuples-only --no-align --set=ON_ERROR_STOP=1 --set=owner="${database_owner}" <<'SQL' + SET ROLE :"owner"; + SELECT CASE WHEN has_schema_privilege(current_user, 'public', 'USAGE') + AND has_schema_privilege(current_user, 'public', 'CREATE') + AND NOT EXISTS ( + SELECT 1 FROM pg_class AS object + JOIN pg_namespace AS namespace ON namespace.oid = object.relnamespace + WHERE namespace.nspname = 'public' + AND object.relkind IN ('r','p','S') + AND NOT has_table_privilege(current_user, object.oid, 'SELECT')) + THEN 'yes' ELSE 'no' END; +SQL + ) || + die "application role cannot access the candidate database" + app_access=${app_access##*$'\n'} + [[ "${app_access}" == yes ]] || die "candidate database failed application-role readiness checks" +} + +switch_candidate_database() { + local maintenance_database=${PGMAINTENANCEDATABASE:-postgres} + local previous_database="sdw_previous_$(date -u +%Y%m%d%H%M%S)_$$" active_connections + active_connections=$(psql --no-psqlrc --dbname "${maintenance_database}" \ + --tuples-only --no-align --set=ON_ERROR_STOP=1 --set=target="${PGDATABASE}" \ + --set=candidate="${restore_candidate_database}" <<'SQL' +SELECT count(*) FROM pg_stat_activity + WHERE datname IN (:'target', :'candidate') AND pid <> pg_backend_pid(); +SQL + ) || + die "cannot inspect active database connections" + [[ "${active_connections}" == 0 ]] || + die "target database still has active connections; stop the application before restore" + + psql --no-psqlrc --dbname "${maintenance_database}" --set=ON_ERROR_STOP=1 \ + --set=target="${PGDATABASE}" --set=previous="${previous_database}" \ + >/dev/null <<'SQL' || +ALTER DATABASE :"target" RENAME TO :"previous"; +SQL + die "could not preserve the original database before switching" + if ! psql --no-psqlrc --dbname "${maintenance_database}" --set=ON_ERROR_STOP=1 \ + --set=candidate="${restore_candidate_database}" --set=target="${PGDATABASE}" \ + >/dev/null <<'SQL' +ALTER DATABASE :"candidate" RENAME TO :"target"; +SQL + then + psql --no-psqlrc --dbname "${maintenance_database}" --set=ON_ERROR_STOP=1 \ + --set=previous="${previous_database}" --set=target="${PGDATABASE}" \ + >/dev/null <<'SQL' || +ALTER DATABASE :"previous" RENAME TO :"target"; +SQL + die "candidate switch failed and automatic database-name rollback also failed" + die "candidate switch failed; original database name was restored" + fi + restore_candidate_database="" + restore_state_rollback_active=false + printf 'original database retained as %s for rollback\n' "${previous_database}" >&2 } restore_backup() { @@ -557,6 +876,7 @@ restore_backup() { local expected_schema="${SDW_EXPECTED_SCHEMA_VERSION:-}" local identity="${SDW_BACKUP_AGE_IDENTITY:-}" local safety_directory="${SDW_BACKUP_DIRECTORY:-/var/lib/sdw-redive/backups}" + local postgres_available="${SDW_POSTGRESQL_AVAILABLE_BYTES:-}" local confirmed=false allow_version_mismatch=false while [[ $# -gt 0 ]]; do @@ -569,6 +889,7 @@ restore_backup() { --expected-schema) expected_schema=$2; shift 2 ;; --age-identity) identity=$2; shift 2 ;; --safety-directory) safety_directory=$2; shift 2 ;; + --postgres-available-bytes) postgres_available=$2; shift 2 ;; --allow-version-mismatch) allow_version_mismatch=true; shift ;; --confirm-replace) confirmed=true; shift ;; *) die "unknown restore option: $1" ;; @@ -586,6 +907,10 @@ restore_backup() { prepare_database_replacement prepare_archive "${archive}" "${identity}" + local maintenance_database=${PGMAINTENANCEDATABASE:-postgres} + acquire_migration_lease "${maintenance_database}" false + assert_migration_lease_held "${maintenance_database}" + local extracted="${backup_temp_dir}/extracted" local manifest="${extracted}/manifest.env" local backup_version backup_schema @@ -594,13 +919,23 @@ restore_backup() { if [[ "${allow_version_mismatch}" != true ]]; then [[ -n "${expected_version}" ]] || die "--expected-version is required unless --allow-version-mismatch is explicit" - [[ "$(major_version "${backup_version}")" == "$(major_version "${expected_version}")" ]] || + local backup_major expected_major + backup_major=$(major_version "${backup_version}") || + die "backup application version is invalid" + expected_major=$(major_version "${expected_version}") || + die "expected application version is invalid" + [[ "${backup_major}" == "${expected_major}" ]] || die "backup application major version is incompatible" fi - if [[ -n "${expected_schema}" && "${backup_schema}" != "${expected_schema}" ]]; then + [[ -n "${expected_schema}" ]] || + die "--expected-schema is required for fail-closed application compatibility" + safe_token "${expected_schema}" || die "expected schema contains unsupported characters" + if [[ "${backup_schema}" != "${expected_schema}" ]]; then die "backup schema is incompatible with the requested schema" fi + postgres_available=$(postgresql_available_bytes "${PGDATABASE}" "${postgres_available}") + local restored_config required_bytes destination_parent probe restored_config=$(find "${extracted}/state/config" -maxdepth 1 -type f -print -quit) [[ -n "${restored_config}" && -f "${extracted}/state/password.json" && @@ -624,44 +959,80 @@ restore_backup() { local timestamp safety_dump timestamp=$(date -u +%Y%m%dT%H%M%SZ) safety_dump="${safety_directory}/pre-restore-${timestamp}-$$.dump" - pg_dump --format=custom --compress=6 --no-owner --no-acl --file "${safety_dump}.partial" + pg_dump --format=custom --compress=6 --no-owner --no-acl --no-tablespaces \ + --file "${safety_dump}.partial" chmod 0600 "${safety_dump}.partial" mv "${safety_dump}.partial" "${safety_dump}" - replace_database_from_archive "${extracted}/database.dump" - [[ "$(database_schema_version)" == "${backup_schema}" ]] || - die "restored database schema does not match the backup" + restore_candidate_from_archive \ + "${extracted}/database.dump" "${backup_schema}" "${postgres_available}" + + # Prepare every state artifact before the database-name switch. Each staged + # path lives beside its destination, so the final rename is same-filesystem. + local config_partial="${config_destination}.sdw-restore-$$.partial" + local password_partial="${password_destination}.sdw-restore-$$.partial" + local key_ring_partial="${key_ring_destination}.sdw-restore-$$.partial" + local plugin_partial="${plugin_destination}.sdw-restore-$$.partial" + [[ ! -e "${config_partial}" && ! -L "${config_partial}" && + ! -e "${password_partial}" && ! -L "${password_partial}" && + ! -e "${key_ring_partial}" && ! -L "${key_ring_partial}" && + ! -e "${plugin_partial}" && ! -L "${plugin_partial}" ]] || + die "restore staging path already exists" + restore_staging_paths=( + "${config_partial}" "${password_partial}" + "${key_ring_partial}" "${plugin_partial}") + install -m 0600 "${restored_config}" "${config_partial}" + install -m 0600 "${extracted}/state/password.json" "${password_partial}" + mkdir -m 0700 "${key_ring_partial}" + cp -a "${extracted}/state/data-protection-keys/." "${key_ring_partial}/" + find "${key_ring_partial}" -type d -exec chmod 0700 {} + + find "${key_ring_partial}" -type f -exec chmod 0600 {} + + mkdir -m 0700 "${plugin_partial}" + if [[ -d "${extracted}/state/plugin-manifests" && + ! -f "${extracted}/state/plugin-manifests/none" ]]; then + cp -a "${extracted}/state/plugin-manifests/." "${plugin_partial}/" + fi if [[ -f "${config_destination}" ]]; then + restore_config_had_original=true install -m 0600 "${config_destination}" "${config_destination}.pre-restore-${timestamp}" fi if [[ -f "${password_destination}" ]]; then + restore_password_had_original=true install -m 0600 "${password_destination}" "${password_destination}.pre-restore-${timestamp}" fi - install -m 0600 "${restored_config}" "${config_destination}.partial" - mv "${config_destination}.partial" "${config_destination}" - install -m 0600 "${extracted}/state/password.json" "${password_destination}.partial" - mv "${password_destination}.partial" "${password_destination}" + restore_config_destination="${config_destination}" + restore_password_destination="${password_destination}" + restore_key_ring_destination="${key_ring_destination}" + restore_plugin_destination="${plugin_destination}" + restore_state_timestamp="${timestamp}" + restore_state_rollback_active=true + mv "${config_partial}" "${config_destination}" + mv "${password_partial}" "${password_destination}" if [[ -d "${key_ring_destination}" ]]; then + restore_key_ring_had_original=true mv "${key_ring_destination}" "${key_ring_destination}.pre-restore-${timestamp}" fi - mkdir -p "${key_ring_destination}" - chmod 0700 "${key_ring_destination}" - cp -a "${extracted}/state/data-protection-keys/." "${key_ring_destination}/" - find "${key_ring_destination}" -type f -exec chmod 0600 {} + + mv "${key_ring_partial}" "${key_ring_destination}" - if [[ -d "${extracted}/state/plugin-manifests" && - ! -f "${extracted}/state/plugin-manifests/none" ]]; then - mkdir -p "${plugin_destination}" - cp -a "${extracted}/state/plugin-manifests/." "${plugin_destination}/" + if [[ -d "${plugin_destination}" ]]; then + restore_plugin_had_original=true + mv "${plugin_destination}" "${plugin_destination}.pre-restore-${timestamp}" fi + mv "${plugin_partial}" "${plugin_destination}" + assert_migration_lease_held "${maintenance_database}" + switch_candidate_database + restore_state_rollback_active=false + restore_staging_paths=() + release_migration_lease printf 'restore completed; restart the application and run its health check\n' } command=${1:-} [[ -n "${command}" ]] || { usage; exit 1; } shift +validate_failure_webhook case "${command}" in create) create_backup "$@" ;; list) list_backups "$@" ;; diff --git a/deployments/tests/backup-restore-smoke.sh b/deployments/tests/backup-restore-smoke.sh index 34d7059..e6a548c 100755 --- a/deployments/tests/backup-restore-smoke.sh +++ b/deployments/tests/backup-restore-smoke.sh @@ -15,33 +15,93 @@ trap cleanup EXIT mkdir -p \ "${drill_root}/keys" \ - "${drill_root}/plugins" \ + "${drill_root}/plugins/example" \ "${drill_root}/backups" \ "${drill_root}/restored" install -m 0600 "${repo_root}/VERSION" "${drill_root}/appsettings.yml" install -m 0600 "${repo_root}/VERSION" "${drill_root}/password.json" +printf '\n' >"${drill_root}/keys/key.xml" +printf '{"name":"example","version":"1"}\n' >"${drill_root}/plugins/example/manifest.json" app_version=$(tr -d '[:space:]' <"${repo_root}/VERSION") +# EXIT, rather than ERR alone, must notify explicit die/exit paths without +# exposing the configured URL or any environment secret in the diagnostic. +mkdir "${drill_root}/webhook-bin" +cat >"${drill_root}/webhook-bin/curl" <<'EOF' +#!/usr/bin/env bash +while [[ $# -gt 0 ]]; do + if [[ "$1" == --data ]]; then + printf '%s\n' "$2" >"${WEBHOOK_CAPTURE}" + exit 0 + fi + shift +done +exit 2 +EOF +chmod +x "${drill_root}/webhook-bin/curl" +webhook_log="${drill_root}/webhook.log" +if WEBHOOK_CAPTURE="${drill_root}/webhook-payload" \ + SDW_BACKUP_FAILURE_WEBHOOK='https://secret.invalid/opaque-token' \ + PATH="${drill_root}/webhook-bin:/usr/bin:/bin" \ + "${repo_root}/deployments/sdw-backup" invalid-command >"${webhook_log}" 2>&1; then + printf 'invalid command unexpectedly succeeded\n' >&2 + exit 1 +fi +test "$(cat "${drill_root}/webhook-payload")" = '{"event":"sdw_backup_failed"}' +! grep -q 'opaque-token' "${webhook_log}" +if SDW_BACKUP_FAILURE_WEBHOOK='https://secret.invalid/opaque-token' PATH="${drill_root}/empty-path" \ + /bin/bash "${repo_root}/deployments/sdw-backup" invalid-command >/dev/null 2>&1; then + printf 'configured webhook unexpectedly accepted a missing curl\n' >&2 + exit 1 +fi + podman run --rm --detach --name "${container_name}" \ --env POSTGRES_PASSWORD=postgres \ --env POSTGRES_USER=postgres \ - --env POSTGRES_DB=sdw_source \ + --env POSTGRES_DB=postgres \ --publish 127.0.0.1::5432 \ postgres:17-alpine >/dev/null port=$(podman port "${container_name}" 5432/tcp | sed -E 's/.*:([0-9]+)$/\1/') -export PGHOST=127.0.0.1 PGPORT="${port}" PGUSER=postgres PGPASSWORD=postgres PGDATABASE=sdw_source +export PGHOST=127.0.0.1 PGPORT="${port}" PGUSER=postgres PGPASSWORD=postgres PGDATABASE=postgres for _ in {1..30}; do pg_isready --quiet && break sleep 1 done pg_isready --quiet +psql --no-psqlrc --set=ON_ERROR_STOP=1 <<'SQL' >/dev/null +CREATE ROLE sdw_app LOGIN PASSWORD 'app-password'; +CREATE ROLE sdw_restore_admin LOGIN CREATEDB PASSWORD 'restore-password'; +GRANT sdw_app TO sdw_restore_admin; +CREATE DATABASE sdw_source OWNER sdw_app; +SQL + +export PGUSER=sdw_app PGPASSWORD=app-password PGDATABASE=sdw_source psql --no-psqlrc --set=ON_ERROR_STOP=1 --command \ "CREATE TABLE drill_items (id integer PRIMARY KEY, value text NOT NULL); INSERT INTO drill_items VALUES (1, 'round-trip');" \ >/dev/null -export ConnectionStrings__sdw="Host=127.0.0.1;Port=${port};User ID=postgres;Password=postgres;Database=sdw_source" -unset PGHOST PGPORT PGUSER PGPASSWORD PGDATABASE +# Simulate the application startup hook already owning SDWMIGR1. The explicit +# flag must avoid a child-process self-deadlock while the dump remains valid. +coproc HELD_LEASE { + psql --no-psqlrc --quiet --tuples-only --no-align --set=ON_ERROR_STOP=1 +} +printf 'SELECT pg_advisory_lock(6000016593017852465);\n\\echo HELD\n' >&"${HELD_LEASE[1]}" +while IFS= read -r lease_line <&"${HELD_LEASE[0]}"; do + [[ "${lease_line}" == HELD ]] && break +done +archive=$("${repo_root}/deployments/sdw-backup" create --migration-lock-held \ + --output "${drill_root}/backups" \ + --config "${drill_root}/appsettings.yml" \ + --password-file "${drill_root}/password.json" \ + --key-ring "${drill_root}/keys" \ + --plugin-dir "${drill_root}/plugins" \ + --retention-days 7 \ + --app-version "${app_version}") +printf '\\q\n' >&"${HELD_LEASE[1]}" +wait "${HELD_LEASE_PID}" + +# Exercise normal lease acquisition too; use this archive for the restore drill. archive=$("${repo_root}/deployments/sdw-backup" create \ --output "${drill_root}/backups" \ --config "${drill_root}/appsettings.yml" \ @@ -50,10 +110,8 @@ archive=$("${repo_root}/deployments/sdw-backup" create \ --plugin-dir "${drill_root}/plugins" \ --retention-days 7 \ --app-version "${app_version}") -unset ConnectionStrings__sdw -export PGHOST=127.0.0.1 PGPORT="${port}" PGUSER=postgres PGPASSWORD=postgres PGDATABASE=sdw_source -"${repo_root}/deployments/sdw-backup" verify "${archive}" +"${repo_root}/deployments/sdw-backup" verify "${archive}" cp -- "${archive}" "${drill_root}/corrupt.tar.gz" archive_size=$(stat --format=%s "${drill_root}/corrupt.tar.gz") printf CORRUPT | dd of="${drill_root}/corrupt.tar.gz" bs=1 \ @@ -63,23 +121,66 @@ if "${repo_root}/deployments/sdw-backup" verify "${drill_root}/corrupt.tar.gz" > exit 1 fi -createdb sdw_restore -export PGDATABASE=sdw_restore +export PGUSER=postgres PGPASSWORD=postgres PGDATABASE=postgres +createdb --template=sdw_source --owner=sdw_app sdw_restore +export PGUSER=sdw_restore_admin PGPASSWORD=restore-password PGDATABASE=sdw_restore +export PGMAINTENANCEDATABASE=postgres psql --no-psqlrc --set=ON_ERROR_STOP=1 --command \ - "CREATE TABLE destination_only (id integer); INSERT INTO destination_only VALUES (99);" \ + "CREATE TABLE restore_guard (id integer PRIMARY KEY); INSERT INTO restore_guard VALUES (99);" \ >/dev/null -"${repo_root}/deployments/sdw-backup" restore "${archive}" \ - --confirm-replace \ - --expected-version "${app_version}" \ - --config-destination "${drill_root}/restored/appsettings.yml" \ - --password-destination "${drill_root}/restored/password.json" \ - --key-ring-destination "${drill_root}/restored/keys" \ - --plugin-destination "${drill_root}/restored/plugins" \ - --safety-directory "${drill_root}/backups" +available_kib=$(podman exec "${container_name}" df -Pk /var/lib/postgresql/data | awk 'NR == 2 {print $4}') +available_bytes=$((available_kib * 1024)) +restore_options=( + --confirm-replace + --expected-version "${app_version}" + --expected-schema uninitialized + --postgres-available-bytes "${available_bytes}" + --config-destination "${drill_root}/restored/appsettings.yml" + --password-destination "${drill_root}/restored/password.json" + --key-ring-destination "${drill_root}/restored/keys" + --plugin-destination "${drill_root}/restored/plugins" + --safety-directory "${drill_root}/backups") + +if "${repo_root}/deployments/sdw-backup" restore "${archive}" \ + "${restore_options[@]}" --expected-schema incompatible >/dev/null 2>&1; then + printf 'schema mismatch unexpectedly restored\n' >&2 + exit 1 +fi +test "$(psql --no-psqlrc -Atc 'SELECT id FROM restore_guard')" = 99 +if "${repo_root}/deployments/sdw-backup" restore "${archive}" \ + "${restore_options[@]/${available_bytes}/1}" >/dev/null 2>&1; then + printf 'insufficient PostgreSQL capacity unexpectedly restored\n' >&2 + exit 1 +fi +test "$(psql --no-psqlrc -Atc 'SELECT id FROM restore_guard')" = 99 + +mkdir "${drill_root}/fault-bin" +real_pg_restore=$(command -v pg_restore) +cat >"${drill_root}/fault-bin/pg_restore" </dev/null 2>&1; then + printf 'injected candidate failure unexpectedly restored\n' >&2 + exit 1 +fi +test "$(psql --no-psqlrc -Atc 'SELECT id FROM restore_guard')" = 99 +test "$(psql --no-psqlrc -Atc "SELECT count(*) FROM pg_database WHERE datname LIKE 'sdw_restore_%'")" = 0 -test "$(psql --no-psqlrc --tuples-only --no-align --command \ - 'SELECT value FROM drill_items WHERE id = 1')" = round-trip -test "$(psql --no-psqlrc --tuples-only --no-align --command \ - "SELECT to_regclass('public.destination_only') IS NULL")" = t +"${repo_root}/deployments/sdw-backup" restore "${archive}" "${restore_options[@]}" +test "$(psql --no-psqlrc -Atc 'SELECT value FROM drill_items WHERE id = 1')" = round-trip +test "$(psql --no-psqlrc -Atc "SELECT to_regclass('public.restore_guard') IS NULL")" = t +test "$(psql --no-psqlrc -Atc \ + "SELECT count(*) FROM pg_class c JOIN pg_namespace n ON n.oid=c.relnamespace WHERE n.nspname='public' AND c.relkind IN ('r','p','v','m','S','f') AND pg_get_userbyid(c.relowner) <> 'sdw_app'")" = 0 cmp "${drill_root}/password.json" "${drill_root}/restored/password.json" +cmp "${drill_root}/keys/key.xml" "${drill_root}/restored/keys/key.xml" +cmp "${drill_root}/plugins/example/manifest.json" \ + "${drill_root}/restored/plugins/example/manifest.json" +export PGUSER=sdw_app PGPASSWORD=app-password +test "$(psql --no-psqlrc -Atc 'SELECT value FROM drill_items WHERE id = 1')" = round-trip printf 'backup restore smoke test passed\n' diff --git a/docs/backup-restore.md b/docs/backup-restore.md index 3858501..b2315d9 100644 --- a/docs/backup-restore.md +++ b/docs/backup-restore.md @@ -10,11 +10,11 @@ - Data Protection 密钥环是恢复数据库内加密运行时凭据的必要条件,必须与数据库来自同一个恢复点。 - Valkey 中的会话和短期状态不恢复;恢复后用户可能需要重新登录。 -每个归档包含时间戳、应用版本、最新 EF schema 版本、最低临时空间估算和 SHA-256 文件清单;旁边的同名 `.sha256` 文件校验整个压缩或加密归档。manifest 和旁路校验文件均不包含数据库口令、JWT、上游 API key 或连接字符串。未加密归档仍包含配置和密钥文件,因此必须按秘密处理。 +每个归档包含时间戳、应用版本、最新 EF schema 版本、数据库实际字节数、最低临时空间估算和 SHA-256 文件清单;旁边的同名 `.sha256` 文件校验整个压缩或加密归档。manifest 和旁路校验文件均不包含数据库口令、JWT、上游 API key 或连接字符串。未加密归档仍包含配置和密钥文件,因此必须按秘密处理。 ## 创建、列出和验证 -命令读取标准 `PGHOST`、`PGPORT`、`PGUSER`、`PGPASSWORD`、`PGDATABASE`;在容器内也可直接解析已有的 `ConnectionStrings__sdw` 环境变量。 +命令读取标准 `PGHOST`、`PGPORT`、`PGUSER`、`PGPASSWORD`、`PGDATABASE`;在容器内也可直接解析已有的 `ConnectionStrings__sdw` 环境变量。创建期间会获取与应用迁移相同的 `SDWMIGR1` PostgreSQL advisory lease,并在 lease 内依次读取 schema、完成一致性 dump、再次核对 schema,然后复制 Data Protection 密钥环。密钥在数据库快照之后复制,因此归档包含 dump 内所有加密值所需密钥的安全超集。 ```bash export PGHOST=localhost PGPORT=5432 PGUSER=sdw PGDATABASE=sdw @@ -41,7 +41,7 @@ sdw-backup create ... --age-recipient 'age1...' sdw-backup verify backup.tar.gz.age --age-identity /secure/backup-key.txt ``` -私钥不写入归档、manifest 或日志。若使用 webhook,只会发送固定的 `sdw_backup_failed` 事件,不发送错误文本、路径或凭据: +私钥不写入归档、manifest 或日志。若使用 webhook,只会发送固定的 `sdw_backup_failed` 事件,不发送错误文本、路径或凭据。显式参数错误等 `exit` 路径同样通知;配置了 webhook 但没有 `curl` 时命令会立即失败: ```bash export SDW_BACKUP_FAILURE_WEBHOOK=https://monitor.example/hooks/opaque-token @@ -51,7 +51,7 @@ export SDW_BACKUP_FAILURE_WEBHOOK=https://monitor.example/hooks/opaque-token ## systemd 定时执行 -系统包安装 `/etc/sdw-redive/backup.env`、`sdw-backup.service` 和 `sdw-backup.timer`,但不会在口令仍为占位符时自动启用。安装 PostgreSQL client,编辑并保护环境文件后启用: +系统包依赖 PostgreSQL client 与 `curl`,并安装 `/etc/sdw-redive/backup.env`、`sdw-backup.service` 和 `sdw-backup.timer`,但不会在口令仍为占位符时自动启用。编辑并保护环境文件后启用: ```bash sudoedit /etc/sdw-redive/backup.env @@ -62,7 +62,7 @@ sudo systemctl start sdw-backup.service sudo journalctl -u sdw-backup.service ``` -升级前可执行 `sudo systemctl start sdw-backup.service`,验证新归档后再升级。这样自动迁移数据库前已有明确恢复点。 +升级前可执行 `sudo systemctl start sdw-backup.service`,验证新归档后再升级。应用的 pre-migration backup hook 已经持有 `SDWMIGR1` lease 时,必须把 `create --migration-lock-held` 作为 hook 参数;该选项只供持锁父进程使用,普通定时任务不得设置,否则会绕过迁移串行化。 ## 容器部署 @@ -84,10 +84,11 @@ podman-compose exec sdw-redive sdw-backup verify /app/backups/sdw-backup-....tar 恢复是替换操作。先停止所有应用副本和后台任务;仅停止应用,不要停止 PostgreSQL。 -1. 把目标应用安装为与备份相同的 major 版本。恢复脚本会在生成 safety dump 后删除并重建目标数据库,数据库登录角色必须拥有目标数据库并具有 `CREATEDB`(或使用 PostgreSQL 超级用户);不要把 `PGDATABASE` 指向 `postgres`、`template0` 或 `template1`。默认的低权限应用账号不应长期获得 `CREATEDB`,恢复时请临时提供独立的数据库管理员凭据。 -2. 预先挂载足够空间;脚本在任何数据库写入前验证路径、链接、所有 SHA-256、`pg_restore --list`、格式、major 版本、可选 schema 版本、临时空间和目标目录可写性。 -3. 执行恢复。命令先在 safety directory 创建现有数据库 dump,旧配置、密码和密钥环也会重命名为 `.pre-restore-*`,可人工回退。 -4. 修正文件所有者与权限,启动应用,检查 `/api/auth/allowRegister`、登录、订阅和文件浏览。 +1. 把目标应用安装为与备份相同的 major 版本。数据库恢复登录角色必须是超级用户,或同时具有 `CREATEDB` 且是目标数据库 owner 的成员;脚本通过 `pg_restore --role=` 保证恢复对象仍归应用 owner。不要把 `PGDATABASE` 指向 `postgres`、`template0` 或 `template1`,也不要长期提升低权限应用账号。 +2. 预先挂载足够空间。脚本先验证路径、链接、所有 SHA-256、`pg_restore --list`、格式、major、精确 schema、临时空间和目标目录。必须用 `--expected-schema`(或 `SDW_EXPECTED_SCHEMA_VERSION`)传入当前应用构建所要求的完整 migration id,不能只依赖同 major 或目标库碰巧已有的版本。对于远程 PostgreSQL,脚本无法从客户端可靠读取服务端文件系统剩余字节,必须用 `--postgres-available-bytes` 提供数据库主机或监控刚采集的 default tablespace 可用量;本地可见的 tablespace 会直接用 `df` 测量。 +3. 执行恢复。命令先创建 safety dump,再把归档以 `--single-transaction --exit-on-error --role=` 恢复到候选数据库;schema、对象 owner、索引和应用角色访问检查全部通过后,才用可回滚的数据库 rename 切换。任何候选恢复失败都删除候选库,目标库不变。成功后原数据库以 `sdw_previous_*` 名称保留,确认应用和备份后再由管理员删除。 +4. 配置、密码、密钥环和插件 manifest 在切换前同文件系统 staging;切换失败时自动恢复原状态。旧状态仍以 `.pre-restore-*` 保留。 +5. 修正文件所有者与权限,启动应用,检查 `/api/auth/allowRegister`、登录、加密运行时设置、订阅和文件浏览。 ```bash sudo systemctl stop sdw-redive @@ -98,6 +99,7 @@ sudo --preserve-env=PGHOST,PGPORT,PGUSER,PGPASSWORD,PGDATABASE,PGMAINTENANCEDATA --confirm-replace \ --expected-version 2.3.0 \ --expected-schema 20260801000000_ExpectedMigration \ + --postgres-available-bytes 21474836480 \ --config-destination /etc/sdw-redive/appsettings.yml \ --password-destination /var/lib/sdw-redive/password.json \ --key-ring-destination /var/lib/sdw-redive/data-protection-keys @@ -115,6 +117,8 @@ podman-compose stop sdw-redive podman-compose run --rm --no-deps --entrypoint sdw-backup sdw-redive \ restore /app/backups/sdw-backup-....tar.gz \ --confirm-replace --expected-version 2.3.0 \ + --expected-schema 20260801000000_ExpectedMigration \ + --postgres-available-bytes "$MEASURED_DATABASE_FREE_BYTES" \ --config-destination /app/backups/restored/podman-compose.yml \ --password-destination /app/data/password.json \ --key-ring-destination /app/data/data-protection-keys \ @@ -124,7 +128,7 @@ podman-compose up -d curl --fail http://127.0.0.1:5097/api/auth/allowRegister ``` -如果版本、schema 或空间检查失败,数据库不会被写入。只有在经过人工评估后才能使用 `--allow-version-mismatch`;该开关仍不会跳过格式、校验和、dump 与空间验证。 +如果版本、schema 或空间检查失败,候选库也不会创建。有效归档在 `pg_restore`、owner 或可用性检查中失败时,正式目标库仍保持原名和原内容。只有在经过人工评估后才能使用 `--allow-version-mismatch`;该开关不会跳过精确 schema、格式、校验和、dump 与空间验证。 ## 逻辑 JSON 导出与导入 @@ -141,6 +145,8 @@ curl --fail -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json https://sdw.example/api/data-transfer/import ``` -导出 envelope 内的 SHA-256 在任何写入前验证。`skip` 可安全重复导入;`overwrite` 更新稳定键冲突项;`fail` 在首个冲突处返回 409,事务不会提交。订阅以 URL、规则以 TMDB id + pattern、人工修正以 release URL + title + publish time、播放进度以虚拟路径匹配。目标实例缺少对应 release 或虚拟文件时会明确计入 skipped,不会制造指向不存在媒体的记录。人工修正迁移当前元数据和审计操作,但不会覆盖物理路径;映射仍由目标实例的文件映射流程负责。 +导出在一个 repeatable-read 快照内读取所有类别;导出与导入共同限制为每类 10,000 条、完整导入请求 10 MiB,因此不会生成自身无法重新导入的文件。envelope 内的 SHA-256 在任何写入前验证。`skip` 可安全重复导入;`overwrite` 更新稳定键冲突项;`fail` 在首个冲突处返回 409,事务不会提交。生产 Npgsql 重试的每个 attempt 都使用全新 scope、DbContext 和 mapper 状态。 + +订阅以 URL、规则以 TMDB id + pattern、人工修正以 release URL + title + publish time、播放进度以虚拟路径匹配。目标实例缺少对应 release 或虚拟文件时会明确计入 skipped,不会制造指向不存在媒体的记录。人工修正不会覆盖目标中已有同 TMDB Animation 的全局名称、原名或海报,也不会改动共享该 Animation 的其他 release;物理路径由目标实例的映射预览与事务性替换流程处理。 逻辑导出不含 JWT、登录密码、WebDAV token、Data Protection key、AI/qBittorrent 凭据、聊天内容或媒体文件。跨 major 格式、不匹配校验和、未知类别、非法数值与超大类别会在事务开始前拒绝。 diff --git a/packaging/backup.env b/packaging/backup.env index 400ebdf..75a6e02 100644 --- a/packaging/backup.env +++ b/packaging/backup.env @@ -4,10 +4,13 @@ PGPORT=5432 PGUSER=sdw PGDATABASE=sdw PGPASSWORD=CHANGE_ME -# Replacement restore additionally requires a temporary database administrator -# that owns PGDATABASE and has CREATEDB. Do not grant that privilege permanently -# to this runtime/backup account; override PGUSER/PGPASSWORD for the manual restore. +# Replacement restore additionally requires a temporary database administrator: +# superuser, or CREATEDB plus membership in PGDATABASE's owner role. Do not grant +# that privilege permanently to the runtime account; override credentials manually. # PGMAINTENANCEDATABASE=postgres +# For remote PostgreSQL, set this only from a fresh database-host/tablespace +# measurement. Local visible tablespaces are measured automatically. +# SDW_POSTGRESQL_AVAILABLE_BYTES=21474836480 SDW_BACKUP_DIRECTORY=/var/lib/sdw-redive/backups SDW_BACKUP_RETENTION_DAYS=14 diff --git a/packaging/nfpm.yaml b/packaging/nfpm.yaml index d3ce403..88f6757 100644 --- a/packaging/nfpm.yaml +++ b/packaging/nfpm.yaml @@ -11,14 +11,23 @@ license: "Apache-2.0" depends: - aspnetcore-runtime-10.0 + - postgresql-client + - curl recommends: - valkey overrides: + rpm: + depends: + - aspnetcore-runtime-10.0 + - postgresql + - curl archlinux: depends: - aspnet-runtime-10.0 + - postgresql + - curl recommends: - valkey @@ -62,9 +71,6 @@ contents: owner: root group: sdw-redive - - src: ./VERSION - dst: /usr/lib/sdw-redive/VERSION - - src: ./LICENSE dst: /usr/share/doc/sdw-redive/LICENSE diff --git a/packaging/postinstall.sh b/packaging/postinstall.sh index 10cd22a..d580dbd 100755 --- a/packaging/postinstall.sh +++ b/packaging/postinstall.sh @@ -57,5 +57,8 @@ find /var/lib/sdw-redive/data-protection-keys -type f \ -exec chown sdw-redive:sdw-redive {} + \ -exec chmod 0600 {} + -# Reload systemd -systemctl daemon-reload +# Package-image roots do not necessarily run systemd. On a real host, malformed +# units and daemon failures must still fail package installation. +if command -v systemctl >/dev/null 2>&1 && [ -d /run/systemd/system ]; then + systemctl daemon-reload +fi diff --git a/packaging/preremove.sh b/packaging/preremove.sh index 9da2e22..dadcf43 100755 --- a/packaging/preremove.sh +++ b/packaging/preremove.sh @@ -1,11 +1,18 @@ #!/bin/bash set -e -# Stop and disable the service before removal -if systemctl is-active --quiet sdw-redive; then - systemctl stop sdw-redive -fi +# Debian passes "upgrade" and RPM passes 1 for replacement upgrades. Preserve +# the administrator's enabled/running state in those cases; this hook owns only +# final package removal. +case "${1:-remove}" in + upgrade|1) exit 0 ;; +esac -if systemctl is-enabled --quiet sdw-redive 2>/dev/null; then - systemctl disable sdw-redive +# Package-image roots do not necessarily run systemd. On a real host, stop the +# timer before its service and propagate every genuine systemctl failure. +if command -v systemctl >/dev/null 2>&1 && [ -d /run/systemd/system ]; then + # stop/disable are idempotent for inactive/disabled installed units. Calling + # them directly keeps D-Bus and unit errors visible to the package manager. + systemctl stop sdw-backup.timer sdw-backup.service sdw-redive.service + systemctl disable sdw-backup.timer sdw-redive.service fi From 81903d5444ba77a1b59e9a8e7b2457484f9c38d0 Mon Sep 17 00:00:00 2001 From: mahoshojoHCG Date: Mon, 31 Aug 2026 15:20:01 +0800 Subject: [PATCH 11/37] Fix staged release activation and search cursors --- .../DataRepository/ReleaseUpgrade.cs | 4 + ...4_StageInactiveReleaseMappings.Designer.cs | 1642 +++++++++++++++++ ...0831071234_StageInactiveReleaseMappings.cs | 102 + .../ApplicationContextModelSnapshot.cs | 43 +- .../Models/ApplicationContext.cs | 20 + .../Models/StagedFileMapping.cs | 14 + .../Repositories/AnimationInfoRepository.cs | 125 +- .../Repositories/FileMappingRepository.cs | 68 +- .../Repositories/LibrarySearchRepository.cs | 24 +- .../Repositories/MetadataReviewRepository.cs | 120 +- .../Repositories/ReleaseUpgradeRepository.cs | 66 +- .../ReleaseUpgradeCoordinator.cs | 2 +- 12 files changed, 2164 insertions(+), 66 deletions(-) create mode 100644 SecondDimensionWatcherReDive/Migrations/20260831071234_StageInactiveReleaseMappings.Designer.cs create mode 100644 SecondDimensionWatcherReDive/Migrations/20260831071234_StageInactiveReleaseMappings.cs create mode 100644 SecondDimensionWatcherReDive/Models/StagedFileMapping.cs diff --git a/SecondDimensionWatcherReDive.Framework/DataRepository/ReleaseUpgrade.cs b/SecondDimensionWatcherReDive.Framework/DataRepository/ReleaseUpgrade.cs index a01bc05..2b97331 100644 --- a/SecondDimensionWatcherReDive.Framework/DataRepository/ReleaseUpgrade.cs +++ b/SecondDimensionWatcherReDive.Framework/DataRepository/ReleaseUpgrade.cs @@ -79,6 +79,10 @@ Task> GetReadyCandidateIdsAsync( Guid candidateReleaseId, CancellationToken cancellationToken); + Task> GetCandidateMappingsAsync( + Guid candidateReleaseId, + CancellationToken cancellationToken); + Task GetRollbackAsync( Guid operationId, CancellationToken cancellationToken); diff --git a/SecondDimensionWatcherReDive/Migrations/20260831071234_StageInactiveReleaseMappings.Designer.cs b/SecondDimensionWatcherReDive/Migrations/20260831071234_StageInactiveReleaseMappings.Designer.cs new file mode 100644 index 0000000..7723a0c --- /dev/null +++ b/SecondDimensionWatcherReDive/Migrations/20260831071234_StageInactiveReleaseMappings.Designer.cs @@ -0,0 +1,1642 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using SecondDimensionWatcherReDive.Models; + +#nullable disable + +namespace SecondDimensionWatcherReDive.Migrations +{ + [DbContext(typeof(ApplicationContext))] + [Migration("20260831071234_StageInactiveReleaseMappings")] + partial class StageInactiveReleaseMappings + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.11") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.Animation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("OriginalName") + .IsRequired() + .HasColumnType("text"); + + b.Property("PosterPath") + .HasColumnType("text"); + + b.Property("TmdbId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("TmdbId") + .IsUnique(); + + b.ToTable("Animations"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.AnimationCatalogEntry", b => + { + b.Property("AnimationId") + .HasColumnType("uuid"); + + b.Property("AutomationAttentionCount") + .HasColumnType("integer"); + + b.Property("EpisodeCount") + .HasColumnType("integer"); + + b.Property("LatestPublishTime") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("OriginalName") + .IsRequired() + .HasColumnType("text"); + + b.Property("PosterPath") + .HasColumnType("text"); + + b.Property("ReleaseCount") + .HasColumnType("integer"); + + b.Property("TmdbId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("AnimationId"); + + b.HasIndex("TmdbId") + .IsUnique(); + + b.HasIndex("LatestPublishTime", "TmdbId") + .IsDescending(); + + b.ToTable("AnimationCatalogEntries", t => + { + t.HasCheckConstraint("CK_AnimationCatalogEntries_Counts", "\"EpisodeCount\" >= 0 AND \"ReleaseCount\" > 0 AND \"AutomationAttentionCount\" >= 0"); + }); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.AnimationCatalogState", b => + { + b.Property("Id") + .HasColumnType("integer"); + + b.Property("Revision") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.ToTable("AnimationCatalogStates", t => + { + t.HasCheckConstraint("CK_AnimationCatalogStates_Revision_Positive", "\"Revision\" > 0"); + + t.HasCheckConstraint("CK_AnimationCatalogStates_Singleton", "\"Id\" = 1"); + }); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.AnimationGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("AnimationGroups"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.AnimationInfo", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AdditionalDownloadInfo") + .IsRequired() + .HasColumnType("text"); + + b.Property("AiRetryCount") + .HasColumnType("integer"); + + b.Property("AnimationId") + .HasColumnType("uuid"); + + b.Property("AutomationDisposition") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("AutomationExplanationJson") + .HasColumnType("text"); + + b.Property("CachedDownloadData") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("CurrentMetadataReviewOperationId") + .HasColumnType("uuid"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("DownloadAttemptId") + .HasColumnType("uuid"); + + b.Property("DownloadCancellationId") + .HasColumnType("uuid"); + + b.Property("DownloadEndTime") + .HasColumnType("timestamp with time zone"); + + b.Property("DownloadStartTime") + .HasColumnType("timestamp with time zone"); + + b.Property("DownloadType") + .IsRequired() + .HasColumnType("text"); + + b.Property("DownloadUrl") + .IsRequired() + .HasColumnType("text"); + + b.Property("EnclosureId") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("Episode") + .HasColumnType("integer"); + + b.Property("ExpectedEpisodeCount") + .HasColumnType("integer"); + + b.Property("FeedItemGuid") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("FileStore") + .HasColumnType("text"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("IngestedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("IsActiveRelease") + .HasColumnType("boolean"); + + b.Property("IsAiProcessed") + .HasColumnType("boolean"); + + b.Property("IsDownloadFinished") + .HasColumnType("boolean"); + + b.Property("IsDownloadTracked") + .HasColumnType("boolean"); + + b.Property("MediaLibraryMissingSince") + .HasColumnType("timestamp with time zone"); + + b.Property("MediaLibrarySourceId") + .HasColumnType("uuid"); + + b.Property("MetadataConfidence") + .HasColumnType("double precision"); + + b.Property("MetadataLastError") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("MetadataReviewedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("MetadataStatus") + .HasColumnType("integer"); + + b.Property("PublishTime") + .HasColumnType("timestamp with time zone"); + + b.Property("ReleaseCodec") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("ReleaseIdentity") + .HasMaxLength(192) + .HasColumnType("character varying(192)"); + + b.PrimitiveCollection("ReleaseLanguages") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("ReleaseResolution") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("ReleaseScore") + .HasColumnType("integer"); + + b.Property("ReleaseScoreReasonsJson") + .HasColumnType("text"); + + b.Property("ReleaseSizeBytes") + .HasColumnType("bigint"); + + b.Property("ReleaseSubtitleGroup") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Season") + .HasColumnType("integer"); + + b.Property("SourceFeedId") + .HasColumnType("uuid"); + + b.Property("StateVersion") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.Property("StorePath") + .HasColumnType("text"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.Property("TorrentInfoHash") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.HasKey("Id"); + + b.HasIndex("CurrentMetadataReviewOperationId") + .IsUnique(); + + b.HasIndex("GroupId"); + + b.HasIndex("MediaLibrarySourceId"); + + b.HasIndex("ReleaseCodec"); + + b.HasIndex("ReleaseIdentity") + .IsUnique() + .HasDatabaseName("UX_AnimationInfo_ReleaseIdentity") + .HasFilter("\"ReleaseIdentity\" IS NOT NULL"); + + b.HasIndex("ReleaseResolution"); + + b.HasIndex("ReleaseSubtitleGroup"); + + b.HasIndex("SourceFeedId"); + + b.HasIndex("AutomationDisposition", "PublishTime"); + + b.HasIndex("FileStore", "StorePath") + .IsUnique() + .HasFilter("\"DownloadType\" = 'http://schemas.hcgstudio.com/ws/2023/06/sdw/downloadtype/media-library-import'"); + + b.HasIndex("MetadataStatus", "PublishTime"); + + b.HasIndex("AnimationId", "PublishTime", "Id"); + + b.HasIndex("AnimationId", "Season", "Episode") + .IsUnique() + .HasDatabaseName("UX_AnimationInfo_ActiveEpisodeRelease") + .HasFilter("\"IsActiveRelease\" = TRUE AND \"AnimationId\" IS NOT NULL AND \"Season\" IS NOT NULL AND \"Episode\" IS NOT NULL"); + + b.HasIndex("DownloadType", "IsDownloadFinished", "IsDownloadTracked"); + + b.HasIndex("MediaLibraryMissingSince", "PublishTime", "Id"); + + b.HasIndex("Season", "Episode", "ReleaseScore"); + + b.ToTable("AnimationInfo", t => + { + t.HasCheckConstraint("CK_AnimationInfo_ExpectedEpisodeCount_Positive", "\"ExpectedEpisodeCount\" IS NULL OR \"ExpectedEpisodeCount\" > 0"); + + t.HasCheckConstraint("CK_AnimationInfo_MetadataConfidence_Range", "\"MetadataConfidence\" IS NULL OR (\"MetadataConfidence\" >= 0 AND \"MetadataConfidence\" <= 1)"); + + t.HasCheckConstraint("CK_AnimationInfo_ReleaseScore_NonNegative", "\"ReleaseScore\" >= 0"); + }); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.ApplicationSettings", b => + { + b.Property("Id") + .HasColumnType("integer"); + + b.Property("ProtectedSecrets") + .HasColumnType("text"); + + b.Property("Revision") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ValuesJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.HasKey("Id"); + + b.ToTable("ApplicationSettings", t => + { + t.HasCheckConstraint("CK_ApplicationSettings_Revision_Positive", "\"Revision\" > 0"); + + t.HasCheckConstraint("CK_ApplicationSettings_Singleton", "\"Id\" = 1"); + }); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.AuthenticationState", b => + { + b.Property("Id") + .HasColumnType("integer"); + + b.Property("ClaimId") + .HasColumnType("uuid"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("RegisteredAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("AuthenticationStates", t => + { + t.HasCheckConstraint("CK_AuthenticationStates_Singleton", "\"Id\" = 1"); + }); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.BangumiSubgroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("MikanSubgroupId") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("ScrapedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SeasonBangumiId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("SeasonBangumiId", "MikanSubgroupId") + .IsUnique(); + + b.ToTable("BangumiSubgroups"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.ChatConversation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Title") + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("ChatConversations"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.ChatMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Content") + .HasColumnType("text"); + + b.Property("ConversationId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Order") + .HasColumnType("integer"); + + b.Property("Role") + .IsRequired() + .HasColumnType("text"); + + b.Property("ToolCallId") + .HasColumnType("text"); + + b.Property("ToolCallsJson") + .HasColumnType("text"); + + b.Property("ToolName") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ConversationId"); + + b.ToTable("ChatMessages"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.Feed", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Url") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Feeds"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.FileMapping", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AnimationInfoId") + .HasColumnType("uuid"); + + b.Property("FileStore") + .IsRequired() + .HasColumnType("text"); + + b.Property("PhysicalPath") + .IsRequired() + .HasColumnType("text"); + + b.Property("VirtualPath") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("AnimationInfoId"); + + b.HasIndex("VirtualPath") + .IsUnique(); + + b.ToTable("FileMappings", t => + { + t.HasCheckConstraint("CK_FileMappings_VirtualPath_Canonical", "\"VirtualPath\" ~ '^/[^/]+(?:/[^/]+)*$' AND \"VirtualPath\" !~ '(^|/)\\.\\.?($|/)'"); + }); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.FileNameRegexRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AnimationId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Pattern") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.HasKey("Id"); + + b.HasIndex("AnimationId", "CreatedAt"); + + b.HasIndex("AnimationId", "Pattern") + .IsUnique(); + + b.ToTable("FileNameRegexRules"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.FileSystemDirectoryState", b => + { + b.Property("Path") + .HasColumnType("text"); + + b.Property("Generation") + .HasColumnType("bigint"); + + b.HasKey("Path"); + + b.ToTable("FileSystemDirectoryStates", t => + { + t.HasCheckConstraint("CK_FileSystemDirectoryStates_Generation_Positive", "\"Generation\" > 0"); + }); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.FileSystemEntry", b => + { + b.Property("Path") + .HasColumnType("text"); + + b.Property("Cookie") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValueSql("nextval('sdw_file_system_entry_cookie_seq')"); + + b.Property("DescendantFileCount") + .HasColumnType("integer"); + + b.Property("EntryId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("FileMappingId") + .HasColumnType("uuid"); + + b.Property("IsDirectory") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("ParentPath") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Path"); + + b.HasIndex("Cookie") + .IsUnique(); + + b.HasIndex("EntryId") + .IsUnique(); + + b.HasIndex("FileMappingId") + .IsUnique() + .HasFilter("\"FileMappingId\" IS NOT NULL"); + + b.HasIndex("ParentPath", "Cookie"); + + b.HasIndex("ParentPath", "IsDirectory", "Name") + .IsDescending(false, true, false); + + b.ToTable("FileSystemEntries", t => + { + t.HasCheckConstraint("CK_FileSystemEntries_NodeShape", "(\"IsDirectory\" AND \"FileMappingId\" IS NULL AND \"DescendantFileCount\" > 0) OR (NOT \"IsDirectory\" AND \"FileMappingId\" IS NOT NULL AND \"DescendantFileCount\" = 1)"); + }); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.Incident", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Detail") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("DetectedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Fingerprint") + .IsRequired() + .HasMaxLength(96) + .HasColumnType("character varying(96)"); + + b.Property("LastRetryAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastRetryError") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("Occurrence") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(1); + + b.Property("ResolvedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RetryCount") + .HasColumnType("integer"); + + b.Property("Severity") + .HasColumnType("integer"); + + b.Property("SourceId") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("Fingerprint") + .IsUnique(); + + b.HasIndex("ResolvedAt", "Type", "UpdatedAt"); + + b.ToTable("Incidents", t => + { + t.HasCheckConstraint("CK_Incidents_Occurrence_Positive", "\"Occurrence\" > 0"); + }); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.MediaLibrarySource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsMonitoring") + .HasColumnType("boolean"); + + b.Property("LastError") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("LastImportedCount") + .HasColumnType("integer"); + + b.Property("LastRemovedCount") + .HasColumnType("integer"); + + b.Property("LastScanAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSkippedCount") + .HasColumnType("integer"); + + b.Property("LastUpdatedCount") + .HasColumnType("integer"); + + b.Property("Path") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Path") + .IsUnique(); + + b.ToTable("MediaLibrarySources"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.MetadataReviewMappingSnapshot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("FileStore") + .IsRequired() + .HasColumnType("text"); + + b.Property("Kind") + .HasColumnType("integer"); + + b.Property("OperationId") + .HasColumnType("uuid"); + + b.Property("PhysicalPath") + .IsRequired() + .HasColumnType("text"); + + b.Property("VirtualPath") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("OperationId", "Kind", "VirtualPath") + .IsUnique(); + + b.ToTable("MetadataReviewMappingSnapshots", t => + { + t.HasCheckConstraint("CK_MetadataReviewMappingSnapshots_VirtualPath_Canonical", "\"VirtualPath\" ~ '^/[^/]+(?:/[^/]+)*$' AND \"VirtualPath\" !~ '(^|/)\\.\\.?($|/)'"); + }); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.MetadataReviewOperation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AnimationInfoId") + .HasColumnType("uuid"); + + b.Property("AppliedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("AppliedVersion") + .HasColumnType("bigint"); + + b.Property("BaseFileStore") + .HasColumnType("text"); + + b.Property("BaseIsDownloadFinished") + .HasColumnType("boolean"); + + b.Property("BaseStorePath") + .HasColumnType("text"); + + b.Property("BaseVersion") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PreviousAiRetryCount") + .HasColumnType("integer"); + + b.Property("PreviousAnimationId") + .HasColumnType("uuid"); + + b.Property("PreviousConfidence") + .HasColumnType("double precision"); + + b.Property("PreviousCurrentOperationId") + .HasColumnType("uuid"); + + b.Property("PreviousDescription") + .HasColumnType("text"); + + b.Property("PreviousEpisode") + .HasColumnType("integer"); + + b.Property("PreviousGroupId") + .HasColumnType("uuid"); + + b.Property("PreviousIsAiProcessed") + .HasColumnType("boolean"); + + b.Property("PreviousLastError") + .HasColumnType("text"); + + b.Property("PreviousMetadataStatus") + .HasColumnType("integer"); + + b.Property("PreviousReviewedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PreviousSeason") + .HasColumnType("integer"); + + b.Property("ProposedAnimationName") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProposedAnimationOriginalName") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProposedAnimationPosterPath") + .HasColumnType("text"); + + b.Property("ProposedAnimationTmdbId") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProposedDescription") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProposedEpisode") + .HasColumnType("integer"); + + b.Property("ProposedGroupName") + .HasColumnType("text"); + + b.Property("ProposedSeason") + .HasColumnType("integer"); + + b.Property("State") + .HasColumnType("integer"); + + b.Property("UndoneAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("AnimationInfoId", "AppliedVersion") + .IsUnique(); + + b.HasIndex("AnimationInfoId", "State"); + + b.HasIndex("State", "ExpiresAt"); + + b.ToTable("MetadataReviewOperations", t => + { + t.HasCheckConstraint("CK_MetadataReviewOperations_Expiry", "\"ExpiresAt\" > \"CreatedAt\""); + }); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.MigrationExecutionState", b => + { + b.Property("Key") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Version") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(1); + + b.Property("AttemptCount") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(1); + + b.Property("Checkpoint") + .HasMaxLength(4096) + .HasColumnType("character varying(4096)"); + + b.Property("FinishedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastErrorSummary") + .HasMaxLength(4096) + .HasColumnType("character varying(4096)"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(3); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.HasKey("Key", "Version"); + + b.ToTable("MigrationMarkers", null, t => + { + t.HasCheckConstraint("CK_MigrationMarkers_AttemptCount_NonNegative", "\"AttemptCount\" >= 0"); + + t.HasCheckConstraint("CK_MigrationMarkers_Status_Range", "\"Status\" BETWEEN 0 AND 3"); + + t.HasCheckConstraint("CK_MigrationMarkers_Version_Positive", "\"Version\" > 0"); + }); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.NotificationOutboxMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AttemptCount") + .HasColumnType("integer"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("Channel") + .IsRequired() + .HasMaxLength(24) + .HasColumnType("character varying(24)"); + + b.Property("DeduplicationKey") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("DeepLink") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("DeliveredAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EventId") + .HasColumnType("uuid"); + + b.Property("LastAttemptAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastError") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("NextAttemptAt") + .HasColumnType("timestamp with time zone"); + + b.Property("OccurredAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PayloadJson") + .HasColumnType("jsonb"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(24) + .HasColumnType("character varying(24)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(48) + .HasColumnType("character varying(48)"); + + b.Property("WebPushSubscriptionId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("DeduplicationKey") + .IsUnique(); + + b.HasIndex("WebPushSubscriptionId"); + + b.HasIndex("Status", "NextAttemptAt"); + + b.ToTable("NotificationOutboxMessages"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.PlaybackPreference", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("AudioLanguage") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("AudioTrackLabel") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("AutoPlayNext") + .HasColumnType("boolean"); + + b.Property("SubtitleLanguage") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("SubtitleTrackLabel") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("UserId"); + + b.ToTable("PlaybackPreferences"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.PlaybackProgress", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AnimationInfoId") + .HasColumnType("uuid"); + + b.Property("DurationSeconds") + .HasColumnType("double precision"); + + b.Property("IsWatched") + .HasColumnType("boolean"); + + b.Property("PositionSeconds") + .HasColumnType("double precision"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("VirtualPath") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("WatchedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("AnimationInfoId"); + + b.HasIndex("UserId", "AnimationInfoId", "VirtualPath") + .IsUnique(); + + b.HasIndex("UserId", "IsWatched", "UpdatedAt"); + + b.ToTable("PlaybackProgresses", t => + { + t.HasCheckConstraint("CK_PlaybackProgresses_Duration_NonNegative", "\"DurationSeconds\" >= 0"); + + t.HasCheckConstraint("CK_PlaybackProgresses_Position_NonNegative", "\"PositionSeconds\" >= 0"); + }); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.ReleaseUpgradeMappingSnapshot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AnimationInfoId") + .HasColumnType("uuid"); + + b.Property("FileStore") + .IsRequired() + .HasColumnType("text"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("OperationId") + .HasColumnType("uuid"); + + b.Property("OriginalMappingId") + .HasColumnType("uuid"); + + b.Property("PhysicalPath") + .IsRequired() + .HasColumnType("text"); + + b.Property("VirtualPath") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.HasKey("Id"); + + b.HasIndex("OperationId", "Kind", "OriginalMappingId") + .IsUnique(); + + b.ToTable("ReleaseUpgradeMappingSnapshots", t => + { + t.HasCheckConstraint("CK_ReleaseUpgradeMappingSnapshots_VirtualPath_Canonical", "\"VirtualPath\" ~ '^/[^/]+(?:/[^/]+)*$' AND \"VirtualPath\" !~ '(^|/)\\.\\.?($|/)'"); + }); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.ReleaseUpgradeOperation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AppliedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CandidateReleaseId") + .HasColumnType("uuid"); + + b.Property("CandidateScore") + .HasColumnType("integer"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrentReleaseId") + .HasColumnType("uuid"); + + b.Property("CurrentScore") + .HasColumnType("integer"); + + b.Property("FailureSummary") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("RollbackUntil") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("VerifiedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CandidateReleaseId") + .IsUnique() + .HasFilter("\"Status\" <> 'Failed'"); + + b.HasIndex("CurrentReleaseId") + .IsUnique() + .HasDatabaseName("UX_ReleaseUpgradeOperations_ActiveCurrentRelease") + .HasFilter("\"Status\" IN ('Downloading', 'Verifying', 'Applied')"); + + b.HasIndex("Status", "CreatedAt"); + + b.ToTable("ReleaseUpgradeOperations", t => + { + t.HasCheckConstraint("CK_ReleaseUpgradeOperations_ScoreIncrease", "\"CandidateScore\" > \"CurrentScore\""); + }); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.SeasonBangumi", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("DayOfWeek") + .HasColumnType("integer"); + + b.Property("ImageUrl") + .HasColumnType("text"); + + b.Property("MikanId") + .HasColumnType("integer"); + + b.Property("ScrapedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("MikanId") + .IsUnique(); + + b.ToTable("SeasonBangumis"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.StagedFileMapping", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AnimationInfoId") + .HasColumnType("uuid"); + + b.Property("FileStore") + .IsRequired() + .HasColumnType("text"); + + b.Property("PhysicalPath") + .IsRequired() + .HasColumnType("text"); + + b.Property("VirtualPath") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.HasKey("Id"); + + b.HasIndex("AnimationInfoId", "VirtualPath") + .IsUnique(); + + b.ToTable("StagedFileMappings", t => + { + t.HasCheckConstraint("CK_StagedFileMappings_VirtualPath_Canonical", "\"VirtualPath\" ~ '^/[^/]+(?:/[^/]+)*$' AND \"VirtualPath\" !~ '(^|/)\\.\\.?($|/)'"); + }); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.SubscriptionAutomationPolicy", b => + { + b.Property("FeedId") + .HasColumnType("uuid"); + + b.PrimitiveCollection("Codecs") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EnableVersionUpgrade") + .HasColumnType("boolean"); + + b.PrimitiveCollection("ExcludedKeywords") + .IsRequired() + .HasColumnType("text[]"); + + b.PrimitiveCollection("Languages") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("MaxSizeBytes") + .HasColumnType("bigint"); + + b.Property("MinSizeBytes") + .HasColumnType("bigint"); + + b.Property("MinimumUpgradeScore") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(25); + + b.Property("Mode") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.PrimitiveCollection("Resolutions") + .IsRequired() + .HasColumnType("text[]"); + + b.PrimitiveCollection("SubtitleGroups") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpgradeRollbackHours") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(72); + + b.HasKey("FeedId"); + + b.HasIndex("UpdatedAt"); + + b.ToTable("SubscriptionAutomationPolicies", t => + { + t.HasCheckConstraint("CK_SubscriptionAutomationPolicies_MinimumUpgradeScore", "\"MinimumUpgradeScore\" >= 1 AND \"MinimumUpgradeScore\" <= 1000"); + + t.HasCheckConstraint("CK_SubscriptionAutomationPolicies_UpgradeRollbackHours", "\"UpgradeRollbackHours\" >= 1 AND \"UpgradeRollbackHours\" <= 720"); + }); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.TodoItemState", b => + { + b.Property("Key") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ReadAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SnoozedUntil") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Key"); + + b.ToTable("TodoItemStates"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.WebDavToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("TokenHash") + .IsRequired() + .HasColumnType("text"); + + b.Property("Username") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("WebDavTokens"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.WebPushSubscription", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EndpointHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("LastError") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("LastFailureAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSuccessAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ProtectedAuth") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("ProtectedEndpoint") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("character varying(4096)"); + + b.Property("ProtectedP256Dh") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("EndpointHash") + .IsUnique(); + + b.ToTable("WebPushSubscriptions"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.AnimationCatalogEntry", b => + { + b.HasOne("SecondDimensionWatcherReDive.Models.Animation", "Animation") + .WithOne() + .HasForeignKey("SecondDimensionWatcherReDive.Models.AnimationCatalogEntry", "AnimationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Animation"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.AnimationInfo", b => + { + b.HasOne("SecondDimensionWatcherReDive.Models.Animation", "Animation") + .WithMany() + .HasForeignKey("AnimationId"); + + b.HasOne("SecondDimensionWatcherReDive.Models.AnimationGroup", "Group") + .WithMany() + .HasForeignKey("GroupId"); + + b.HasOne("SecondDimensionWatcherReDive.Models.MediaLibrarySource", null) + .WithMany() + .HasForeignKey("MediaLibrarySourceId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("SecondDimensionWatcherReDive.Models.Feed", null) + .WithMany() + .HasForeignKey("SourceFeedId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Animation"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.BangumiSubgroup", b => + { + b.HasOne("SecondDimensionWatcherReDive.Models.SeasonBangumi", "SeasonBangumi") + .WithMany("Subgroups") + .HasForeignKey("SeasonBangumiId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SeasonBangumi"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.ChatMessage", b => + { + b.HasOne("SecondDimensionWatcherReDive.Models.ChatConversation", "Conversation") + .WithMany("Messages") + .HasForeignKey("ConversationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Conversation"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.FileNameRegexRule", b => + { + b.HasOne("SecondDimensionWatcherReDive.Models.Animation", null) + .WithMany() + .HasForeignKey("AnimationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.FileSystemEntry", b => + { + b.HasOne("SecondDimensionWatcherReDive.Models.FileMapping", "FileMapping") + .WithOne() + .HasForeignKey("SecondDimensionWatcherReDive.Models.FileSystemEntry", "FileMappingId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("FileMapping"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.MetadataReviewMappingSnapshot", b => + { + b.HasOne("SecondDimensionWatcherReDive.Models.MetadataReviewOperation", "Operation") + .WithMany("MappingSnapshots") + .HasForeignKey("OperationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Operation"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.MetadataReviewOperation", b => + { + b.HasOne("SecondDimensionWatcherReDive.Models.AnimationInfo", "AnimationInfo") + .WithMany() + .HasForeignKey("AnimationInfoId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AnimationInfo"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.PlaybackProgress", b => + { + b.HasOne("SecondDimensionWatcherReDive.Models.AnimationInfo", "AnimationInfo") + .WithMany() + .HasForeignKey("AnimationInfoId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AnimationInfo"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.ReleaseUpgradeMappingSnapshot", b => + { + b.HasOne("SecondDimensionWatcherReDive.Models.ReleaseUpgradeOperation", "Operation") + .WithMany("MappingSnapshots") + .HasForeignKey("OperationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Operation"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.ReleaseUpgradeOperation", b => + { + b.HasOne("SecondDimensionWatcherReDive.Models.AnimationInfo", "CandidateRelease") + .WithMany() + .HasForeignKey("CandidateReleaseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SecondDimensionWatcherReDive.Models.AnimationInfo", "CurrentRelease") + .WithMany() + .HasForeignKey("CurrentReleaseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("CandidateRelease"); + + b.Navigation("CurrentRelease"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.StagedFileMapping", b => + { + b.HasOne("SecondDimensionWatcherReDive.Models.AnimationInfo", null) + .WithMany() + .HasForeignKey("AnimationInfoId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.SubscriptionAutomationPolicy", b => + { + b.HasOne("SecondDimensionWatcherReDive.Models.Feed", "Feed") + .WithOne() + .HasForeignKey("SecondDimensionWatcherReDive.Models.SubscriptionAutomationPolicy", "FeedId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Feed"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.ChatConversation", b => + { + b.Navigation("Messages"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.MetadataReviewOperation", b => + { + b.Navigation("MappingSnapshots"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.ReleaseUpgradeOperation", b => + { + b.Navigation("MappingSnapshots"); + }); + + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.SeasonBangumi", b => + { + b.Navigation("Subgroups"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/SecondDimensionWatcherReDive/Migrations/20260831071234_StageInactiveReleaseMappings.cs b/SecondDimensionWatcherReDive/Migrations/20260831071234_StageInactiveReleaseMappings.cs new file mode 100644 index 0000000..5eba90c --- /dev/null +++ b/SecondDimensionWatcherReDive/Migrations/20260831071234_StageInactiveReleaseMappings.cs @@ -0,0 +1,102 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace SecondDimensionWatcherReDive.Migrations +{ + /// + public partial class StageInactiveReleaseMappings : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "StagedFileMappings", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + AnimationInfoId = table.Column(type: "uuid", nullable: false), + VirtualPath = table.Column(type: "character varying(2048)", maxLength: 2048, nullable: false), + PhysicalPath = table.Column(type: "text", nullable: false), + FileStore = table.Column(type: "text", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_StagedFileMappings", x => x.Id); + table.CheckConstraint("CK_StagedFileMappings_VirtualPath_Canonical", "\"VirtualPath\" ~ '^/[^/]+(?:/[^/]+)*$' AND \"VirtualPath\" !~ '(^|/)\\.\\.?($|/)'"); + table.ForeignKey( + name: "FK_StagedFileMappings_AnimationInfo_AnimationInfoId", + column: x => x.AnimationInfoId, + principalTable: "AnimationInfo", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_StagedFileMappings_AnimationInfoId_VirtualPath", + table: "StagedFileMappings", + columns: new[] { "AnimationInfoId", "VirtualPath" }, + unique: true); + + // Alternative releases used to share the live FileMappings namespace. + // Move only known-episode alternatives that have a different active + // release; unknown/unmatched downloads remain ordinary live content. + migrationBuilder.Sql( + """ + INSERT INTO "StagedFileMappings" + ("Id", "AnimationInfoId", "VirtualPath", "PhysicalPath", "FileStore") + SELECT mapping."Id", mapping."AnimationInfoId", mapping."VirtualPath", + mapping."PhysicalPath", mapping."FileStore" + FROM "FileMappings" AS mapping + JOIN "AnimationInfo" AS candidate + ON candidate."Id" = mapping."AnimationInfoId" + WHERE NOT candidate."IsActiveRelease" + AND candidate."AnimationId" IS NOT NULL + AND candidate."Season" IS NOT NULL + AND candidate."Episode" IS NOT NULL + AND EXISTS ( + SELECT 1 + FROM "AnimationInfo" AS active + WHERE active."Id" <> candidate."Id" + AND active."IsActiveRelease" + AND active."AnimationId" = candidate."AnimationId" + AND active."Season" = candidate."Season" + AND active."Episode" = candidate."Episode"); + + DELETE FROM "FileMappings" AS mapping + USING "AnimationInfo" AS candidate + WHERE candidate."Id" = mapping."AnimationInfoId" + AND NOT candidate."IsActiveRelease" + AND candidate."AnimationId" IS NOT NULL + AND candidate."Season" IS NOT NULL + AND candidate."Episode" IS NOT NULL + AND EXISTS ( + SELECT 1 + FROM "AnimationInfo" AS active + WHERE active."Id" <> candidate."Id" + AND active."IsActiveRelease" + AND active."AnimationId" = candidate."AnimationId" + AND active."Season" = candidate."Season" + AND active."Episode" = candidate."Episode"); + """); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + // Fail the downgrade transaction if the live namespace changed in a + // way that prevents restoring every staged mapping; never discard it. + migrationBuilder.Sql( + """ + INSERT INTO "FileMappings" + ("Id", "AnimationInfoId", "VirtualPath", "PhysicalPath", "FileStore") + SELECT "Id", "AnimationInfoId", "VirtualPath", "PhysicalPath", "FileStore" + FROM "StagedFileMappings"; + """); + + migrationBuilder.DropTable( + name: "StagedFileMappings"); + } + } +} diff --git a/SecondDimensionWatcherReDive/Migrations/ApplicationContextModelSnapshot.cs b/SecondDimensionWatcherReDive/Migrations/ApplicationContextModelSnapshot.cs index 9a7cc2a..81f2aff 100644 --- a/SecondDimensionWatcherReDive/Migrations/ApplicationContextModelSnapshot.cs +++ b/SecondDimensionWatcherReDive/Migrations/ApplicationContextModelSnapshot.cs @@ -1044,7 +1044,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) modelBuilder.Entity("SecondDimensionWatcherReDive.Models.PlaybackPreference", b => { b.Property("UserId") - .ValueGeneratedNever() .HasColumnType("uuid"); b.Property("AudioLanguage") @@ -1258,6 +1257,39 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("SeasonBangumis"); }); + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.StagedFileMapping", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AnimationInfoId") + .HasColumnType("uuid"); + + b.Property("FileStore") + .IsRequired() + .HasColumnType("text"); + + b.Property("PhysicalPath") + .IsRequired() + .HasColumnType("text"); + + b.Property("VirtualPath") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.HasKey("Id"); + + b.HasIndex("AnimationInfoId", "VirtualPath") + .IsUnique(); + + b.ToTable("StagedFileMappings", t => + { + t.HasCheckConstraint("CK_StagedFileMappings_VirtualPath_Canonical", "\"VirtualPath\" ~ '^/[^/]+(?:/[^/]+)*$' AND \"VirtualPath\" !~ '(^|/)\\.\\.?($|/)'"); + }); + }); + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.SubscriptionAutomationPolicy", b => { b.Property("FeedId") @@ -1562,6 +1594,15 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("CurrentRelease"); }); + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.StagedFileMapping", b => + { + b.HasOne("SecondDimensionWatcherReDive.Models.AnimationInfo", null) + .WithMany() + .HasForeignKey("AnimationInfoId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + modelBuilder.Entity("SecondDimensionWatcherReDive.Models.SubscriptionAutomationPolicy", b => { b.HasOne("SecondDimensionWatcherReDive.Models.Feed", "Feed") diff --git a/SecondDimensionWatcherReDive/Models/ApplicationContext.cs b/SecondDimensionWatcherReDive/Models/ApplicationContext.cs index d36ffcf..fd6d01c 100644 --- a/SecondDimensionWatcherReDive/Models/ApplicationContext.cs +++ b/SecondDimensionWatcherReDive/Models/ApplicationContext.cs @@ -25,6 +25,7 @@ public ApplicationContext(DbContextOptions options) public DbSet ChatConversations { get; set; } public DbSet ChatMessages { get; set; } public DbSet FileMappings { get; set; } + public DbSet StagedFileMappings { get; set; } public DbSet FileSystemEntries { get; set; } public DbSet FileSystemDirectoryStates { get; set; } public DbSet FileNameRegexRules { get; set; } @@ -504,6 +505,25 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) "CK_FileMappings_VirtualPath_Canonical", "\"VirtualPath\" ~ '^/[^/]+(?:/[^/]+)*$' AND \"VirtualPath\" !~ '(^|/)\\.\\.?($|/)'")); + modelBuilder.Entity() + .HasIndex(mapping => new { mapping.AnimationInfoId, mapping.VirtualPath }) + .IsUnique(); + + modelBuilder.Entity() + .Property(mapping => mapping.VirtualPath) + .HasMaxLength(2048); + + modelBuilder.Entity() + .HasOne() + .WithMany() + .HasForeignKey(mapping => mapping.AnimationInfoId) + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity() + .ToTable(table => table.HasCheckConstraint( + "CK_StagedFileMappings_VirtualPath_Canonical", + "\"VirtualPath\" ~ '^/[^/]+(?:/[^/]+)*$' AND \"VirtualPath\" !~ '(^|/)\\.\\.?($|/)'")); + modelBuilder.Entity() .ToTable(table => table.HasCheckConstraint( "CK_MetadataReviewMappingSnapshots_VirtualPath_Canonical", diff --git a/SecondDimensionWatcherReDive/Models/StagedFileMapping.cs b/SecondDimensionWatcherReDive/Models/StagedFileMapping.cs new file mode 100644 index 0000000..57fe3e7 --- /dev/null +++ b/SecondDimensionWatcherReDive/Models/StagedFileMapping.cs @@ -0,0 +1,14 @@ +namespace SecondDimensionWatcherReDive.Models; + +/// +/// A completed alternative release's files before the release is validated and +/// atomically promoted into the live virtual-file-system namespace. +/// +public sealed class StagedFileMapping +{ + public Guid Id { get; set; } + public Guid AnimationInfoId { get; set; } + public string VirtualPath { get; set; } = string.Empty; + public string PhysicalPath { get; set; } = string.Empty; + public string FileStore { get; set; } = string.Empty; +} diff --git a/SecondDimensionWatcherReDive/Repositories/AnimationInfoRepository.cs b/SecondDimensionWatcherReDive/Repositories/AnimationInfoRepository.cs index 07ef042..a1fbc17 100644 --- a/SecondDimensionWatcherReDive/Repositories/AnimationInfoRepository.cs +++ b/SecondDimensionWatcherReDive/Repositories/AnimationInfoRepository.cs @@ -516,7 +516,8 @@ public async Task> GetDownloadedWithoutFileMappings && info.MediaLibraryMissingSince == null && info.FileStore != null && info.StorePath != null - && !context.FileMappings.Any(mapping => mapping.AnimationInfoId == info.Id)) + && !context.FileMappings.Any(mapping => mapping.AnimationInfoId == info.Id) + && !context.StagedFileMappings.Any(mapping => mapping.AnimationInfoId == info.Id)) .OrderBy(info => info.DownloadEndTime) .ToListAsync(cancellationToken); return entities.Select(entity => entity.ToRecord()).ToList(); @@ -610,6 +611,10 @@ await SetEpisodeReleaseActivityAsync( entity, willHaveMappings: false, cancellationToken); + await ReconcileMappingVisibilityAfterMetadataChangeAsync( + writeContext, + entity, + cancellationToken); var currentEpisodeIdentity = entity.IsActiveRelease ? GetEpisodeIdentity(writeContext, entity) : null; @@ -948,6 +953,10 @@ await SetEpisodeReleaseActivityAsync( entity, willHaveMappings: false, cancellationToken); + await ReconcileMappingVisibilityAfterMetadataChangeAsync( + writeContext, + entity, + cancellationToken); var currentEpisodeIdentity = entity.IsActiveRelease ? GetEpisodeIdentity(writeContext, entity) : null; @@ -975,9 +984,11 @@ internal static async Task SetEpisodeReleaseActivityAsync( CancellationToken cancellationToken) { var identity = GetEpisodeIdentity(writeContext, entity); - var hasMappings = willHaveMappings || await writeContext.FileMappings - .AsNoTracking() - .AnyAsync(mapping => mapping.AnimationInfoId == entity.Id, cancellationToken); + var hasMappings = willHaveMappings || + await writeContext.FileMappings.AsNoTracking() + .AnyAsync(mapping => mapping.AnimationInfoId == entity.Id, cancellationToken) || + await writeContext.StagedFileMappings.AsNoTracking() + .AnyAsync(mapping => mapping.AnimationInfoId == entity.Id, cancellationToken); if (identity is null || entity.MediaLibraryMissingSince is not null || !entity.IsDownloadFinished || !hasMappings) { @@ -1023,6 +1034,68 @@ await activeOthers.ExecuteUpdateAsync( : null; } + private static async Task ReconcileMappingVisibilityAfterMetadataChangeAsync( + Models.ApplicationContext writeContext, + Models.AnimationInfo entity, + CancellationToken cancellationToken) + { + var liveMappings = await writeContext.FileMappings + .Where(mapping => mapping.AnimationInfoId == entity.Id) + .ToListAsync(cancellationToken); + var stagedMappings = await writeContext.StagedFileMappings + .Where(mapping => mapping.AnimationInfoId == entity.Id) + .ToListAsync(cancellationToken); + var shouldStage = GetEpisodeIdentity(writeContext, entity) is not null && + !entity.IsActiveRelease; + + if (shouldStage) + { + if (liveMappings.Count == 0) return; + + writeContext.StagedFileMappings.RemoveRange(stagedMappings); + await writeContext.StagedFileMappings.AddRangeAsync( + liveMappings.Select(mapping => new Models.StagedFileMapping + { + Id = mapping.Id, + AnimationInfoId = mapping.AnimationInfoId, + VirtualPath = mapping.VirtualPath, + PhysicalPath = mapping.PhysicalPath, + FileStore = mapping.FileStore + }), + cancellationToken); + writeContext.FileMappings.RemoveRange(liveMappings); + return; + } + + if (liveMappings.Count > 0) + { + writeContext.StagedFileMappings.RemoveRange(stagedMappings); + return; + } + + if (stagedMappings.Count == 0) return; + var conflicts = await VirtualPathNamespaceGuard.FindConflictsAsync( + writeContext, + entity.Id, + stagedMappings.Select(mapping => mapping.VirtualPath).ToArray(), + cancellationToken); + if (conflicts.Count > 0) + throw new InvalidOperationException( + $"Cannot publish the remapped release because '{conflicts[0].OccupiedPath}' is occupied."); + + await writeContext.FileMappings.AddRangeAsync( + stagedMappings.Select(mapping => new Models.FileMapping + { + Id = mapping.Id, + AnimationInfoId = mapping.AnimationInfoId, + VirtualPath = mapping.VirtualPath, + PhysicalPath = mapping.PhysicalPath, + FileStore = mapping.FileStore + }), + cancellationToken); + writeContext.StagedFileMappings.RemoveRange(stagedMappings); + } + internal static async Task PromotePreviousEpisodeSuccessorAsync( Models.ApplicationContext writeContext, Guid changedReleaseId, @@ -1042,29 +1115,63 @@ internal static async Task PromotePreviousEpisodeSuccessorAsync( cancellationToken)) return; - var successorId = await writeContext.AnimationInfo + var successor = await writeContext.AnimationInfo .AsNoTracking() .Where(info => info.Id != changedReleaseId && info.MediaLibraryMissingSince == null && info.IsDownloadFinished && - writeContext.FileMappings.Any(mapping => mapping.AnimationInfoId == info.Id) && + (writeContext.FileMappings.Any(mapping => mapping.AnimationInfoId == info.Id) || + writeContext.StagedFileMappings.Any(mapping => mapping.AnimationInfoId == info.Id)) && EF.Property(info, "AnimationId") == previous.AnimationId && info.Season == previous.Season && info.Episode == previous.Episode) .OrderByDescending(info => info.ReleaseScore) .ThenByDescending(info => info.PublishTime) .ThenBy(info => info.Id) - .Select(info => (Guid?)info.Id) + .Select(info => new + { + info.Id, + HasLiveMappings = writeContext.FileMappings.Any(mapping => mapping.AnimationInfoId == info.Id) + }) .FirstOrDefaultAsync(cancellationToken); - if (successorId is null) return; + if (successor is null) return; + + if (!successor.HasLiveMappings) + { + var stagedMappings = await writeContext.StagedFileMappings + .Where(mapping => mapping.AnimationInfoId == successor.Id) + .OrderBy(mapping => mapping.VirtualPath) + .ToListAsync(cancellationToken); + if (stagedMappings.Count == 0) return; + + var conflicts = await VirtualPathNamespaceGuard.FindConflictsAsync( + writeContext, + successor.Id, + stagedMappings.Select(mapping => mapping.VirtualPath).ToArray(), + cancellationToken); + if (conflicts.Count > 0) return; + + await writeContext.FileMappings.AddRangeAsync( + stagedMappings.Select(mapping => new Models.FileMapping + { + Id = mapping.Id, + AnimationInfoId = mapping.AnimationInfoId, + VirtualPath = mapping.VirtualPath, + PhysicalPath = mapping.PhysicalPath, + FileStore = mapping.FileStore + }), + cancellationToken); + writeContext.StagedFileMappings.RemoveRange(stagedMappings); + } await writeContext.AnimationInfo - .Where(info => info.Id == successorId.Value) + .Where(info => info.Id == successor.Id) .ExecuteUpdateAsync( setters => setters .SetProperty(info => info.IsActiveRelease, true) .SetProperty(info => info.StateVersion, info => info.StateVersion + 1), cancellationToken); + await writeContext.SaveChangesAsync(cancellationToken); } internal readonly record struct EpisodeReleaseIdentity(Guid AnimationId, int Season, int Episode); diff --git a/SecondDimensionWatcherReDive/Repositories/FileMappingRepository.cs b/SecondDimensionWatcherReDive/Repositories/FileMappingRepository.cs index 6423776..51f9af5 100644 --- a/SecondDimensionWatcherReDive/Repositories/FileMappingRepository.cs +++ b/SecondDimensionWatcherReDive/Repositories/FileMappingRepository.cs @@ -128,20 +128,13 @@ public async Task ReplaceForAnimationInfoAsync( .AsNoTracking() .Where(mapping => mapping.AnimationInfoId == animationInfoId) .ToListAsync(cancellationToken); + var existingStagedMappings = await replaceContext.StagedFileMappings + .AsNoTracking() + .Where(mapping => mapping.AnimationInfoId == animationInfoId) + .ToListAsync(cancellationToken); var desiredMappings = mappings .Select(mapping => mapping.ToEntity()) .ToList(); - await PlaybackProgressMappingMigrator.MigrateAsync( - replaceContext, - animationInfoId, - existingMappings, - desiredMappings, - cancellationToken); - var reconciliation = await FileMappingSetReconciler.ReconcileAsync( - replaceContext, - animationInfoId, - desiredMappings, - cancellationToken); var previousEpisodeIdentity = AnimationInfoRepository.GetEpisodeIdentity( replaceContext, current); @@ -151,6 +144,28 @@ await AnimationInfoRepository.SetEpisodeReleaseActivityAsync( current, willHaveMappings: desiredMappings.Count > 0, cancellationToken); + var shouldStage = previousEpisodeIdentity is not null && !current.IsActiveRelease; + var previousMappings = existingMappings.Count > 0 + ? existingMappings + : existingStagedMappings.Select(ToFileMapping).ToList(); + await PlaybackProgressMappingMigrator.MigrateAsync( + replaceContext, + animationInfoId, + previousMappings, + desiredMappings, + cancellationToken); + var reconciliation = await FileMappingSetReconciler.ReconcileAsync( + replaceContext, + animationInfoId, + shouldStage ? [] : desiredMappings, + cancellationToken); + replaceContext.StagedFileMappings.RemoveRange(existingStagedMappings); + if (shouldStage) + { + await replaceContext.StagedFileMappings.AddRangeAsync( + desiredMappings.Select(ToStagedFileMapping), + cancellationToken); + } var currentEpisodeIdentity = current.IsActiveRelease ? AnimationInfoRepository.GetEpisodeIdentity(replaceContext, current) : null; @@ -396,9 +411,10 @@ private static IQueryable ProjectFileSystemEntries( public async Task ExistsForAnimationInfoAsync(Guid animationInfoId, CancellationToken cancellationToken) { - return await context.FileMappings - .AsNoTracking() - .AnyAsync(m => m.AnimationInfoId == animationInfoId, cancellationToken); + return await context.FileMappings.AsNoTracking() + .AnyAsync(m => m.AnimationInfoId == animationInfoId, cancellationToken) + || await context.StagedFileMappings.AsNoTracking() + .AnyAsync(m => m.AnimationInfoId == animationInfoId, cancellationToken); } public async Task TryFinalizeDownloadCancellationAsync( @@ -446,6 +462,9 @@ await finalizeContext.PlaybackProgresses await finalizeContext.FileMappings .Where(mapping => mapping.AnimationInfoId == animationInfoId) .ExecuteDeleteAsync(cancellationToken); + await finalizeContext.StagedFileMappings + .Where(mapping => mapping.AnimationInfoId == animationInfoId) + .ExecuteDeleteAsync(cancellationToken); animationInfo.IsDownloadTracked = false; animationInfo.IsDownloadFinished = false; animationInfo.IsActiveRelease = false; @@ -508,6 +527,9 @@ await strategy.ExecuteAsync(async () => await removeContext.FileMappings .Where(mapping => mapping.AnimationInfoId == animationInfoId) .ExecuteDeleteAsync(cancellationToken); + await removeContext.StagedFileMappings + .Where(mapping => mapping.AnimationInfoId == animationInfoId) + .ExecuteDeleteAsync(cancellationToken); if (animationInfo is not null) { animationInfo.IsActiveRelease = false; @@ -525,4 +547,22 @@ await AnimationInfoRepository.PromotePreviousEpisodeSuccessorAsync( await transaction.CommitAsync(cancellationToken); }); } + + private static Models.FileMapping ToFileMapping(Models.StagedFileMapping mapping) => new() + { + Id = mapping.Id, + AnimationInfoId = mapping.AnimationInfoId, + VirtualPath = mapping.VirtualPath, + PhysicalPath = mapping.PhysicalPath, + FileStore = mapping.FileStore + }; + + private static Models.StagedFileMapping ToStagedFileMapping(Models.FileMapping mapping) => new() + { + Id = mapping.Id, + AnimationInfoId = mapping.AnimationInfoId, + VirtualPath = mapping.VirtualPath, + PhysicalPath = mapping.PhysicalPath, + FileStore = mapping.FileStore + }; } diff --git a/SecondDimensionWatcherReDive/Repositories/LibrarySearchRepository.cs b/SecondDimensionWatcherReDive/Repositories/LibrarySearchRepository.cs index b1f1cf2..5c46a2c 100644 --- a/SecondDimensionWatcherReDive/Repositories/LibrarySearchRepository.cs +++ b/SecondDimensionWatcherReDive/Repositories/LibrarySearchRepository.cs @@ -15,6 +15,7 @@ public sealed class LibrarySearchRepository(Models.ApplicationContext context) private sealed record SearchCursor( DateTimeOffset SnapshotUtc, string Signature, + long Revision, Guid LastId, DateTimeOffset? PublishedAt, int? Score, @@ -75,6 +76,9 @@ public async Task SearchAsync( var cursor = DecodeCursor(request.Cursor); if (cursor is not null && !string.Equals(cursor.Signature, signature, StringComparison.Ordinal)) throw new ArgumentException("The search cursor does not match the active filters.", nameof(request)); + var revision = await ReadLibraryRevisionAsync(cancellationToken); + if (cursor is not null && cursor.Revision != revision) + throw new ArgumentException("The library changed; restart search pagination.", nameof(request)); var snapshot = cursor?.SnapshotUtc ?? DateTimeOffset.UtcNow; if (cursor is not null && cursor.LastId == Guid.Empty) throw new ArgumentException("The search cursor is invalid.", nameof(request)); @@ -102,7 +106,10 @@ public async Task SearchAsync( if (request.Season is { } season) query = query.Where(info => info.Season == season); if (request.Episode is { } episode) query = query.Where(info => info.Episode == episode); if (!string.IsNullOrWhiteSpace(request.SubtitleGroup)) - query = query.Where(info => info.ReleaseSubtitleGroup == request.SubtitleGroup); + query = query.Where(info => + info.ReleaseSubtitleGroup == request.SubtitleGroup || + (info.ReleaseSubtitleGroup == null && info.Group != null && + info.Group.Name == request.SubtitleGroup)); if (!string.IsNullOrWhiteSpace(request.Resolution)) query = query.Where(info => info.ReleaseResolution == request.Resolution); if (!string.IsNullOrWhiteSpace(request.Codec)) @@ -258,8 +265,11 @@ PARTITION BY mapping."AnimationInfoId" info.PublishTime); }).ToList(); + if (await ReadLibraryRevisionAsync(cancellationToken) != revision) + throw new ArgumentException("The library changed; restart search pagination.", nameof(request)); + var nextCursor = hasMore - ? EncodeCursor(CreateCursor(page[^1], request.Sort, snapshot, signature)) + ? EncodeCursor(CreateCursor(page[^1], request.Sort, snapshot, signature, revision)) : null; return new LibrarySearchResult(items, nextCursor); } @@ -316,10 +326,12 @@ private static SearchCursor CreateCursor( SearchRow row, LibrarySearchSort sort, DateTimeOffset snapshot, - string signature) => + string signature, + long revision) => new( snapshot, signature, + revision, row.Id, sort is LibrarySearchSort.PublishedDescending or LibrarySearchSort.ScoreDescending ? row.PublishTime @@ -335,6 +347,12 @@ sort is LibrarySearchSort.TitleAscending or LibrarySearchSort.EpisodeAscending ? row.Episode : null); + private async Task ReadLibraryRevisionAsync(CancellationToken cancellationToken) => + await context.AnimationCatalogStates.AsNoTracking() + .Where(state => state.Id == 1) + .Select(state => state.Revision) + .SingleAsync(cancellationToken); + public async Task> GetIntegrityAsync( string? tmdbId, int? season, diff --git a/SecondDimensionWatcherReDive/Repositories/MetadataReviewRepository.cs b/SecondDimensionWatcherReDive/Repositories/MetadataReviewRepository.cs index 19e0c48..f539420 100644 --- a/SecondDimensionWatcherReDive/Repositories/MetadataReviewRepository.cs +++ b/SecondDimensionWatcherReDive/Repositories/MetadataReviewRepository.cs @@ -52,6 +52,17 @@ public async Task GetQueueAsync( .GroupBy(mapping => mapping.AnimationInfoId) .Select(group => new { AnimationInfoId = group.Key, Count = group.Count() }) .ToDictionaryAsync(row => row.AnimationInfoId, row => row.Count, cancellationToken); + if (animationInfoIds.Length > 0) + { + var stagedCounts = await context.StagedFileMappings + .AsNoTracking() + .Where(mapping => animationInfoIds.Contains(mapping.AnimationInfoId)) + .GroupBy(mapping => mapping.AnimationInfoId) + .Select(group => new { AnimationInfoId = group.Key, Count = group.Count() }) + .ToListAsync(cancellationToken); + foreach (var row in stagedCounts) + mappingCounts[row.AnimationInfoId] = mappingCounts.GetValueOrDefault(row.AnimationInfoId) + row.Count; + } var operationIds = entities .Where(info => info.CurrentMetadataReviewOperationId.HasValue) @@ -227,11 +238,10 @@ public async Task ApplyPreviewAsync( return Failure(MetadataReviewMutationOutcome.NotFound, operationId); if (operation.State == MetadataReviewOperationState.Applied) { - var currentMappings = await applyContext.FileMappings - .AsNoTracking() - .Where(mapping => mapping.AnimationInfoId == animationInfo.Id) - .OrderBy(mapping => mapping.VirtualPath) - .ToListAsync(cancellationToken); + var currentMappings = await LoadOwnedMappingsAsync( + applyContext, + animationInfo.Id, + cancellationToken); var appliedSnapshots = operation.MappingSnapshots .Where(snapshot => snapshot.Kind == MetadataReviewMappingKind.Proposed) .ToList(); @@ -295,11 +305,10 @@ public async Task ApplyPreviewAsync( operationId, animationInfo.Id); - var existingMappings = await applyContext.FileMappings - .AsNoTracking() - .Where(mapping => mapping.AnimationInfoId == animationInfo.Id) - .OrderBy(mapping => mapping.VirtualPath) - .ToListAsync(cancellationToken); + var existingMappings = await LoadOwnedMappingsAsync( + applyContext, + animationInfo.Id, + cancellationToken); var mappingsBefore = existingMappings.Select(mapping => mapping.ToRecord()).ToList(); var animationInfoEntry = applyContext.Entry(animationInfo); @@ -374,6 +383,8 @@ await AnimationInfoRepository.SetEpisodeReleaseActivityAsync( var currentEpisodeIdentity = animationInfo.IsActiveRelease ? AnimationInfoRepository.GetEpisodeIdentity(applyContext, animationInfo) : null; + var shouldStage = currentEpisodeIdentity is null && + AnimationInfoRepository.GetEpisodeIdentity(applyContext, animationInfo) is not null; animationInfo.StateVersion = checked(animationInfo.StateVersion + 1); var desiredMappings = proposedSnapshots @@ -395,9 +406,14 @@ await PlaybackProgressMappingMigrator.MigrateAsync( var reconciliation = await FileMappingSetReconciler.ReconcileAsync( applyContext, animationInfo.Id, - desiredMappings, + shouldStage ? [] : desiredMappings, + cancellationToken); + await ReplaceStagedMappingsAsync( + applyContext, + animationInfo.Id, + shouldStage ? desiredMappings : [], cancellationToken); - var replacementMappings = reconciliation.Mappings; + var replacementMappings = desiredMappings; operation.State = MetadataReviewOperationState.Applied; operation.AppliedAt = appliedAt; @@ -468,11 +484,10 @@ public async Task UndoAsync( return Failure(MetadataReviewMutationOutcome.NotFound, operationId); if (operation.State == MetadataReviewOperationState.Undone) { - var idempotentCurrentMappings = await undoContext.FileMappings - .AsNoTracking() - .Where(mapping => mapping.AnimationInfoId == animationInfo.Id) - .OrderBy(mapping => mapping.VirtualPath) - .ToListAsync(cancellationToken); + var idempotentCurrentMappings = await LoadOwnedMappingsAsync( + undoContext, + animationInfo.Id, + cancellationToken); var restoredSnapshots = operation.MappingSnapshots .Where(snapshot => snapshot.Kind == MetadataReviewMappingKind.Previous) .ToList(); @@ -513,11 +528,10 @@ public async Task UndoAsync( operationId, animationInfo.Id); - var currentMappings = await undoContext.FileMappings - .AsNoTracking() - .Where(mapping => mapping.AnimationInfoId == animationInfo.Id) - .OrderBy(mapping => mapping.VirtualPath) - .ToListAsync(cancellationToken); + var currentMappings = await LoadOwnedMappingsAsync( + undoContext, + animationInfo.Id, + cancellationToken); var proposedSnapshots = operation.MappingSnapshots .Where(snapshot => snapshot.Kind == MetadataReviewMappingKind.Proposed) .ToList(); @@ -619,6 +633,8 @@ await AnimationInfoRepository.SetEpisodeReleaseActivityAsync( var currentEpisodeIdentity = animationInfo.IsActiveRelease ? AnimationInfoRepository.GetEpisodeIdentity(undoContext, animationInfo) : null; + var shouldStage = currentEpisodeIdentity is null && + AnimationInfoRepository.GetEpisodeIdentity(undoContext, animationInfo) is not null; animationInfo.StateVersion = checked(animationInfo.StateVersion + 1); var desiredMappings = previousSnapshots @@ -640,9 +656,14 @@ await PlaybackProgressMappingMigrator.MigrateAsync( var reconciliation = await FileMappingSetReconciler.ReconcileAsync( undoContext, animationInfo.Id, - desiredMappings, + shouldStage ? [] : desiredMappings, cancellationToken); - var restoredMappings = reconciliation.Mappings; + await ReplaceStagedMappingsAsync( + undoContext, + animationInfo.Id, + shouldStage ? desiredMappings : [], + cancellationToken); + var restoredMappings = desiredMappings; var undoneAt = DateTimeOffset.UtcNow; operation.State = MetadataReviewOperationState.Undone; @@ -735,6 +756,57 @@ ON CONFLICT ("Name") DO NOTHING .SingleAsync(group => group.Name == proposedGroupName, cancellationToken); } + private static async Task> LoadOwnedMappingsAsync( + Models.ApplicationContext operationContext, + Guid animationInfoId, + CancellationToken cancellationToken) + { + var liveMappings = await operationContext.FileMappings + .AsNoTracking() + .Where(mapping => mapping.AnimationInfoId == animationInfoId) + .ToListAsync(cancellationToken); + var stagedMappings = await operationContext.StagedFileMappings + .AsNoTracking() + .Where(mapping => mapping.AnimationInfoId == animationInfoId) + .Select(mapping => new Models.FileMapping + { + Id = mapping.Id, + AnimationInfoId = mapping.AnimationInfoId, + VirtualPath = mapping.VirtualPath, + PhysicalPath = mapping.PhysicalPath, + FileStore = mapping.FileStore + }) + .ToListAsync(cancellationToken); + return liveMappings + .Concat(stagedMappings) + .OrderBy(mapping => mapping.VirtualPath, StringComparer.Ordinal) + .ToList(); + } + + private static async Task ReplaceStagedMappingsAsync( + Models.ApplicationContext operationContext, + Guid animationInfoId, + IReadOnlyList desiredMappings, + CancellationToken cancellationToken) + { + var existingMappings = await operationContext.StagedFileMappings + .Where(mapping => mapping.AnimationInfoId == animationInfoId) + .ToListAsync(cancellationToken); + operationContext.StagedFileMappings.RemoveRange(existingMappings); + if (desiredMappings.Count == 0) return; + + await operationContext.StagedFileMappings.AddRangeAsync( + desiredMappings.Select(mapping => new Models.StagedFileMapping + { + Id = mapping.Id, + AnimationInfoId = mapping.AnimationInfoId, + VirtualPath = mapping.VirtualPath, + PhysicalPath = mapping.PhysicalPath, + FileStore = mapping.FileStore + }), + cancellationToken); + } + private static bool MappingSetsMatch( IReadOnlyList mappings, IReadOnlyList snapshots) diff --git a/SecondDimensionWatcherReDive/Repositories/ReleaseUpgradeRepository.cs b/SecondDimensionWatcherReDive/Repositories/ReleaseUpgradeRepository.cs index bc67c77..072434a 100644 --- a/SecondDimensionWatcherReDive/Repositories/ReleaseUpgradeRepository.cs +++ b/SecondDimensionWatcherReDive/Repositories/ReleaseUpgradeRepository.cs @@ -2,6 +2,7 @@ using Microsoft.EntityFrameworkCore; using Npgsql; using SecondDimensionWatcherReDive.Framework.DataRepository; +using SecondDimensionWatcherReDive.Utils.FileStore; namespace SecondDimensionWatcherReDive.Repositories; @@ -307,7 +308,7 @@ public async Task> GetReadyCandidateIdsAsync( (operation.Status == ReleaseUpgradeStatus.Downloading || operation.Status == ReleaseUpgradeStatus.Verifying) && operation.CandidateRelease.IsDownloadFinished && - context.FileMappings.Any(mapping => + context.StagedFileMappings.Any(mapping => mapping.AnimationInfoId == operation.CandidateReleaseId)) .OrderBy(operation => operation.CreatedAt) .Select(operation => operation.CandidateReleaseId) @@ -326,19 +327,34 @@ public async Task> GetReadyCandidateIdsAsync( cancellationToken); if (operation is null) return null; - var mappings = await context.FileMappings.AsNoTracking() - .Where(mapping => mapping.AnimationInfoId == operation.CurrentReleaseId || - mapping.AnimationInfoId == operation.CandidateReleaseId) + var previousMappings = await context.FileMappings.AsNoTracking() + .Where(mapping => mapping.AnimationInfoId == operation.CurrentReleaseId) + .OrderBy(mapping => mapping.VirtualPath) + .ToListAsync(cancellationToken); + var candidateMappings = await context.StagedFileMappings.AsNoTracking() + .Where(mapping => mapping.AnimationInfoId == operation.CandidateReleaseId) .OrderBy(mapping => mapping.VirtualPath) .ToListAsync(cancellationToken); return new ReleaseUpgradeActivation( operation.ToRecord(), - mappings.Where(mapping => mapping.AnimationInfoId == operation.CurrentReleaseId) - .Select(mapping => mapping.ToRecord()).ToList(), - mappings.Where(mapping => mapping.AnimationInfoId == operation.CandidateReleaseId) - .Select(mapping => mapping.ToRecord()).ToList()); + previousMappings.Select(mapping => mapping.ToRecord()).ToList(), + candidateMappings.Select(ToRecord).ToList()); } + public async Task> GetCandidateMappingsAsync( + Guid candidateReleaseId, + CancellationToken cancellationToken) => + await context.StagedFileMappings.AsNoTracking() + .Where(mapping => mapping.AnimationInfoId == candidateReleaseId) + .OrderBy(mapping => mapping.VirtualPath) + .Select(mapping => new FileMapping( + mapping.Id, + mapping.AnimationInfoId, + mapping.VirtualPath, + mapping.PhysicalPath, + mapping.FileStore)) + .ToListAsync(cancellationToken); + public async Task GetRollbackAsync( Guid operationId, CancellationToken cancellationToken) @@ -407,13 +423,15 @@ public async Task ActivateAsync( candidate.ReleaseScore <= current.ReleaseScore) return new ReleaseUpgradeMutationResult(false, "release_changed", operation.ToRecord()); - var mappings = await writeContext.FileMappings - .Where(mapping => mapping.AnimationInfoId == current.Id || - mapping.AnimationInfoId == candidate.Id) + var previous = await writeContext.FileMappings + .Where(mapping => mapping.AnimationInfoId == current.Id) + .OrderBy(mapping => mapping.VirtualPath) + .ToListAsync(cancellationToken); + var stagedNext = await writeContext.StagedFileMappings + .Where(mapping => mapping.AnimationInfoId == candidate.Id) .OrderBy(mapping => mapping.VirtualPath) .ToListAsync(cancellationToken); - var previous = mappings.Where(mapping => mapping.AnimationInfoId == current.Id).ToList(); - var next = mappings.Where(mapping => mapping.AnimationInfoId == candidate.Id).ToList(); + var next = stagedNext.Select(ToFileMapping).ToList(); if (previous.Count == 0 || next.Count == 0) return new ReleaseUpgradeMutationResult(false, "mapping_missing", operation.ToRecord()); if (!MappingSetsMatch(previous, expectedPreviousMappings) || @@ -445,6 +463,7 @@ await SnapshotEntryIdentitiesAsync( [current.Id, candidate.Id], replacement.Mappings, cancellationToken); + writeContext.StagedFileMappings.RemoveRange(stagedNext); await TransferPlaybackProgressAsync( writeContext, BuildActivationPlaybackTransfers(current.Id, candidate.Id, replacement), @@ -648,6 +667,22 @@ private static Models.ReleaseUpgradeMappingSnapshot ToSnapshot( FileStore = mapping.FileStore }; + private static FileMapping ToRecord(Models.StagedFileMapping mapping) => new( + mapping.Id, + mapping.AnimationInfoId, + mapping.VirtualPath, + mapping.PhysicalPath, + mapping.FileStore); + + private static Models.FileMapping ToFileMapping(Models.StagedFileMapping mapping) => new() + { + Id = mapping.Id, + AnimationInfoId = mapping.AnimationInfoId, + VirtualPath = mapping.VirtualPath, + PhysicalPath = mapping.PhysicalPath, + FileStore = mapping.FileStore + }; + private static Task SnapshotEntryIdentitiesAsync( Models.ApplicationContext writeContext, Guid operationId, @@ -741,7 +776,10 @@ private static string GetStableFileRole(string virtualPath) var fileName = virtualPath[(virtualPath.LastIndexOf('/') + 1)..]; var extension = Path.GetExtension(fileName); var stem = extension.Length == 0 ? fileName : fileName[..^extension.Length]; - return CollisionSuffixRegex().Replace(stem, string.Empty) + extension; + var roleExtension = MediaFileTypes.VideoExtensions.Contains(extension) + ? "