From b2c685c5d6d7eb1a7f4df3064cd10055267452cb Mon Sep 17 00:00:00 2001 From: mahoshojoHCG Date: Sat, 29 Aug 2026 23:31:41 +0800 Subject: [PATCH 01/18] 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 f6fda4e09f6f74dcff987e88a0e26a5514e3450d Mon Sep 17 00:00:00 2001 From: mahoshojoHCG Date: Sun, 30 Aug 2026 11:47:06 +0800 Subject: [PATCH 02/18] 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 81903d5444ba77a1b59e9a8e7b2457484f9c38d0 Mon Sep 17 00:00:00 2001 From: mahoshojoHCG Date: Mon, 31 Aug 2026 15:20:01 +0800 Subject: [PATCH 03/18] 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) + ? "