diff --git a/Plugins/SecondDimensionWatcherReDive.Chat/Tools/ManageDownloadsTool.cs b/Plugins/SecondDimensionWatcherReDive.Chat/Tools/ManageDownloadsTool.cs index 831ec4b..9e22f06 100644 --- a/Plugins/SecondDimensionWatcherReDive.Chat/Tools/ManageDownloadsTool.cs +++ b/Plugins/SecondDimensionWatcherReDive.Chat/Tools/ManageDownloadsTool.cs @@ -1,3 +1,4 @@ +using System.Diagnostics; using SecondDimensionWatcherReDive.AI.Models; using SecondDimensionWatcherReDive.Framework.AI; using SecondDimensionWatcherReDive.Framework.Attributes; @@ -14,6 +15,12 @@ internal sealed partial class ManageDownloadsTool( IFileMappingRepository fileMappingRepository, IFileDownloadClientProvider fileDownloadClientProvider) : ITool { + private static readonly TimeSpan DownloadSubmissionLeaseDuration = TimeSpan.FromMinutes(3); + private static readonly TimeSpan DownloadSubmissionRemoteBudget = TimeSpan.FromSeconds(90); + private static readonly TimeSpan DownloadCancellationLeaseDuration = TimeSpan.FromMinutes(3); + private static readonly TimeSpan DownloadCancellationRemoteBudget = TimeSpan.FromSeconds(90); + private static readonly TimeSpan DownloadLeaseSafetyMargin = TimeSpan.FromSeconds(1); + private async Task ExecuteCoreAsync( ManageDownloadsParams param, CancellationToken cancellationToken) { @@ -45,32 +52,71 @@ private async Task StartDownloadAsync( return new ToolFailureResult("Download already tracked"); var downloadAttemptId = Guid.NewGuid(); + var submissionLeaseId = Guid.NewGuid(); + var leaseRequestStartedAt = Stopwatch.GetTimestamp(); var submissionAttempted = false; try { - if (!await animationInfoRepository.TryStartDownloadAsync( + var submissionLease = await animationInfoRepository.TryStartDownloadAsync( info.Id, downloadAttemptId, + submissionLeaseId, + DownloadSubmissionLeaseDuration, DateTimeOffset.Now, queuedDisposition: null, - cancellationToken)) + cancellationToken); + if (submissionLease is null) return new ToolFailureResult("Download already tracked"); + var remainingRemoteBudget = DownloadSubmissionRemoteBudget - + Stopwatch.GetElapsedTime(leaseRequestStartedAt); + if (remainingRemoteBudget <= TimeSpan.Zero) + { + await CompensateFailedStartAsync( + info, + client, + downloadAttemptId, + submissionLeaseId, + remoteMayHaveAccepted: false); + return new ToolFailureResult("Download submission lease expired"); + } + + using var submissionCancellation = CancellationTokenSource.CreateLinkedTokenSource( + cancellationToken); + submissionCancellation.CancelAfter(remainingRemoteBudget); + submissionCancellation.Token.ThrowIfCancellationRequested(); submissionAttempted = true; if (!await client.SubmitDownloadTaskAsync( info.Id, info.DownloadUrl, info.CachedDownloadData, info.AdditionalDownloadInfo, - cancellationToken)) + submissionCancellation.Token)) { await CompensateFailedStartAsync( info, client, downloadAttemptId, + submissionLeaseId, remoteMayHaveAccepted: false); return new ToolFailureResult("Download client rejected the task"); } + + using var markCancellation = CreateDownloadSagaTokenSource(); + if (!await animationInfoRepository.TryMarkDownloadSubmittedAsync( + info.Id, + downloadAttemptId, + submissionLeaseId, + markCancellation.Token)) + { + await CompensateFailedStartAsync( + info, + client, + downloadAttemptId, + submissionLeaseId, + remoteMayHaveAccepted: true); + return new ToolFailureResult("Download state changed during submission"); + } } catch { @@ -80,6 +126,7 @@ await CompensateFailedStartAsync( info, client, downloadAttemptId, + submissionLeaseId, submissionAttempted); } catch @@ -112,35 +159,57 @@ private async Task CancelDownloadAsync( AnimationInfo info, IFileDownloadClient client, bool removeFile, CancellationToken cancellationToken) { var cancellationAttemptId = info.DownloadCancellationId ?? Guid.NewGuid(); + var cancellationLeaseId = Guid.NewGuid(); + var leaseRequestStartedAt = Stopwatch.GetTimestamp(); + DownloadCancellationLease? cancellationLease; cancellationToken.ThrowIfCancellationRequested(); using (var beginCancellation = CreateDownloadSagaTokenSource()) { - if (!await animationInfoRepository.TryBeginCancelDownloadAsync( + cancellationLease = await animationInfoRepository.TryBeginCancelDownloadAsync( info.Id, info.DownloadAttemptId, cancellationAttemptId, - beginCancellation.Token)) + cancellationLeaseId, + DownloadCancellationLeaseDuration, + removeFile, + requireUnfinished: false, + SubscriptionAutomationDisposition.DownloadCancelled, + beginCancellation.Token); + if (cancellationLease is null) return new ToolFailureResult("Download state changed before cancellation"); } + var remainingRemoteBudget = DownloadCancellationRemoteBudget - + Stopwatch.GetElapsedTime(leaseRequestStartedAt); + if (remainingRemoteBudget <= TimeSpan.Zero) + return new ToolFailureResult("Download cancellation lease expired"); + using var remoteCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + remoteCancellation.CancelAfter(remainingRemoteBudget); + remoteCancellation.Token.ThrowIfCancellationRequested(); var result = await client.CancelDownloadTaskAsync( info.Id, info.DownloadUrl, info.CachedDownloadData, info.AdditionalDownloadInfo, - removeFile, - cancellationToken); + cancellationLease.RemoveFile, + remoteCancellation.Token); if (!result.IsSuccess) { return new ToolSuccessResult(false); } - using var finalizeCancellation = CreateDownloadSagaTokenSource(); + using var finalizeCancellation = CreateLeaseBoundSagaTokenSource( + leaseRequestStartedAt, + DownloadCancellationLeaseDuration); + if (finalizeCancellation is null) + return new ToolFailureResult("Download cancellation lease expired"); var cancelled = await fileMappingRepository.TryFinalizeDownloadCancellationAsync( info.Id, info.DownloadAttemptId, cancellationAttemptId, + cancellationLease.Id, + SubscriptionAutomationDisposition.DownloadCancelled, finalizeCancellation.Token); if (!cancelled) return new ToolFailureResult("Download state changed during cancellation"); @@ -151,19 +220,35 @@ private async Task CompensateFailedStartAsync( AnimationInfo info, IFileDownloadClient client, Guid downloadAttemptId, + Guid submissionLeaseId, bool remoteMayHaveAccepted) { using var cleanup = CreateDownloadSagaTokenSource(); + var cancellationAttemptId = Guid.NewGuid(); + var cancellationLease = await animationInfoRepository.TryBeginCancelDownloadAsync( + info.Id, + downloadAttemptId, + cancellationAttemptId, + submissionLeaseId, + DownloadCancellationLeaseDuration, + removeFile: false, + requireUnfinished: true, + terminalDisposition: null, + cleanup.Token); + if (cancellationLease is null) + return; + if (remoteMayHaveAccepted) { try { + cleanup.Token.ThrowIfCancellationRequested(); var remoteCancellation = await client.CancelDownloadTaskAsync( info.Id, info.DownloadUrl, info.CachedDownloadData, info.AdditionalDownloadInfo, - removeFile: false, + cancellationLease.RemoveFile, cleanup.Token); if (!remoteCancellation.IsSuccess) { @@ -178,9 +263,12 @@ private async Task CompensateFailedStartAsync( } } - await animationInfoRepository.TryCancelDownloadAsync( + cleanup.Token.ThrowIfCancellationRequested(); + await fileMappingRepository.TryFinalizeDownloadCancellationAsync( info.Id, downloadAttemptId, + cancellationAttemptId, + cancellationLease.Id, terminalDisposition: null, cleanup.Token); } @@ -207,6 +295,19 @@ await client.SubmitQueryDownloadProgressAsync( private static CancellationTokenSource CreateDownloadSagaTokenSource() => new(TimeSpan.FromSeconds(10)); + + private static CancellationTokenSource? CreateLeaseBoundSagaTokenSource( + long leaseRequestStartedAt, + TimeSpan leaseDuration) + { + var remaining = leaseDuration - + Stopwatch.GetElapsedTime(leaseRequestStartedAt) - + DownloadLeaseSafetyMargin; + if (remaining <= TimeSpan.Zero) + return null; + return new CancellationTokenSource( + remaining < TimeSpan.FromSeconds(10) ? remaining : TimeSpan.FromSeconds(10)); + } } internal enum ManageDownloadsAction diff --git a/Plugins/SecondDimensionWatcherReDive.Inference.AI/Tools/TmdbTool.cs b/Plugins/SecondDimensionWatcherReDive.Inference.AI/Tools/TmdbTool.cs index 0d30c24..2054522 100644 --- a/Plugins/SecondDimensionWatcherReDive.Inference.AI/Tools/TmdbTool.cs +++ b/Plugins/SecondDimensionWatcherReDive.Inference.AI/Tools/TmdbTool.cs @@ -178,6 +178,33 @@ 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 (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + 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 3583b98..888a001 100644 --- a/SecondDimensionWatcherReDive.Client/mock-server.mjs +++ b/SecondDimensionWatcherReDive.Client/mock-server.mjs @@ -423,6 +423,7 @@ function initAnimations() { } : null, isAiProcessed: !!entry.animeName, + isMediaLibraryImport: i === 2, }); }); } @@ -742,6 +743,9 @@ const subscriptionPolicies = new Map([ 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(), }, @@ -758,6 +762,9 @@ const subscriptionPolicies = new Map([ 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(), }, @@ -2503,6 +2510,166 @@ 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, + virtualPathCount: virtualPaths.length, + 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); @@ -2956,6 +3123,13 @@ async function route(method, pathname, searchParams, req, res) { ) ? 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(), }; diff --git a/SecondDimensionWatcherReDive.Client/src/Main.tsx b/SecondDimensionWatcherReDive.Client/src/Main.tsx index accfaf7..b43716c 100644 --- a/SecondDimensionWatcherReDive.Client/src/Main.tsx +++ b/SecondDimensionWatcherReDive.Client/src/Main.tsx @@ -17,6 +17,7 @@ import { loadMainPage, loadMetadataReviewPage, loadPlayerPage, + loadSearchPage, loadSettingsPage, loadTasksPage, loadTodoPage, @@ -55,6 +56,9 @@ const MetadataReviewPage = React.lazy(async () => ({ const PlayerPage = React.lazy(async () => ({ default: (await loadPlayerPage()).PlayerPage, })); +const SearchPage = React.lazy(async () => ({ + default: (await loadSearchPage()).SearchPage, +})); const SettingsPage = React.lazy(async () => ({ default: (await loadSettingsPage()).SettingsPage, })); @@ -120,6 +124,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 8ee9f6d..5389ff1 100644 --- a/SecondDimensionWatcherReDive.Client/src/components/AppHeader.tsx +++ b/SecondDimensionWatcherReDive.Client/src/components/AppHeader.tsx @@ -17,6 +17,7 @@ import { List, Menu, MessageSquare, + Search, Settings, User, } from "lucide-react"; @@ -55,6 +56,7 @@ const createNavItems = ( todoCount?: number, ): NavItem[] => [ { icon: , labelKey: "nav.home", path: "/" }, + { icon: , labelKey: "nav.search", path: "/search" }, { icon: , labelKey: "nav.todo", @@ -267,7 +269,18 @@ export const AppHeader: React.FC = () => { const { data: todos } = useTodos({ take: 1 }); const navigate = useNavigate(); const items = createNavItems(incidents?.openCount, todos?.unreadCount); + 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 (