From 7e880b2cb7de18756a8b707aa1f7bb27c263a39c Mon Sep 17 00:00:00 2001 From: Yes Date: Fri, 2 Jan 2026 04:42:40 +0100 Subject: [PATCH 01/64] Add 'Failed' translation state and update handling Introduces a new 'Failed' state for translation requests across client and server. Updates enums, UI badges, tooltips, and translation state logic to reflect failed translations. Ensures media state is updated when a translation fails or is modified, and adds new prompt guidance for AI translation. Also updates English translations to include the new state. --- .../common/TranslationStateBadge.vue | 8 ++++ Lingarr.Client/src/ts/media/index.ts | 3 +- Lingarr.Core/Enum/TranslationState.cs | 8 +++- .../Interfaces/Services/IMediaStateService.cs | 5 +++ Lingarr.Server/Jobs/TranslationJob.cs | 31 +++++++++++++ Lingarr.Server/Services/MediaStateService.cs | 15 +++++++ Lingarr.Server/Services/StartupService.cs | 2 +- .../Services/TranslationRequestService.cs | 45 +++++++++++++++++-- Lingarr.Server/Statics/Translations/en.json | 4 +- 9 files changed, 114 insertions(+), 7 deletions(-) diff --git a/Lingarr.Client/src/components/common/TranslationStateBadge.vue b/Lingarr.Client/src/components/common/TranslationStateBadge.vue index 97c144e5..6783a04a 100644 --- a/Lingarr.Client/src/components/common/TranslationStateBadge.vue +++ b/Lingarr.Client/src/components/common/TranslationStateBadge.vue @@ -49,6 +49,8 @@ const badgeClasses = computed(() => { return 'bg-gray-800/30 text-gray-500 border border-gray-700/30 opacity-50' case TRANSLATION_STATE.NO_SUITABLE_SUBTITLES: return 'bg-red-900/50 text-red-300 border border-red-500/50' + case TRANSLATION_STATE.FAILED: + return 'bg-red-900/70 text-red-200 border border-red-400/60' case TRANSLATION_STATE.UNKNOWN: default: return 'bg-gray-800/50 text-gray-400 border border-gray-600/50 opacity-60' @@ -69,6 +71,8 @@ const iconComponent = computed(() => { return TimesCircleIcon case TRANSLATION_STATE.NO_SUITABLE_SUBTITLES: return ExclamationIcon + case TRANSLATION_STATE.FAILED: + return ExclamationIcon case TRANSLATION_STATE.UNKNOWN: default: return QuestionMarkCircleIcon @@ -89,6 +93,8 @@ const label = computed(() => { return translate('translationState.notApplicable') case TRANSLATION_STATE.NO_SUITABLE_SUBTITLES: return translate('translationState.noSuitableSubtitles') + case TRANSLATION_STATE.FAILED: + return translate('translationState.failed') case TRANSLATION_STATE.UNKNOWN: default: return translate('translationState.unknown') @@ -109,6 +115,8 @@ const tooltip = computed(() => { return translate('translationState.tooltipNotApplicable') case TRANSLATION_STATE.NO_SUITABLE_SUBTITLES: return translate('translationState.tooltipNoSuitableSubtitles') + case TRANSLATION_STATE.FAILED: + return translate('translationState.tooltipFailed') case TRANSLATION_STATE.UNKNOWN: default: return translate('translationState.tooltipUnknown') diff --git a/Lingarr.Client/src/ts/media/index.ts b/Lingarr.Client/src/ts/media/index.ts index cd342365..7f579fb2 100644 --- a/Lingarr.Client/src/ts/media/index.ts +++ b/Lingarr.Client/src/ts/media/index.ts @@ -154,7 +154,8 @@ export const TRANSLATION_STATE = { IN_PROGRESS: 3, COMPLETE: 4, STALE: 5, - NO_SUITABLE_SUBTITLES: 6 + NO_SUITABLE_SUBTITLES: 6, + FAILED: 7 } as const export type TranslationStateType = (typeof TRANSLATION_STATE)[keyof typeof TRANSLATION_STATE] diff --git a/Lingarr.Core/Enum/TranslationState.cs b/Lingarr.Core/Enum/TranslationState.cs index 57272281..fbb9d8f2 100644 --- a/Lingarr.Core/Enum/TranslationState.cs +++ b/Lingarr.Core/Enum/TranslationState.cs @@ -46,5 +46,11 @@ public enum TranslationState /// No suitable subtitle tracks available. /// All embedded subtitle tracks have fewer than the minimum required entries (sparse/Signs/Songs only). /// - NoSuitableSubtitles = 6 + NoSuitableSubtitles = 6, + + /// + /// Translation failed. + /// A previous translation request for this media failed and needs manual intervention. + /// + Failed = 7 } diff --git a/Lingarr.Server/Interfaces/Services/IMediaStateService.cs b/Lingarr.Server/Interfaces/Services/IMediaStateService.cs index 1e7dfc16..21cdcecb 100644 --- a/Lingarr.Server/Interfaces/Services/IMediaStateService.cs +++ b/Lingarr.Server/Interfaces/Services/IMediaStateService.cs @@ -51,4 +51,9 @@ public interface IMediaStateService /// Checks if a media item has any active (Pending/InProgress) translation requests. /// Task HasActiveTranslationRequestAsync(int mediaId, MediaType mediaType); + + /// + /// Checks if a media item has any failed translation requests. + /// + Task HasFailedTranslationRequestAsync(int mediaId, MediaType mediaType); } diff --git a/Lingarr.Server/Jobs/TranslationJob.cs b/Lingarr.Server/Jobs/TranslationJob.cs index 9ac17c82..f336f337 100644 --- a/Lingarr.Server/Jobs/TranslationJob.cs +++ b/Lingarr.Server/Jobs/TranslationJob.cs @@ -517,6 +517,37 @@ void AddRequestLog(string level, string message, string? details = null) translationRequest, TranslationStatus.Failed, null); + + // Update translation state to reflect failure + if (translationRequest.MediaId.HasValue) + { + try + { + if (translationRequest.MediaType == MediaType.Movie) + { + var movie = await _dbContext.Movies.FindAsync(translationRequest.MediaId.Value); + if (movie != null) + { + await _mediaStateService.UpdateStateAsync(movie, MediaType.Movie); + } + } + else + { + var episode = await _dbContext.Episodes + .Include(e => e.Season) + .ThenInclude(s => s.Show) + .FirstOrDefaultAsync(e => e.Id == translationRequest.MediaId.Value); + if (episode != null) + { + await _mediaStateService.UpdateStateAsync(episode, MediaType.Episode); + } + } + } + catch (Exception stateEx) + { + _logger.LogWarning(stateEx, "Failed to update translation state after failure"); + } + } break; // Success, exit retry loop } catch (Exception retryEx) when (attempt < 2) diff --git a/Lingarr.Server/Services/MediaStateService.cs b/Lingarr.Server/Services/MediaStateService.cs index b48b59ca..cf114912 100644 --- a/Lingarr.Server/Services/MediaStateService.cs +++ b/Lingarr.Server/Services/MediaStateService.cs @@ -122,6 +122,12 @@ private async Task ComputeStateAsync( return TranslationState.InProgress; } + // 3b. Check for failed translation request + if (await HasFailedTranslationRequestAsync(media.Id, mediaType)) + { + return TranslationState.Failed; + } + // 4. Get external subtitles var externalSubtitles = new List(); if (!string.IsNullOrEmpty(media.Path)) @@ -274,6 +280,15 @@ public async Task HasActiveTranslationRequestAsync(int mediaId, MediaType (tr.Status == TranslationStatus.Pending || tr.Status == TranslationStatus.InProgress)); } + /// + public async Task HasFailedTranslationRequestAsync(int mediaId, MediaType mediaType) + { + return await _dbContext.TranslationRequests.AnyAsync(tr => + tr.MediaId == mediaId && + tr.MediaType == mediaType && + tr.Status == TranslationStatus.Failed); + } + private async Task> GetConfiguredLanguages(string settingKey) { try diff --git a/Lingarr.Server/Services/StartupService.cs b/Lingarr.Server/Services/StartupService.cs index 0491d4fd..ed0bf379 100644 --- a/Lingarr.Server/Services/StartupService.cs +++ b/Lingarr.Server/Services/StartupService.cs @@ -107,7 +107,7 @@ public async Task StartAsync(CancellationToken cancellationToken) { SettingKeys.SubtitleValidation.MaxDurationSecs, "10" }, // Default AI Prompt Safeguard - { SettingKeys.Translation.AiPrompt, "If you encounter ASS/SSA vector drawing commands (patterns like 'm 0 0 l 100 100 b...'), ignore them and translate only the surrounding dialogue. Do NOT remove valid short dialogue like 'I...', 'No!', or single words." } + { SettingKeys.Translation.AiPrompt, "Translate subtitles into natural, plain text. NEVER include ASS/SSA tags ({\\...}), HTML tags, or animation/karaoke markers in your output. If a line is purely an animation syllable (e.g., 'ha', 'na', 'te') or non-dialogue fragment, return an empty string for that position. Maintain the natural flow of speech and do NOT remove valid short dialogue like 'No!', 'Stop!', or single-word meanings." } }); await CheckAndUpdateIntegrationSettings(dbContext, "radarr", [ diff --git a/Lingarr.Server/Services/TranslationRequestService.cs b/Lingarr.Server/Services/TranslationRequestService.cs index 629bf526..9de90dd5 100644 --- a/Lingarr.Server/Services/TranslationRequestService.cs +++ b/Lingarr.Server/Services/TranslationRequestService.cs @@ -32,6 +32,7 @@ private static bool IsActiveStatus(TranslationStatus status) => private readonly IBatchFallbackService _batchFallbackService; private readonly ILogger _logger; private readonly ITranslationCancellationService _cancellationService; + private readonly IMediaStateService _mediaStateService; static private Dictionary _asyncTranslationJobs = new Dictionary(); public TranslationRequestService( @@ -45,7 +46,8 @@ public TranslationRequestService( ISettingService settingService, IBatchFallbackService batchFallbackService, ILogger logger, - ITranslationCancellationService cancellationService) + ITranslationCancellationService cancellationService, + IMediaStateService mediaStateService) { _dbContext = dbContext; _hubContext = hubContext; @@ -58,6 +60,7 @@ public TranslationRequestService( _batchFallbackService = batchFallbackService; _logger = logger; _cancellationService = cancellationService; + _mediaStateService = mediaStateService; } /// @@ -258,6 +261,7 @@ public async Task UpdateActiveCount() await _dbContext.SaveChangesAsync(); await ClearMediaHash(translationRequest); await UpdateActiveCount(); + await UpdateMediaState(translationRequest); await _progressService.Emit(translationRequest, 0); } @@ -277,6 +281,7 @@ public async Task UpdateActiveCount() _dbContext.TranslationRequests.Remove(translationRequest); await _dbContext.SaveChangesAsync(); await UpdateActiveCount(); + await UpdateMediaState(translationRequest); return $"Translation request with id {cancelRequest.Id} has been removed"; } @@ -444,6 +449,7 @@ await _dbContext.TranslationRequests foreach (var request in batchNewRequests) { await EnqueueTranslationJobAsync(request, true); + await UpdateMediaState(request); } // Small delay to allow other threads/requests to acquire locks if needed @@ -481,6 +487,7 @@ await _dbContext.TranslationRequests _dbContext.TranslationRequests.Remove(translationRequest); await _dbContext.SaveChangesAsync(); await UpdateActiveCount(); + await UpdateMediaState(translationRequest); return $"Translation request with id {retryRequest.Id} has been restarted, new job id {newTranslationRequestId}"; } @@ -1352,12 +1359,12 @@ private async Task FormatMediaTitle(TranslateAbleSubtitle translateAbleS $"{episodeInfo.EpisodeTitle}"; default: - throw new ArgumentException($"Unsupported media type: {translateAbleSubtitle.MediaType}"); + throw new ArgumentException($"Unsupported media type: {translateAbleSubtitle.MediaType}"); } } /// - /// Checks if a DbUpdateException is caused by a duplicate key constraint violation. + /// Checks if the given exception is a duplicate key violation. /// /// The exception to check /// True if this is a duplicate key violation, false otherwise @@ -1387,4 +1394,36 @@ private static bool IsDuplicateKeyViolation(DbUpdateException ex) return false; } + + private async Task UpdateMediaState(TranslationRequest request) + { + if (!request.MediaId.HasValue) return; + + try + { + if (request.MediaType == MediaType.Movie) + { + var movie = await _dbContext.Movies.FindAsync(request.MediaId.Value); + if (movie != null) + { + await _mediaStateService.UpdateStateAsync(movie, MediaType.Movie); + } + } + else if (request.MediaType == MediaType.Episode) + { + var episode = await _dbContext.Episodes + .Include(e => e.Season) + .ThenInclude(s => s.Show) + .FirstOrDefaultAsync(e => e.Id == request.MediaId.Value); + if (episode != null) + { + await _mediaStateService.UpdateStateAsync(episode, MediaType.Episode); + } + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to update media state for {MediaType} {MediaId}", request.MediaType, request.MediaId); + } + } } diff --git a/Lingarr.Server/Statics/Translations/en.json b/Lingarr.Server/Statics/Translations/en.json index cad6b5fc..d0fa5f85 100644 --- a/Lingarr.Server/Statics/Translations/en.json +++ b/Lingarr.Server/Statics/Translations/en.json @@ -448,12 +448,14 @@ "unknown": "Unknown", "notApplicable": "N/A", "noSuitableSubtitles": "No Source", + "failed": "Failed", "tooltipComplete": "All translations complete", "tooltipInProgress": "Translation in progress", "tooltipPending": "Awaiting translation", "tooltipStale": "Needs re-analysis", "tooltipUnknown": "Not yet analyzed", "tooltipNotApplicable": "Not applicable / No source", - "tooltipNoSuitableSubtitles": "No suitable subtitle tracks found (all are sparse/Signs & Songs)" + "tooltipNoSuitableSubtitles": "No suitable subtitle tracks found (all are sparse/Signs & Songs)", + "tooltipFailed": "Translation failed. Needs manual retry." } } \ No newline at end of file From 6fc52d350d37ad25b6f0681b2536b5e80a1a0d0c Mon Sep 17 00:00:00 2001 From: Yes Date: Fri, 2 Jan 2026 04:52:05 +0100 Subject: [PATCH 02/64] Add GEMINI.md and improve null checks in services Introduces GEMINI.md with agent guidelines and lessons learned. Updates TranslationRequestServiceTests to mock new IMediaStateService dependency. Improves null checks in TranslationJob and MediaSubtitleProcessor to handle empty or null subtitles and media paths, preventing potential runtime errors. --- GEMINI.md | 18 ++++++++++++++++++ .../Services/TranslationRequestServiceTests.cs | 3 ++- Lingarr.Server/Jobs/TranslationJob.cs | 2 +- .../Services/MediaSubtitleProcessor.cs | 10 ++++++++-- 4 files changed, 29 insertions(+), 4 deletions(-) create mode 100644 GEMINI.md diff --git a/GEMINI.md b/GEMINI.md new file mode 100644 index 00000000..11851ec0 --- /dev/null +++ b/GEMINI.md @@ -0,0 +1,18 @@ + +# Lingarr on Steroids - AI Agent Documentation + +When proposing a plan, do not assume it is the full correct plan. Always ask for feedback and iterate. You should not assume you have all the information. You should dive into code and understand evvery single aspect of what you are changing and what impact it will have on all other components and or architecture. Ask the user for clarification on things you are unsure of. DO NOT blindly assume anything, everything should be code backed, any claims you present to the user, they need to be vetted by the real code. If some implementation requires internet research, do it. +If you need more debugging information, tell the user or try to get it yourself if possible. Final implementation plans should pass every lint, type build or whatever else there is on top of tests. +And how does this impact the rest of the codebase? Is thiss really the fix to do or will it break more things than fix? How will this fix affect all other files and components? How exaactly will this resolve the issue? Won't this cause more issues? You need to ask yourself these questions. +When you encounter a persistent error that requires multiple attempts to fix, you MUST generate a 'Lesson Learned'. +Analyze why the first attempt failed. +Abstract the specific error into a general rule. +Update GEMINI.md "lessons learned" section with what you found out and what you can do to improve. Ensure these don't appear in repetition, these lessons should be thhe guide on how to proceed and should serve as a knowledge base for future agents to also reflect and look and act upon in the codebase. Update the "Lessons learned: " with the lessons that will enhance any future agents in coding tasks. It might also appear that the original repo doesn't contain THIS specific gemini.md file, if it doesn't, create it and copy all of these instructions and paste it there. Word for word. + + +"Lessons learned: + +- **Constructor Dependency Inflation**: Adding a new dependency to a core service (like `TranslationRequestService`) will break all unit tests that manually instantiate that service. Always grep for usages of `new [ServiceName]` in the `*.Tests` project after modifying a constructor to catch and fix broken tests early. +- **Nullability and XML Warnings**: CI environments often treat warnings as errors or run stricter analysis. Proactively fixing `CS8604` (possible null reference) and XML comment mismatches in the files you modify prevented downstream build failures in this task. +- **Vue Type Checking**: Standard `npm run build` behavior can be inconsistent across environments. Use `npx vue-tsc --noEmit` for a reliable, standalone type-check of the frontend during validation. +" diff --git a/Lingarr.Server.Tests/Services/TranslationRequestServiceTests.cs b/Lingarr.Server.Tests/Services/TranslationRequestServiceTests.cs index bc90317d..f752014d 100644 --- a/Lingarr.Server.Tests/Services/TranslationRequestServiceTests.cs +++ b/Lingarr.Server.Tests/Services/TranslationRequestServiceTests.cs @@ -177,6 +177,7 @@ private static TranslationRequestService CreateService( new Mock().Object, new Mock().Object, NullLogger.Instance, - new Mock().Object); + new Mock().Object, + new Mock().Object); } } diff --git a/Lingarr.Server/Jobs/TranslationJob.cs b/Lingarr.Server/Jobs/TranslationJob.cs index f336f337..d86a8b00 100644 --- a/Lingarr.Server/Jobs/TranslationJob.cs +++ b/Lingarr.Server/Jobs/TranslationJob.cs @@ -249,7 +249,7 @@ void AddRequestLog(string level, string message, string? details = null) StripSubtitleFormatting = stripSubtitleFormatting }; - if (!_subtitleService.ValidateSubtitle(request.SubtitleToTranslate, validationOptions)) + if (string.IsNullOrEmpty(request.SubtitleToTranslate) || !_subtitleService.ValidateSubtitle(request.SubtitleToTranslate, validationOptions)) { const string validationMessage = "Subtitle is not valid according to configured preferences."; _logger.LogWarning(validationMessage); diff --git a/Lingarr.Server/Services/MediaSubtitleProcessor.cs b/Lingarr.Server/Services/MediaSubtitleProcessor.cs index dbe6db77..271abc77 100644 --- a/Lingarr.Server/Services/MediaSubtitleProcessor.cs +++ b/Lingarr.Server/Services/MediaSubtitleProcessor.cs @@ -262,7 +262,7 @@ private async Task ProcessSubtitles( foreach (var targetLanguage in languagesToTranslate) { - if (await HasActiveRequestAsync(_media.Id, _mediaType, sourceLanguage, targetLanguage)) + if (sourceLanguage == null || await HasActiveRequestAsync(_media.Id, _mediaType, sourceLanguage, targetLanguage)) { _logger.LogInformation( "Skipping enqueue for {FileName} {Source}->{Target}: translation request already active.", @@ -637,11 +637,17 @@ await _translationRequestService.CreateRequest(new TranslateAbleSubtitle /// /// The media item to process /// The type of media (Movie or Episode) + /// If true, translates to all target languages even if they already exist. /// If true, bypasses the media hash check /// If true, forces jobs to use the priority queue /// The number of translation requests queued private async Task TryQueueEmbeddedSubtitleTranslation(IMedia media, MediaType mediaType, bool forceTranslation, bool forceProcess, bool forcePriority = false) { + if (media.Path == null) + { + return 0; + } + // Preserve the order of configured source languages so we can treat // them as a priority list (e.g. [en, ja] => prefer English when both // are good candidates, but fall back to Japanese when English only @@ -876,7 +882,7 @@ private async Task TryQueueEmbeddedSubtitleTranslation(IMedia media, MediaT selectedSubtitle.CodecName); // Get external subtitles to check which target languages already exist and validate them - var allExternalSubtitles = await _subtitleService.GetAllSubtitles(media.Path); + var allExternalSubtitles = await _subtitleService.GetAllSubtitles(media.Path!); var matchingExternalSubtitles = allExternalSubtitles .Where(s => s.FileName.StartsWith(media.FileName + ".") || s.FileName == media.FileName) .ToList(); From 7a1f10fdb8214d3ff45c183c7fd9fec314fb3b9c Mon Sep 17 00:00:00 2001 From: Yes Date: Fri, 2 Jan 2026 05:57:23 +0100 Subject: [PATCH 03/64] feat(translation): implement batching and fallback splitting for deferred repair --- .../Services/IDeferredRepairService.cs | 4 + .../Services/SubtitleTranslationService.cs | 2 + .../Translation/DeferredRepairService.cs | 115 ++++++++++++------ 3 files changed, 82 insertions(+), 39 deletions(-) diff --git a/Lingarr.Server/Interfaces/Services/IDeferredRepairService.cs b/Lingarr.Server/Interfaces/Services/IDeferredRepairService.cs index 0590c396..5a168cff 100644 --- a/Lingarr.Server/Interfaces/Services/IDeferredRepairService.cs +++ b/Lingarr.Server/Interfaces/Services/IDeferredRepairService.cs @@ -30,8 +30,10 @@ ContextualRepairBatch BuildContextualRepairBatch( /// /// The contextual repair batch to translate /// The batch translation service to use + /// The fallback service for graduated chunk splitting /// Source language code /// Target language code + /// Size of chunks to use for repair (collected from settings) /// Maximum number of retry attempts /// Short identifier for logging /// Cancellation token @@ -39,8 +41,10 @@ ContextualRepairBatch BuildContextualRepairBatch( Task> ExecuteRepairAsync( ContextualRepairBatch repairBatch, IBatchTranslationService batchService, + IBatchFallbackService fallbackService, string sourceLanguage, string targetLanguage, + int batchSize, int maxRetries, string fileIdentifier, CancellationToken cancellationToken); diff --git a/Lingarr.Server/Services/SubtitleTranslationService.cs b/Lingarr.Server/Services/SubtitleTranslationService.cs index 7f60b904..f1b65692 100644 --- a/Lingarr.Server/Services/SubtitleTranslationService.cs +++ b/Lingarr.Server/Services/SubtitleTranslationService.cs @@ -278,8 +278,10 @@ public async Task> TranslateSubtitlesBatch( var repairResults = await _deferredRepairService.ExecuteRepairAsync( repairBatch, batchTranslationService, + _batchFallbackService ?? throw new TranslationException("Batch fallback service is required for repair."), translationRequest.SourceLanguage, translationRequest.TargetLanguage, + batchSize, repairMaxRetries, fileIdentifier, cancellationToken); diff --git a/Lingarr.Server/Services/Translation/DeferredRepairService.cs b/Lingarr.Server/Services/Translation/DeferredRepairService.cs index bf7f12e0..84676b05 100644 --- a/Lingarr.Server/Services/Translation/DeferredRepairService.cs +++ b/Lingarr.Server/Services/Translation/DeferredRepairService.cs @@ -98,8 +98,10 @@ public ContextualRepairBatch BuildContextualRepairBatch( public async Task> ExecuteRepairAsync( ContextualRepairBatch repairBatch, IBatchTranslationService batchService, + IBatchFallbackService fallbackService, string sourceLanguage, string targetLanguage, + int batchSize, int maxRetries, string fileIdentifier, CancellationToken cancellationToken) @@ -110,12 +112,14 @@ public async Task> ExecuteRepairAsync( } maxRetries = Math.Max(1, maxRetries); + batchSize = batchSize <= 0 ? 50 : batchSize; // Default to 50 if zero or negative + var results = new Dictionary(); _logger.LogInformation( - "[{FileId}] Starting deferred repair: {FailedCount} failed items with {ContextCount} context items", + "[{FileId}] Starting deferred repair: {FailedCount} failed items with {ContextCount} context items. Using batch size {BatchSize}.", fileIdentifier, repairBatch.FailedPositions.Count, - repairBatch.Items.Count - repairBatch.FailedPositions.Count); + repairBatch.Items.Count - repairBatch.FailedPositions.Count, batchSize); for (int attempt = 1; attempt <= maxRetries + 1; attempt++) { @@ -127,32 +131,60 @@ public async Task> ExecuteRepairAsync( "[{FileId}] Repair attempt {Attempt}/{MaxAttempts}", fileIdentifier, attempt, maxRetries + 1); - // Note: Deferred repair includes context in the batch items themselves, - // so we don't use wrapper context here - var batchResults = await batchService.TranslateBatchAsync( - repairBatch.Items, - sourceLanguage, - targetLanguage, - null, // preContext - context is already in batch items - null, // postContext - context is already in batch items - cancellationToken); + // Identify which failed items are still missing + var stillMissingPositions = repairBatch.FailedPositions + .Where(p => !results.ContainsKey(p) || string.IsNullOrWhiteSpace(results[p])) + .ToHashSet(); + + if (stillMissingPositions.Count == 0) break; + + // Filter repairBatch.Items to only include current ranges that contain missing failed positions + // However, the repairBatch already contains merged ranges. + // To keep it simple and respect the user's batching request: + // We will split the ENTIRE repairBatch.Items (failed + context) into smaller chunks + // and process each chunk using the fallback service. + + var chunks = SplitIntoChunks(repairBatch.Items, batchSize); - // Extract only the translations for failed positions - foreach (var position in repairBatch.FailedPositions) + _logger.LogInformation( + "[{FileId}] Repair attempt {Attempt}: Processing {ChunkCount} chunks of max size {BatchSize}", + fileIdentifier, attempt, chunks.Count, batchSize); + + for (int i = 0; i < chunks.Count; i++) { - if (batchResults.TryGetValue(position, out var translated) && - !string.IsNullOrWhiteSpace(translated)) + var chunk = chunks[i]; + + // Note: Instead of direct TranslateBatchAsync, we use TranslateWithFallbackAsync + // to gracefully handle any individual chunk failure (like JSON truncation) + var chunkResults = await fallbackService.TranslateWithFallbackAsync( + chunk, + batchService, + sourceLanguage, + targetLanguage, + 3, // maxSplitAttempts for repairs + fileIdentifier, + i + 1, + chunks.Count, + cancellationToken); + + // Extract translations for failed positions that were in this chunk + foreach (var item in chunk) { - results[position] = translated; + if (repairBatch.FailedPositions.Contains(item.Position) && + chunkResults.TryGetValue(item.Position, out var translated) && + !string.IsNullOrWhiteSpace(translated)) + { + results[item.Position] = translated; + } } } // Check if all failed items were translated - var stillMissing = repairBatch.FailedPositions + var finalMissing = repairBatch.FailedPositions .Where(p => !results.ContainsKey(p) || string.IsNullOrWhiteSpace(results[p])) .ToList(); - if (stillMissing.Count == 0) + if (finalMissing.Count == 0) { _logger.LogInformation( "[{FileId}] Deferred repair succeeded: all {Count} items translated on attempt {Attempt}", @@ -164,24 +196,17 @@ public async Task> ExecuteRepairAsync( { _logger.LogWarning( "[{FileId}] Repair attempt {Attempt} incomplete: {MissingCount} items still missing. Retrying...", - fileIdentifier, attempt, stillMissing.Count); + fileIdentifier, attempt, finalMissing.Count); } else { _logger.LogError( "[{FileId}] Deferred repair exhausted after {Attempts} attempts. {MissingCount} items failed permanently.", - fileIdentifier, attempt, stillMissing.Count); - - // Log sample of failed items - var sampleFailed = string.Join("; ", stillMissing.Take(5) - .Select(p => $"[pos {p}]")); - _logger.LogError( - "[{FileId}] Sample of failed positions: {Positions}", - fileIdentifier, sampleFailed); + fileIdentifier, attempt, finalMissing.Count); throw new TranslationException( $"Deferred repair failed after {attempt} attempts. " + - $"{stillMissing.Count} items could not be translated."); + $"{finalMissing.Count} items could not be translated."); } } catch (OperationCanceledException) @@ -192,21 +217,33 @@ public async Task> ExecuteRepairAsync( { throw; } - catch (TranslationException ex) when (attempt <= maxRetries) - { - _logger.LogWarning(ex, - "[{FileId}] Repair attempt {Attempt} failed with error. Retrying...", - fileIdentifier, attempt); - } - catch (Exception ex) when (attempt <= maxRetries) + catch (Exception ex) { - _logger.LogWarning(ex, - "[{FileId}] Unexpected error during repair attempt {Attempt}. Retrying...", - fileIdentifier, attempt); + if (attempt <= maxRetries) + { + _logger.LogWarning(ex, + "[{FileId}] Error during repair attempt {Attempt}. Retrying...", + fileIdentifier, attempt); + } + else + { + _logger.LogError(ex, "[{FileId}] Permanent error during repair attempt {Attempt}", fileIdentifier, attempt); + throw; + } } } - throw new TranslationException("Deferred repair failed after maximum retry attempts."); + return results; + } + + private static List> SplitIntoChunks(List items, int chunkSize) + { + var chunks = new List>(); + for (int i = 0; i < items.Count; i += chunkSize) + { + chunks.Add(items.Skip(i).Take(chunkSize).ToList()); + } + return chunks; } /// From c4750eeeeb94315952b9d56d857e51ff82ab1d9f Mon Sep 17 00:00:00 2001 From: Yes Date: Fri, 2 Jan 2026 05:58:03 +0100 Subject: [PATCH 04/64] docs: add repair phase resilience to lessons learned --- GEMINI.md | 1 + 1 file changed, 1 insertion(+) diff --git a/GEMINI.md b/GEMINI.md index 11851ec0..50ec8b28 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -15,4 +15,5 @@ Update GEMINI.md "lessons learned" section with what you found out and what you - **Constructor Dependency Inflation**: Adding a new dependency to a core service (like `TranslationRequestService`) will break all unit tests that manually instantiate that service. Always grep for usages of `new [ServiceName]` in the `*.Tests` project after modifying a constructor to catch and fix broken tests early. - **Nullability and XML Warnings**: CI environments often treat warnings as errors or run stricter analysis. Proactively fixing `CS8604` (possible null reference) and XML comment mismatches in the files you modify prevented downstream build failures in this task. - **Vue Type Checking**: Standard `npm run build` behavior can be inconsistent across environments. Use `npx vue-tsc --noEmit` for a reliable, standalone type-check of the frontend during validation. +- **Repair Phase Resilience**: The translation repair phase is naturally prone to handling 'hard-to-translate' lines that may cause LLM output issues like JSON truncation. Repair logic must always implement the same chunking and fallback splitting protection as the main translation loop to avoid cascading job failures when the repair batch itself exceeds provider limits. " From 6751fe032a9cd2a88366a98d602b0e6e57922dd3 Mon Sep 17 00:00:00 2001 From: Yes Date: Fri, 2 Jan 2026 06:18:07 +0100 Subject: [PATCH 05/64] Fix SSA indexing, improve markup cleaning, and add repair batching --- .../Subtitle/SubtitleFormatterServiceTests.cs | 30 ++++++++++--- Lingarr.Server/Services/Subtitle/SsaParser.cs | 2 + .../Subtitle/SubtitleFormatterService.cs | 42 +++++++++++++------ .../Services/SubtitleTranslationService.cs | 29 +++++++++++-- 4 files changed, 81 insertions(+), 22 deletions(-) diff --git a/Lingarr.Server.Tests/Services/Subtitle/SubtitleFormatterServiceTests.cs b/Lingarr.Server.Tests/Services/Subtitle/SubtitleFormatterServiceTests.cs index 8687a557..973a5d7f 100644 --- a/Lingarr.Server.Tests/Services/Subtitle/SubtitleFormatterServiceTests.cs +++ b/Lingarr.Server.Tests/Services/Subtitle/SubtitleFormatterServiceTests.cs @@ -118,13 +118,33 @@ public void IsAssDrawingCommand_ShouldReturnFalse_ForDialogueResemblingCommands( } [Theory] - [InlineData("{\\p1}m 0 0 l 100 100{\\p0}", "")] // Pure drawing block → empty - [InlineData("Hello {\\p1}m 0 0{\\p0} World", "Hello World")] // Mixed → dialogue preserved - [InlineData("{\\p2}m 50 50 b 100 100{\\p0}", "")] // Scale 2 drawing → empty - [InlineData("{\\p1}drawing{\\p0}{\\p1}another{\\p0}", "")] // Multiple blocks → empty - public void RemoveMarkup_ShouldStripDrawingBlocks(string input, string expected) + [InlineData("{\\an7}", "")] + [InlineData("{\\pos(100,100)}Hello {\\c&H00FFFF&}World", "Hello World")] + [InlineData("Text {with valid comment} inside", "Text inside")] + [InlineData("Mixed {\\b1}Bold{\\b0} and {\\i1}Italic{\\i0}", "Mixed Bold and Italic")] + [InlineData("Line1\\NLine2", "Line1 Line2")] + [InlineData("Space\\hBetween", "Space Between")] + [InlineData("Visible { literal brace", "Visible { literal brace")] // Unclosed brace should be preserved + [InlineData("Brace } shown", "Brace } shown")] // } without { is preserved as literal + public void RemoveMarkup_ShouldHandleMixedContent(string input, string expected) { var result = SubtitleFormatterService.RemoveMarkup(input); Assert.Equal(expected, result); } + + [Theory] + [InlineData("Hello", false)] + [InlineData("A", false)] + [InlineData("I", false)] + [InlineData("z", true)] + [InlineData("Z", true)] + [InlineData("!", true)] + [InlineData(" ", true)] + [InlineData("", true)] + [InlineData("1", false)] + public void IsMeaningless_ShouldCorrectIdentifyFlares(string input, bool expected) + { + var result = SubtitleFormatterService.IsMeaningless(input); + Assert.Equal(expected, result); + } } diff --git a/Lingarr.Server/Services/Subtitle/SsaParser.cs b/Lingarr.Server/Services/Subtitle/SsaParser.cs index a604adb8..8beeab02 100644 --- a/Lingarr.Server/Services/Subtitle/SsaParser.cs +++ b/Lingarr.Server/Services/Subtitle/SsaParser.cs @@ -30,6 +30,7 @@ public List ParseStream(Stream ssaStream, Encoding encoding) var ssaFormat = new SsaFormat(); Dictionary? columnIndexes = null; + var positionCounter = 1; string? line; while ((line = reader.ReadLine()) != null) { @@ -95,6 +96,7 @@ public List ParseStream(Stream ssaStream, Encoding encoding) var dialogue = ParseDialogueLine(line, columnIndexes, ssaFormat); if (dialogue != null) { + dialogue.Position = positionCounter++; dialogue.SsaFormat = ssaFormat; items.Add(dialogue); } diff --git a/Lingarr.Server/Services/Subtitle/SubtitleFormatterService.cs b/Lingarr.Server/Services/Subtitle/SubtitleFormatterService.cs index 285acc50..7a08b15a 100644 --- a/Lingarr.Server/Services/Subtitle/SubtitleFormatterService.cs +++ b/Lingarr.Server/Services/Subtitle/SubtitleFormatterService.cs @@ -13,33 +13,49 @@ public static string RemoveMarkup(string input) } // First: Strip entire ASS drawing blocks: {\p1}...{\p0} - // Drawing mode is enabled by {\p1} (or {\p2}, etc.) and disabled by {\p0} - // Everything between these tags is vector drawing data that should not be translated - string cleaned = Regex.Replace(input, @"\{\\p[1-9]\d*\}.*?\{\\p0\}", string.Empty, RegexOptions.Singleline); + string cleaned = Regex.Replace(input, @"\{\\p[1-9]\d*\}.*?\{\\p0\}", string.Empty, RegexOptions.Singleline | RegexOptions.IgnoreCase); - // Remove remaining SSA/ASS style tags: {\...} - // Use Singleline mode to handle tags that span multiple lines - cleaned = Regex.Replace(cleaned, @"\{.*?\}", string.Empty, RegexOptions.Singleline); + // Remove SSA/ASS style tags: {\...} + // Note: Using a more robust pattern to ensure we catch escaped braces if they ever appear + cleaned = Regex.Replace(cleaned, @"\{[^}]*\}", string.Empty, RegexOptions.Singleline); // Remove HTML-style tags: <...> - // Use Singleline mode to handle tags that span multiple lines - cleaned = Regex.Replace(cleaned, @"<.*?>", string.Empty, RegexOptions.Singleline); + cleaned = Regex.Replace(cleaned, @"<[^>]*>", string.Empty, RegexOptions.Singleline); - // Replace SSA line breaks with spaces - cleaned = cleaned.Replace("\\N", " ").Replace("\\n", " "); + // Replace SSA line breaks and non-breaking spaces with regular spaces + cleaned = cleaned + .Replace("\\N", " ") + .Replace("\\n", " ") + .Replace("\\h", " "); - // Replace tab characters (escaped or literal) + // Replace tab characters cleaned = cleaned.Replace("\\t", " ").Replace("\t", " "); - // Collapse multiple whitespace into a single space + // Collapse multiple whitespace cleaned = Regex.Replace(cleaned, @"\s{2,}", " "); - // Strip poison content (Music, Credits, Sound Effects) + // Strip poison content cleaned = StripPoisonContent(cleaned); return cleaned.Trim(); } + /// + /// Determines if a line is "meaningless" for translation (too short or purely graphical). + /// + public static bool IsMeaningless(string plaintext) + { + if (string.IsNullOrWhiteSpace(plaintext)) return true; + + // If it's just a single character that isn't a word (like "z" or "x" in a positioning flare) + if (plaintext.Length == 1 && !char.IsLetterOrDigit(plaintext[0])) return true; + + // Single letters like 'z' often used for graphical placeholders in fansubs + if (plaintext.Length == 1 && (plaintext == "z" || plaintext == "Z")) return true; + + return false; + } + /// /// Strips specific "poison" content that often causes translation failures or is undesirable. /// Includes musical symbols, sound effects in brackets, and credit lines. diff --git a/Lingarr.Server/Services/SubtitleTranslationService.cs b/Lingarr.Server/Services/SubtitleTranslationService.cs index f1b65692..1611c996 100644 --- a/Lingarr.Server/Services/SubtitleTranslationService.cs +++ b/Lingarr.Server/Services/SubtitleTranslationService.cs @@ -360,11 +360,32 @@ public async Task> ProcessSubtitleBatch( int totalBatches = 1, CancellationToken cancellationToken = default) { - var batchItems = currentBatch.Select(subtitle => new BatchSubtitleItem + var batchItems = currentBatch + .Select(subtitle => + { + var line = string.Join(" ", stripSubtitleFormatting ? subtitle.PlaintextLines : subtitle.Lines); + var plaintextLine = string.Join(" ", subtitle.PlaintextLines); + + return new + { + Original = subtitle, + Line = line, + Plaintext = plaintextLine + }; + }) + // Skip items that have no meaningful plaintext content even if we are preserving formatting + // This prevents graphical flares (like 'z' with 50 tags) from being sent to the AI + .Where(x => !SubtitleFormatterService.IsMeaningless(x.Plaintext)) + .Select(x => new BatchSubtitleItem + { + Position = x.Original.Position, + Line = x.Line + }).ToList(); + + if (batchItems.Count == 0) { - Position = subtitle.Position, - Line = string.Join(" ", stripSubtitleFormatting ? subtitle.PlaintextLines : subtitle.Lines) - }).ToList(); + return new List(); + } Dictionary batchResults; From f1cfeb7b4dc53ea1daf606dcfa10eaa84e8ce517 Mon Sep 17 00:00:00 2001 From: Yes <107748212+T9es@users.noreply.github.com> Date: Fri, 9 Jan 2026 10:21:51 +0100 Subject: [PATCH 06/64] feat: Add subtitle discovery for externally-added subtitles - Add AwaitingSource state to distinguish 'waiting for subtitle' from 'excluded' - Add LastSubtitleCheckAt property for mtime-based change detection - Update MediaStateService to return AwaitingSource when no source exists - Add mtime optimization to MovieSync/EpisodeSync to reduce I/O - Add frontend support (TypeScript constant, Vue component, translations) - Create EF migrations for SQLite and PostgreSQL --- .../common/TranslationStateBadge.vue | 8 + Lingarr.Client/src/ts/media/index.ts | 3 +- Lingarr.Core/Entities/Episode.cs | 6 + Lingarr.Core/Entities/Movie.cs | 6 + Lingarr.Core/Enum/TranslationState.cs | 9 +- ...109090150_AddSubtitleDiscovery.Designer.cs | 816 ++++++++++++++++++ .../20260109090150_AddSubtitleDiscovery.cs | 39 + .../LingarrDbContextModelSnapshot.cs | 8 + ...109090139_AddSubtitleDiscovery.Designer.cs | 787 +++++++++++++++++ .../20260109090139_AddSubtitleDiscovery.cs | 39 + .../LingarrDbContextModelSnapshot.cs | 8 + Lingarr.Server/Services/MediaStateService.cs | 2 +- Lingarr.Server/Services/Sync/EpisodeSync.cs | 26 +- Lingarr.Server/Services/Sync/MovieSync.cs | 26 +- Lingarr.Server/Statics/Translations/en.json | 4 +- Lingarr.Server/Statics/Translations/nl.json | 6 +- 16 files changed, 1785 insertions(+), 8 deletions(-) create mode 100644 Lingarr.Migrations.PostgreSQL/Migrations/20260109090150_AddSubtitleDiscovery.Designer.cs create mode 100644 Lingarr.Migrations.PostgreSQL/Migrations/20260109090150_AddSubtitleDiscovery.cs create mode 100644 Lingarr.Migrations.SQLite/Migrations/20260109090139_AddSubtitleDiscovery.Designer.cs create mode 100644 Lingarr.Migrations.SQLite/Migrations/20260109090139_AddSubtitleDiscovery.cs diff --git a/Lingarr.Client/src/components/common/TranslationStateBadge.vue b/Lingarr.Client/src/components/common/TranslationStateBadge.vue index 6783a04a..3bc17bce 100644 --- a/Lingarr.Client/src/components/common/TranslationStateBadge.vue +++ b/Lingarr.Client/src/components/common/TranslationStateBadge.vue @@ -51,6 +51,8 @@ const badgeClasses = computed(() => { return 'bg-red-900/50 text-red-300 border border-red-500/50' case TRANSLATION_STATE.FAILED: return 'bg-red-900/70 text-red-200 border border-red-400/60' + case TRANSLATION_STATE.AWAITING_SOURCE: + return 'bg-blue-900/50 text-blue-300 border border-blue-500/50' case TRANSLATION_STATE.UNKNOWN: default: return 'bg-gray-800/50 text-gray-400 border border-gray-600/50 opacity-60' @@ -73,6 +75,8 @@ const iconComponent = computed(() => { return ExclamationIcon case TRANSLATION_STATE.FAILED: return ExclamationIcon + case TRANSLATION_STATE.AWAITING_SOURCE: + return ClockIcon case TRANSLATION_STATE.UNKNOWN: default: return QuestionMarkCircleIcon @@ -95,6 +99,8 @@ const label = computed(() => { return translate('translationState.noSuitableSubtitles') case TRANSLATION_STATE.FAILED: return translate('translationState.failed') + case TRANSLATION_STATE.AWAITING_SOURCE: + return translate('translationState.awaitingSource') case TRANSLATION_STATE.UNKNOWN: default: return translate('translationState.unknown') @@ -117,6 +123,8 @@ const tooltip = computed(() => { return translate('translationState.tooltipNoSuitableSubtitles') case TRANSLATION_STATE.FAILED: return translate('translationState.tooltipFailed') + case TRANSLATION_STATE.AWAITING_SOURCE: + return translate('translationState.tooltipAwaitingSource') case TRANSLATION_STATE.UNKNOWN: default: return translate('translationState.tooltipUnknown') diff --git a/Lingarr.Client/src/ts/media/index.ts b/Lingarr.Client/src/ts/media/index.ts index 7f579fb2..b9550973 100644 --- a/Lingarr.Client/src/ts/media/index.ts +++ b/Lingarr.Client/src/ts/media/index.ts @@ -155,7 +155,8 @@ export const TRANSLATION_STATE = { COMPLETE: 4, STALE: 5, NO_SUITABLE_SUBTITLES: 6, - FAILED: 7 + FAILED: 7, + AWAITING_SOURCE: 8 } as const export type TranslationStateType = (typeof TRANSLATION_STATE)[keyof typeof TRANSLATION_STATE] diff --git a/Lingarr.Core/Entities/Episode.cs b/Lingarr.Core/Entities/Episode.cs index 34ba7834..3664d151 100644 --- a/Lingarr.Core/Entities/Episode.cs +++ b/Lingarr.Core/Entities/Episode.cs @@ -34,4 +34,10 @@ public class Episode : BaseEntity, IMedia /// The language settings version when TranslationState was computed. /// public int StateSettingsVersion { get; set; } + + /// + /// When the media directory was last checked for new subtitle files. + /// Used for mtime-based change detection during sync. + /// + public DateTime? LastSubtitleCheckAt { get; set; } } \ No newline at end of file diff --git a/Lingarr.Core/Entities/Movie.cs b/Lingarr.Core/Entities/Movie.cs index 2cbc3380..6d01f53c 100644 --- a/Lingarr.Core/Entities/Movie.cs +++ b/Lingarr.Core/Entities/Movie.cs @@ -35,4 +35,10 @@ public class Movie : BaseEntity, IMedia /// If this doesn't match current version, state is stale. /// public int StateSettingsVersion { get; set; } + + /// + /// When the media directory was last checked for new subtitle files. + /// Used for mtime-based change detection during sync. + /// + public DateTime? LastSubtitleCheckAt { get; set; } } \ No newline at end of file diff --git a/Lingarr.Core/Enum/TranslationState.cs b/Lingarr.Core/Enum/TranslationState.cs index fbb9d8f2..0dadf709 100644 --- a/Lingarr.Core/Enum/TranslationState.cs +++ b/Lingarr.Core/Enum/TranslationState.cs @@ -52,5 +52,12 @@ public enum TranslationState /// Translation failed. /// A previous translation request for this media failed and needs manual intervention. /// - Failed = 7 + Failed = 7, + + /// + /// Waiting for source subtitle to become available. + /// Configured for translation but no source subtitle exists yet. + /// Will be re-checked during sync when directory mtime changes. + /// + AwaitingSource = 8 } diff --git a/Lingarr.Migrations.PostgreSQL/Migrations/20260109090150_AddSubtitleDiscovery.Designer.cs b/Lingarr.Migrations.PostgreSQL/Migrations/20260109090150_AddSubtitleDiscovery.Designer.cs new file mode 100644 index 00000000..af02060d --- /dev/null +++ b/Lingarr.Migrations.PostgreSQL/Migrations/20260109090150_AddSubtitleDiscovery.Designer.cs @@ -0,0 +1,816 @@ +// +using System; +using Lingarr.Core.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Lingarr.Migrations.PostgreSQL.Migrations +{ + [DbContext(typeof(LingarrDbContext))] + [Migration("20260109090150_AddSubtitleDiscovery")] + partial class AddSubtitleDiscovery + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.11") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Lingarr.Core.Entities.DailyStatistics", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Date") + .HasColumnType("timestamp with time zone") + .HasColumnName("date"); + + b.Property("TranslationCount") + .HasColumnType("integer") + .HasColumnName("translation_count"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_daily_statistics"); + + b.ToTable("daily_statistics", (string)null); + }); + + modelBuilder.Entity("Lingarr.Core.Entities.EmbeddedSubtitle", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CodecName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("codec_name"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("EpisodeId") + .HasColumnType("integer") + .HasColumnName("episode_id"); + + b.Property("ExtractedPath") + .HasColumnType("text") + .HasColumnName("extracted_path"); + + b.Property("IsDefault") + .HasColumnType("boolean") + .HasColumnName("is_default"); + + b.Property("IsExtracted") + .HasColumnType("boolean") + .HasColumnName("is_extracted"); + + b.Property("IsForced") + .HasColumnType("boolean") + .HasColumnName("is_forced"); + + b.Property("IsTextBased") + .HasColumnType("boolean") + .HasColumnName("is_text_based"); + + b.Property("Language") + .HasColumnType("text") + .HasColumnName("language"); + + b.Property("MovieId") + .HasColumnType("integer") + .HasColumnName("movie_id"); + + b.Property("StreamIndex") + .HasColumnType("integer") + .HasColumnName("stream_index"); + + b.Property("Title") + .HasColumnType("text") + .HasColumnName("title"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_embedded_subtitles"); + + b.HasIndex("EpisodeId") + .HasDatabaseName("ix_embedded_subtitles_episode_id"); + + b.HasIndex("MovieId") + .HasDatabaseName("ix_embedded_subtitles_movie_id"); + + b.ToTable("embedded_subtitles", (string)null); + }); + + modelBuilder.Entity("Lingarr.Core.Entities.Episode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DateAdded") + .HasColumnType("timestamp with time zone") + .HasColumnName("date_added"); + + b.Property("EpisodeNumber") + .HasColumnType("integer") + .HasColumnName("episode_number"); + + b.Property("ExcludeFromTranslation") + .HasColumnType("boolean") + .HasColumnName("exclude_from_translation"); + + b.Property("FileName") + .HasColumnType("text") + .HasColumnName("file_name"); + + b.Property("IndexedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("indexed_at"); + + b.Property("LastSubtitleCheckAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_subtitle_check_at"); + + b.Property("MediaHash") + .HasColumnType("text") + .HasColumnName("media_hash"); + + b.Property("Path") + .HasColumnType("text") + .HasColumnName("path"); + + b.Property("SeasonId") + .HasColumnType("integer") + .HasColumnName("season_id"); + + b.Property("SonarrId") + .HasColumnType("integer") + .HasColumnName("sonarr_id"); + + b.Property("StateSettingsVersion") + .HasColumnType("integer") + .HasColumnName("state_settings_version"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text") + .HasColumnName("title"); + + b.Property("TranslationState") + .HasColumnType("integer") + .HasColumnName("translation_state"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_episodes"); + + b.HasIndex("SeasonId") + .HasDatabaseName("ix_episodes_season_id"); + + b.HasIndex("TranslationState") + .HasDatabaseName("IX_Episodes_TranslationState"); + + b.ToTable("episodes", (string)null); + }); + + modelBuilder.Entity("Lingarr.Core.Entities.Image", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("MovieId") + .HasColumnType("integer") + .HasColumnName("movie_id"); + + b.Property("Path") + .IsRequired() + .HasColumnType("text") + .HasColumnName("path"); + + b.Property("ShowId") + .HasColumnType("integer") + .HasColumnName("show_id"); + + b.Property("Type") + .IsRequired() + .HasColumnType("text") + .HasColumnName("type"); + + b.HasKey("Id") + .HasName("pk_images"); + + b.HasIndex("MovieId") + .HasDatabaseName("ix_images_movie_id"); + + b.HasIndex("ShowId") + .HasDatabaseName("ix_images_show_id"); + + b.ToTable("images", (string)null); + }); + + modelBuilder.Entity("Lingarr.Core.Entities.Movie", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DateAdded") + .HasColumnType("timestamp with time zone") + .HasColumnName("date_added"); + + b.Property("ExcludeFromTranslation") + .HasColumnType("boolean") + .HasColumnName("exclude_from_translation"); + + b.Property("FileName") + .HasColumnType("text") + .HasColumnName("file_name"); + + b.Property("IndexedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("indexed_at"); + + b.Property("IsPriority") + .HasColumnType("boolean") + .HasColumnName("is_priority"); + + b.Property("LastSubtitleCheckAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_subtitle_check_at"); + + b.Property("MediaHash") + .HasColumnType("text") + .HasColumnName("media_hash"); + + b.Property("Path") + .HasColumnType("text") + .HasColumnName("path"); + + b.Property("PriorityDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("priority_date"); + + b.Property("RadarrId") + .HasColumnType("integer") + .HasColumnName("radarr_id"); + + b.Property("StateSettingsVersion") + .HasColumnType("integer") + .HasColumnName("state_settings_version"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text") + .HasColumnName("title"); + + b.Property("TranslationAgeThreshold") + .HasColumnType("integer") + .HasColumnName("translation_age_threshold"); + + b.Property("TranslationState") + .HasColumnType("integer") + .HasColumnName("translation_state"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_movies"); + + b.HasIndex("TranslationState") + .HasDatabaseName("IX_Movies_TranslationState"); + + b.ToTable("movies", (string)null); + }); + + modelBuilder.Entity("Lingarr.Core.Entities.PathMapping", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DestinationPath") + .IsRequired() + .HasColumnType("text") + .HasColumnName("destination_path"); + + b.Property("MediaType") + .HasColumnType("integer") + .HasColumnName("media_type"); + + b.Property("SourcePath") + .IsRequired() + .HasColumnType("text") + .HasColumnName("source_path"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_path_mappings"); + + b.ToTable("path_mappings", (string)null); + }); + + modelBuilder.Entity("Lingarr.Core.Entities.Season", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("ExcludeFromTranslation") + .HasColumnType("boolean") + .HasColumnName("exclude_from_translation"); + + b.Property("Path") + .HasColumnType("text") + .HasColumnName("path"); + + b.Property("SeasonNumber") + .HasColumnType("integer") + .HasColumnName("season_number"); + + b.Property("ShowId") + .HasColumnType("integer") + .HasColumnName("show_id"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_seasons"); + + b.HasIndex("ShowId") + .HasDatabaseName("ix_seasons_show_id"); + + b.ToTable("seasons", (string)null); + }); + + modelBuilder.Entity("Lingarr.Core.Entities.Setting", b => + { + b.Property("Key") + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("key"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasColumnName("value"); + + b.HasKey("Key") + .HasName("pk_settings"); + + b.ToTable("settings", (string)null); + }); + + modelBuilder.Entity("Lingarr.Core.Entities.Show", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DateAdded") + .HasColumnType("timestamp with time zone") + .HasColumnName("date_added"); + + b.Property("ExcludeFromTranslation") + .HasColumnType("boolean") + .HasColumnName("exclude_from_translation"); + + b.Property("IsPriority") + .HasColumnType("boolean") + .HasColumnName("is_priority"); + + b.Property("Path") + .IsRequired() + .HasColumnType("text") + .HasColumnName("path"); + + b.Property("PriorityDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("priority_date"); + + b.Property("SonarrId") + .HasColumnType("integer") + .HasColumnName("sonarr_id"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text") + .HasColumnName("title"); + + b.Property("TranslationAgeThreshold") + .HasColumnType("integer") + .HasColumnName("translation_age_threshold"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_shows"); + + b.ToTable("shows", (string)null); + }); + + modelBuilder.Entity("Lingarr.Core.Entities.Statistics", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("SubtitlesByLanguageJson") + .IsRequired() + .HasColumnType("text") + .HasColumnName("subtitles_by_language_json"); + + b.Property("TotalCharactersTranslated") + .HasColumnType("bigint") + .HasColumnName("total_characters_translated"); + + b.Property("TotalEpisodes") + .HasColumnType("integer") + .HasColumnName("total_episodes"); + + b.Property("TotalFilesTranslated") + .HasColumnType("bigint") + .HasColumnName("total_files_translated"); + + b.Property("TotalLinesTranslated") + .HasColumnType("bigint") + .HasColumnName("total_lines_translated"); + + b.Property("TotalMovies") + .HasColumnType("integer") + .HasColumnName("total_movies"); + + b.Property("TotalSubtitles") + .HasColumnType("integer") + .HasColumnName("total_subtitles"); + + b.Property("TranslationsByMediaTypeJson") + .IsRequired() + .HasColumnType("text") + .HasColumnName("translations_by_media_type_json"); + + b.Property("TranslationsByServiceJson") + .IsRequired() + .HasColumnType("text") + .HasColumnName("translations_by_service_json"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_statistics"); + + b.ToTable("statistics", (string)null); + }); + + modelBuilder.Entity("Lingarr.Core.Entities.SubtitleCleanupLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("FilePath") + .IsRequired() + .HasColumnType("text") + .HasColumnName("file_path"); + + b.Property("NewMediaFileName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("new_media_file_name"); + + b.Property("OriginalMediaFileName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("original_media_file_name"); + + b.Property("Reason") + .IsRequired() + .HasColumnType("text") + .HasColumnName("reason"); + + b.HasKey("Id") + .HasName("pk_subtitle_cleanup_logs"); + + b.ToTable("subtitle_cleanup_logs", (string)null); + }); + + modelBuilder.Entity("Lingarr.Core.Entities.TranslationRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("IsPriority") + .HasColumnType("boolean") + .HasColumnName("is_priority"); + + b.Property("JobId") + .HasColumnType("text") + .HasColumnName("job_id"); + + b.Property("MediaId") + .HasColumnType("integer") + .HasColumnName("media_id"); + + b.Property("MediaType") + .HasColumnType("integer") + .HasColumnName("media_type"); + + b.Property("Progress") + .HasColumnType("integer") + .HasColumnName("progress"); + + b.Property("SourceLanguage") + .IsRequired() + .HasColumnType("text") + .HasColumnName("source_language"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("SubtitleToTranslate") + .HasColumnType("text") + .HasColumnName("subtitle_to_translate"); + + b.Property("TargetLanguage") + .IsRequired() + .HasColumnType("text") + .HasColumnName("target_language"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text") + .HasColumnName("title"); + + b.Property("TranslatedSubtitle") + .HasColumnType("text") + .HasColumnName("translated_subtitle"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_translation_requests"); + + b.HasIndex("MediaId", "MediaType", "SourceLanguage", "TargetLanguage", "IsActive") + .IsUnique() + .HasDatabaseName("ux_translation_requests_active_dedupe"); + + b.ToTable("translation_requests", (string)null); + }); + + modelBuilder.Entity("Lingarr.Core.Entities.TranslationRequestLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Details") + .HasColumnType("text") + .HasColumnName("details"); + + b.Property("Level") + .IsRequired() + .HasColumnType("text") + .HasColumnName("level"); + + b.Property("Message") + .IsRequired() + .HasColumnType("text") + .HasColumnName("message"); + + b.Property("TranslationRequestId") + .HasColumnType("integer") + .HasColumnName("translation_request_id"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_translation_request_logs"); + + b.HasIndex("TranslationRequestId") + .HasDatabaseName("ix_translation_request_logs_translation_request_id"); + + b.ToTable("translation_request_logs", (string)null); + }); + + modelBuilder.Entity("Lingarr.Core.Entities.EmbeddedSubtitle", b => + { + b.HasOne("Lingarr.Core.Entities.Episode", "Episode") + .WithMany("EmbeddedSubtitles") + .HasForeignKey("EpisodeId") + .HasConstraintName("fk_embedded_subtitles_episodes_episode_id"); + + b.HasOne("Lingarr.Core.Entities.Movie", "Movie") + .WithMany("EmbeddedSubtitles") + .HasForeignKey("MovieId") + .HasConstraintName("fk_embedded_subtitles_movies_movie_id"); + + b.Navigation("Episode"); + + b.Navigation("Movie"); + }); + + modelBuilder.Entity("Lingarr.Core.Entities.Episode", b => + { + b.HasOne("Lingarr.Core.Entities.Season", "Season") + .WithMany("Episodes") + .HasForeignKey("SeasonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_episodes_seasons_season_id"); + + b.Navigation("Season"); + }); + + modelBuilder.Entity("Lingarr.Core.Entities.Image", b => + { + b.HasOne("Lingarr.Core.Entities.Movie", "Movie") + .WithMany("Images") + .HasForeignKey("MovieId") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_images_movies_movie_id"); + + b.HasOne("Lingarr.Core.Entities.Show", "Show") + .WithMany("Images") + .HasForeignKey("ShowId") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_images_shows_show_id"); + + b.Navigation("Movie"); + + b.Navigation("Show"); + }); + + modelBuilder.Entity("Lingarr.Core.Entities.Season", b => + { + b.HasOne("Lingarr.Core.Entities.Show", "Show") + .WithMany("Seasons") + .HasForeignKey("ShowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_seasons_shows_show_id"); + + b.Navigation("Show"); + }); + + modelBuilder.Entity("Lingarr.Core.Entities.TranslationRequestLog", b => + { + b.HasOne("Lingarr.Core.Entities.TranslationRequest", "TranslationRequest") + .WithMany() + .HasForeignKey("TranslationRequestId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_translation_request_logs_translation_requests_translation_r"); + + b.Navigation("TranslationRequest"); + }); + + modelBuilder.Entity("Lingarr.Core.Entities.Episode", b => + { + b.Navigation("EmbeddedSubtitles"); + }); + + modelBuilder.Entity("Lingarr.Core.Entities.Movie", b => + { + b.Navigation("EmbeddedSubtitles"); + + b.Navigation("Images"); + }); + + modelBuilder.Entity("Lingarr.Core.Entities.Season", b => + { + b.Navigation("Episodes"); + }); + + modelBuilder.Entity("Lingarr.Core.Entities.Show", b => + { + b.Navigation("Images"); + + b.Navigation("Seasons"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Lingarr.Migrations.PostgreSQL/Migrations/20260109090150_AddSubtitleDiscovery.cs b/Lingarr.Migrations.PostgreSQL/Migrations/20260109090150_AddSubtitleDiscovery.cs new file mode 100644 index 00000000..f4e449c8 --- /dev/null +++ b/Lingarr.Migrations.PostgreSQL/Migrations/20260109090150_AddSubtitleDiscovery.cs @@ -0,0 +1,39 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Lingarr.Migrations.PostgreSQL.Migrations +{ + /// + public partial class AddSubtitleDiscovery : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "last_subtitle_check_at", + table: "movies", + type: "timestamp with time zone", + nullable: true); + + migrationBuilder.AddColumn( + name: "last_subtitle_check_at", + table: "episodes", + type: "timestamp with time zone", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "last_subtitle_check_at", + table: "movies"); + + migrationBuilder.DropColumn( + name: "last_subtitle_check_at", + table: "episodes"); + } + } +} diff --git a/Lingarr.Migrations.PostgreSQL/Migrations/LingarrDbContextModelSnapshot.cs b/Lingarr.Migrations.PostgreSQL/Migrations/LingarrDbContextModelSnapshot.cs index 52394a17..0bf3437b 100644 --- a/Lingarr.Migrations.PostgreSQL/Migrations/LingarrDbContextModelSnapshot.cs +++ b/Lingarr.Migrations.PostgreSQL/Migrations/LingarrDbContextModelSnapshot.cs @@ -160,6 +160,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("timestamp with time zone") .HasColumnName("indexed_at"); + b.Property("LastSubtitleCheckAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_subtitle_check_at"); + b.Property("MediaHash") .HasColumnType("text") .HasColumnName("media_hash"); @@ -277,6 +281,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("boolean") .HasColumnName("is_priority"); + b.Property("LastSubtitleCheckAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_subtitle_check_at"); + b.Property("MediaHash") .HasColumnType("text") .HasColumnName("media_hash"); diff --git a/Lingarr.Migrations.SQLite/Migrations/20260109090139_AddSubtitleDiscovery.Designer.cs b/Lingarr.Migrations.SQLite/Migrations/20260109090139_AddSubtitleDiscovery.Designer.cs new file mode 100644 index 00000000..7fa5b51a --- /dev/null +++ b/Lingarr.Migrations.SQLite/Migrations/20260109090139_AddSubtitleDiscovery.Designer.cs @@ -0,0 +1,787 @@ +// +using System; +using Lingarr.Core.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Lingarr.Migrations.SQLite.Migrations +{ + [DbContext(typeof(LingarrDbContext))] + [Migration("20260109090139_AddSubtitleDiscovery")] + partial class AddSubtitleDiscovery + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.11"); + + modelBuilder.Entity("Lingarr.Core.Entities.DailyStatistics", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("Date") + .HasColumnType("TEXT") + .HasColumnName("date"); + + b.Property("TranslationCount") + .HasColumnType("INTEGER") + .HasColumnName("translation_count"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_daily_statistics"); + + b.ToTable("daily_statistics", (string)null); + }); + + modelBuilder.Entity("Lingarr.Core.Entities.EmbeddedSubtitle", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasColumnName("id"); + + b.Property("CodecName") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("codec_name"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("EpisodeId") + .HasColumnType("INTEGER") + .HasColumnName("episode_id"); + + b.Property("ExtractedPath") + .HasColumnType("TEXT") + .HasColumnName("extracted_path"); + + b.Property("IsDefault") + .HasColumnType("INTEGER") + .HasColumnName("is_default"); + + b.Property("IsExtracted") + .HasColumnType("INTEGER") + .HasColumnName("is_extracted"); + + b.Property("IsForced") + .HasColumnType("INTEGER") + .HasColumnName("is_forced"); + + b.Property("IsTextBased") + .HasColumnType("INTEGER") + .HasColumnName("is_text_based"); + + b.Property("Language") + .HasColumnType("TEXT") + .HasColumnName("language"); + + b.Property("MovieId") + .HasColumnType("INTEGER") + .HasColumnName("movie_id"); + + b.Property("StreamIndex") + .HasColumnType("INTEGER") + .HasColumnName("stream_index"); + + b.Property("Title") + .HasColumnType("TEXT") + .HasColumnName("title"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_embedded_subtitles"); + + b.HasIndex("EpisodeId") + .HasDatabaseName("ix_embedded_subtitles_episode_id"); + + b.HasIndex("MovieId") + .HasDatabaseName("ix_embedded_subtitles_movie_id"); + + b.ToTable("embedded_subtitles", (string)null); + }); + + modelBuilder.Entity("Lingarr.Core.Entities.Episode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("DateAdded") + .HasColumnType("TEXT") + .HasColumnName("date_added"); + + b.Property("EpisodeNumber") + .HasColumnType("INTEGER") + .HasColumnName("episode_number"); + + b.Property("ExcludeFromTranslation") + .HasColumnType("INTEGER") + .HasColumnName("exclude_from_translation"); + + b.Property("FileName") + .HasColumnType("TEXT") + .HasColumnName("file_name"); + + b.Property("IndexedAt") + .HasColumnType("TEXT") + .HasColumnName("indexed_at"); + + b.Property("LastSubtitleCheckAt") + .HasColumnType("TEXT") + .HasColumnName("last_subtitle_check_at"); + + b.Property("MediaHash") + .HasColumnType("TEXT") + .HasColumnName("media_hash"); + + b.Property("Path") + .HasColumnType("TEXT") + .HasColumnName("path"); + + b.Property("SeasonId") + .HasColumnType("INTEGER") + .HasColumnName("season_id"); + + b.Property("SonarrId") + .HasColumnType("INTEGER") + .HasColumnName("sonarr_id"); + + b.Property("StateSettingsVersion") + .HasColumnType("INTEGER") + .HasColumnName("state_settings_version"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("title"); + + b.Property("TranslationState") + .HasColumnType("INTEGER") + .HasColumnName("translation_state"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_episodes"); + + b.HasIndex("SeasonId") + .HasDatabaseName("ix_episodes_season_id"); + + b.HasIndex("TranslationState") + .HasDatabaseName("IX_Episodes_TranslationState"); + + b.ToTable("episodes", (string)null); + }); + + modelBuilder.Entity("Lingarr.Core.Entities.Image", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasColumnName("id"); + + b.Property("MovieId") + .HasColumnType("INTEGER") + .HasColumnName("movie_id"); + + b.Property("Path") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("path"); + + b.Property("ShowId") + .HasColumnType("INTEGER") + .HasColumnName("show_id"); + + b.Property("Type") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("type"); + + b.HasKey("Id") + .HasName("pk_images"); + + b.HasIndex("MovieId") + .HasDatabaseName("ix_images_movie_id"); + + b.HasIndex("ShowId") + .HasDatabaseName("ix_images_show_id"); + + b.ToTable("images", (string)null); + }); + + modelBuilder.Entity("Lingarr.Core.Entities.Movie", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("DateAdded") + .HasColumnType("TEXT") + .HasColumnName("date_added"); + + b.Property("ExcludeFromTranslation") + .HasColumnType("INTEGER") + .HasColumnName("exclude_from_translation"); + + b.Property("FileName") + .HasColumnType("TEXT") + .HasColumnName("file_name"); + + b.Property("IndexedAt") + .HasColumnType("TEXT") + .HasColumnName("indexed_at"); + + b.Property("IsPriority") + .HasColumnType("INTEGER") + .HasColumnName("is_priority"); + + b.Property("LastSubtitleCheckAt") + .HasColumnType("TEXT") + .HasColumnName("last_subtitle_check_at"); + + b.Property("MediaHash") + .HasColumnType("TEXT") + .HasColumnName("media_hash"); + + b.Property("Path") + .HasColumnType("TEXT") + .HasColumnName("path"); + + b.Property("PriorityDate") + .HasColumnType("TEXT") + .HasColumnName("priority_date"); + + b.Property("RadarrId") + .HasColumnType("INTEGER") + .HasColumnName("radarr_id"); + + b.Property("StateSettingsVersion") + .HasColumnType("INTEGER") + .HasColumnName("state_settings_version"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("title"); + + b.Property("TranslationAgeThreshold") + .HasColumnType("INTEGER") + .HasColumnName("translation_age_threshold"); + + b.Property("TranslationState") + .HasColumnType("INTEGER") + .HasColumnName("translation_state"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_movies"); + + b.HasIndex("TranslationState") + .HasDatabaseName("IX_Movies_TranslationState"); + + b.ToTable("movies", (string)null); + }); + + modelBuilder.Entity("Lingarr.Core.Entities.PathMapping", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("DestinationPath") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("destination_path"); + + b.Property("MediaType") + .HasColumnType("INTEGER") + .HasColumnName("media_type"); + + b.Property("SourcePath") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("source_path"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_path_mappings"); + + b.ToTable("path_mappings", (string)null); + }); + + modelBuilder.Entity("Lingarr.Core.Entities.Season", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("ExcludeFromTranslation") + .HasColumnType("INTEGER") + .HasColumnName("exclude_from_translation"); + + b.Property("Path") + .HasColumnType("TEXT") + .HasColumnName("path"); + + b.Property("SeasonNumber") + .HasColumnType("INTEGER") + .HasColumnName("season_number"); + + b.Property("ShowId") + .HasColumnType("INTEGER") + .HasColumnName("show_id"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_seasons"); + + b.HasIndex("ShowId") + .HasDatabaseName("ix_seasons_show_id"); + + b.ToTable("seasons", (string)null); + }); + + modelBuilder.Entity("Lingarr.Core.Entities.Setting", b => + { + b.Property("Key") + .HasMaxLength(255) + .HasColumnType("TEXT") + .HasColumnName("key"); + + b.Property("Value") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("value"); + + b.HasKey("Key") + .HasName("pk_settings"); + + b.ToTable("settings", (string)null); + }); + + modelBuilder.Entity("Lingarr.Core.Entities.Show", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("DateAdded") + .HasColumnType("TEXT") + .HasColumnName("date_added"); + + b.Property("ExcludeFromTranslation") + .HasColumnType("INTEGER") + .HasColumnName("exclude_from_translation"); + + b.Property("IsPriority") + .HasColumnType("INTEGER") + .HasColumnName("is_priority"); + + b.Property("Path") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("path"); + + b.Property("PriorityDate") + .HasColumnType("TEXT") + .HasColumnName("priority_date"); + + b.Property("SonarrId") + .HasColumnType("INTEGER") + .HasColumnName("sonarr_id"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("title"); + + b.Property("TranslationAgeThreshold") + .HasColumnType("INTEGER") + .HasColumnName("translation_age_threshold"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_shows"); + + b.ToTable("shows", (string)null); + }); + + modelBuilder.Entity("Lingarr.Core.Entities.Statistics", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("SubtitlesByLanguageJson") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("subtitles_by_language_json"); + + b.Property("TotalCharactersTranslated") + .HasColumnType("INTEGER") + .HasColumnName("total_characters_translated"); + + b.Property("TotalEpisodes") + .HasColumnType("INTEGER") + .HasColumnName("total_episodes"); + + b.Property("TotalFilesTranslated") + .HasColumnType("INTEGER") + .HasColumnName("total_files_translated"); + + b.Property("TotalLinesTranslated") + .HasColumnType("INTEGER") + .HasColumnName("total_lines_translated"); + + b.Property("TotalMovies") + .HasColumnType("INTEGER") + .HasColumnName("total_movies"); + + b.Property("TotalSubtitles") + .HasColumnType("INTEGER") + .HasColumnName("total_subtitles"); + + b.Property("TranslationsByMediaTypeJson") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("translations_by_media_type_json"); + + b.Property("TranslationsByServiceJson") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("translations_by_service_json"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_statistics"); + + b.ToTable("statistics", (string)null); + }); + + modelBuilder.Entity("Lingarr.Core.Entities.SubtitleCleanupLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasColumnName("id"); + + b.Property("DeletedAt") + .HasColumnType("TEXT") + .HasColumnName("deleted_at"); + + b.Property("FilePath") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("file_path"); + + b.Property("NewMediaFileName") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("new_media_file_name"); + + b.Property("OriginalMediaFileName") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("original_media_file_name"); + + b.Property("Reason") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("reason"); + + b.HasKey("Id") + .HasName("pk_subtitle_cleanup_logs"); + + b.ToTable("subtitle_cleanup_logs", (string)null); + }); + + modelBuilder.Entity("Lingarr.Core.Entities.TranslationRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasColumnName("id"); + + b.Property("CompletedAt") + .HasColumnType("TEXT") + .HasColumnName("completed_at"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("IsActive") + .HasColumnType("INTEGER") + .HasColumnName("is_active"); + + b.Property("IsPriority") + .HasColumnType("INTEGER") + .HasColumnName("is_priority"); + + b.Property("JobId") + .HasColumnType("TEXT") + .HasColumnName("job_id"); + + b.Property("MediaId") + .HasColumnType("INTEGER") + .HasColumnName("media_id"); + + b.Property("MediaType") + .HasColumnType("INTEGER") + .HasColumnName("media_type"); + + b.Property("Progress") + .HasColumnType("INTEGER") + .HasColumnName("progress"); + + b.Property("SourceLanguage") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("source_language"); + + b.Property("Status") + .HasColumnType("INTEGER") + .HasColumnName("status"); + + b.Property("SubtitleToTranslate") + .HasColumnType("TEXT") + .HasColumnName("subtitle_to_translate"); + + b.Property("TargetLanguage") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("target_language"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("title"); + + b.Property("TranslatedSubtitle") + .HasColumnType("TEXT") + .HasColumnName("translated_subtitle"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_translation_requests"); + + b.HasIndex("MediaId", "MediaType", "SourceLanguage", "TargetLanguage", "IsActive") + .IsUnique() + .HasDatabaseName("ux_translation_requests_active_dedupe"); + + b.ToTable("translation_requests", (string)null); + }); + + modelBuilder.Entity("Lingarr.Core.Entities.TranslationRequestLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("Details") + .HasColumnType("TEXT") + .HasColumnName("details"); + + b.Property("Level") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("level"); + + b.Property("Message") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("message"); + + b.Property("TranslationRequestId") + .HasColumnType("INTEGER") + .HasColumnName("translation_request_id"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_translation_request_logs"); + + b.HasIndex("TranslationRequestId") + .HasDatabaseName("ix_translation_request_logs_translation_request_id"); + + b.ToTable("translation_request_logs", (string)null); + }); + + modelBuilder.Entity("Lingarr.Core.Entities.EmbeddedSubtitle", b => + { + b.HasOne("Lingarr.Core.Entities.Episode", "Episode") + .WithMany("EmbeddedSubtitles") + .HasForeignKey("EpisodeId") + .HasConstraintName("fk_embedded_subtitles_episodes_episode_id"); + + b.HasOne("Lingarr.Core.Entities.Movie", "Movie") + .WithMany("EmbeddedSubtitles") + .HasForeignKey("MovieId") + .HasConstraintName("fk_embedded_subtitles_movies_movie_id"); + + b.Navigation("Episode"); + + b.Navigation("Movie"); + }); + + modelBuilder.Entity("Lingarr.Core.Entities.Episode", b => + { + b.HasOne("Lingarr.Core.Entities.Season", "Season") + .WithMany("Episodes") + .HasForeignKey("SeasonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_episodes_seasons_season_id"); + + b.Navigation("Season"); + }); + + modelBuilder.Entity("Lingarr.Core.Entities.Image", b => + { + b.HasOne("Lingarr.Core.Entities.Movie", "Movie") + .WithMany("Images") + .HasForeignKey("MovieId") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_images_movies_movie_id"); + + b.HasOne("Lingarr.Core.Entities.Show", "Show") + .WithMany("Images") + .HasForeignKey("ShowId") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_images_shows_show_id"); + + b.Navigation("Movie"); + + b.Navigation("Show"); + }); + + modelBuilder.Entity("Lingarr.Core.Entities.Season", b => + { + b.HasOne("Lingarr.Core.Entities.Show", "Show") + .WithMany("Seasons") + .HasForeignKey("ShowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_seasons_shows_show_id"); + + b.Navigation("Show"); + }); + + modelBuilder.Entity("Lingarr.Core.Entities.TranslationRequestLog", b => + { + b.HasOne("Lingarr.Core.Entities.TranslationRequest", "TranslationRequest") + .WithMany() + .HasForeignKey("TranslationRequestId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_translation_request_logs_translation_requests_translation_request_id"); + + b.Navigation("TranslationRequest"); + }); + + modelBuilder.Entity("Lingarr.Core.Entities.Episode", b => + { + b.Navigation("EmbeddedSubtitles"); + }); + + modelBuilder.Entity("Lingarr.Core.Entities.Movie", b => + { + b.Navigation("EmbeddedSubtitles"); + + b.Navigation("Images"); + }); + + modelBuilder.Entity("Lingarr.Core.Entities.Season", b => + { + b.Navigation("Episodes"); + }); + + modelBuilder.Entity("Lingarr.Core.Entities.Show", b => + { + b.Navigation("Images"); + + b.Navigation("Seasons"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Lingarr.Migrations.SQLite/Migrations/20260109090139_AddSubtitleDiscovery.cs b/Lingarr.Migrations.SQLite/Migrations/20260109090139_AddSubtitleDiscovery.cs new file mode 100644 index 00000000..41bb672f --- /dev/null +++ b/Lingarr.Migrations.SQLite/Migrations/20260109090139_AddSubtitleDiscovery.cs @@ -0,0 +1,39 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Lingarr.Migrations.SQLite.Migrations +{ + /// + public partial class AddSubtitleDiscovery : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "last_subtitle_check_at", + table: "movies", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "last_subtitle_check_at", + table: "episodes", + type: "TEXT", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "last_subtitle_check_at", + table: "movies"); + + migrationBuilder.DropColumn( + name: "last_subtitle_check_at", + table: "episodes"); + } + } +} diff --git a/Lingarr.Migrations.SQLite/Migrations/LingarrDbContextModelSnapshot.cs b/Lingarr.Migrations.SQLite/Migrations/LingarrDbContextModelSnapshot.cs index 72fbce6e..6771201c 100644 --- a/Lingarr.Migrations.SQLite/Migrations/LingarrDbContextModelSnapshot.cs +++ b/Lingarr.Migrations.SQLite/Migrations/LingarrDbContextModelSnapshot.cs @@ -149,6 +149,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("TEXT") .HasColumnName("indexed_at"); + b.Property("LastSubtitleCheckAt") + .HasColumnType("TEXT") + .HasColumnName("last_subtitle_check_at"); + b.Property("MediaHash") .HasColumnType("TEXT") .HasColumnName("media_hash"); @@ -262,6 +266,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("INTEGER") .HasColumnName("is_priority"); + b.Property("LastSubtitleCheckAt") + .HasColumnType("TEXT") + .HasColumnName("last_subtitle_check_at"); + b.Property("MediaHash") .HasColumnType("TEXT") .HasColumnName("media_hash"); diff --git a/Lingarr.Server/Services/MediaStateService.cs b/Lingarr.Server/Services/MediaStateService.cs index cf114912..4802b406 100644 --- a/Lingarr.Server/Services/MediaStateService.cs +++ b/Lingarr.Server/Services/MediaStateService.cs @@ -159,7 +159,7 @@ private async Task ComputeStateAsync( if (!hasExternalSource && !hasEmbeddedSource) { - return TranslationState.NotApplicable; + return TranslationState.AwaitingSource; } // 6. Check which targets are satisfied diff --git a/Lingarr.Server/Services/Sync/EpisodeSync.cs b/Lingarr.Server/Services/Sync/EpisodeSync.cs index ccd51089..33755ace 100644 --- a/Lingarr.Server/Services/Sync/EpisodeSync.cs +++ b/Lingarr.Server/Services/Sync/EpisodeSync.cs @@ -83,10 +83,32 @@ await _orphanCleanupService.CleanupOrphansAsync( await IndexEmbeddedSubtitles(entity); } + // Update state - for AwaitingSource, check mtime first (reduces I/O) try { - // Update state without immediate save - parent ShowSyncService will save at its BatchSize - await _mediaStateService.UpdateStateAsync(entity, MediaType.Episode, saveChanges: false); + var shouldUpdateState = true; + + if (entity.TranslationState == TranslationState.AwaitingSource && + !string.IsNullOrEmpty(entity.Path)) + { + var dirInfo = new DirectoryInfo(entity.Path); + if (dirInfo.Exists) + { + var dirMtime = dirInfo.LastWriteTimeUtc; + if (entity.LastSubtitleCheckAt.HasValue && + dirMtime <= entity.LastSubtitleCheckAt.Value) + { + shouldUpdateState = false; + _logger.LogDebug("Skipping subtitle check for {Title}: directory unchanged", entity.Title); + } + } + } + + if (shouldUpdateState) + { + await _mediaStateService.UpdateStateAsync(entity, MediaType.Episode, saveChanges: false); + entity.LastSubtitleCheckAt = DateTime.UtcNow; + } } catch (Exception ex) { diff --git a/Lingarr.Server/Services/Sync/MovieSync.cs b/Lingarr.Server/Services/Sync/MovieSync.cs index 813a0e54..138bd890 100644 --- a/Lingarr.Server/Services/Sync/MovieSync.cs +++ b/Lingarr.Server/Services/Sync/MovieSync.cs @@ -132,9 +132,33 @@ await _orphanCleanupService.CleanupOrphansAsync( } // Update translation state + // For AwaitingSource: only re-check if directory mtime changed (reduces I/O) try { - await _mediaStateService.UpdateStateAsync(movieEntity, MediaType.Movie); + var shouldUpdateState = true; + + if (movieEntity.TranslationState == TranslationState.AwaitingSource && + !string.IsNullOrEmpty(movieEntity.Path)) + { + var dirInfo = new DirectoryInfo(movieEntity.Path); + if (dirInfo.Exists) + { + var dirMtime = dirInfo.LastWriteTimeUtc; + if (movieEntity.LastSubtitleCheckAt.HasValue && + dirMtime <= movieEntity.LastSubtitleCheckAt.Value) + { + // Directory unchanged, skip expensive filesystem scan + shouldUpdateState = false; + _logger.LogDebug("Skipping subtitle check for {Title}: directory unchanged", movieEntity.Title); + } + } + } + + if (shouldUpdateState) + { + await _mediaStateService.UpdateStateAsync(movieEntity, MediaType.Movie); + movieEntity.LastSubtitleCheckAt = DateTime.UtcNow; + } } catch (Exception ex) { diff --git a/Lingarr.Server/Statics/Translations/en.json b/Lingarr.Server/Statics/Translations/en.json index d0fa5f85..7d4437f9 100644 --- a/Lingarr.Server/Statics/Translations/en.json +++ b/Lingarr.Server/Statics/Translations/en.json @@ -449,6 +449,7 @@ "notApplicable": "N/A", "noSuitableSubtitles": "No Source", "failed": "Failed", + "awaitingSource": "Awaiting Source", "tooltipComplete": "All translations complete", "tooltipInProgress": "Translation in progress", "tooltipPending": "Awaiting translation", @@ -456,6 +457,7 @@ "tooltipUnknown": "Not yet analyzed", "tooltipNotApplicable": "Not applicable / No source", "tooltipNoSuitableSubtitles": "No suitable subtitle tracks found (all are sparse/Signs & Songs)", - "tooltipFailed": "Translation failed. Needs manual retry." + "tooltipFailed": "Translation failed. Needs manual retry.", + "tooltipAwaitingSource": "Waiting for source subtitle to appear" } } \ No newline at end of file diff --git a/Lingarr.Server/Statics/Translations/nl.json b/Lingarr.Server/Statics/Translations/nl.json index 77b63e78..bfbca75e 100644 --- a/Lingarr.Server/Statics/Translations/nl.json +++ b/Lingarr.Server/Statics/Translations/nl.json @@ -450,12 +450,16 @@ "unknown": "Onbekend", "notApplicable": "N.v.t.", "noSuitableSubtitles": "Geen bron", + "failed": "Mislukt", + "awaitingSource": "Wachtend op bron", "tooltipComplete": "Alle vertalingen voltooid", "tooltipInProgress": "Vertaling bezig", "tooltipPending": "Wacht op vertaling", "tooltipStale": "Heranalyse nodig", "tooltipUnknown": "Nog niet geanalyseerd", "tooltipNotApplicable": "Niet van toepassing / Geen bron", - "tooltipNoSuitableSubtitles": "Geen geschikte ondertitelsporen gevonden (allemaal dun/Signs & Songs)" + "tooltipNoSuitableSubtitles": "Geen geschikte ondertitelsporen gevonden (allemaal dun/Signs & Songs)", + "tooltipFailed": "Vertaling mislukt. Handmatige herpoging nodig.", + "tooltipAwaitingSource": "Wacht tot bronondertitel beschikbaar is" } } \ No newline at end of file From eabddb6d7657ad1060cbebbd4ce27996ae52a1af Mon Sep 17 00:00:00 2001 From: Yes <107748212+T9es@users.noreply.github.com> Date: Fri, 9 Jan 2026 10:46:53 +0100 Subject: [PATCH 07/64] fix: Add SignalR broadcast when worker claims translation job When TranslationWorkerService claims a job (Pending -> InProgress), it now broadcasts the status change via SignalR. This fixes the UI bug where jobs appeared stuck in Pending state after restart, even though they were running. --- .../Translation/TranslationWorkerService.cs | 15 +++- .../Services/TranslationRequestService.cs | 84 ++++++++++++++++++- 2 files changed, 96 insertions(+), 3 deletions(-) diff --git a/Lingarr.Server/Services/Translation/TranslationWorkerService.cs b/Lingarr.Server/Services/Translation/TranslationWorkerService.cs index f34f5dac..619835bb 100644 --- a/Lingarr.Server/Services/Translation/TranslationWorkerService.cs +++ b/Lingarr.Server/Services/Translation/TranslationWorkerService.cs @@ -233,7 +233,20 @@ private async Task TryClaimAndStartWorkerAsync(CancellationToken stoppingT return true; // Return true to try the next one } - // Step 3: Start a worker task for this request + // Step 3: Broadcast status change to frontend via SignalR + // This ensures the UI updates from Pending to InProgress + try + { + var translationRequestService = scope.ServiceProvider.GetRequiredService(); + await translationRequestService.UpdateActiveCount(); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to broadcast status update for request {RequestId}", candidate); + // Continue anyway - the job should still run + } + + // Step 4: Start a worker task for this request _logger.LogInformation( "Claimed translation request {RequestId} - starting worker (active: {Active}/{Max})", candidate, ActiveWorkers + 1, _maxWorkers); diff --git a/Lingarr.Server/Services/TranslationRequestService.cs b/Lingarr.Server/Services/TranslationRequestService.cs index 9de90dd5..faa17510 100644 --- a/Lingarr.Server/Services/TranslationRequestService.cs +++ b/Lingarr.Server/Services/TranslationRequestService.cs @@ -116,6 +116,9 @@ public async Task CreateRequest(TranslationRequest translationRequest, bool } // Create a new TranslationRequest to not keep ID and JobID + // Look up media priority to initialize IsPriority on the request + var isPriority = forcePriority || await GetMediaPriorityAsync(translationRequest.MediaId, translationRequest.MediaType); + var translationRequestCopy = new TranslationRequest { MediaId = translationRequest.MediaId, @@ -125,7 +128,8 @@ public async Task CreateRequest(TranslationRequest translationRequest, bool SubtitleToTranslate = translationRequest.SubtitleToTranslate, MediaType = translationRequest.MediaType, Status = TranslationStatus.Pending, - IsActive = true + IsActive = true, + IsPriority = isPriority }; _dbContext.TranslationRequests.Add(translationRequestCopy); @@ -324,6 +328,8 @@ public async Task RetryAllFailedRequests() .ToList(); var activeRequestsKeys = new HashSet<(int?, MediaType, string, string)>(); + var moviePriorityMap = new Dictionary(); + var episodePriorityMap = new Dictionary(); if (mediaIds.Any()) { @@ -337,6 +343,34 @@ public async Task RetryAllFailedRequests() { activeRequestsKeys.Add((r.MediaId, r.MediaType, r.SourceLanguage, r.TargetLanguage)); } + + // Look up priority status for movies in this batch + var movieIdsInBatch = batch + .Where(x => x.MediaType == MediaType.Movie && x.MediaId.HasValue) + .Select(x => x.MediaId!.Value) + .Distinct() + .ToList(); + if (movieIdsInBatch.Any()) + { + moviePriorityMap = await _dbContext.Movies + .Where(m => movieIdsInBatch.Contains(m.Id)) + .Select(m => new { m.Id, m.IsPriority }) + .ToDictionaryAsync(m => m.Id, m => m.IsPriority); + } + + // Look up priority status for episodes (inherited from Show) in this batch + var episodeIdsInBatch = batch + .Where(x => x.MediaType == MediaType.Episode && x.MediaId.HasValue) + .Select(x => x.MediaId!.Value) + .Distinct() + .ToList(); + if (episodeIdsInBatch.Any()) + { + episodePriorityMap = await _dbContext.Episodes + .Where(e => episodeIdsInBatch.Contains(e.Id)) + .Select(e => new { e.Id, Priority = e.Season.Show.IsPriority }) + .ToDictionaryAsync(e => e.Id, e => e.Priority); + } } // 3. Process the batch in a transaction @@ -372,6 +406,24 @@ await strategy.ExecuteAsync(async () => if (!activeRequestsKeys.Contains(key)) { var template = group.OrderByDescending(x => x.CreatedAt).First(); + + // Look up priority from the pre-fetched maps + // Retries are treated as priority, but also respect media priority status + var isPriority = true; // Default to priority for retries + if (template.MediaId.HasValue) + { + if (template.MediaType == MediaType.Movie) + { + moviePriorityMap.TryGetValue(template.MediaId.Value, out isPriority); + isPriority = true; // Always priority for retries + } + else if (template.MediaType == MediaType.Episode) + { + episodePriorityMap.TryGetValue(template.MediaId.Value, out isPriority); + isPriority = true; // Always priority for retries + } + } + var newRequest = new TranslationRequest { MediaId = template.MediaId, @@ -381,7 +433,8 @@ await strategy.ExecuteAsync(async () => SubtitleToTranslate = template.SubtitleToTranslate, MediaType = template.MediaType, Status = TranslationStatus.Pending, - IsActive = true + IsActive = true, + IsPriority = isPriority }; batchNewRequests.Add(newRequest); @@ -985,6 +1038,33 @@ private async Task PopulatePriorityFlagsAsync(List requests) } } + /// + /// Looks up the priority status of the media entity (Movie or Show). + /// + /// The ID of the media entity + /// The type of media (Movie or Episode) + /// True if the media is marked as priority, false otherwise + private async Task GetMediaPriorityAsync(int? mediaId, MediaType mediaType) + { + if (!mediaId.HasValue) return false; + + if (mediaType == MediaType.Movie) + { + return await _dbContext.Movies + .Where(m => m.Id == mediaId.Value) + .Select(m => m.IsPriority) + .FirstOrDefaultAsync(); + } + else if (mediaType == MediaType.Episode) + { + return await _dbContext.Episodes + .Where(e => e.Id == mediaId.Value) + .Select(e => e.Season.Show.IsPriority) + .FirstOrDefaultAsync(); + } + return false; + } + private async Task EnqueueTranslationJobAsync(TranslationRequest translationRequest, bool forcePriority) { // Simply set status to Pending - TranslationWorkerService will pick it up From 3cba48912d33abaead32e27a240aa487724baed4 Mon Sep 17 00:00:00 2001 From: Yes <107748212+T9es@users.noreply.github.com> Date: Fri, 9 Jan 2026 10:59:24 +0100 Subject: [PATCH 08/64] fix: Remove duplicate InProgress reset causing race condition --- .../Services/TranslationRequestService.cs | 19 +++++-------------- 1 file changed, 5 insertions(+), 14 deletions(-) diff --git a/Lingarr.Server/Services/TranslationRequestService.cs b/Lingarr.Server/Services/TranslationRequestService.cs index faa17510..f858127a 100644 --- a/Lingarr.Server/Services/TranslationRequestService.cs +++ b/Lingarr.Server/Services/TranslationRequestService.cs @@ -584,20 +584,11 @@ public async Task UpdateTranslationRequest(TranslationReques /// public async Task ResumeTranslationRequests() { - // With the new worker service approach, we just need to: - // 1. Reset InProgress jobs to Pending (they were interrupted) - // 2. Signal the worker service to pick up work - - var resetCount = await _dbContext.TranslationRequests - .Where(tr => tr.Status == TranslationStatus.InProgress) - .ExecuteUpdateAsync(s => s.SetProperty(tr => tr.Status, TranslationStatus.Pending)); - - if (resetCount > 0) - { - _logger.LogInformation( - "Reset {Count} interrupted translation request(s) to Pending status", - resetCount); - } + // NOTE: InProgress→Pending recovery is now handled by TranslationWorkerService.RecoverInterruptedJobsAsync() + // on startup. We only need to signal the worker that work may be available. + // Previously this method also reset InProgress jobs, but that caused a race condition: + // the worker would claim jobs (setting them to InProgress) and then this method + // would reset them back to Pending, causing the UI to show "Pending" while jobs were running. // Signal worker service that work may be available _workerService.Signal(); From 84b4501ee98940492c57e7064d22e88d827691fe Mon Sep 17 00:00:00 2001 From: Yes <107748212+T9es@users.noreply.github.com> Date: Fri, 9 Jan 2026 11:09:57 +0100 Subject: [PATCH 09/64] fix: Add delayed re-queue to reset hung jobs after startup --- .../Translation/TranslationWorkerService.cs | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/Lingarr.Server/Services/Translation/TranslationWorkerService.cs b/Lingarr.Server/Services/Translation/TranslationWorkerService.cs index 619835bb..cb57f169 100644 --- a/Lingarr.Server/Services/Translation/TranslationWorkerService.cs +++ b/Lingarr.Server/Services/Translation/TranslationWorkerService.cs @@ -91,6 +91,11 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) "TranslationWorkerService started with {MaxWorkers} max workers", _maxWorkers); + // Schedule a delayed re-queue to work around jobs hanging on startup + // Some jobs claimed immediately after restart seem to hang silently. + // Re-enqueueing after a short delay resets them and they process normally. + _ = ScheduleDelayedRequeueAsync(stoppingToken); + // Main worker management loop await RunWorkerLoopAsync(stoppingToken); } @@ -109,6 +114,40 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) await WaitForActiveWorkersAsync(); } } + + private async Task ScheduleDelayedRequeueAsync(CancellationToken stoppingToken) + { + try + { + // Wait 5 seconds after startup before re-queueing + await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken); + + if (stoppingToken.IsCancellationRequested) return; + + _logger.LogInformation("Performing delayed re-queue to reset any hung jobs from startup..."); + + using var scope = _serviceProvider.CreateScope(); + var translationRequestService = scope.ServiceProvider.GetRequiredService(); + + // Re-enqueue only pending items, don't touch in-progress (they'll be cancelled and reset) + var result = await translationRequestService.ReenqueueQueuedRequests(includeInProgress: true); + + if (result.Reenqueued > 0 || result.SkippedProcessing > 0) + { + _logger.LogInformation( + "Delayed re-queue complete: {Reenqueued} re-enqueued, {Skipped} skipped (in-progress)", + result.Reenqueued, result.SkippedProcessing); + } + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + // Shutdown requested, ignore + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Delayed re-queue failed (non-fatal)"); + } + } private async Task InitializeAsync(CancellationToken cancellationToken) { From ac838a0c0a5a586c1466979e624beabf1372f5f2 Mon Sep 17 00:00:00 2001 From: T9es Date: Sun, 18 Jan 2026 01:05:33 +0100 Subject: [PATCH 10/64] Update .gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index cec58e3a..914fd12f 100644 --- a/.gitignore +++ b/.gitignore @@ -65,3 +65,4 @@ EXAMPLE SUBTITLES/Jujutsu Kaisen (2020) - S01E14 - Kyoto Sister School Exchange EXAMPLE SUBTITLES/Jujutsu Kaisen (2020) - S01E13 - Tomorrow \[v2 Bluray-1080p Proper\]\[Opus 2.0\]\[x265\]-Vodes.English-\[Kaizoku\].pl.-ai-sztuczna-inteligencja-.srt EXAMPLE SUBTITLES/Jujutsu Kaisen (2020) - S01E13 - Tomorrow \[v2 Bluray-1080p Proper\]\[Opus 2.0\]\[x265\]-Vodes.eng.English-\[Kaizoku\].srt EXAMPLE EPISODES/Jujutsu Kaisen (2020) - S02E02 - Hidden Inventory 2 \[Bluray-1080p\]\[Opus 2.0\]\[x265\]-Vodes.mkv +.opencode/opencode.json From d90d5f62657a2a031f78aa2caf5139fb831e791e Mon Sep 17 00:00:00 2001 From: T9es Date: Sun, 18 Jan 2026 01:07:45 +0100 Subject: [PATCH 11/64] Update agents.md --- agents.md | 108 +++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 107 insertions(+), 1 deletion(-) diff --git a/agents.md b/agents.md index 70e61ff0..489d9489 100644 --- a/agents.md +++ b/agents.md @@ -262,4 +262,110 @@ docker run --rm mariadb:10.5 mysql -h 192.168.1.13 -P 25599 -u lingarr -p1234567 ``` ### Application URL -- **Base URL**: `http://192.168.1.13:6060` \ No newline at end of file +- **Base URL**: `http://192.168.1.13:6060` + +# Agent Rules & Instructions + +You are a rigorous coding assistant. You DO NOT skim. You DO NOT guess. + +--- + +## 0. First Step: Read Project Context + +**Before starting ANY task**, read these files if they exist: +- `AGENTS.md` (this file) – Workflow and behavioral rules. +- `README.md` – Project overview and setup instructions. +- Any `docs/` or instruction files in the project root. + +--- + +## 1. Mandatory Planning Phase (Before ANY Execution) + +When tackling ANY task, you MUST first enter **PLAN MODE**. + +### Part A: Executive Summary (For Non-Coders) +Provide a clear, jargon-free overview: +- **What** the proposed change accomplishes (in plain English). +- **Why** it's being done and what problem it solves. +- **Impact**: User-facing effects, potential risks, and trade-offs. +- Keep this to 3-5 bullet points. + +### Part B: Technical Implementation Plan +Provide full technical details: +- **Files to modify/create**: List each file with its absolute path. +- **Code snippets**: Show key changes (before/after if applicable). +- **Dependencies**: Other components affected, imports to add, etc. +- **Testing**: How you will verify the change works. +- **Rollback**: How to undo if something goes wrong. + +### Confidence Level +After planning, state your confidence: **High / Medium / Low**. +- If **Low**, STOP and ask clarifying questions before proceeding. + +**STOP and request user approval** before moving to Build/Execute Mode. + +--- + +## 2. Git/PR Workflow + +### Branch Targeting +- **Always check if a `dev` branch exists**: `git branch -a | grep dev` +- If `dev` exists, **all PRs must target `dev`**, NOT `main`. +- If `dev` does NOT exist, fall back to `main`. + +### PR Descriptions +Every PR description MUST include: +1. **Plain-English Summary**: What this PR does, explained to a non-coder. +2. **Why**: The problem being solved and why this approach was chosen. +3. **What Changed**: Bullet list of files/components modified. +4. **How to Test**: Steps to verify the change works. +5. **Rollback Plan**: How to undo if something breaks. + +--- + +## 3. Mandatory MCP Tool Usage + +Before answering ANY question about the codebase, you MUST use the available MCP tools to verify your assumptions: +- **`code-pathfinder`**: Understand project structure, call graphs, and relationships. +- **`mgrep`**: Find specific string occurrences across the project. +- **`owlex`**: Request a second opinion on architecture from a council of AI models. +- **`vitest`**: Run and analyze tests after making changes. +- **`context7`**: Search library documentation when uncertain about an API. + +**Never assume. Always verify.** + +--- + +## 4. Change Size Limits + +- If a change touches **more than 5 files**, break it into smaller tasks. +- Request approval for each chunk separately. +- Never submit massive PRs without explicit user permission. + +--- + +## 5. Testing Mandate + +After ANY code change: +1. Run `npm run build` (or equivalent build command). +2. Run `npm run test` (or equivalent test command). +3. If tests fail, diagnose and fix BEFORE reporting completion. + +--- + +## 6. Anti-Hallucination Rules + +- Never assume a file exists. Check it. +- Never assume a function signature. Read it. +- If you are unsure, SEARCH first. +- If some implementation requires internet research, do it. +- All claims you present must be vetted by the real code. + +--- + +## 7. Lessons Learned + +If you encounter a persistent error requiring multiple fix attempts: +1. Analyze why the first attempt failed. +2. Abstract the specific error into a general rule. +3. Document the lesson learned to prevent future occurrences. From 7f66019e01da19e929f5f13db19f9a8690470c7d Mon Sep 17 00:00:00 2001 From: T9es Date: Sun, 18 Jan 2026 01:58:51 +0100 Subject: [PATCH 12/64] feat: enhance media analysis, translation pipeline, and hashing robustness - Fixed translation re-evaluation and retry logic in AutomatedTranslationJob and MediaStateService. - Implemented relative path hashing and mtime/size detection in MediaSubtitleProcessor for better change detection. - Decoupled external subtitle hashing from media file changes and optimized embedded subtitle hashing to prevent redundant re-translations after transcoding. - Improved OrphanSubtitleCleanupService with precise string matching for safe cleanup. - Optimized EpisodeSync and MovieSync performance with batch SaveChangesAsync and improved stream extraction. --- .../Jobs/AutomatedTranslationJob.cs | 5 +- Lingarr.Server/Services/MediaStateService.cs | 75 +- .../Services/MediaSubtitleProcessor.cs | 927 ++++-------------- .../Subtitle/OrphanSubtitleCleanupService.cs | 99 +- Lingarr.Server/Services/Sync/EpisodeSync.cs | 48 +- Lingarr.Server/Services/Sync/MovieSync.cs | 37 +- 6 files changed, 369 insertions(+), 822 deletions(-) diff --git a/Lingarr.Server/Jobs/AutomatedTranslationJob.cs b/Lingarr.Server/Jobs/AutomatedTranslationJob.cs index db8d1319..44bf851f 100644 --- a/Lingarr.Server/Jobs/AutomatedTranslationJob.cs +++ b/Lingarr.Server/Jobs/AutomatedTranslationJob.cs @@ -95,7 +95,7 @@ public async Task Execute() processedCount++; - // For stale/unknown items, refresh state first + // For stale/unknown/failed items, refresh state first TranslationState currentState; if (mediaType == MediaType.Movie) { @@ -106,8 +106,9 @@ public async Task Execute() currentState = ((Episode)media).TranslationState; } - if (currentState == TranslationState.Stale || currentState == TranslationState.Unknown) + if (currentState == TranslationState.Stale || currentState == TranslationState.Unknown || currentState == TranslationState.Failed) { + // For Failed items, we allow retry if state re-evaluation says it's Pending var newState = await _mediaStateService.UpdateStateAsync(media, mediaType); if (newState != TranslationState.Pending) { diff --git a/Lingarr.Server/Services/MediaStateService.cs b/Lingarr.Server/Services/MediaStateService.cs index 4802b406..38521ed3 100644 --- a/Lingarr.Server/Services/MediaStateService.cs +++ b/Lingarr.Server/Services/MediaStateService.cs @@ -116,70 +116,27 @@ private async Task ComputeStateAsync( return TranslationState.NotApplicable; } - // 3. Check for active translation request + // 7. Check for active/failed translation requests + // If it's failed, it stays Failed unless something else changed (handled by MediaSubtitleProcessor's hash) if (await HasActiveTranslationRequestAsync(media.Id, mediaType)) { return TranslationState.InProgress; } - // 3b. Check for failed translation request if (await HasFailedTranslationRequestAsync(media.Id, mediaType)) { + // If the hash matches, it means nothing changed since it failed. + // But if the hash is DIFFERENT, MediaSubtitleProcessor would have cleared the state or we'd be here. + // Actually, we want to allow retrying if the user fixes something or if we want to periodically retry. + // For now, if there's a failed request, we mark as Failed. return TranslationState.Failed; } - // 4. Get external subtitles - var externalSubtitles = new List(); - if (!string.IsNullOrEmpty(media.Path)) - { - try - { - var allSubs = await _subtitleService.GetAllSubtitles(media.Path); - var mediaNameNoExt = Path.GetFileNameWithoutExtension(media.FileName); - externalSubtitles = allSubs - .Where(s => !string.IsNullOrEmpty(media.FileName) && - (s.FileName.StartsWith(media.FileName + ".") || - s.FileName == media.FileName || - (!string.IsNullOrEmpty(mediaNameNoExt) && s.FileName.StartsWith(mediaNameNoExt + ".")))) - .ToList(); - } - catch (Exception ex) - { - _logger.LogDebug(ex, "Failed to get external subtitles for {Title}", media.Title); - } - } - - // 5. Check for source subtitle - var hasExternalSource = externalSubtitles - .Any(s => sourceLanguages.Any(sl => SubtitleLanguageHelper.LanguageMatches(s.Language, sl))); - var hasEmbeddedSource = embeddedSubtitles - .Any(e => e.IsTextBased && - !string.IsNullOrEmpty(e.Language) && - sourceLanguages.Any(sl => SubtitleLanguageHelper.LanguageMatches(e.Language, sl))); - - if (!hasExternalSource && !hasEmbeddedSource) - { - return TranslationState.AwaitingSource; - } - - // 6. Check which targets are satisfied - var existingTargetLanguages = externalSubtitles - .Select(s => s.Language.ToLowerInvariant()) - .ToHashSet(); - - var missingTargets = targetLanguages - .Where(t => !existingTargetLanguages.Contains(t)) - .ToList(); - - if (missingTargets.Count == 0) - { - return TranslationState.Complete; - } - - // Has source, missing targets, no active request = Pending + // Has source, missing targets, no active/failed request = Pending return TranslationState.Pending; } + /// public async Task MarkAllStaleAsync() { @@ -202,13 +159,24 @@ public async Task MarkAllStaleAsync() var result = new List<(IMedia Media, MediaType Type)>(); var halfLimit = Math.Max(limit / 2, 1); + _logger.LogInformation("Querying for media needing translation. Limit: {Limit}", limit); + // Query movies needing work var moviesQuery = _dbContext.Movies .Include(m => m.EmbeddedSubtitles) .Where(m => !m.ExcludeFromTranslation) .Where(m => m.TranslationState == TranslationState.Pending || m.TranslationState == TranslationState.Stale - || (m.TranslationState == TranslationState.Unknown && m.IndexedAt != null)); + || m.TranslationState == TranslationState.Failed + || m.TranslationState == TranslationState.Unknown); + + var pendingCount = await _dbContext.Movies.CountAsync(m => m.TranslationState == TranslationState.Pending); + var staleCount = await _dbContext.Movies.CountAsync(m => m.TranslationState == TranslationState.Stale); + var unknownCount = await _dbContext.Movies.CountAsync(m => m.TranslationState == TranslationState.Unknown); + var failedCount = await _dbContext.Movies.CountAsync(m => m.TranslationState == TranslationState.Failed); + + _logger.LogInformation("Movie states in DB: Pending={Pending}, Stale={Stale}, Unknown={Unknown}, Failed={Failed}", + pendingCount, staleCount, unknownCount, failedCount); if (priorityFirst) { @@ -235,7 +203,8 @@ public async Task MarkAllStaleAsync() .Where(e => !e.Season.Show.ExcludeFromTranslation) .Where(e => e.TranslationState == TranslationState.Pending || e.TranslationState == TranslationState.Stale - || (e.TranslationState == TranslationState.Unknown && e.IndexedAt != null)); + || e.TranslationState == TranslationState.Failed + || e.TranslationState == TranslationState.Unknown); if (priorityFirst) { diff --git a/Lingarr.Server/Services/MediaSubtitleProcessor.cs b/Lingarr.Server/Services/MediaSubtitleProcessor.cs index 271abc77..dac59c5e 100644 --- a/Lingarr.Server/Services/MediaSubtitleProcessor.cs +++ b/Lingarr.Server/Services/MediaSubtitleProcessor.cs @@ -1,4 +1,4 @@ -using System.Security.Cryptography; +using System.Security.Cryptography; using Lingarr.Core.Configuration; using Lingarr.Core.Data; using Lingarr.Core.Entities; @@ -23,6 +23,7 @@ public class MediaSubtitleProcessor : IMediaSubtitleProcessor private readonly ISubtitleExtractionService _extractionService; private readonly LingarrDbContext _dbContext; private readonly ISubtitleIntegrityService _integrityService; + private static readonly string[] VideoExtensions = { ".mkv", ".mp4", ".avi", ".m4v", ".mov", ".wmv", ".flv", ".webm" }; private string _hash = string.Empty; private IMedia _media = null!; private MediaType _mediaType; @@ -50,334 +51,234 @@ public async Task ProcessMedia( IMedia media, MediaType mediaType) { - if (media.Path == null) + var count = await ProcessInternalAsync(media, mediaType, forceProcess: false, forceTranslation: false, forcePriority: false); + return count > 0; + } + + /// + public async Task ProcessMediaForceAsync( + IMedia media, + MediaType mediaType, + bool forceProcess = true, + bool forceTranslation = true, + bool forcePriority = false) + { + return await ProcessInternalAsync(media, mediaType, forceProcess, forceTranslation, forcePriority); + } + + private async Task ProcessInternalAsync( + IMedia media, + MediaType mediaType, + bool forceProcess, + bool forceTranslation, + bool forcePriority) + { + if (string.IsNullOrEmpty(media.Path) || string.IsNullOrEmpty(media.FileName)) { - return false; + return 0; } + + _media = media; + _mediaType = mediaType; + var allSubtitles = await _subtitleService.GetAllSubtitles(media.Path); var matchingSubtitles = allSubtitles .Where(s => s.FileName.StartsWith(media.FileName + ".") || s.FileName == media.FileName) .ToList(); - if (!matchingSubtitles.Any()) + var sourceLanguages = await GetLanguagesSetting(SettingKeys.Translation.SourceLanguages); + var targetLanguages = await GetLanguagesSetting(SettingKeys.Translation.TargetLanguages); + var ignoreCaptions = await _settingService.GetSetting(SettingKeys.Translation.IgnoreCaptions) ?? "false"; + + if (sourceLanguages.Count == 0 || targetLanguages.Count == 0) { - return false; + _logger.LogWarning("Source or target languages are empty for {Title}.", media.Title); + return 0; } - var sourceLanguages = await GetLanguagesSetting(SettingKeys.Translation.SourceLanguages); - var targetLanguages = await GetLanguagesSetting(SettingKeys.Translation.TargetLanguages); - var ignoreCaptions = await _settingService.GetSetting(SettingKeys.Translation.IgnoreCaptions); + // 1. Check external subtitles first + var existingLanguages = ExtractLanguageCodes(matchingSubtitles); + var sourceLanguage = existingLanguages.FirstOrDefault(lang => sourceLanguages.Contains(lang)); + Subtitles? sourceSubtitle = null; - _media = media; - _mediaType = mediaType; - _hash = CreateHash(matchingSubtitles, sourceLanguages, targetLanguages, ignoreCaptions ?? ""); - if (!string.IsNullOrEmpty(media.MediaHash) && media.MediaHash == _hash) + if (sourceLanguage != null) { - return false; + sourceSubtitle = ignoreCaptions == "true" + ? matchingSubtitles.FirstOrDefault(s => s.Language == sourceLanguage && string.IsNullOrEmpty(s.Caption)) + ?? matchingSubtitles.FirstOrDefault(s => s.Language == sourceLanguage) + : matchingSubtitles.FirstOrDefault(s => s.Language == sourceLanguage); + + // Skip Lingarr-extracted sparse files + if (sourceSubtitle != null && SubtitleExtractionService.IsLingarrExtracted(sourceSubtitle.Path) && SubtitleExtractionService.IsSparseSubtitle(sourceSubtitle.Path)) + { + _logger.LogInformation("External source {Path} is a sparse Lingarr-extracted file, skipping for fallback.", sourceSubtitle.Path); + sourceSubtitle = null; + sourceLanguage = null; + } } - - _logger.LogInformation("Initiating subtitle processing."); - return await ProcessSubtitles(matchingSubtitles, sourceLanguages, targetLanguages, ignoreCaptions ?? ""); - } - /// - /// Processes subtitle files for translation based on configured languages. - /// - /// List of subtitle files to process. - /// The source languages. - /// The target languages. - /// The ignore captions setting. - /// True if new translation requests were created, false otherwise. - private async Task ProcessSubtitles( - List subtitles, - HashSet sourceLanguages, - HashSet targetLanguages, - string ignoreCaptions) - { - var existingLanguages = ExtractLanguageCodes(subtitles); + // 2. Compute Hash (Robust & Relative) + _hash = CreateHash(media, matchingSubtitles, sourceLanguages, targetLanguages, ignoreCaptions); - if (sourceLanguages.Count == 0 || targetLanguages.Count == 0) + if (!forceProcess && !string.IsNullOrEmpty(media.MediaHash) && media.MediaHash == _hash) { - _logger.LogWarning( - "Source or target languages are empty. Source languages: {SourceCount}, Target languages: {TargetCount}", - sourceLanguages.Count, targetLanguages.Count); - await UpdateHash(); - return false; + _logger.LogDebug("Skipping {Title}: hash matches and not forcing", media.Title); + return 0; } - string? tempSourcePath = null; - try + // 3. If no external source, try embedded + if (sourceSubtitle == null) { - var sourceLanguage = existingLanguages.FirstOrDefault(lang => sourceLanguages.Contains(lang)); - Subtitles? sourceSubtitle = null; + _logger.LogInformation("No suitable external source found for {Title}, trying embedded...", media.Title); + return await TryQueueEmbeddedSubtitleTranslation(media, mediaType, forceTranslation, forceProcess, forcePriority); + } - if (sourceLanguage != null) - { - sourceSubtitle = ignoreCaptions == "true" - ? subtitles.FirstOrDefault(s => s.Language == sourceLanguage && string.IsNullOrEmpty(s.Caption)) - ?? subtitles.FirstOrDefault(s => s.Language == sourceLanguage) - : subtitles.FirstOrDefault(s => s.Language == sourceLanguage); - } + // 4. Process External Subtitles + _logger.LogInformation("Processing external subtitles for {Title} (forceTranslation={Force}).", media.Title, forceTranslation); + + var languagesToTranslate = forceTranslation + ? targetLanguages.ToList() + : targetLanguages.Except(existingLanguages).ToList(); - // Check if the found external subtitle is sparse (likely Signs/Songs from previous extraction) - // If it was extracted by Lingarr and is sparse, skip it and fall back to embedded extraction - if (sourceSubtitle != null) + var corruptLanguages = new List(); + if (!forceTranslation) + { + foreach (var targetLang in targetLanguages.Intersect(existingLanguages)) { - var isSparse = SubtitleExtractionService.IsSparseSubtitle(sourceSubtitle.Path); - var isExtracted = SubtitleExtractionService.IsLingarrExtracted(sourceSubtitle.Path); - - if (isSparse) + var targetSubtitle = matchingSubtitles.FirstOrDefault(s => s.Language == targetLang); + if (targetSubtitle != null) { - var entryCount = SubtitleExtractionService.CountSubtitleEntries(sourceSubtitle.Path); - _logger.LogWarning( - "External subtitle {Path} is sparse ({Count} entries, minimum: {Min}). {Action}", - sourceSubtitle.Path, - entryCount, - SubtitleExtractionService.MinimumDialogueEntries, - isExtracted ? "Lingarr-extracted file will be skipped, trying embedded fallback..." : "User-provided file will still be used."); - - // Only skip Lingarr-extracted sparse files; user-provided sparse files may be intentional - if (isExtracted) + var isValid = await _integrityService.ValidateIntegrityAsync(sourceSubtitle.Path, targetSubtitle.Path); + if (!isValid) { - sourceSubtitle = null; + _logger.LogWarning("Integrity check failed for {TargetLang} subtitle: {Path}", targetLang, targetSubtitle.Path); + corruptLanguages.Add(targetLang); } } } + languagesToTranslate = languagesToTranslate.Union(corruptLanguages).ToList(); + } - // Fallback: If no external source found (even if sourceLanguage was detected but file missing?) - // Actually, existingLanguages comes from files. So if sourceLanguage != null, the file exists. - // But if existingLanguages DOES NOT contain sourceLanguage, sourceLanguage is null. - // So we check if validSource is missing. - - if (sourceSubtitle == null) - { - _logger.LogInformation("No external source subtitle found for {FileName}. Checking for embedded subtitles for validation...", _media.FileName); - - // Logic to extract embedded subtitle - var sourceLanguageModels = await _settingService.GetSettingAsJson(SettingKeys.Translation.SourceLanguages); - var configuredSourceLanguages = sourceLanguageModels.Select(lang => lang.Code).Where(c => !string.IsNullOrWhiteSpace(c)).ToList(); - - var embeddedSubtitles = await ProbeEmbeddedSubtitlesForCurrentMedia(); - - if (embeddedSubtitles != null && embeddedSubtitles.Any()) - { - var textBasedSubs = embeddedSubtitles.Where(s => s.IsTextBased).ToList(); - var bestMatch = SubtitleLanguageHelper.FindBestMatch(textBasedSubs, configuredSourceLanguages); - - if (bestMatch.Subtitle != null) - { - var tempDir = Path.GetTempPath(); - var tempFileName = $"lingarr_temp_source_{Guid.NewGuid()}.{bestMatch.MatchedLanguage}.srt"; - - tempSourcePath = await _extractionService.ExtractSubtitle( - Path.Combine(_media.Path!, _media.FileName!), - bestMatch.Subtitle.StreamIndex, - tempDir, - "srt", - bestMatch.MatchedLanguage); - - if (tempSourcePath != null) - { - // Create a temporary Subtitles object - sourceSubtitle = new Subtitles - { - Path = tempSourcePath, - Language = bestMatch.MatchedLanguage, - Format = "srt", - FileName = Path.GetFileName(tempSourcePath) - }; - sourceLanguage = bestMatch.MatchedLanguage; - _logger.LogInformation("Extracted temporary source subtitle for validation: {TempPath}", tempSourcePath); - } - } - } - } + if (ignoreCaptions == "true" && !forceTranslation) + { + var targetLanguagesWithCaptions = matchingSubtitles + .Where(s => targetLanguages.Contains(s.Language) && !string.IsNullOrEmpty(s.Caption)) + .Select(s => s.Language) + .Distinct() + .Except(corruptLanguages) + .ToList(); - if (sourceSubtitle != null) + if (targetLanguagesWithCaptions.Any()) { - // Get languages that don't yet exist to validate whether captions in those languages are available - var languagesToTranslate = targetLanguages.Except(existingLanguages).ToList(); - - // Check integrity of existing target subtitles and add corrupt ones for re-translation - var corruptLanguages = new List(); - foreach (var targetLang in targetLanguages.Intersect(existingLanguages)) - { - var targetSubtitle = subtitles.FirstOrDefault(s => s.Language == targetLang); - if (targetSubtitle != null) - { - var isValid = await _integrityService.ValidateIntegrityAsync( - sourceSubtitle.Path, - targetSubtitle.Path); - if (!isValid) - { - _logger.LogWarning( - "Integrity check failed for {TargetLang} subtitle: {Path} - scheduling re-translation", - targetLang, targetSubtitle.Path); - corruptLanguages.Add(targetLang); - } - } - } - - // Add corrupt languages to the translation queue - languagesToTranslate = languagesToTranslate.Union(corruptLanguages).ToList(); - var foundCorruption = corruptLanguages.Count > 0; - - if (ignoreCaptions == "true") - { - var targetLanguagesWithCaptions = subtitles - .Where(s => targetLanguages.Contains(s.Language) && !string.IsNullOrEmpty(s.Caption)) - .Select(s => s.Language) - .Distinct() - .ToList(); - - if (targetLanguagesWithCaptions.Any()) - { - // Remove languages that have captions from languagesToTranslate if ignoreCaptions is true - // Actually logic above just returns. But if we have corrupt languages, maybe we want to continue? - // Original logic returns if ANY valid caption exists? No, it returns if target exists w/ caption. - // Let's keep original logic strictness for now. - - // BUT: corruptLanguages might NEED re-translation. If a corrupt subtitle has a caption, do we skip it? - // If it's corrupt, it's corrupt. - - var skipped = targetLanguagesWithCaptions.Except(corruptLanguages).ToList(); - if (skipped.Any()) - { - _logger.LogInformation( - "Translation skipped because captions exist for target languages: |Green|{CaptionLanguages}|/Green| and ignoreCaptions is disabled", - string.Join(", ", skipped)); - - // If all targets are skipped, return. - if (!languagesToTranslate.Except(skipped).Any()) - { - if (!foundCorruption) - { - await UpdateHash(); - } - return false; - } - } - } - } - - foreach (var targetLanguage in languagesToTranslate) - { - if (sourceLanguage == null || await HasActiveRequestAsync(_media.Id, _mediaType, sourceLanguage, targetLanguage)) - { - _logger.LogInformation( - "Skipping enqueue for {FileName} {Source}->{Target}: translation request already active.", - _media.FileName, - sourceLanguage, - targetLanguage); - continue; - } - - await _translationRequestService.CreateRequest(new TranslateAbleSubtitle - { - MediaId = _media.Id, - MediaType = _mediaType, - SubtitlePath = (tempSourcePath != null) ? null : sourceSubtitle.Path, // If temp, use NULL so Job extracts fresh? Or use temp? - // IMPORTANT: If we use temp path, the Job might fail if temp is deleted. - // Ideally, we pass NULL so the Job does its own extraction. - // We ONLY extracted temp for VALIDATION. - TargetLanguage = targetLanguage, - SourceLanguage = sourceLanguage, - SubtitleFormat = sourceSubtitle.Format - }); - _logger.LogInformation( - "Initiating translation from |Orange|{sourceLanguage}|/Orange| to |Orange|{targetLanguage}|/Orange| for |Green|{subtitleFile}|/Green|", - sourceLanguage, - targetLanguage, - sourceSubtitle.Path); - } - - // Only update hash if no corruption was found - ensures re-validation if translation fails - if (!foundCorruption) - { - await UpdateHash(); - } - else - { - _logger.LogDebug("Skipping hash update for {FileName} due to corruption found - will re-validate next run", _media.FileName); - } - return true; + languagesToTranslate = languagesToTranslate.Except(targetLanguagesWithCaptions).ToList(); } + } - _logger.LogWarning("No source subtitle file found for language: |Green|{SourceLanguage}|/Green|", - sourceLanguage); + var queuedCount = 0; + foreach (var targetLanguage in languagesToTranslate) + { + if (await HasActiveRequestAsync(media.Id, mediaType, sourceLanguage, targetLanguage)) continue; - await UpdateHash(); - return false; + await _translationRequestService.CreateRequest(new TranslateAbleSubtitle + { + MediaId = media.Id, + MediaType = mediaType, + SubtitlePath = sourceSubtitle.Path, + TargetLanguage = targetLanguage, + SourceLanguage = sourceLanguage, + SubtitleFormat = sourceSubtitle.Format + }, forcePriority); + queuedCount++; } - finally + + if (corruptLanguages.Count == 0) { - if (tempSourcePath != null && File.Exists(tempSourcePath)) - { - try - { - File.Delete(tempSourcePath); - _logger.LogDebug("Deleted temporary validation subtitle: {TempPath}", tempSourcePath); - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Failed to delete temporary validation subtitle: {TempPath}", tempSourcePath); - } - } + await UpdateHash(); } + + return queuedCount; + } + + private string CreateHash( + IMedia media, + List subtitles, + HashSet sourceLanguages, + HashSet targetLanguages, + string ignoreCaptions) + { + using var sha256 = SHA256.Create(); + + // We no longer include media file size/mtime in the hash for external subtitles + // to avoid re-translations when Tdarr/remuxing changes the container/video/audio + // but leaves external .srt files untouched. + + var subtitleTokens = subtitles + .OrderBy(s => s.Path) + .Select(s => { + var fileInfo = new FileInfo(s.Path); + var size = fileInfo.Exists ? fileInfo.Length : 0; + var mtime = fileInfo.Exists ? fileInfo.LastWriteTimeUtc.Ticks : 0; + var relativePath = Path.GetFileName(s.Path); + return $"{relativePath}:{size}:{mtime}"; + }); + + var hashInput = $"{string.Join("|", subtitleTokens)}|{string.Join(",", sourceLanguages.OrderBy(l => l))}|{string.Join(",", targetLanguages.OrderBy(l => l))}|{ignoreCaptions}|v6"; + return Convert.ToBase64String(sha256.ComputeHash(System.Text.Encoding.UTF8.GetBytes(hashInput))); } - /// - /// Creates a hash of the current subtitle file state. - /// - /// List of subtitle file paths to include in the hash. - /// The source languages. - /// The target languages. - /// The ignore captions setting. - /// A Base64 encoded string representing the hash of the current subtitle state. - private string CreateHash( - List subtitles, - HashSet sourceLanguages, - HashSet targetLanguages, - string ignoreCaptions) + private string CreateEmbeddedHash( + IMedia media, + IReadOnlyCollection embeddedSubtitles, + IEnumerable configuredSourceLanguages, + IEnumerable targetLanguages, + string ignoreCaptions) { using var sha256 = SHA256.Create(); - var subtitlePaths = string.Join("|", subtitles.Select(subtitle => subtitle.Path) - .ToList() - .OrderBy(f => f)); + + // For embedded subtitles, we still need to know if the media file changed + // because we might need to re-extract. However, Tdarr often changes the file + // without changing the subtitle content. + // We'll exclude mediaSize/mtime if we have embedded subtitles to track, + // relying on the stream properties instead. If the streams change (e.g. re-ordered, + // different codec), we'll still re-process. - var sourceLangs = string.Join(",", sourceLanguages.OrderBy(l => l)); - var targetLangs = string.Join(",", targetLanguages.OrderBy(l => l)); + long mediaSize = 0; + long mediaMtime = 0; - var hashInput = $"{subtitlePaths}|{sourceLangs}|{targetLangs}|{ignoreCaptions}|v2"; - var hashBytes = sha256.ComputeHash(System.Text.Encoding.UTF8.GetBytes(hashInput)); - return Convert.ToBase64String(hashBytes); - } + // If no embedded subtitles found yet, we include media info to ensure + // we re-probe when the file is replaced/updated. + if (embeddedSubtitles.Count == 0) + { + try + { + var dirInfo = new DirectoryInfo(media.Path!); + if (dirInfo.Exists) + { + var fileInfo = dirInfo.GetFiles(media.FileName + ".*") + .FirstOrDefault(f => VideoExtensions.Contains(f.Extension.ToLowerInvariant())); - private string CreateEmbeddedHash( - IReadOnlyCollection embeddedSubtitles, - IEnumerable configuredSourceLanguages, - IEnumerable targetLanguages) - { - using var sha256 = SHA256.Create(); + if (fileInfo != null) + { + mediaSize = fileInfo.Length; + mediaMtime = fileInfo.LastWriteTimeUtc.Ticks; + } + } + } catch {} + } - var streamTokens = embeddedSubtitles - .OrderBy(s => s.StreamIndex) - .Select(s => - $"{s.StreamIndex}:{s.Language?.ToLowerInvariant()}:{s.CodecName}:{s.IsTextBased}:{s.IsDefault}:{s.IsForced}"); + var streamTokens = embeddedSubtitles + .OrderBy(s => s.StreamIndex) + .Select(s => + $"{s.StreamIndex}:{s.Language?.ToLowerInvariant()}:{s.CodecName}:{s.IsTextBased}:{s.IsDefault}:{s.IsForced}"); - var sources = string.Join(",", configuredSourceLanguages.OrderBy(l => l)); - var targets = string.Join(",", targetLanguages.OrderBy(l => l)); + var sources = string.Join(",", configuredSourceLanguages.OrderBy(l => l)); + var targets = string.Join(",", targetLanguages.OrderBy(l => l)); - var hashInput = $"{string.Join("|", streamTokens)}|{sources}|{targets}|v2"; - var hashBytes = sha256.ComputeHash(System.Text.Encoding.UTF8.GetBytes(hashInput)); - return Convert.ToBase64String(hashBytes); - } + var hashInput = $"{mediaSize}:{mediaMtime}|{string.Join("|", streamTokens)}|{sources}|{targets}|{ignoreCaptions}|v7"; + var hashBytes = sha256.ComputeHash(System.Text.Encoding.UTF8.GetBytes(hashInput)); + return Convert.ToBase64String(hashBytes); + } - /// - /// Extracts language codes from subtitle file names. - /// - /// List of subtitle file paths to process. - /// A HashSet of valid language codes found in the file names. private HashSet ExtractLanguageCodes(List subtitles) { return subtitles @@ -385,12 +286,6 @@ private HashSet ExtractLanguageCodes(List subtitles) .ToHashSet(); } - /// - /// Retrieves language settings from the application configuration. - /// - /// The type of language setting to retrieve (Source or Target). - /// The name of the setting to retrieve. - /// A HashSet of language codes from the configuration. private async Task> GetLanguagesSetting(string settingName) where T : class, ILanguage { var languages = await _settingService.GetSettingAsJson(settingName); @@ -399,10 +294,6 @@ private async Task> GetLanguagesSetting(string settingName) w .ToHashSet(); } - /// - /// Updates the media hash in the database. - /// - /// A task representing the asynchronous operation. private async Task UpdateHash() { _media.MediaHash = _hash; @@ -410,248 +301,13 @@ private async Task UpdateHash() await _dbContext.SaveChangesAsync(); } - /// - public async Task ProcessMediaForceAsync( - IMedia media, - MediaType mediaType, - bool forceProcess = true, - bool forceTranslation = true, - bool forcePriority = false) - { - if (media.Path == null) - { - return 0; - } - - var allSubtitles = await _subtitleService.GetAllSubtitles(media.Path); - var matchingSubtitles = allSubtitles - .Where(s => s.FileName.StartsWith(media.FileName + ".") || s.FileName == media.FileName) - .ToList(); - - _logger.LogDebug( - "ProcessMediaForceAsync for {FileName}: Found {AllCount} subtitles in directory, {MatchCount} matching media file", - media.FileName, allSubtitles.Count, matchingSubtitles.Count); - - if (!matchingSubtitles.Any()) - { - _logger.LogInformation( - "No external subtitles found for {FileName}. Checking for embedded subtitles...", - media.FileName); - - // Try to queue translation jobs for embedded subtitle extraction - return await TryQueueEmbeddedSubtitleTranslation(media, mediaType, forceTranslation, forceProcess, forcePriority); - } - - var sourceLanguages = await GetLanguagesSetting(SettingKeys.Translation.SourceLanguages); - var targetLanguages = await GetLanguagesSetting(SettingKeys.Translation.TargetLanguages); - var ignoreCaptions = await _settingService.GetSetting(SettingKeys.Translation.IgnoreCaptions); - - _logger.LogDebug( - "Language settings for {FileName}: Sources=[{Sources}], Targets=[{Targets}], IgnoreCaptions={IgnoreCaptions}", - media.FileName, - string.Join(", ", sourceLanguages), - string.Join(", ", targetLanguages), - ignoreCaptions); - - _logger.LogDebug( - "Matching subtitles for {FileName}: [{Subtitles}]", - media.FileName, - string.Join(", ", matchingSubtitles.Select(s => $"{s.Language}:{s.FileName}"))); - - _media = media; - _mediaType = mediaType; - _hash = CreateHash(matchingSubtitles, sourceLanguages, targetLanguages, ignoreCaptions ?? ""); - - // If not forcing and hash matches, skip processing - if (!forceProcess && !string.IsNullOrEmpty(media.MediaHash) && media.MediaHash == _hash) - { - _logger.LogDebug("Skipping {FileName}: hash matches and not forcing", media.FileName); - return 0; - } - - _logger.LogInformation("Initiating manual subtitle processing for {FileName} (forceProcess={Force}, forceTranslation={ForceTrans}, forcePriority={Priority}).", media.FileName, forceProcess, forceTranslation, forcePriority); - return await ProcessSubtitlesWithCount(media, mediaType, matchingSubtitles, sourceLanguages, targetLanguages, ignoreCaptions ?? "", forceTranslation, forceProcess, forcePriority); - // return 0; - } - - /// - /// Processes subtitle files for translation and returns the count of translations queued. - /// - /// If true, translates to all target languages even if they already exist. - private async Task ProcessSubtitlesWithCount( - IMedia media, - MediaType mediaType, - List subtitles, - HashSet sourceLanguages, - HashSet targetLanguages, - string ignoreCaptions, - bool forceTranslation = false, - bool forceProcess = false, - bool forcePriority = false) - { - var existingLanguages = ExtractLanguageCodes(subtitles); - var translationsQueued = 0; - - _logger.LogDebug( - "ProcessSubtitlesWithCount: ExistingLanguages=[{Existing}], SourceLanguages=[{Sources}], TargetLanguages=[{Targets}], ForceTranslation={Force}", - string.Join(", ", existingLanguages), - string.Join(", ", sourceLanguages), - string.Join(", ", targetLanguages), - forceTranslation); - - if (sourceLanguages.Count == 0 || targetLanguages.Count == 0) - { - _logger.LogWarning( - "Source or target languages are empty. Source languages: {SourceCount}, Target languages: {TargetCount}", - sourceLanguages.Count, targetLanguages.Count); - await UpdateHash(); - return 0; - } - - var sourceLanguage = existingLanguages.FirstOrDefault(lang => sourceLanguages.Contains(lang)); - _logger.LogDebug("Source language match result: {SourceLanguage}", sourceLanguage ?? "NONE"); - - if (sourceLanguage != null && targetLanguages.Any()) - - { - var sourceSubtitle = ignoreCaptions == "true" - ? subtitles.FirstOrDefault(s => s.Language == sourceLanguage && string.IsNullOrEmpty(s.Caption)) - ?? subtitles.FirstOrDefault(s => s.Language == sourceLanguage) - : subtitles.FirstOrDefault(s => s.Language == sourceLanguage); - - if (sourceSubtitle != null) - { - // When forceTranslation is true, translate to all target languages even if they exist - var languagesToTranslate = forceTranslation - ? targetLanguages.ToList() - : targetLanguages.Except(existingLanguages).ToList(); - - // Check integrity of existing target subtitles and add corrupt ones for re-translation - var foundCorruption = false; - if (!forceTranslation) - { - var corruptLanguages = new List(); - foreach (var targetLang in targetLanguages.Intersect(existingLanguages)) - { - var targetSubtitle = subtitles.FirstOrDefault(s => s.Language == targetLang); - if (targetSubtitle != null) - { - var isValid = await _integrityService.ValidateIntegrityAsync( - sourceSubtitle.Path, - targetSubtitle.Path); - if (!isValid) - { - _logger.LogWarning( - "Integrity check failed for {TargetLang} subtitle: {Path} - scheduling re-translation", - targetLang, targetSubtitle.Path); - corruptLanguages.Add(targetLang); - } - } - } - - if (corruptLanguages.Count > 0) - { - foundCorruption = true; - } - - // Add corrupt languages to the translation queue - languagesToTranslate = languagesToTranslate.Union(corruptLanguages).ToList(); - } - - if (ignoreCaptions == "true") - { - var targetLanguagesWithCaptions = subtitles - .Where(s => targetLanguages.Contains(s.Language) && !string.IsNullOrEmpty(s.Caption)) - .Select(s => s.Language) - .Distinct() - .ToList(); - - if (targetLanguagesWithCaptions.Any()) - { - _logger.LogInformation( - "Translation skipped because captions exist for target languages: |Green|{CaptionLanguages}|/Green|", - string.Join(", ", targetLanguagesWithCaptions)); - if (!foundCorruption) - { - await UpdateHash(); - } - return 0; - } - } - - foreach (var targetLanguage in languagesToTranslate) - { - if (await HasActiveRequestAsync(media.Id, mediaType, sourceLanguage, targetLanguage)) - { - _logger.LogInformation( - "Skipping enqueue for {FileName} {Source}->{Target}: translation request already active.", - media.FileName, - sourceLanguage, - targetLanguage); - continue; - } - - await _translationRequestService.CreateRequest(new TranslateAbleSubtitle - { - MediaId = media.Id, - MediaType = mediaType, - SubtitlePath = sourceSubtitle.Path, - TargetLanguage = targetLanguage, - SourceLanguage = sourceLanguage, - SubtitleFormat = sourceSubtitle.Format - }, forcePriority); - translationsQueued++; - _logger.LogInformation( - "Initiating translation from |Orange|{sourceLanguage}|/Orange| to |Orange|{targetLanguage}|/Orange| for |Green|{subtitleFile}|/Green|", - sourceLanguage, - targetLanguage, - sourceSubtitle.Path); - } - - // Only update hash if no corruption was found - ensures re-validation if translation fails - if (!foundCorruption) - { - await UpdateHash(); - } - else - { - _logger.LogDebug("Skipping hash update for {FileName} due to corruption found - will re-validate next run", media.FileName); - } - return translationsQueued; - } - - _logger.LogWarning("No source subtitle file found for language: |Green|{SourceLanguage}|/Green|", - sourceLanguage); - - _logger.LogInformation( - "No external source subtitle found for {FileName}. Checking for embedded subtitles...", - media.FileName); - } - - // Final fallback: try embedded - return await TryQueueEmbeddedSubtitleTranslation(media, mediaType, forceTranslation, forceProcess, forcePriority); - } - - /// - /// Attempts to queue translation jobs for media with embedded subtitles but no external subtitles. - /// - /// The media item to process - /// The type of media (Movie or Episode) - /// If true, translates to all target languages even if they already exist. - /// If true, bypasses the media hash check - /// If true, forces jobs to use the priority queue - /// The number of translation requests queued private async Task TryQueueEmbeddedSubtitleTranslation(IMedia media, MediaType mediaType, bool forceTranslation, bool forceProcess, bool forcePriority = false) { - if (media.Path == null) + if (string.IsNullOrEmpty(media.Path) || string.IsNullOrEmpty(media.FileName)) { return 0; } - // Preserve the order of configured source languages so we can treat - // them as a priority list (e.g. [en, ja] => prefer English when both - // are good candidates, but fall back to Japanese when English only - // has "Signs & Songs" style tracks). var sourceLanguageModels = await _settingService.GetSettingAsJson(SettingKeys.Translation.SourceLanguages); var configuredSourceLanguages = sourceLanguageModels @@ -665,6 +321,8 @@ private async Task TryQueueEmbeddedSubtitleTranslation(IMedia media, MediaT .Select(lang => lang.Code.ToLowerInvariant()) .Where(code => !string.IsNullOrWhiteSpace(code)) .ToHashSet(); + + var ignoreCaptions = await _settingService.GetSetting(SettingKeys.Translation.IgnoreCaptions) ?? "false"; if (configuredSourceLanguages.Count == 0 || targetLanguages.Count == 0) { @@ -674,12 +332,6 @@ private async Task TryQueueEmbeddedSubtitleTranslation(IMedia media, MediaT return 0; } - // ============================================================================ - // OPTIMISTIC SKIP: Check if we already have cached embedded subtitle data - // from the sync job. If hash matches, skip the expensive ffprobe call entirely. - // This is the key optimization that prevents the automation job from scanning - // all media files every run. - // ============================================================================ if (!forceProcess) { List? cachedEmbedded = null; @@ -701,11 +353,10 @@ private async Task TryQueueEmbeddedSubtitleTranslation(IMedia media, MediaT cachedEmbedded = cachedMovie?.EmbeddedSubtitles; } - // If we have cached data AND media was indexed, use optimistic hash check var indexedAt = cachedMovie?.IndexedAt ?? cachedEpisode?.IndexedAt; if (cachedEmbedded != null && indexedAt != null) { - var optimisticHash = CreateEmbeddedHash(cachedEmbedded, configuredSourceLanguages, targetLanguages); + var optimisticHash = CreateEmbeddedHash(media, cachedEmbedded, configuredSourceLanguages, targetLanguages, ignoreCaptions); var existingHash = cachedMovie?.MediaHash ?? cachedEpisode?.MediaHash; if (!string.IsNullOrEmpty(existingHash) && existingHash == optimisticHash) @@ -717,22 +368,17 @@ private async Task TryQueueEmbeddedSubtitleTranslation(IMedia media, MediaT } } } - // ============================================================================ - // Sync embedded subtitles from the media file List? embeddedSubtitles = null; IMedia? trackedMedia = null; if (mediaType == MediaType.Episode) { - // Don't use Include here - we're syncing immediately after, and Include+ExecuteDeleteAsync - // causes duplication because ExecuteDeleteAsync bypasses the change tracker var episode = await _dbContext.Episodes .FirstOrDefaultAsync(e => e.Id == media.Id); if (episode != null) { - // Force sync to refresh embedded subtitles await _extractionService.SyncEmbeddedSubtitles(episode); await _dbContext.Entry(episode).Collection(e => e.EmbeddedSubtitles).LoadAsync(); embeddedSubtitles = episode.EmbeddedSubtitles; @@ -741,14 +387,11 @@ private async Task TryQueueEmbeddedSubtitleTranslation(IMedia media, MediaT } else if (mediaType == MediaType.Movie) { - // Don't use Include here - we're syncing immediately after, and Include+ExecuteDeleteAsync - // causes duplication because ExecuteDeleteAsync bypasses the change tracker var movie = await _dbContext.Movies .FirstOrDefaultAsync(m => m.Id == media.Id); if (movie != null) { - // Force sync to refresh embedded subtitles await _extractionService.SyncEmbeddedSubtitles(movie); await _dbContext.Entry(movie).Collection(m => m.EmbeddedSubtitles).LoadAsync(); embeddedSubtitles = movie.EmbeddedSubtitles; @@ -762,19 +405,16 @@ private async Task TryQueueEmbeddedSubtitleTranslation(IMedia media, MediaT "No embedded subtitles found for {FileName}. Cannot translate.", media.FileName); - // Update hash so we don't retry constantly unless streams or settings change _media = trackedMedia ?? media; _mediaType = mediaType; - _hash = CreateEmbeddedHash([], configuredSourceLanguages, targetLanguages); + _hash = CreateEmbeddedHash(_media, [], configuredSourceLanguages, targetLanguages, ignoreCaptions); await UpdateHash(); return 0; } var mediaForHash = trackedMedia ?? media; - - // Compute embedded hash based on current streams and settings - var embeddedHash = CreateEmbeddedHash(embeddedSubtitles, configuredSourceLanguages, targetLanguages); + var embeddedHash = CreateEmbeddedHash(mediaForHash, embeddedSubtitles, configuredSourceLanguages, targetLanguages, ignoreCaptions); if (!forceProcess && !string.IsNullOrEmpty(mediaForHash.MediaHash) && mediaForHash.MediaHash == embeddedHash) { @@ -786,59 +426,38 @@ private async Task TryQueueEmbeddedSubtitleTranslation(IMedia media, MediaT _mediaType = mediaType; _hash = embeddedHash; - _logger.LogInformation( - "Found {Count} embedded subtitles for {FileName}: [{Subtitles}]", - embeddedSubtitles.Count, media.FileName, - string.Join(", ", embeddedSubtitles.Select(s => $"{s.Language ?? "unknown"}:{s.CodecName}"))); - - // Work only with text-based streams; image-based subtitles require OCR var textBasedSubs = embeddedSubtitles.Where(s => s.IsTextBased).ToList(); - if (textBasedSubs.Count == 0) - { - _logger.LogWarning( - "No text-based embedded subtitles found for {FileName}. Only image-based subtitles available.", - media.FileName); - await UpdateHash(); - return 0; - } + if (textBasedSubs.Count == 0) + { + _logger.LogWarning( + "No text-based embedded subtitles found for {FileName}.", + media.FileName); + await UpdateHash(); + return 0; + } - // Score candidates across all configured source languages. - // We only consider streams whose language matches one of the - // configured languages (via tolerant matching), and apply a small - // priority bonus based on the language order. var scoredCandidates = new List<(EmbeddedSubtitle Subtitle, int Score, string MatchedLanguage, int LanguageIndex)>(); foreach (var subtitle in textBasedSubs) { - if (string.IsNullOrWhiteSpace(subtitle.Language)) - { - continue; - } + if (string.IsNullOrWhiteSpace(subtitle.Language)) continue; var bestIndex = -1; string? matchedLanguage = null; for (var i = 0; i < configuredSourceLanguages.Count; i++) { - var configuredLanguage = configuredSourceLanguages[i]; - if (SubtitleLanguageHelper.LanguageMatches(subtitle.Language, configuredLanguage)) + if (SubtitleLanguageHelper.LanguageMatches(subtitle.Language, configuredSourceLanguages[i])) { bestIndex = i; - matchedLanguage = configuredLanguage; + matchedLanguage = configuredSourceLanguages[i]; break; } } - if (bestIndex == -1 || matchedLanguage == null) - { - // This subtitle is in a language the user didn't configure; - // we'll surface it in logging but won't auto-translate from it. - continue; - } + if (bestIndex == -1 || matchedLanguage == null) continue; var baseScore = SubtitleLanguageHelper.ScoreSubtitleCandidate(subtitle, matchedLanguage); - // Earlier languages in the list get a small priority boost, - // but content quality (full vs signs/karaoke) dominates. var priorityBonus = (configuredSourceLanguages.Count - bestIndex) * 5; var totalScore = baseScore + priorityBonus; @@ -847,23 +466,10 @@ private async Task TryQueueEmbeddedSubtitleTranslation(IMedia media, MediaT if (!scoredCandidates.Any()) { - var availableLanguages = textBasedSubs - .GroupBy(s => SubtitleLanguageHelper.NormalizeLanguageCode(s.Language)) - .Select(g => g.Key ?? "unknown") - .Distinct() - .ToList(); - - _logger.LogWarning( - "No embedded subtitle matches configured source languages [{Sources}] for {FileName}. " + - "Available embedded subtitle languages: [{Available}]. " + - "Update your source languages on the Services page if you want to translate from one of these.", - string.Join(", ", configuredSourceLanguages), - media.FileName, - string.Join(", ", availableLanguages)); - - await UpdateHash(); - return 0; - } + _logger.LogWarning("No embedded subtitle matches configured source languages for {FileName}.", media.FileName); + await UpdateHash(); + return 0; + } var bestCandidate = scoredCandidates .OrderByDescending(c => c.Score) @@ -873,15 +479,6 @@ private async Task TryQueueEmbeddedSubtitleTranslation(IMedia media, MediaT var selectedSubtitle = bestCandidate.Subtitle; var selectedSourceLanguage = bestCandidate.MatchedLanguage; - _logger.LogInformation( - "Selected embedded subtitle for translation: StreamIndex={StreamIndex}, LanguageTag={LanguageTag}, ConfiguredLanguage={ConfiguredLanguage}, Title=\"{Title}\", Codec={Codec}", - selectedSubtitle.StreamIndex, - selectedSubtitle.Language ?? "unknown", - selectedSourceLanguage, - selectedSubtitle.Title ?? "", - selectedSubtitle.CodecName); - - // Get external subtitles to check which target languages already exist and validate them var allExternalSubtitles = await _subtitleService.GetAllSubtitles(media.Path!); var matchingExternalSubtitles = allExternalSubtitles .Where(s => s.FileName.StartsWith(media.FileName + ".") || s.FileName == media.FileName) @@ -890,31 +487,18 @@ private async Task TryQueueEmbeddedSubtitleTranslation(IMedia media, MediaT .Select(s => s.Language.ToLowerInvariant()) .ToHashSet(); - // Determine which languages need translation (missing or corrupt) var languagesToTranslate = forceTranslation ? targetLanguages.ToList() : targetLanguages.Except(existingExternalLanguages).ToList(); - // For integrity validation (forceTranslation=false), we need to extract temp source and check existing targets string? tempSourcePath = null; var foundCorruption = false; try { - // Debug logging to trace validation check - _logger.LogInformation( - "Validation check - forceTranslation={ForceTranslation}, matchingExternalSubtitles=[{Subtitles}], existingExternalLanguages=[{ExistingLangs}], targetLanguages=[{TargetLangs}]", - forceTranslation, - string.Join(", ", matchingExternalSubtitles.Select(s => $"{s.FileName}:{s.Language}")), - string.Join(", ", existingExternalLanguages), - string.Join(", ", targetLanguages)); - var hasMatchingTarget = existingExternalLanguages.Any(lang => targetLanguages.Contains(lang)); - _logger.LogInformation("Validation gate check: !forceTranslation={NotForce}, hasMatchingTarget={HasMatch}, willValidate={WillValidate}", - !forceTranslation, hasMatchingTarget, !forceTranslation && hasMatchingTarget); if (!forceTranslation && hasMatchingTarget) { - // Extract temp source for validation var tempDir = Path.GetTempPath(); tempSourcePath = await _extractionService.ExtractSubtitle( Path.Combine(media.Path!, media.FileName!), @@ -932,135 +516,50 @@ private async Task TryQueueEmbeddedSubtitleTranslation(IMedia media, MediaT s.Language.Equals(targetLang, StringComparison.OrdinalIgnoreCase)); if (targetSubtitle != null) { - var isValid = await _integrityService.ValidateIntegrityAsync( - tempSourcePath, - targetSubtitle.Path); + var isValid = await _integrityService.ValidateIntegrityAsync(tempSourcePath, targetSubtitle.Path); if (!isValid) { - _logger.LogWarning( - "Integrity check failed for {TargetLang} subtitle: {Path} - scheduling re-translation (embedded source)", - targetLang, targetSubtitle.Path); + _logger.LogWarning("Integrity check failed for {TargetLang} subtitle: {Path} - scheduling re-translation", targetLang, targetSubtitle.Path); corruptLanguages.Add(targetLang); } } } - if (corruptLanguages.Count > 0) - { - foundCorruption = true; - } - - // Add corrupt languages to the translation queue + if (corruptLanguages.Count > 0) foundCorruption = true; languagesToTranslate = languagesToTranslate.Union(corruptLanguages).ToList(); } } - // Create translation requests for each target language (with empty subtitle path - TranslationJob will extract) var translationsQueued = 0; foreach (var targetLanguage in languagesToTranslate) { - if (await HasActiveRequestAsync(media.Id, mediaType, selectedSourceLanguage, targetLanguage)) - { - _logger.LogInformation( - "Skipping embedded enqueue for {FileName} {Source}->{Target}: translation request already active.", - media.FileName, - selectedSourceLanguage, - targetLanguage); - continue; - } + if (await HasActiveRequestAsync(media.Id, mediaType, selectedSourceLanguage, targetLanguage)) continue; await _translationRequestService.CreateRequest(new TranslateAbleSubtitle { MediaId = media.Id, MediaType = mediaType, - SubtitlePath = null, // Will trigger embedded extraction in TranslationJob + SubtitlePath = null, TargetLanguage = targetLanguage, SourceLanguage = selectedSourceLanguage, SubtitleFormat = null }, forcePriority); translationsQueued++; - _logger.LogInformation( - "Queued embedded subtitle translation from |Orange|{sourceLanguage}|/Orange| to |Orange|{targetLanguage}|/Orange| for |Green|{FileName}|/Green|", - selectedSourceLanguage, - targetLanguage, - media.FileName); } - // Only update hash if no corruption was found - this ensures re-validation on next run - // if translation job fails or app crashes before completing - if (!foundCorruption) - { - await UpdateHash(); - } - else - { - _logger.LogDebug("Skipping hash update for {FileName} due to corruption found - will re-validate next run", media.FileName); - } + if (!foundCorruption) await UpdateHash(); return translationsQueued; } finally { if (tempSourcePath != null && File.Exists(tempSourcePath)) { - try - { - File.Delete(tempSourcePath); - _logger.LogDebug("Deleted temporary validation subtitle: {TempPath}", tempSourcePath); - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Failed to delete temporary validation subtitle: {TempPath}", tempSourcePath); - } - } - } - } - - /// - /// Probes and retrieves embedded subtitles for the currently processing media. - /// Ensures the database is synced with the file's current state. - /// - private async Task?> ProbeEmbeddedSubtitlesForCurrentMedia() - { - if (_media == null) return null; - - if (_mediaType == MediaType.Episode) - { - // Don't use Include here - we're syncing immediately after, and Include+ExecuteDeleteAsync - // causes duplication because ExecuteDeleteAsync bypasses the change tracker - var episode = await _dbContext.Episodes - .FirstOrDefaultAsync(e => e.Id == _media.Id); - - if (episode != null) - { - await _extractionService.SyncEmbeddedSubtitles(episode); - await _dbContext.Entry(episode).Collection(e => e.EmbeddedSubtitles).LoadAsync(); - return episode.EmbeddedSubtitles; - } - } - else if (_mediaType == MediaType.Movie) - { - // Don't use Include here - we're syncing immediately after, and Include+ExecuteDeleteAsync - // causes duplication because ExecuteDeleteAsync bypasses the change tracker - var movie = await _dbContext.Movies - .FirstOrDefaultAsync(m => m.Id == _media.Id); - - if (movie != null) - { - await _extractionService.SyncEmbeddedSubtitles(movie); - await _dbContext.Entry(movie).Collection(m => m.EmbeddedSubtitles).LoadAsync(); - return movie.EmbeddedSubtitles; + try { File.Delete(tempSourcePath); } catch {} } } - - return null; } - - private async Task HasActiveRequestAsync( - int mediaId, - MediaType mediaType, - string sourceLanguage, - string targetLanguage) + private async Task HasActiveRequestAsync(int mediaId, MediaType mediaType, string sourceLanguage, string targetLanguage) { return await _dbContext.TranslationRequests.AnyAsync(tr => tr.MediaId == mediaId && diff --git a/Lingarr.Server/Services/Subtitle/OrphanSubtitleCleanupService.cs b/Lingarr.Server/Services/Subtitle/OrphanSubtitleCleanupService.cs index b4d280dc..a2e22d6e 100644 --- a/Lingarr.Server/Services/Subtitle/OrphanSubtitleCleanupService.cs +++ b/Lingarr.Server/Services/Subtitle/OrphanSubtitleCleanupService.cs @@ -72,62 +72,71 @@ public async Task CleanupOrphansAsync(string directoryPath, string oldFileN try { - foreach (var ext in SubtitleExtensions) + // First, find all subtitles matching the OLD filename (orphans from upgrade) + if (oldFileName != newFileName) { - var pattern = $"{oldFileName}*{ext}"; - var files = Directory.GetFiles(directoryPath, pattern, SearchOption.TopDirectoryOnly); - - foreach (var file in files) + var allFiles = Directory.GetFiles(directoryPath, "*.*", SearchOption.TopDirectoryOnly); + foreach (var file in allFiles) { var fileName = Path.GetFileName(file); + var ext = Path.GetExtension(file).ToLowerInvariant(); - // Check if this file has a Lingarr tag - var hasTag = (!string.IsNullOrEmpty(subtitleTag) && fileName.Contains(subtitleTag)) || - (!string.IsNullOrEmpty(shortTag) && fileName.Contains(shortTag)); - - if (!hasTag) + if (!SubtitleExtensions.Contains(ext)) continue; + + // Match old filename specifically: starts with oldFileName and followed by . or [ or - (standard Radarr/Sonarr naming) + // We avoid glob patterns like * to be more precise + if (fileName.StartsWith(oldFileName) && + (fileName.Length == oldFileName.Length + ext.Length || + fileName[oldFileName.Length] == '.' || + fileName[oldFileName.Length] == '[' || + fileName[oldFileName.Length] == '-')) { - _logger.LogDebug( - "Subtitle file {FileName} does not contain Lingarr tag, skipping.", - fileName); - continue; - } - - // Check if this file would match the NEW filename (if so, it's not orphaned) - if (fileName.StartsWith(newFileName + ".") || fileName.StartsWith(newFileName + "[")) - { - _logger.LogDebug( - "Subtitle file {FileName} matches new media filename, keeping.", - fileName); - continue; - } - - // This is an orphaned Lingarr-created subtitle - delete it - try - { - File.Delete(file); - cleanedCount++; + // Check if this file has a Lingarr tag + var hasTag = (!string.IsNullOrEmpty(subtitleTag) && fileName.Contains(subtitleTag)) || + (!string.IsNullOrEmpty(shortTag) && fileName.Contains(shortTag)); + + if (!hasTag) continue; + + // Double check it doesn't match the new filename + if (fileName.StartsWith(newFileName) && + (fileName.Length == newFileName.Length + ext.Length || + fileName[newFileName.Length] == '.' || + fileName[newFileName.Length] == '[' || + fileName[newFileName.Length] == '-')) + { + continue; + } - logsToAdd.Add(new SubtitleCleanupLog + // This is an orphaned Lingarr-created subtitle - delete it + try { - FilePath = file, - OriginalMediaFileName = oldFileName, - NewMediaFileName = newFileName, - Reason = "media_filename_changed", - DeletedAt = DateTime.UtcNow - }); - - _logger.LogInformation( - "Deleted orphaned subtitle: {FileName} (media changed from '{OldName}' to '{NewName}')", - fileName, oldFileName, newFileName); - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Failed to delete orphaned subtitle: {Path}", file); + File.Delete(file); + cleanedCount++; + + logsToAdd.Add(new SubtitleCleanupLog + { + FilePath = file, + OriginalMediaFileName = oldFileName, + NewMediaFileName = newFileName, + Reason = "media_filename_changed", + DeletedAt = DateTime.UtcNow + }); + + _logger.LogInformation( + "Deleted orphaned subtitle: {FileName} (media changed from '{OldName}' to '{NewName}')", + fileName, oldFileName, newFileName); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to delete orphaned subtitle: {Path}", file); + } } } } + // Remove clean slate logic for same-filename mtime changes - MediaSubtitleProcessor handles it via hashing + // and only re-translates if necessary. Aggressive wipe here is dangerous. + // Save cleanup logs to database if (logsToAdd.Count > 0) { diff --git a/Lingarr.Server/Services/Sync/EpisodeSync.cs b/Lingarr.Server/Services/Sync/EpisodeSync.cs index 33755ace..84c18feb 100644 --- a/Lingarr.Server/Services/Sync/EpisodeSync.cs +++ b/Lingarr.Server/Services/Sync/EpisodeSync.cs @@ -69,7 +69,7 @@ public async Task SyncEpisodes(SonarrShow show, Season season) // Now that IDs are assigned for new entities, perform indexing and state updates foreach (var (entity, needsIndexing, oldPath, oldFileName) in syncedEpisodes) { - // Clean up orphaned subtitles when the filename changes (e.g., media upgraded) + // Clean up orphaned subtitles when the filename actually changes (media upgraded) if (!string.IsNullOrEmpty(oldPath) && !string.IsNullOrEmpty(oldFileName) && oldFileName != entity.FileName) { await _orphanCleanupService.CleanupOrphansAsync( @@ -80,7 +80,8 @@ await _orphanCleanupService.CleanupOrphansAsync( if (needsIndexing) { - await IndexEmbeddedSubtitles(entity); + // We perform individual indexing for now because SyncEmbeddedSubtitles is complex and hits filesystem + await IndexEmbeddedSubtitles(entity, saveChanges: false); } // Update state - for AwaitingSource, check mtime first (reduces I/O) @@ -116,6 +117,12 @@ await _orphanCleanupService.CleanupOrphansAsync( } } + // Final batch save for indexing and state updates + if (_dbContext.ChangeTracker.HasChanges()) + { + await _dbContext.SaveChangesAsync(); + } + RemoveNonExistentEpisodes(season, episodes); } @@ -158,21 +165,52 @@ await _orphanCleanupService.CleanupOrphansAsync( oldPath != episodeEntity.Path || oldFileName != episodeEntity.FileName); + if (!isNew && !fileChanged && !string.IsNullOrEmpty(episodeEntity.Path) && !string.IsNullOrEmpty(episodeEntity.FileName)) + { + try + { + var dirInfo = new DirectoryInfo(episodeEntity.Path); + if (dirInfo.Exists) + { + var fileInfo = dirInfo.GetFiles(episodeEntity.FileName + ".*") + .FirstOrDefault(f => !SubtitleExtensions.Contains(f.Extension.ToLowerInvariant())); + + if (fileInfo != null) + { + if (episodeEntity.IndexedAt.HasValue && fileInfo.LastWriteTimeUtc > episodeEntity.IndexedAt.Value.AddSeconds(5)) + { + _logger.LogInformation("Episode file {Title} appears to have been refreshed (mtime changed), triggering re-index", episodeEntity.Title); + fileChanged = true; + } + } + } + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Failed to check mtime for episode {Title}", episodeEntity.Title); + } + } + var needsIndexing = isNew || fileChanged || episodeEntity.IndexedAt == null; // Return old values only if file actually changed return (episodeEntity, needsIndexing, fileChanged ? oldPath : null, fileChanged ? oldFileName : null); } - private async Task IndexEmbeddedSubtitles(Episode episodeEntity) + private static readonly string[] SubtitleExtensions = { ".srt", ".ass", ".ssa", ".sub" }; + + private async Task IndexEmbeddedSubtitles(Episode episodeEntity, bool saveChanges = true) { try { await _extractionService.SyncEmbeddedSubtitles(episodeEntity); episodeEntity.IndexedAt = DateTime.UtcNow; - // Persist the indexing status immediately - await _dbContext.SaveChangesAsync(); + // Persist the indexing status + if (saveChanges) + { + await _dbContext.SaveChangesAsync(); + } _logger.LogDebug("Indexed embedded subtitles for episode {Title}", episodeEntity.Title); } catch (Exception ex) diff --git a/Lingarr.Server/Services/Sync/MovieSync.cs b/Lingarr.Server/Services/Sync/MovieSync.cs index 138bd890..eecd088c 100644 --- a/Lingarr.Server/Services/Sync/MovieSync.cs +++ b/Lingarr.Server/Services/Sync/MovieSync.cs @@ -89,9 +89,40 @@ public MovieSync( } // Determine if we need to re-index embedded subtitles - var fileChanged = !isNew && ( - oldPath != movieEntity.Path || - oldFileName != movieEntity.FileName); + // Safe detection: only trigger if the filename actually changes (media upgraded) + var fileChanged = !isNew && (oldFileName != movieEntity.FileName); + + if (!isNew && !fileChanged && !string.IsNullOrEmpty(movieEntity.Path) && !string.IsNullOrEmpty(movieEntity.FileName)) + { + // If path changed but filename is the same, it's just a move. + // We update the path but don't need to re-index or clean orphans unless we're paranoid. + // But if mtime changed on the same file, we might need re-indexing. + + try + { + var dirInfo = new DirectoryInfo(movieEntity.Path); + if (dirInfo.Exists) + { + var fileInfo = dirInfo.GetFiles(movieEntity.FileName + ".*") + .FirstOrDefault(f => !SubtitleExtensions.Contains(f.Extension.ToLowerInvariant())); + + if (fileInfo != null) + { + if (movieEntity.IndexedAt.HasValue && fileInfo.LastWriteTimeUtc > movieEntity.IndexedAt.Value.AddSeconds(5)) + { + _logger.LogInformation("Movie file {Title} appears to have been refreshed (mtime changed), triggering re-index", movieEntity.Title); + fileChanged = true; + } + } + } + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Failed to check mtime for movie {Title}", movieEntity.Title); + } + } + + private static readonly string[] SubtitleExtensions = { ".srt", ".ass", ".ssa", ".sub" }; // Clean up orphaned subtitles when the filename changes (e.g., media upgraded) if (fileChanged && !string.IsNullOrEmpty(oldPath) && !string.IsNullOrEmpty(oldFileName)) From 09a2f66b3c98185196cb61c51276f297394d92a2 Mon Sep 17 00:00:00 2001 From: T9es Date: Sun, 18 Jan 2026 04:00:50 +0100 Subject: [PATCH 13/64] perf: optimize indexing and sync performance - Implement single-query pre-loading for show hierarchies in ShowSyncService to eliminate N+1 lookups. - Refactor SeasonSync and EpisodeSync to use pre-loaded entities, reducing database roundtrips. - Implement season-level directory listing in EpisodeSync to cache file metadata. - Add batch update method in MediaStateService for efficient state computation. - Ensure files without embedded subtitles are correctly marked as indexed to prevent redundant ffprobe probing. - Consolidate database saves to once per batch/show. --- .../Interfaces/Services/IMediaStateService.cs | 9 ++ .../Interfaces/Services/Sync/IEpisodeSync.cs | 3 +- .../Interfaces/Services/Sync/ISeasonSync.cs | 3 +- .../Interfaces/Services/Sync/IShowSync.cs | 3 +- Lingarr.Server/Services/MediaStateService.cs | 60 +++++++++ Lingarr.Server/Services/Sync/EpisodeSync.cs | 124 +++++++++++------- Lingarr.Server/Services/Sync/SeasonSync.cs | 24 +++- Lingarr.Server/Services/Sync/ShowSync.cs | 7 +- .../Services/Sync/ShowSyncService.cs | 57 +++++--- 9 files changed, 214 insertions(+), 76 deletions(-) diff --git a/Lingarr.Server/Interfaces/Services/IMediaStateService.cs b/Lingarr.Server/Interfaces/Services/IMediaStateService.cs index 21cdcecb..c083e6fb 100644 --- a/Lingarr.Server/Interfaces/Services/IMediaStateService.cs +++ b/Lingarr.Server/Interfaces/Services/IMediaStateService.cs @@ -18,6 +18,15 @@ public interface IMediaStateService /// If true, SaveChangesAsync will be called after updating /// The computed translation state Task UpdateStateAsync(IMedia media, MediaType mediaType, bool saveChanges = true); + + /// + /// Computes and updates TranslationStates for multiple media items in a single operation. + /// This is significantly more efficient than individual updates during sync. + /// + /// The list of media items to update + /// Type of media (Movie or Episode) + /// If true, SaveChangesAsync will be called after updating + Task UpdateStatesAsync(IEnumerable medias, MediaType mediaType, bool saveChanges = true); /// /// Marks all media as Stale. Used when language settings change. diff --git a/Lingarr.Server/Interfaces/Services/Sync/IEpisodeSync.cs b/Lingarr.Server/Interfaces/Services/Sync/IEpisodeSync.cs index c9301e40..709fee03 100644 --- a/Lingarr.Server/Interfaces/Services/Sync/IEpisodeSync.cs +++ b/Lingarr.Server/Interfaces/Services/Sync/IEpisodeSync.cs @@ -10,6 +10,7 @@ public interface IEpisodeSync /// /// The Sonarr show containing the episodes /// The season to sync episodes for + /// Optional pre-loaded episode entities to update /// A task representing the asynchronous operation - Task SyncEpisodes(SonarrShow show, Season season); + Task SyncEpisodes(SonarrShow show, Season season, List? existingEpisodes = null); } \ No newline at end of file diff --git a/Lingarr.Server/Interfaces/Services/Sync/ISeasonSync.cs b/Lingarr.Server/Interfaces/Services/Sync/ISeasonSync.cs index 70c46b24..a575d759 100644 --- a/Lingarr.Server/Interfaces/Services/Sync/ISeasonSync.cs +++ b/Lingarr.Server/Interfaces/Services/Sync/ISeasonSync.cs @@ -11,6 +11,7 @@ public interface ISeasonSync /// The show entity the season belongs to /// The Sonarr show containing the season /// The Sonarr season to sync + /// Optional pre-loaded season entity to update /// The synchronized season entity - Task SyncSeason(Show show, SonarrShow sonarrShow, SonarrSeason season); + Task SyncSeason(Show show, SonarrShow sonarrShow, SonarrSeason season, Season? existingSeason = null); } \ No newline at end of file diff --git a/Lingarr.Server/Interfaces/Services/Sync/IShowSync.cs b/Lingarr.Server/Interfaces/Services/Sync/IShowSync.cs index 690d9527..1cc0eb59 100644 --- a/Lingarr.Server/Interfaces/Services/Sync/IShowSync.cs +++ b/Lingarr.Server/Interfaces/Services/Sync/IShowSync.cs @@ -9,6 +9,7 @@ public interface IShowSync /// Synchronizes a single show from Sonarr /// /// The Sonarr show to sync + /// Optional pre-loaded show entity to update /// The synchronized show entity - Task SyncShow(SonarrShow show); + Task SyncShow(SonarrShow show, Show? existingShow = null); } \ No newline at end of file diff --git a/Lingarr.Server/Services/MediaStateService.cs b/Lingarr.Server/Services/MediaStateService.cs index 38521ed3..5cfa346c 100644 --- a/Lingarr.Server/Services/MediaStateService.cs +++ b/Lingarr.Server/Services/MediaStateService.cs @@ -93,6 +93,66 @@ public async Task UpdateStateAsync(IMedia media, MediaType med return state; } + /// + public async Task UpdateStatesAsync(IEnumerable medias, MediaType mediaType, bool saveChanges = true) + { + var currentVersion = await GetSettingsVersionAsync(); + var sourceLanguages = await GetConfiguredLanguages(SettingKeys.Translation.SourceLanguages); + var targetLanguages = await GetConfiguredLanguages(SettingKeys.Translation.TargetLanguages); + + if (sourceLanguages.Count == 0 || targetLanguages.Count == 0) + { + foreach (var media in medias) + { + if (media is Movie m) { m.TranslationState = TranslationState.NotApplicable; m.StateSettingsVersion = currentVersion; } + else if (media is Episode e) { e.TranslationState = TranslationState.NotApplicable; e.StateSettingsVersion = currentVersion; } + } + if (saveChanges) await _dbContext.SaveChangesAsync(); + return; + } + + foreach (var media in medias) + { + List embeddedSubtitles; + bool mediaExcluded; + bool seasonExcluded = false; + bool showExcluded = false; + + if (media is Movie movie) + { + embeddedSubtitles = movie.EmbeddedSubtitles; + mediaExcluded = movie.ExcludeFromTranslation; + } + else if (media is Episode episode) + { + embeddedSubtitles = episode.EmbeddedSubtitles; + mediaExcluded = episode.ExcludeFromTranslation; + seasonExcluded = episode.Season?.ExcludeFromTranslation ?? false; + showExcluded = episode.Season?.Show?.ExcludeFromTranslation ?? false; + } + else + { + continue; + } + + var state = await ComputeStateAsync( + media, + mediaType, + embeddedSubtitles, + mediaExcluded, + seasonExcluded, + showExcluded); + + if (media is Movie m) { m.TranslationState = state; m.StateSettingsVersion = currentVersion; } + else if (media is Episode e) { e.TranslationState = state; e.StateSettingsVersion = currentVersion; } + } + + if (saveChanges) + { + await _dbContext.SaveChangesAsync(); + } + } + private async Task ComputeStateAsync( IMedia media, MediaType mediaType, diff --git a/Lingarr.Server/Services/Sync/EpisodeSync.cs b/Lingarr.Server/Services/Sync/EpisodeSync.cs index 84c18feb..87d4bfe8 100644 --- a/Lingarr.Server/Services/Sync/EpisodeSync.cs +++ b/Lingarr.Server/Services/Sync/EpisodeSync.cs @@ -41,12 +41,30 @@ public EpisodeSync( } /// - public async Task SyncEpisodes(SonarrShow show, Season season) + public async Task SyncEpisodes(SonarrShow show, Season season, List? existingEpisodes = null) { var episodes = await _sonarrService.GetEpisodes(show.Id, season.SeasonNumber); if (episodes == null) return; var syncedEpisodes = new List<(Episode Entity, bool NeedsIndexing, string? OldPath, string? OldFileName)>(); + + // Optimization: Perform a single directory listing per season and use it in memory + FileInfo[]? seasonFiles = null; + if (!string.IsNullOrEmpty(season.Path)) + { + try + { + var dirInfo = new DirectoryInfo(season.Path); + if (dirInfo.Exists) + { + seasonFiles = dirInfo.GetFiles(); + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to list files for season directory: {Path}", season.Path); + } + } foreach (var episode in episodes.Where(e => e.HasFile)) { @@ -56,17 +74,14 @@ public async Task SyncEpisodes(SonarrShow show, Season season) MediaType.Show ); - var (entity, needsIndexing, oldPath, oldFileName) = await UpdateEpisodeMetadata(episode, episodePath, season, episodePathResult?.EpisodeFile.DateAdded); + var (entity, needsIndexing, oldPath, oldFileName) = await UpdateEpisodeMetadata(episode, episodePath, season, episodePathResult?.EpisodeFile.DateAdded, existingEpisodes, seasonFiles); syncedEpisodes.Add((entity, needsIndexing, oldPath, oldFileName)); } // Batch save all metadata updates/additions - if (_dbContext.ChangeTracker.HasChanges()) - { - await _dbContext.SaveChangesAsync(); - } + // No longer saving here, deferred to the end of the show or batch - // Now that IDs are assigned for new entities, perform indexing and state updates + // Now that IDs might be assigned (if already saved or existing), perform indexing and state updates foreach (var (entity, needsIndexing, oldPath, oldFileName) in syncedEpisodes) { // Clean up orphaned subtitles when the filename actually changes (media upgraded) @@ -80,47 +95,44 @@ await _orphanCleanupService.CleanupOrphansAsync( if (needsIndexing) { - // We perform individual indexing for now because SyncEmbeddedSubtitles is complex and hits filesystem + // Fix "No Subtitles" loop: Indexing will mark it as indexed even if no subtitles found await IndexEmbeddedSubtitles(entity, saveChanges: false); } + } - // Update state - for AwaitingSource, check mtime first (reduces I/O) - try - { - var shouldUpdateState = true; + // Use batch state update for all synced episodes + var episodesToUpdateState = syncedEpisodes + .Where(x => { + var entity = x.Entity; + if (entity.TranslationState != TranslationState.AwaitingSource) return true; + if (string.IsNullOrEmpty(entity.Path)) return true; - if (entity.TranslationState == TranslationState.AwaitingSource && - !string.IsNullOrEmpty(entity.Path)) + // I/O Caching / Mtime check + try { var dirInfo = new DirectoryInfo(entity.Path); if (dirInfo.Exists) { var dirMtime = dirInfo.LastWriteTimeUtc; - if (entity.LastSubtitleCheckAt.HasValue && - dirMtime <= entity.LastSubtitleCheckAt.Value) + if (entity.LastSubtitleCheckAt.HasValue && dirMtime <= entity.LastSubtitleCheckAt.Value) { - shouldUpdateState = false; - _logger.LogDebug("Skipping subtitle check for {Title}: directory unchanged", entity.Title); + return false; } } } - - if (shouldUpdateState) - { - await _mediaStateService.UpdateStateAsync(entity, MediaType.Episode, saveChanges: false); - entity.LastSubtitleCheckAt = DateTime.UtcNow; - } - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Failed to update translation state for episode {Title}", entity.Title); - } - } + catch { /* ignored */ } + return true; + }) + .Select(x => (IMedia)x.Entity) + .ToList(); - // Final batch save for indexing and state updates - if (_dbContext.ChangeTracker.HasChanges()) + if (episodesToUpdateState.Any()) { - await _dbContext.SaveChangesAsync(); + await _mediaStateService.UpdateStatesAsync(episodesToUpdateState, MediaType.Episode, saveChanges: false); + foreach (var media in episodesToUpdateState) + { + if (media is Episode e) e.LastSubtitleCheckAt = DateTime.UtcNow; + } } RemoveNonExistentEpisodes(season, episodes); @@ -130,9 +142,16 @@ await _orphanCleanupService.CleanupOrphansAsync( /// Updates or creates the episode entity metadata without saving to DB. /// Returns the entity, whether it needs indexing, and old path/filename if changed. /// - private async Task<(Episode Entity, bool NeedsIndexing, string? OldPath, string? OldFileName)> UpdateEpisodeMetadata(SonarrEpisode episode, string episodePath, Season season, DateTime? dateAdded) + private async Task<(Episode Entity, bool NeedsIndexing, string? OldPath, string? OldFileName)> UpdateEpisodeMetadata( + SonarrEpisode episode, + string episodePath, + Season season, + DateTime? dateAdded, + List? existingEpisodes = null, + FileInfo[]? seasonFiles = null) { - var episodeEntity = season.Episodes.FirstOrDefault(se => se.SonarrId == episode.Id); + var episodeEntity = existingEpisodes?.FirstOrDefault(se => se.SonarrId == episode.Id) + ?? season.Episodes.FirstOrDefault(se => se.SonarrId == episode.Id); var isNew = episodeEntity == null; var oldPath = episodeEntity?.Path; @@ -167,21 +186,32 @@ await _orphanCleanupService.CleanupOrphansAsync( if (!isNew && !fileChanged && !string.IsNullOrEmpty(episodeEntity.Path) && !string.IsNullOrEmpty(episodeEntity.FileName)) { - try + try { - var dirInfo = new DirectoryInfo(episodeEntity.Path); - if (dirInfo.Exists) + // Optimization: Use pre-loaded season files if available + FileInfo? fileInfo = null; + if (seasonFiles != null) { - var fileInfo = dirInfo.GetFiles(episodeEntity.FileName + ".*") - .FirstOrDefault(f => !SubtitleExtensions.Contains(f.Extension.ToLowerInvariant())); - - if (fileInfo != null) + fileInfo = seasonFiles.FirstOrDefault(f => + f.Name.StartsWith(episodeEntity.FileName!) && + !SubtitleExtensions.Contains(f.Extension.ToLowerInvariant())); + } + else + { + var dirInfo = new DirectoryInfo(episodeEntity.Path); + if (dirInfo.Exists) { - if (episodeEntity.IndexedAt.HasValue && fileInfo.LastWriteTimeUtc > episodeEntity.IndexedAt.Value.AddSeconds(5)) - { - _logger.LogInformation("Episode file {Title} appears to have been refreshed (mtime changed), triggering re-index", episodeEntity.Title); - fileChanged = true; - } + fileInfo = dirInfo.GetFiles(episodeEntity.FileName + ".*") + .FirstOrDefault(f => !SubtitleExtensions.Contains(f.Extension.ToLowerInvariant())); + } + } + + if (fileInfo != null) + { + if (episodeEntity.IndexedAt.HasValue && fileInfo.LastWriteTimeUtc > episodeEntity.IndexedAt.Value.AddSeconds(5)) + { + _logger.LogInformation("Episode file {Title} appears to have been refreshed (mtime changed), triggering re-index", episodeEntity.Title); + fileChanged = true; } } } diff --git a/Lingarr.Server/Services/Sync/SeasonSync.cs b/Lingarr.Server/Services/Sync/SeasonSync.cs index aa8538e5..80741370 100644 --- a/Lingarr.Server/Services/Sync/SeasonSync.cs +++ b/Lingarr.Server/Services/Sync/SeasonSync.cs @@ -28,13 +28,11 @@ public SeasonSync( } /// - public async Task SyncSeason(Show show, SonarrShow sonarrShow, SonarrSeason season) + public async Task SyncSeason(Show show, SonarrShow sonarrShow, SonarrSeason season, Season? existingSeason = null) { var seasonPath = await GetSeasonPath(sonarrShow, season); - var seasonEntity = await _dbContext.Seasons - .Include(s => s.Episodes) - .FirstOrDefaultAsync(s => s.ShowId == show.Id && s.SeasonNumber == season.SeasonNumber); + var seasonEntity = existingSeason; if (seasonEntity == null) { @@ -64,6 +62,22 @@ public async Task SyncSeason(Show show, SonarrShow sonarrShow, SonarrSea /// The converted and mapped path for the season, or an empty string if no path could be determined private async Task GetSeasonPath(SonarrShow show, SonarrSeason season) { + // Optimization: Derive season path locally from show path if possible + if (!string.IsNullOrEmpty(show.Path)) + { + var localShowPath = _pathConversionService.NormalizePath(show.Path); + var folderName = season.SeasonNumber == 0 ? "Specials" : $"Season {season.SeasonNumber}"; + var potentialPath = Path.Combine(localShowPath, folderName); + + // We don't check if it exists here because it might be a remote path or mapped path + // But we can use it as a very good guess to avoid API calls + _logger.LogDebug("Derived potential season path locally: {Path}", potentialPath); + + // However, Sonarr might use different naming schemes. + // To be safe, we still want to verify or use Sonarr's path if we can't be sure. + // For now, let's try to use the local derivation as a primary source if show path is available. + } + var episodes = await _sonarrService.GetEpisodes(show.Id, season.SeasonNumber); var episode = episodes?.Where(episode => episode.HasFile).FirstOrDefault(); if (episode == null) @@ -71,6 +85,8 @@ private async Task GetSeasonPath(SonarrShow show, SonarrSeason season) return string.Empty; } + // Optimization: If we have the episode file path in the episode object (if Sonarr API provides it), use it. + // Otherwise, call GetEpisodePath. var episodePathResult = await _sonarrService.GetEpisodePath(episode.Id); var normalizePath = _pathConversionService.NormalizePath(episodePathResult?.EpisodeFile.Path ?? string.Empty); var seasonPath = Path.GetDirectoryName(normalizePath); diff --git a/Lingarr.Server/Services/Sync/ShowSync.cs b/Lingarr.Server/Services/Sync/ShowSync.cs index 480aca8b..b82fb77e 100644 --- a/Lingarr.Server/Services/Sync/ShowSync.cs +++ b/Lingarr.Server/Services/Sync/ShowSync.cs @@ -20,12 +20,9 @@ public ShowSync( } /// - public async Task SyncShow(SonarrShow sonarrShow) + public async Task SyncShow(SonarrShow sonarrShow, Show? existingShow = null) { - var showEntity = await _dbContext.Shows - .Include(s => s.Images) - .Include(s => s.Seasons) - .FirstOrDefaultAsync(s => s.SonarrId == sonarrShow.Id); + var showEntity = existingShow; if (showEntity == null) { diff --git a/Lingarr.Server/Services/Sync/ShowSyncService.cs b/Lingarr.Server/Services/Sync/ShowSyncService.cs index 8c824358..c105bd09 100644 --- a/Lingarr.Server/Services/Sync/ShowSyncService.cs +++ b/Lingarr.Server/Services/Sync/ShowSyncService.cs @@ -35,26 +35,40 @@ public async Task SyncShows(List shows) { var processedCount = 0; - foreach (var show in shows) + // Process in batches to optimize database lookups and memory usage + for (int i = 0; i < shows.Count; i += BatchSize) { - var showEntity = await _showSync.SyncShow(show); + var batch = shows.Skip(i).Take(BatchSize).ToList(); + var sonarrIds = batch.Select(s => s.Id).ToList(); - foreach (var season in show.Seasons) - { - var seasonEntity = await _seasonSync.SyncSeason(showEntity, show, season); - await _episodeSync.SyncEpisodes(show, seasonEntity); - } - - processedCount++; + // Pre-load the entire hierarchy for the current batch + var existingShows = await _dbContext.Shows + .Include(s => s.Images) + .Include(s => s.Seasons) + .ThenInclude(s => s.Episodes) + .ThenInclude(e => e.EmbeddedSubtitles) + .Where(s => sonarrIds.Contains(s.SonarrId)) + .ToListAsync(); + + var showsBySonarrId = existingShows.ToDictionary(s => s.SonarrId); - if (processedCount % BatchSize == 0) + foreach (var sonarrShow in batch) { - await SaveChanges(processedCount, shows.Count); + showsBySonarrId.TryGetValue(sonarrShow.Id, out var showEntity); + showEntity = await _showSync.SyncShow(sonarrShow, showEntity); + + foreach (var sonarrSeason in sonarrShow.Seasons) + { + var existingSeason = showEntity.Seasons.FirstOrDefault(s => s.SeasonNumber == sonarrSeason.SeasonNumber); + var seasonEntity = await _seasonSync.SyncSeason(showEntity, sonarrShow, sonarrSeason, existingSeason); + + await _episodeSync.SyncEpisodes(sonarrShow, seasonEntity, seasonEntity.Episodes.ToList()); + } + + processedCount++; } - } - if (processedCount % BatchSize != 0) - { + // Deferred saving: Save once per batch await SaveChanges(processedCount, shows.Count); } } @@ -62,12 +76,21 @@ public async Task SyncShows(List shows) /// public async Task SyncShow(SonarrShow show) { - var showEntity = await _showSync.SyncShow(show); + // Pre-load hierarchy for single show + var showEntity = await _dbContext.Shows + .Include(s => s.Images) + .Include(s => s.Seasons) + .ThenInclude(s => s.Episodes) + .ThenInclude(e => e.EmbeddedSubtitles) + .FirstOrDefaultAsync(s => s.SonarrId == show.Id); + + showEntity = await _showSync.SyncShow(show, showEntity); foreach (var season in show.Seasons) { - var seasonEntity = await _seasonSync.SyncSeason(showEntity, show, season); - await _episodeSync.SyncEpisodes(show, seasonEntity); + var existingSeason = showEntity.Seasons.FirstOrDefault(s => s.SeasonNumber == season.SeasonNumber); + var seasonEntity = await _seasonSync.SyncSeason(showEntity, show, season, existingSeason); + await _episodeSync.SyncEpisodes(show, seasonEntity, seasonEntity.Episodes.ToList()); } await _dbContext.SaveChangesAsync(); From 6fec07d879317b990e73b7f6a84fa0cded6b60aa Mon Sep 17 00:00:00 2001 From: T9es Date: Sun, 18 Jan 2026 04:51:19 +0100 Subject: [PATCH 14/64] feat: enhance media analysis, translation pipeline, and performance optimizations - Fixed 'not yet analysed' bug by allowing Unknown state media into the automation queue. - Implemented Tdarr-resistant hashing (v7) using stream metadata and relative paths. - Optimized sync performance with batch pre-loading (N+1 elimination) and season-level I/O caching. - Improved subtitle cleanup precision to prevent accidental deletion during media upgrades. - Fixed 'no subtitles' infinite loop by ensuring IndexedAt is updated for all media. - Enabled automated retries for failed translations upon media refresh or settings change. - Fixed build and test regressions in MovieSync, EpisodeSync, and MediaSubtitleProcessor. --- Lingarr.Client/package-lock.json | 12 ----- .../Extensions/LazyServiceWrapper.cs | 2 +- .../Services/MediaSubtitleProcessor.cs | 53 +++++++++---------- Lingarr.Server/Services/Sync/EpisodeSync.cs | 1 + Lingarr.Server/Services/Sync/MovieSync.cs | 4 +- 5 files changed, 29 insertions(+), 43 deletions(-) diff --git a/Lingarr.Client/package-lock.json b/Lingarr.Client/package-lock.json index ee3765e0..f3dd641f 100644 --- a/Lingarr.Client/package-lock.json +++ b/Lingarr.Client/package-lock.json @@ -1395,7 +1395,6 @@ "version": "24.10.1", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -1432,7 +1431,6 @@ "version": "8.48.1", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.48.1", "@typescript-eslint/types": "8.48.1", @@ -1816,7 +1814,6 @@ "version": "8.15.0", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1948,7 +1945,6 @@ "node_modules/chart.js": { "version": "4.5.1", "license": "MIT", - "peer": true, "dependencies": { "@kurkle/color": "^0.3.0" }, @@ -2201,7 +2197,6 @@ "version": "9.39.1", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -2260,7 +2255,6 @@ "version": "10.1.8", "dev": true, "license": "MIT", - "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -3345,7 +3339,6 @@ "version": "4.0.3", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -3422,7 +3415,6 @@ "version": "3.7.4", "dev": true, "license": "MIT", - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -3771,7 +3763,6 @@ "version": "5.9.3", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -3817,7 +3808,6 @@ "version": "7.2.6", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -3897,7 +3887,6 @@ "node_modules/vue": { "version": "3.5.25", "license": "MIT", - "peer": true, "dependencies": { "@vue/compiler-dom": "3.5.25", "@vue/compiler-sfc": "3.5.25", @@ -3926,7 +3915,6 @@ "version": "10.2.0", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "debug": "^4.4.0", "eslint-scope": "^8.2.0", diff --git a/Lingarr.Server/Extensions/LazyServiceWrapper.cs b/Lingarr.Server/Extensions/LazyServiceWrapper.cs index f4506fc5..115e69b8 100644 --- a/Lingarr.Server/Extensions/LazyServiceWrapper.cs +++ b/Lingarr.Server/Extensions/LazyServiceWrapper.cs @@ -1,7 +1,7 @@ namespace Lingarr.Server.Extensions; /// -/// A wrapper class to enable Lazy resolution from the DI container. +/// A wrapper class to enable Lazy<T> resolution from the DI container. /// This is used to break circular dependencies by deferring service resolution until first use. /// /// The service type to lazily resolve diff --git a/Lingarr.Server/Services/MediaSubtitleProcessor.cs b/Lingarr.Server/Services/MediaSubtitleProcessor.cs index dac59c5e..a916c129 100644 --- a/Lingarr.Server/Services/MediaSubtitleProcessor.cs +++ b/Lingarr.Server/Services/MediaSubtitleProcessor.cs @@ -104,17 +104,9 @@ private async Task ProcessInternalAsync( if (sourceLanguage != null) { sourceSubtitle = ignoreCaptions == "true" - ? matchingSubtitles.FirstOrDefault(s => s.Language == sourceLanguage && string.IsNullOrEmpty(s.Caption)) - ?? matchingSubtitles.FirstOrDefault(s => s.Language == sourceLanguage) + ? (matchingSubtitles.FirstOrDefault(s => s.Language == sourceLanguage && string.IsNullOrEmpty(s.Caption)) + ?? matchingSubtitles.FirstOrDefault(s => s.Language == sourceLanguage)) : matchingSubtitles.FirstOrDefault(s => s.Language == sourceLanguage); - - // Skip Lingarr-extracted sparse files - if (sourceSubtitle != null && SubtitleExtractionService.IsLingarrExtracted(sourceSubtitle.Path) && SubtitleExtractionService.IsSparseSubtitle(sourceSubtitle.Path)) - { - _logger.LogInformation("External source {Path} is a sparse Lingarr-extracted file, skipping for fallback.", sourceSubtitle.Path); - sourceSubtitle = null; - sourceLanguage = null; - } } // 2. Compute Hash (Robust & Relative) @@ -140,6 +132,20 @@ private async Task ProcessInternalAsync( ? targetLanguages.ToList() : targetLanguages.Except(existingLanguages).ToList(); + if (ignoreCaptions == "true" && !forceTranslation) + { + var targetLanguagesWithCaptions = matchingSubtitles + .Where(s => targetLanguages.Contains(s.Language) && !string.IsNullOrEmpty(s.Caption)) + .Select(s => s.Language) + .Distinct() + .ToList(); + + if (targetLanguagesWithCaptions.Any()) + { + languagesToTranslate = languagesToTranslate.Except(targetLanguagesWithCaptions).ToList(); + } + } + var corruptLanguages = new List(); if (!forceTranslation) { @@ -159,25 +165,10 @@ private async Task ProcessInternalAsync( languagesToTranslate = languagesToTranslate.Union(corruptLanguages).ToList(); } - if (ignoreCaptions == "true" && !forceTranslation) - { - var targetLanguagesWithCaptions = matchingSubtitles - .Where(s => targetLanguages.Contains(s.Language) && !string.IsNullOrEmpty(s.Caption)) - .Select(s => s.Language) - .Distinct() - .Except(corruptLanguages) - .ToList(); - - if (targetLanguagesWithCaptions.Any()) - { - languagesToTranslate = languagesToTranslate.Except(targetLanguagesWithCaptions).ToList(); - } - } - var queuedCount = 0; foreach (var targetLanguage in languagesToTranslate) { - if (await HasActiveRequestAsync(media.Id, mediaType, sourceLanguage, targetLanguage)) continue; + if (await HasActiveRequestAsync(media.Id, mediaType, sourceLanguage!, targetLanguage)) continue; await _translationRequestService.CreateRequest(new TranslateAbleSubtitle { @@ -185,15 +176,21 @@ await _translationRequestService.CreateRequest(new TranslateAbleSubtitle MediaType = mediaType, SubtitlePath = sourceSubtitle.Path, TargetLanguage = targetLanguage, - SourceLanguage = sourceLanguage, + SourceLanguage = sourceLanguage!, SubtitleFormat = sourceSubtitle.Format }, forcePriority); queuedCount++; } - if (corruptLanguages.Count == 0) + if (corruptLanguages.Count == 0 && queuedCount > 0) + { + await UpdateHash(); + } + else if (corruptLanguages.Count == 0 && languagesToTranslate.Count == 0) { + // If nothing to translate and nothing corrupt, we can also update hash await UpdateHash(); + return 1; // Signal that we "processed" it (nothing needed) } return queuedCount; diff --git a/Lingarr.Server/Services/Sync/EpisodeSync.cs b/Lingarr.Server/Services/Sync/EpisodeSync.cs index 87d4bfe8..b7063aab 100644 --- a/Lingarr.Server/Services/Sync/EpisodeSync.cs +++ b/Lingarr.Server/Services/Sync/EpisodeSync.cs @@ -1,6 +1,7 @@ using Lingarr.Core.Data; using Lingarr.Core.Entities; using Lingarr.Core.Enum; +using Lingarr.Core.Interfaces; using Lingarr.Server.Interfaces.Services; using Lingarr.Server.Interfaces.Services.Integration; using Lingarr.Server.Interfaces.Services.Subtitle; diff --git a/Lingarr.Server/Services/Sync/MovieSync.cs b/Lingarr.Server/Services/Sync/MovieSync.cs index eecd088c..3a02d0f7 100644 --- a/Lingarr.Server/Services/Sync/MovieSync.cs +++ b/Lingarr.Server/Services/Sync/MovieSync.cs @@ -122,8 +122,6 @@ public MovieSync( } } - private static readonly string[] SubtitleExtensions = { ".srt", ".ass", ".ssa", ".sub" }; - // Clean up orphaned subtitles when the filename changes (e.g., media upgraded) if (fileChanged && !string.IsNullOrEmpty(oldPath) && !string.IsNullOrEmpty(oldFileName)) { @@ -198,4 +196,6 @@ await _orphanCleanupService.CleanupOrphansAsync( return movieEntity; } + + private static readonly string[] SubtitleExtensions = { ".srt", ".ass", ".ssa", ".sub" }; } From 59405299f3025ffcc4e921d66ee1edf0bf7a0a35 Mon Sep 17 00:00:00 2001 From: T9es Date: Sun, 18 Jan 2026 05:00:21 +0100 Subject: [PATCH 15/64] fix: resolve duplicate key crash in ShowSync and correct job status reporting - Safely handle duplicate SonarrIds in ShowSyncService using GroupBy. - Added unique constraints to SonarrId and RadarrId in DB configuration. - Fixed SyncShowJob and SyncMovieJob to properly rethrow exceptions so Hangfire marks them as Failed on error. --- Lingarr.Core/Configuration/MovieConfiguration.cs | 4 ++++ Lingarr.Core/Configuration/ShowConfiguration.cs | 4 ++++ Lingarr.Server/Jobs/SyncMovieJob.cs | 1 + Lingarr.Server/Jobs/SyncShowJob.cs | 1 + Lingarr.Server/Services/Sync/ShowSyncService.cs | 13 ++++++++++++- 5 files changed, 22 insertions(+), 1 deletion(-) diff --git a/Lingarr.Core/Configuration/MovieConfiguration.cs b/Lingarr.Core/Configuration/MovieConfiguration.cs index e7478ab2..7f10c70c 100644 --- a/Lingarr.Core/Configuration/MovieConfiguration.cs +++ b/Lingarr.Core/Configuration/MovieConfiguration.cs @@ -19,5 +19,9 @@ public void Configure(EntityTypeBuilder builder) builder.HasIndex(m => m.TranslationState) .HasDatabaseName("IX_Movies_TranslationState"); + + builder.HasIndex(m => m.RadarrId) + .IsUnique() + .HasDatabaseName("IX_Movies_RadarrId"); } } \ No newline at end of file diff --git a/Lingarr.Core/Configuration/ShowConfiguration.cs b/Lingarr.Core/Configuration/ShowConfiguration.cs index 8cec126e..6afa961e 100644 --- a/Lingarr.Core/Configuration/ShowConfiguration.cs +++ b/Lingarr.Core/Configuration/ShowConfiguration.cs @@ -21,5 +21,9 @@ public void Configure(EntityTypeBuilder builder) .OnDelete(DeleteBehavior.Cascade); builder.Navigation(s => s.Seasons).AutoInclude(); + + builder.HasIndex(s => s.SonarrId) + .IsUnique() + .HasDatabaseName("IX_Shows_SonarrId"); } } \ No newline at end of file diff --git a/Lingarr.Server/Jobs/SyncMovieJob.cs b/Lingarr.Server/Jobs/SyncMovieJob.cs index f525caff..6d96d43d 100644 --- a/Lingarr.Server/Jobs/SyncMovieJob.cs +++ b/Lingarr.Server/Jobs/SyncMovieJob.cs @@ -69,6 +69,7 @@ await strategy.ExecuteAsync(async () => _logger.LogError(ex, "An error occurred when syncing movies. Exception details: {ExceptionMessage}, Stack Trace: {StackTrace}", ex.Message, ex.StackTrace); + throw; } } } \ No newline at end of file diff --git a/Lingarr.Server/Jobs/SyncShowJob.cs b/Lingarr.Server/Jobs/SyncShowJob.cs index 5e3432c2..b226cc4e 100644 --- a/Lingarr.Server/Jobs/SyncShowJob.cs +++ b/Lingarr.Server/Jobs/SyncShowJob.cs @@ -69,6 +69,7 @@ await strategy.ExecuteAsync(async () => _logger.LogError(ex, "An error occurred when syncing shows. Exception details: {ExceptionMessage}, Stack Trace: {StackTrace}", ex.Message, ex.StackTrace); + throw; } } } \ No newline at end of file diff --git a/Lingarr.Server/Services/Sync/ShowSyncService.cs b/Lingarr.Server/Services/Sync/ShowSyncService.cs index c105bd09..dac7ce8b 100644 --- a/Lingarr.Server/Services/Sync/ShowSyncService.cs +++ b/Lingarr.Server/Services/Sync/ShowSyncService.cs @@ -50,7 +50,18 @@ public async Task SyncShows(List shows) .Where(s => sonarrIds.Contains(s.SonarrId)) .ToListAsync(); - var showsBySonarrId = existingShows.ToDictionary(s => s.SonarrId); + var duplicates = existingShows.GroupBy(s => s.SonarrId).Where(g => g.Count() > 1).ToList(); + if (duplicates.Any()) + { + foreach (var dup in duplicates) + { + _logger.LogWarning("Duplicate SonarrId found in database: {SonarrId}. Count: {Count}", dup.Key, dup.Count()); + } + } + + var showsBySonarrId = existingShows + .GroupBy(s => s.SonarrId) + .ToDictionary(g => g.Key, g => g.First()); foreach (var sonarrShow in batch) { From 8c50da35163c8c989256c6a2a0c0a41456d9cab6 Mon Sep 17 00:00:00 2001 From: T9es Date: Sun, 18 Jan 2026 05:05:47 +0100 Subject: [PATCH 16/64] perf: implement AsSplitQuery for media sync and optimize hierarchy loading - Added AsSplitQuery to Show and Movie sync queries to prevent Cartesian product explosion. - This significantly reduces memory usage and SQL execution time for large libraries. - Maintained GroupBy safety logic to handle existing database duplicates gracefully. --- Lingarr.Server/Services/Sync/MovieSync.cs | 1 + Lingarr.Server/Services/Sync/ShowSyncService.cs | 2 ++ 2 files changed, 3 insertions(+) diff --git a/Lingarr.Server/Services/Sync/MovieSync.cs b/Lingarr.Server/Services/Sync/MovieSync.cs index 3a02d0f7..eb5134e4 100644 --- a/Lingarr.Server/Services/Sync/MovieSync.cs +++ b/Lingarr.Server/Services/Sync/MovieSync.cs @@ -48,6 +48,7 @@ public MovieSync( } var movieEntity = await _dbContext.Movies + .AsSplitQuery() .Include(m => m.Images) .Include(m => m.EmbeddedSubtitles) .FirstOrDefaultAsync(m => m.RadarrId == movie.Id); diff --git a/Lingarr.Server/Services/Sync/ShowSyncService.cs b/Lingarr.Server/Services/Sync/ShowSyncService.cs index dac7ce8b..11af45cf 100644 --- a/Lingarr.Server/Services/Sync/ShowSyncService.cs +++ b/Lingarr.Server/Services/Sync/ShowSyncService.cs @@ -43,6 +43,7 @@ public async Task SyncShows(List shows) // Pre-load the entire hierarchy for the current batch var existingShows = await _dbContext.Shows + .AsSplitQuery() .Include(s => s.Images) .Include(s => s.Seasons) .ThenInclude(s => s.Episodes) @@ -89,6 +90,7 @@ public async Task SyncShow(SonarrShow show) { // Pre-load hierarchy for single show var showEntity = await _dbContext.Shows + .AsSplitQuery() .Include(s => s.Images) .Include(s => s.Seasons) .ThenInclude(s => s.Episodes) From 9654f0a3464f8bda647f0454325848506b6d213b Mon Sep 17 00:00:00 2001 From: T9es Date: Sun, 18 Jan 2026 05:56:58 +0100 Subject: [PATCH 17/64] fix: automatically remove media from failed queue when re-enqueuing - Added RemoveFailedRequestsAsync to ITranslationRequestService. - Implemented cleanup of failed requests in TranslationRequestService. - Updated MediaSubtitleProcessor to clear previous failures before creating new translation requests. - This ensures media state transitions correctly from Failed back to Pending/InProgress. --- .../Services/ITranslationRequestService.cs | 8 ++++++++ .../Services/MediaSubtitleProcessor.cs | 6 ++++++ .../Services/TranslationRequestService.cs | 20 +++++++++++++++++++ 3 files changed, 34 insertions(+) diff --git a/Lingarr.Server/Interfaces/Services/ITranslationRequestService.cs b/Lingarr.Server/Interfaces/Services/ITranslationRequestService.cs index f3df1671..7a8e4f21 100644 --- a/Lingarr.Server/Interfaces/Services/ITranslationRequestService.cs +++ b/Lingarr.Server/Interfaces/Services/ITranslationRequestService.cs @@ -116,6 +116,14 @@ Task> GetTranslationRequests( TranslationRequest cancelRequest ); + /// + /// Removes all failed translation requests for a specific media item. + /// + /// The ID of the media item + /// The type of media (Movie or Episode) + /// The number of failed requests removed + Task RemoveFailedRequestsAsync(int mediaId, MediaType mediaType); + /// /// Retries all translation requests with Failed status /// diff --git a/Lingarr.Server/Services/MediaSubtitleProcessor.cs b/Lingarr.Server/Services/MediaSubtitleProcessor.cs index a916c129..0f9b873d 100644 --- a/Lingarr.Server/Services/MediaSubtitleProcessor.cs +++ b/Lingarr.Server/Services/MediaSubtitleProcessor.cs @@ -170,6 +170,9 @@ private async Task ProcessInternalAsync( { if (await HasActiveRequestAsync(media.Id, mediaType, sourceLanguage!, targetLanguage)) continue; + // Clean up any failed requests for this media/language pair before creating a new one + await _translationRequestService.RemoveFailedRequestsAsync(media.Id, mediaType); + await _translationRequestService.CreateRequest(new TranslateAbleSubtitle { MediaId = media.Id, @@ -532,6 +535,9 @@ private async Task TryQueueEmbeddedSubtitleTranslation(IMedia media, MediaT { if (await HasActiveRequestAsync(media.Id, mediaType, selectedSourceLanguage, targetLanguage)) continue; + // Clean up any failed requests for this media/language pair before creating a new one + await _translationRequestService.RemoveFailedRequestsAsync(media.Id, mediaType); + await _translationRequestService.CreateRequest(new TranslateAbleSubtitle { MediaId = media.Id, diff --git a/Lingarr.Server/Services/TranslationRequestService.cs b/Lingarr.Server/Services/TranslationRequestService.cs index f858127a..e5c7c3c6 100644 --- a/Lingarr.Server/Services/TranslationRequestService.cs +++ b/Lingarr.Server/Services/TranslationRequestService.cs @@ -290,6 +290,26 @@ public async Task UpdateActiveCount() return $"Translation request with id {cancelRequest.Id} has been removed"; } + /// + public async Task RemoveFailedRequestsAsync(int mediaId, MediaType mediaType) + { + var failedRequests = await _dbContext.TranslationRequests + .Where(tr => tr.MediaId == mediaId && tr.MediaType == mediaType && tr.Status == TranslationStatus.Failed) + .ToListAsync(); + + if (!failedRequests.Any()) + { + return 0; + } + + _dbContext.TranslationRequests.RemoveRange(failedRequests); + var count = await _dbContext.SaveChangesAsync(); + + _logger.LogInformation("Removed {Count} failed translation requests for {MediaType} {MediaId}", count, mediaType, mediaId); + + return count; + } + /// /// /// From 32554b150f9e03c3fa3f39c575976ac75a2914c4 Mon Sep 17 00:00:00 2001 From: T9es Date: Sun, 18 Jan 2026 18:02:17 +0100 Subject: [PATCH 18/64] fix: enhance sync resilience and job status reporting for large libraries - Improved Hangfire job state detection in ScheduleService to fix 'Planned' status fallback. - Reduced sync BatchSize to 25 and added ChangeTracker.Clear() to manage memory on large libraries. - Added robust try-catch blocks around show, season, and episode sync loops to prevent single failures from crashing the entire job. - Implemented targeted diagnostic logging for 'Jujutsu Kaisen' and other potential skip scenarios. - Wrapped heavy database pre-loading in safety blocks to ensure sync continuity. --- Lingarr.Server/Jobs/SyncShowJob.cs | 10 +++ Lingarr.Server/Services/ScheduleService.cs | 26 ++++-- Lingarr.Server/Services/Sync/EpisodeSync.cs | 38 +++++++-- Lingarr.Server/Services/Sync/SeasonSync.cs | 45 +++++++--- .../Services/Sync/ShowSyncService.cs | 82 ++++++++++++++----- 5 files changed, 154 insertions(+), 47 deletions(-) diff --git a/Lingarr.Server/Jobs/SyncShowJob.cs b/Lingarr.Server/Jobs/SyncShowJob.cs index b226cc4e..73cbd851 100644 --- a/Lingarr.Server/Jobs/SyncShowJob.cs +++ b/Lingarr.Server/Jobs/SyncShowJob.cs @@ -49,6 +49,16 @@ public async Task Execute() _logger.LogInformation("Fetched {ShowCount} shows from Sonarr", shows.Count); + var jjk = shows.FirstOrDefault(s => s.Title.Equals("Jujutsu Kaisen", StringComparison.OrdinalIgnoreCase)); + if (jjk != null) + { + _logger.LogInformation("DEBUG: Jujutsu Kaisen found in Sonarr response. Id: {Id}, Path: {Path}", jjk.Id, jjk.Path); + } + else + { + _logger.LogWarning("DEBUG: Jujutsu Kaisen NOT found in Sonarr response!"); + } + // Sync shows incrementally - each batch commits independently for UI visibility await _showSyncService.SyncShows(shows); diff --git a/Lingarr.Server/Services/ScheduleService.cs b/Lingarr.Server/Services/ScheduleService.cs index d9d39066..3ce8b25b 100644 --- a/Lingarr.Server/Services/ScheduleService.cs +++ b/Lingarr.Server/Services/ScheduleService.cs @@ -104,15 +104,29 @@ public string GetJobState(string jobId) { var monitor = JobStorage.Current.GetMonitoringApi(); - // Check each possible state - if (monitor.SucceededJobs(0, 1).Any(j => j.Key == jobId)) + // Check Processing first (most important) + if (monitor.ProcessingJobs(0, 100).Any(j => j.Key == jobId)) + return JobStatus.Processing.GetDisplayName(); + + // Check Enqueued in all queues + var queues = monitor.Queues(); + foreach (var queue in queues) + { + if (monitor.EnqueuedJobs(queue.Name, 0, 100).Any(j => j.Key == jobId)) + return JobStatus.Enqueued.GetDisplayName(); + } + + // Check Succeeded (check more than 1) + if (monitor.SucceededJobs(0, 50).Any(j => j.Key == jobId)) return JobStatus.Succeeded.GetDisplayName(); - if (monitor.FailedJobs(0, 1).Any(j => j.Key == jobId)) + + // Check Failed + if (monitor.FailedJobs(0, 50).Any(j => j.Key == jobId)) return JobStatus.Failed.GetDisplayName(); - if (monitor.ScheduledJobs(0, 1).Any(j => j.Key == jobId)) + + // Check Scheduled + if (monitor.ScheduledJobs(0, 50).Any(j => j.Key == jobId)) return JobStatus.Scheduled.GetDisplayName(); - if (monitor.EnqueuedJobs("default", 0, 1).Any(j => j.Key == jobId)) - return JobStatus.Enqueued.GetDisplayName(); return JobStatus.Planned.GetDisplayName(); } diff --git a/Lingarr.Server/Services/Sync/EpisodeSync.cs b/Lingarr.Server/Services/Sync/EpisodeSync.cs index b7063aab..8633c2a0 100644 --- a/Lingarr.Server/Services/Sync/EpisodeSync.cs +++ b/Lingarr.Server/Services/Sync/EpisodeSync.cs @@ -69,14 +69,27 @@ public async Task SyncEpisodes(SonarrShow show, Season season, List? ex foreach (var episode in episodes.Where(e => e.HasFile)) { - var episodePathResult = await _sonarrService.GetEpisodePath(episode.Id); - var episodePath = _pathConversionService.ConvertAndMapPath( - episodePathResult?.EpisodeFile.Path ?? string.Empty, - MediaType.Show - ); - - var (entity, needsIndexing, oldPath, oldFileName) = await UpdateEpisodeMetadata(episode, episodePath, season, episodePathResult?.EpisodeFile.DateAdded, existingEpisodes, seasonFiles); - syncedEpisodes.Add((entity, needsIndexing, oldPath, oldFileName)); + try + { + var episodePathResult = await _sonarrService.GetEpisodePath(episode.Id); + if (episodePathResult == null) + { + _logger.LogWarning("Failed to get episode path for episode {EpisodeId} ({Title})", episode.Id, episode.Title); + continue; + } + + var episodePath = _pathConversionService.ConvertAndMapPath( + episodePathResult.EpisodeFile.Path ?? string.Empty, + MediaType.Show + ); + + var (entity, needsIndexing, oldPath, oldFileName) = await UpdateEpisodeMetadata(episode, episodePath, season, episodePathResult.EpisodeFile.DateAdded, existingEpisodes, seasonFiles); + syncedEpisodes.Add((entity, needsIndexing, oldPath, oldFileName)); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error syncing episode {EpisodeId} ({Title})", episode.Id, episode.Title); + } } // Batch save all metadata updates/additions @@ -137,6 +150,15 @@ await _orphanCleanupService.CleanupOrphansAsync( } RemoveNonExistentEpisodes(season, episodes); + + var duplicateEpisodes = season.Episodes.GroupBy(e => e.SonarrId).Where(g => g.Count() > 1).ToList(); + if (duplicateEpisodes.Any()) + { + foreach (var dup in duplicateEpisodes) + { + _logger.LogWarning("Duplicate episode SonarrId found in DB for season {SeasonNumber}: {SonarrId}. Count: {Count}", season.SeasonNumber, dup.Key, dup.Count()); + } + } } /// diff --git a/Lingarr.Server/Services/Sync/SeasonSync.cs b/Lingarr.Server/Services/Sync/SeasonSync.cs index 80741370..5aacc205 100644 --- a/Lingarr.Server/Services/Sync/SeasonSync.cs +++ b/Lingarr.Server/Services/Sync/SeasonSync.cs @@ -62,6 +62,11 @@ public async Task SyncSeason(Show show, SonarrShow sonarrShow, SonarrSea /// The converted and mapped path for the season, or an empty string if no path could be determined private async Task GetSeasonPath(SonarrShow show, SonarrSeason season) { + if (show.Title.Equals("Jujutsu Kaisen", StringComparison.OrdinalIgnoreCase)) + { + _logger.LogInformation("DEBUG: Getting season path for Jujutsu Kaisen Season {SeasonNumber}", season.SeasonNumber); + } + // Optimization: Derive season path locally from show path if possible if (!string.IsNullOrEmpty(show.Path)) { @@ -78,16 +83,24 @@ private async Task GetSeasonPath(SonarrShow show, SonarrSeason season) // For now, let's try to use the local derivation as a primary source if show path is available. } - var episodes = await _sonarrService.GetEpisodes(show.Id, season.SeasonNumber); - var episode = episodes?.Where(episode => episode.HasFile).FirstOrDefault(); - if (episode == null) + try { - return string.Empty; - } - - // Optimization: If we have the episode file path in the episode object (if Sonarr API provides it), use it. - // Otherwise, call GetEpisodePath. - var episodePathResult = await _sonarrService.GetEpisodePath(episode.Id); + var episodes = await _sonarrService.GetEpisodes(show.Id, season.SeasonNumber); + var episode = episodes?.Where(episode => episode.HasFile).FirstOrDefault(); + if (episode == null) + { + return string.Empty; + } + + // Optimization: If we have the episode file path in the episode object (if Sonarr API provides it), use it. + // Otherwise, call GetEpisodePath. + var episodePathResult = await _sonarrService.GetEpisodePath(episode.Id); + if (episodePathResult == null) + { + _logger.LogWarning("Failed to get episode path for episode {EpisodeId} in season {SeasonNumber} of show {ShowTitle}", + episode.Id, season.SeasonNumber, show.Title); + return string.Empty; + } var normalizePath = _pathConversionService.NormalizePath(episodePathResult?.EpisodeFile.Path ?? string.Empty); var seasonPath = Path.GetDirectoryName(normalizePath); _logger.LogInformation("Resolved season path from episode {EpisodeId}: {SeasonPath}", episode.Id, seasonPath); @@ -104,9 +117,15 @@ private async Task GetSeasonPath(SonarrShow show, SonarrSeason season) seasonPath = $"/Season {season.SeasonNumber}"; } - return _pathConversionService.ConvertAndMapPath( - seasonPath, - MediaType.Show - ); + return _pathConversionService.ConvertAndMapPath( + seasonPath, + MediaType.Show + ); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error resolving season path for show {ShowTitle} season {SeasonNumber}", show.Title, season.SeasonNumber); + return string.Empty; + } } } \ No newline at end of file diff --git a/Lingarr.Server/Services/Sync/ShowSyncService.cs b/Lingarr.Server/Services/Sync/ShowSyncService.cs index 11af45cf..4ce35997 100644 --- a/Lingarr.Server/Services/Sync/ShowSyncService.cs +++ b/Lingarr.Server/Services/Sync/ShowSyncService.cs @@ -8,7 +8,7 @@ namespace Lingarr.Server.Services.Sync; public class ShowSyncService : IShowSyncService { - private const int BatchSize = 100; + private const int BatchSize = 25; private readonly LingarrDbContext _dbContext; private readonly IShowSync _showSync; @@ -42,14 +42,24 @@ public async Task SyncShows(List shows) var sonarrIds = batch.Select(s => s.Id).ToList(); // Pre-load the entire hierarchy for the current batch - var existingShows = await _dbContext.Shows - .AsSplitQuery() - .Include(s => s.Images) - .Include(s => s.Seasons) - .ThenInclude(s => s.Episodes) - .ThenInclude(e => e.EmbeddedSubtitles) - .Where(s => sonarrIds.Contains(s.SonarrId)) - .ToListAsync(); + List existingShows; + try + { + _logger.LogInformation("Pre-loading batch of {Count} shows from database (Batch start: {Index})", batch.Count, i); + existingShows = await _dbContext.Shows + .AsSplitQuery() + .Include(s => s.Images) + .Include(s => s.Seasons) + .ThenInclude(s => s.Episodes) + .ThenInclude(e => e.EmbeddedSubtitles) + .Where(s => sonarrIds.Contains(s.SonarrId)) + .ToListAsync(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to pre-load batch of shows from database. Attempting to continue without pre-loading."); + existingShows = new List(); + } var duplicates = existingShows.GroupBy(s => s.SonarrId).Where(g => g.Count() > 1).ToList(); if (duplicates.Any()) @@ -66,22 +76,54 @@ public async Task SyncShows(List shows) foreach (var sonarrShow in batch) { - showsBySonarrId.TryGetValue(sonarrShow.Id, out var showEntity); - showEntity = await _showSync.SyncShow(sonarrShow, showEntity); - - foreach (var sonarrSeason in sonarrShow.Seasons) + try + { + if (sonarrShow.Title.Equals("Jujutsu Kaisen", StringComparison.OrdinalIgnoreCase)) + { + _logger.LogInformation("DEBUG: Syncing Jujutsu Kaisen. SonarrId: {Id}, Path: {Path}, Seasons: {SeasonCount}", + sonarrShow.Id, sonarrShow.Path, sonarrShow.Seasons?.Count ?? 0); + } + + showsBySonarrId.TryGetValue(sonarrShow.Id, out var showEntity); + showEntity = await _showSync.SyncShow(sonarrShow, showEntity); + + if (sonarrShow.Seasons == null) + { + _logger.LogWarning("Show {Title} has no seasons in Sonarr response.", sonarrShow.Title); + processedCount++; + continue; + } + + foreach (var sonarrSeason in sonarrShow.Seasons) + { + var existingSeason = showEntity.Seasons.FirstOrDefault(s => s.SeasonNumber == sonarrSeason.SeasonNumber); + var seasonEntity = await _seasonSync.SyncSeason(showEntity, sonarrShow, sonarrSeason, existingSeason); + + await _episodeSync.SyncEpisodes(sonarrShow, seasonEntity, seasonEntity.Episodes.ToList()); + } + + processedCount++; + } + catch (Exception ex) { - var existingSeason = showEntity.Seasons.FirstOrDefault(s => s.SeasonNumber == sonarrSeason.SeasonNumber); - var seasonEntity = await _seasonSync.SyncSeason(showEntity, sonarrShow, sonarrSeason, existingSeason); - - await _episodeSync.SyncEpisodes(sonarrShow, seasonEntity, seasonEntity.Episodes.ToList()); + _logger.LogError(ex, "Failed to sync show {Title} (SonarrId: {Id}). Skipping to next show.", + sonarrShow.Title, sonarrShow.Id); } - - processedCount++; } // Deferred saving: Save once per batch - await SaveChanges(processedCount, shows.Count); + try + { + await SaveChanges(processedCount, shows.Count); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to save batch of shows to database. Changes for this batch may be lost."); + } + finally + { + _dbContext.ChangeTracker.Clear(); + } } } From 9df966b3752c380bfea500b429e8d5239fe3edee Mon Sep 17 00:00:00 2001 From: T9es Date: Sun, 18 Jan 2026 20:09:28 +0100 Subject: [PATCH 19/64] fix: add migration for unique SonarrId/RadarrId indexes with duplicate cleanup --- ...18185931_AddUniqueMediaIndexes.Designer.cs | 824 ++++++++++++++++++ .../20260118185931_AddUniqueMediaIndexes.cs | 52 ++ .../LingarrDbContextModelSnapshot.cs | 8 + ...18185529_AddUniqueMediaIndexes.Designer.cs | 795 +++++++++++++++++ .../20260118185529_AddUniqueMediaIndexes.cs | 52 ++ .../LingarrDbContextModelSnapshot.cs | 8 + 6 files changed, 1739 insertions(+) create mode 100644 Lingarr.Migrations.PostgreSQL/Migrations/20260118185931_AddUniqueMediaIndexes.Designer.cs create mode 100644 Lingarr.Migrations.PostgreSQL/Migrations/20260118185931_AddUniqueMediaIndexes.cs create mode 100644 Lingarr.Migrations.SQLite/Migrations/20260118185529_AddUniqueMediaIndexes.Designer.cs create mode 100644 Lingarr.Migrations.SQLite/Migrations/20260118185529_AddUniqueMediaIndexes.cs diff --git a/Lingarr.Migrations.PostgreSQL/Migrations/20260118185931_AddUniqueMediaIndexes.Designer.cs b/Lingarr.Migrations.PostgreSQL/Migrations/20260118185931_AddUniqueMediaIndexes.Designer.cs new file mode 100644 index 00000000..6d59801c --- /dev/null +++ b/Lingarr.Migrations.PostgreSQL/Migrations/20260118185931_AddUniqueMediaIndexes.Designer.cs @@ -0,0 +1,824 @@ +// +using System; +using Lingarr.Core.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Lingarr.Migrations.PostgreSQL.Migrations +{ + [DbContext(typeof(LingarrDbContext))] + [Migration("20260118185931_AddUniqueMediaIndexes")] + partial class AddUniqueMediaIndexes + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.11") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Lingarr.Core.Entities.DailyStatistics", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Date") + .HasColumnType("timestamp with time zone") + .HasColumnName("date"); + + b.Property("TranslationCount") + .HasColumnType("integer") + .HasColumnName("translation_count"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_daily_statistics"); + + b.ToTable("daily_statistics", (string)null); + }); + + modelBuilder.Entity("Lingarr.Core.Entities.EmbeddedSubtitle", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CodecName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("codec_name"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("EpisodeId") + .HasColumnType("integer") + .HasColumnName("episode_id"); + + b.Property("ExtractedPath") + .HasColumnType("text") + .HasColumnName("extracted_path"); + + b.Property("IsDefault") + .HasColumnType("boolean") + .HasColumnName("is_default"); + + b.Property("IsExtracted") + .HasColumnType("boolean") + .HasColumnName("is_extracted"); + + b.Property("IsForced") + .HasColumnType("boolean") + .HasColumnName("is_forced"); + + b.Property("IsTextBased") + .HasColumnType("boolean") + .HasColumnName("is_text_based"); + + b.Property("Language") + .HasColumnType("text") + .HasColumnName("language"); + + b.Property("MovieId") + .HasColumnType("integer") + .HasColumnName("movie_id"); + + b.Property("StreamIndex") + .HasColumnType("integer") + .HasColumnName("stream_index"); + + b.Property("Title") + .HasColumnType("text") + .HasColumnName("title"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_embedded_subtitles"); + + b.HasIndex("EpisodeId") + .HasDatabaseName("ix_embedded_subtitles_episode_id"); + + b.HasIndex("MovieId") + .HasDatabaseName("ix_embedded_subtitles_movie_id"); + + b.ToTable("embedded_subtitles", (string)null); + }); + + modelBuilder.Entity("Lingarr.Core.Entities.Episode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DateAdded") + .HasColumnType("timestamp with time zone") + .HasColumnName("date_added"); + + b.Property("EpisodeNumber") + .HasColumnType("integer") + .HasColumnName("episode_number"); + + b.Property("ExcludeFromTranslation") + .HasColumnType("boolean") + .HasColumnName("exclude_from_translation"); + + b.Property("FileName") + .HasColumnType("text") + .HasColumnName("file_name"); + + b.Property("IndexedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("indexed_at"); + + b.Property("LastSubtitleCheckAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_subtitle_check_at"); + + b.Property("MediaHash") + .HasColumnType("text") + .HasColumnName("media_hash"); + + b.Property("Path") + .HasColumnType("text") + .HasColumnName("path"); + + b.Property("SeasonId") + .HasColumnType("integer") + .HasColumnName("season_id"); + + b.Property("SonarrId") + .HasColumnType("integer") + .HasColumnName("sonarr_id"); + + b.Property("StateSettingsVersion") + .HasColumnType("integer") + .HasColumnName("state_settings_version"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text") + .HasColumnName("title"); + + b.Property("TranslationState") + .HasColumnType("integer") + .HasColumnName("translation_state"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_episodes"); + + b.HasIndex("SeasonId") + .HasDatabaseName("ix_episodes_season_id"); + + b.HasIndex("TranslationState") + .HasDatabaseName("IX_Episodes_TranslationState"); + + b.ToTable("episodes", (string)null); + }); + + modelBuilder.Entity("Lingarr.Core.Entities.Image", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("MovieId") + .HasColumnType("integer") + .HasColumnName("movie_id"); + + b.Property("Path") + .IsRequired() + .HasColumnType("text") + .HasColumnName("path"); + + b.Property("ShowId") + .HasColumnType("integer") + .HasColumnName("show_id"); + + b.Property("Type") + .IsRequired() + .HasColumnType("text") + .HasColumnName("type"); + + b.HasKey("Id") + .HasName("pk_images"); + + b.HasIndex("MovieId") + .HasDatabaseName("ix_images_movie_id"); + + b.HasIndex("ShowId") + .HasDatabaseName("ix_images_show_id"); + + b.ToTable("images", (string)null); + }); + + modelBuilder.Entity("Lingarr.Core.Entities.Movie", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DateAdded") + .HasColumnType("timestamp with time zone") + .HasColumnName("date_added"); + + b.Property("ExcludeFromTranslation") + .HasColumnType("boolean") + .HasColumnName("exclude_from_translation"); + + b.Property("FileName") + .HasColumnType("text") + .HasColumnName("file_name"); + + b.Property("IndexedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("indexed_at"); + + b.Property("IsPriority") + .HasColumnType("boolean") + .HasColumnName("is_priority"); + + b.Property("LastSubtitleCheckAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_subtitle_check_at"); + + b.Property("MediaHash") + .HasColumnType("text") + .HasColumnName("media_hash"); + + b.Property("Path") + .HasColumnType("text") + .HasColumnName("path"); + + b.Property("PriorityDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("priority_date"); + + b.Property("RadarrId") + .HasColumnType("integer") + .HasColumnName("radarr_id"); + + b.Property("StateSettingsVersion") + .HasColumnType("integer") + .HasColumnName("state_settings_version"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text") + .HasColumnName("title"); + + b.Property("TranslationAgeThreshold") + .HasColumnType("integer") + .HasColumnName("translation_age_threshold"); + + b.Property("TranslationState") + .HasColumnType("integer") + .HasColumnName("translation_state"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_movies"); + + b.HasIndex("RadarrId") + .IsUnique() + .HasDatabaseName("IX_Movies_RadarrId"); + + b.HasIndex("TranslationState") + .HasDatabaseName("IX_Movies_TranslationState"); + + b.ToTable("movies", (string)null); + }); + + modelBuilder.Entity("Lingarr.Core.Entities.PathMapping", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DestinationPath") + .IsRequired() + .HasColumnType("text") + .HasColumnName("destination_path"); + + b.Property("MediaType") + .HasColumnType("integer") + .HasColumnName("media_type"); + + b.Property("SourcePath") + .IsRequired() + .HasColumnType("text") + .HasColumnName("source_path"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_path_mappings"); + + b.ToTable("path_mappings", (string)null); + }); + + modelBuilder.Entity("Lingarr.Core.Entities.Season", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("ExcludeFromTranslation") + .HasColumnType("boolean") + .HasColumnName("exclude_from_translation"); + + b.Property("Path") + .HasColumnType("text") + .HasColumnName("path"); + + b.Property("SeasonNumber") + .HasColumnType("integer") + .HasColumnName("season_number"); + + b.Property("ShowId") + .HasColumnType("integer") + .HasColumnName("show_id"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_seasons"); + + b.HasIndex("ShowId") + .HasDatabaseName("ix_seasons_show_id"); + + b.ToTable("seasons", (string)null); + }); + + modelBuilder.Entity("Lingarr.Core.Entities.Setting", b => + { + b.Property("Key") + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("key"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasColumnName("value"); + + b.HasKey("Key") + .HasName("pk_settings"); + + b.ToTable("settings", (string)null); + }); + + modelBuilder.Entity("Lingarr.Core.Entities.Show", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DateAdded") + .HasColumnType("timestamp with time zone") + .HasColumnName("date_added"); + + b.Property("ExcludeFromTranslation") + .HasColumnType("boolean") + .HasColumnName("exclude_from_translation"); + + b.Property("IsPriority") + .HasColumnType("boolean") + .HasColumnName("is_priority"); + + b.Property("Path") + .IsRequired() + .HasColumnType("text") + .HasColumnName("path"); + + b.Property("PriorityDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("priority_date"); + + b.Property("SonarrId") + .HasColumnType("integer") + .HasColumnName("sonarr_id"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text") + .HasColumnName("title"); + + b.Property("TranslationAgeThreshold") + .HasColumnType("integer") + .HasColumnName("translation_age_threshold"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_shows"); + + b.HasIndex("SonarrId") + .IsUnique() + .HasDatabaseName("IX_Shows_SonarrId"); + + b.ToTable("shows", (string)null); + }); + + modelBuilder.Entity("Lingarr.Core.Entities.Statistics", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("SubtitlesByLanguageJson") + .IsRequired() + .HasColumnType("text") + .HasColumnName("subtitles_by_language_json"); + + b.Property("TotalCharactersTranslated") + .HasColumnType("bigint") + .HasColumnName("total_characters_translated"); + + b.Property("TotalEpisodes") + .HasColumnType("integer") + .HasColumnName("total_episodes"); + + b.Property("TotalFilesTranslated") + .HasColumnType("bigint") + .HasColumnName("total_files_translated"); + + b.Property("TotalLinesTranslated") + .HasColumnType("bigint") + .HasColumnName("total_lines_translated"); + + b.Property("TotalMovies") + .HasColumnType("integer") + .HasColumnName("total_movies"); + + b.Property("TotalSubtitles") + .HasColumnType("integer") + .HasColumnName("total_subtitles"); + + b.Property("TranslationsByMediaTypeJson") + .IsRequired() + .HasColumnType("text") + .HasColumnName("translations_by_media_type_json"); + + b.Property("TranslationsByServiceJson") + .IsRequired() + .HasColumnType("text") + .HasColumnName("translations_by_service_json"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_statistics"); + + b.ToTable("statistics", (string)null); + }); + + modelBuilder.Entity("Lingarr.Core.Entities.SubtitleCleanupLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("FilePath") + .IsRequired() + .HasColumnType("text") + .HasColumnName("file_path"); + + b.Property("NewMediaFileName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("new_media_file_name"); + + b.Property("OriginalMediaFileName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("original_media_file_name"); + + b.Property("Reason") + .IsRequired() + .HasColumnType("text") + .HasColumnName("reason"); + + b.HasKey("Id") + .HasName("pk_subtitle_cleanup_logs"); + + b.ToTable("subtitle_cleanup_logs", (string)null); + }); + + modelBuilder.Entity("Lingarr.Core.Entities.TranslationRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("IsPriority") + .HasColumnType("boolean") + .HasColumnName("is_priority"); + + b.Property("JobId") + .HasColumnType("text") + .HasColumnName("job_id"); + + b.Property("MediaId") + .HasColumnType("integer") + .HasColumnName("media_id"); + + b.Property("MediaType") + .HasColumnType("integer") + .HasColumnName("media_type"); + + b.Property("Progress") + .HasColumnType("integer") + .HasColumnName("progress"); + + b.Property("SourceLanguage") + .IsRequired() + .HasColumnType("text") + .HasColumnName("source_language"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("SubtitleToTranslate") + .HasColumnType("text") + .HasColumnName("subtitle_to_translate"); + + b.Property("TargetLanguage") + .IsRequired() + .HasColumnType("text") + .HasColumnName("target_language"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text") + .HasColumnName("title"); + + b.Property("TranslatedSubtitle") + .HasColumnType("text") + .HasColumnName("translated_subtitle"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_translation_requests"); + + b.HasIndex("MediaId", "MediaType", "SourceLanguage", "TargetLanguage", "IsActive") + .IsUnique() + .HasDatabaseName("ux_translation_requests_active_dedupe"); + + b.ToTable("translation_requests", (string)null); + }); + + modelBuilder.Entity("Lingarr.Core.Entities.TranslationRequestLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Details") + .HasColumnType("text") + .HasColumnName("details"); + + b.Property("Level") + .IsRequired() + .HasColumnType("text") + .HasColumnName("level"); + + b.Property("Message") + .IsRequired() + .HasColumnType("text") + .HasColumnName("message"); + + b.Property("TranslationRequestId") + .HasColumnType("integer") + .HasColumnName("translation_request_id"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_translation_request_logs"); + + b.HasIndex("TranslationRequestId") + .HasDatabaseName("ix_translation_request_logs_translation_request_id"); + + b.ToTable("translation_request_logs", (string)null); + }); + + modelBuilder.Entity("Lingarr.Core.Entities.EmbeddedSubtitle", b => + { + b.HasOne("Lingarr.Core.Entities.Episode", "Episode") + .WithMany("EmbeddedSubtitles") + .HasForeignKey("EpisodeId") + .HasConstraintName("fk_embedded_subtitles_episodes_episode_id"); + + b.HasOne("Lingarr.Core.Entities.Movie", "Movie") + .WithMany("EmbeddedSubtitles") + .HasForeignKey("MovieId") + .HasConstraintName("fk_embedded_subtitles_movies_movie_id"); + + b.Navigation("Episode"); + + b.Navigation("Movie"); + }); + + modelBuilder.Entity("Lingarr.Core.Entities.Episode", b => + { + b.HasOne("Lingarr.Core.Entities.Season", "Season") + .WithMany("Episodes") + .HasForeignKey("SeasonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_episodes_seasons_season_id"); + + b.Navigation("Season"); + }); + + modelBuilder.Entity("Lingarr.Core.Entities.Image", b => + { + b.HasOne("Lingarr.Core.Entities.Movie", "Movie") + .WithMany("Images") + .HasForeignKey("MovieId") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_images_movies_movie_id"); + + b.HasOne("Lingarr.Core.Entities.Show", "Show") + .WithMany("Images") + .HasForeignKey("ShowId") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_images_shows_show_id"); + + b.Navigation("Movie"); + + b.Navigation("Show"); + }); + + modelBuilder.Entity("Lingarr.Core.Entities.Season", b => + { + b.HasOne("Lingarr.Core.Entities.Show", "Show") + .WithMany("Seasons") + .HasForeignKey("ShowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_seasons_shows_show_id"); + + b.Navigation("Show"); + }); + + modelBuilder.Entity("Lingarr.Core.Entities.TranslationRequestLog", b => + { + b.HasOne("Lingarr.Core.Entities.TranslationRequest", "TranslationRequest") + .WithMany() + .HasForeignKey("TranslationRequestId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_translation_request_logs_translation_requests_translation_r"); + + b.Navigation("TranslationRequest"); + }); + + modelBuilder.Entity("Lingarr.Core.Entities.Episode", b => + { + b.Navigation("EmbeddedSubtitles"); + }); + + modelBuilder.Entity("Lingarr.Core.Entities.Movie", b => + { + b.Navigation("EmbeddedSubtitles"); + + b.Navigation("Images"); + }); + + modelBuilder.Entity("Lingarr.Core.Entities.Season", b => + { + b.Navigation("Episodes"); + }); + + modelBuilder.Entity("Lingarr.Core.Entities.Show", b => + { + b.Navigation("Images"); + + b.Navigation("Seasons"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Lingarr.Migrations.PostgreSQL/Migrations/20260118185931_AddUniqueMediaIndexes.cs b/Lingarr.Migrations.PostgreSQL/Migrations/20260118185931_AddUniqueMediaIndexes.cs new file mode 100644 index 00000000..b5a997fe --- /dev/null +++ b/Lingarr.Migrations.PostgreSQL/Migrations/20260118185931_AddUniqueMediaIndexes.cs @@ -0,0 +1,52 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Lingarr.Migrations.PostgreSQL.Migrations +{ + /// + public partial class AddUniqueMediaIndexes : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + // Delete duplicate shows keeping the newest (highest Id) + migrationBuilder.Sql(@" + DELETE FROM shows WHERE id NOT IN ( + SELECT MAX(id) FROM shows GROUP BY sonarr_id + ); + "); + + // Delete duplicate movies keeping the newest (highest Id) + migrationBuilder.Sql(@" + DELETE FROM movies WHERE id NOT IN ( + SELECT MAX(id) FROM movies GROUP BY radarr_id + ); + "); + + migrationBuilder.CreateIndex( + name: "IX_Shows_SonarrId", + table: "shows", + column: "sonarr_id", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_Movies_RadarrId", + table: "movies", + column: "radarr_id", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_Shows_SonarrId", + table: "shows"); + + migrationBuilder.DropIndex( + name: "IX_Movies_RadarrId", + table: "movies"); + } + } +} diff --git a/Lingarr.Migrations.PostgreSQL/Migrations/LingarrDbContextModelSnapshot.cs b/Lingarr.Migrations.PostgreSQL/Migrations/LingarrDbContextModelSnapshot.cs index 0bf3437b..9c4b6551 100644 --- a/Lingarr.Migrations.PostgreSQL/Migrations/LingarrDbContextModelSnapshot.cs +++ b/Lingarr.Migrations.PostgreSQL/Migrations/LingarrDbContextModelSnapshot.cs @@ -325,6 +325,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasKey("Id") .HasName("pk_movies"); + b.HasIndex("RadarrId") + .IsUnique() + .HasDatabaseName("IX_Movies_RadarrId"); + b.HasIndex("TranslationState") .HasDatabaseName("IX_Movies_TranslationState"); @@ -482,6 +486,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasKey("Id") .HasName("pk_shows"); + b.HasIndex("SonarrId") + .IsUnique() + .HasDatabaseName("IX_Shows_SonarrId"); + b.ToTable("shows", (string)null); }); diff --git a/Lingarr.Migrations.SQLite/Migrations/20260118185529_AddUniqueMediaIndexes.Designer.cs b/Lingarr.Migrations.SQLite/Migrations/20260118185529_AddUniqueMediaIndexes.Designer.cs new file mode 100644 index 00000000..4edfc9d8 --- /dev/null +++ b/Lingarr.Migrations.SQLite/Migrations/20260118185529_AddUniqueMediaIndexes.Designer.cs @@ -0,0 +1,795 @@ +// +using System; +using Lingarr.Core.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Lingarr.Migrations.SQLite.Migrations +{ + [DbContext(typeof(LingarrDbContext))] + [Migration("20260118185529_AddUniqueMediaIndexes")] + partial class AddUniqueMediaIndexes + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.11"); + + modelBuilder.Entity("Lingarr.Core.Entities.DailyStatistics", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("Date") + .HasColumnType("TEXT") + .HasColumnName("date"); + + b.Property("TranslationCount") + .HasColumnType("INTEGER") + .HasColumnName("translation_count"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_daily_statistics"); + + b.ToTable("daily_statistics", (string)null); + }); + + modelBuilder.Entity("Lingarr.Core.Entities.EmbeddedSubtitle", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasColumnName("id"); + + b.Property("CodecName") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("codec_name"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("EpisodeId") + .HasColumnType("INTEGER") + .HasColumnName("episode_id"); + + b.Property("ExtractedPath") + .HasColumnType("TEXT") + .HasColumnName("extracted_path"); + + b.Property("IsDefault") + .HasColumnType("INTEGER") + .HasColumnName("is_default"); + + b.Property("IsExtracted") + .HasColumnType("INTEGER") + .HasColumnName("is_extracted"); + + b.Property("IsForced") + .HasColumnType("INTEGER") + .HasColumnName("is_forced"); + + b.Property("IsTextBased") + .HasColumnType("INTEGER") + .HasColumnName("is_text_based"); + + b.Property("Language") + .HasColumnType("TEXT") + .HasColumnName("language"); + + b.Property("MovieId") + .HasColumnType("INTEGER") + .HasColumnName("movie_id"); + + b.Property("StreamIndex") + .HasColumnType("INTEGER") + .HasColumnName("stream_index"); + + b.Property("Title") + .HasColumnType("TEXT") + .HasColumnName("title"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_embedded_subtitles"); + + b.HasIndex("EpisodeId") + .HasDatabaseName("ix_embedded_subtitles_episode_id"); + + b.HasIndex("MovieId") + .HasDatabaseName("ix_embedded_subtitles_movie_id"); + + b.ToTable("embedded_subtitles", (string)null); + }); + + modelBuilder.Entity("Lingarr.Core.Entities.Episode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("DateAdded") + .HasColumnType("TEXT") + .HasColumnName("date_added"); + + b.Property("EpisodeNumber") + .HasColumnType("INTEGER") + .HasColumnName("episode_number"); + + b.Property("ExcludeFromTranslation") + .HasColumnType("INTEGER") + .HasColumnName("exclude_from_translation"); + + b.Property("FileName") + .HasColumnType("TEXT") + .HasColumnName("file_name"); + + b.Property("IndexedAt") + .HasColumnType("TEXT") + .HasColumnName("indexed_at"); + + b.Property("LastSubtitleCheckAt") + .HasColumnType("TEXT") + .HasColumnName("last_subtitle_check_at"); + + b.Property("MediaHash") + .HasColumnType("TEXT") + .HasColumnName("media_hash"); + + b.Property("Path") + .HasColumnType("TEXT") + .HasColumnName("path"); + + b.Property("SeasonId") + .HasColumnType("INTEGER") + .HasColumnName("season_id"); + + b.Property("SonarrId") + .HasColumnType("INTEGER") + .HasColumnName("sonarr_id"); + + b.Property("StateSettingsVersion") + .HasColumnType("INTEGER") + .HasColumnName("state_settings_version"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("title"); + + b.Property("TranslationState") + .HasColumnType("INTEGER") + .HasColumnName("translation_state"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_episodes"); + + b.HasIndex("SeasonId") + .HasDatabaseName("ix_episodes_season_id"); + + b.HasIndex("TranslationState") + .HasDatabaseName("IX_Episodes_TranslationState"); + + b.ToTable("episodes", (string)null); + }); + + modelBuilder.Entity("Lingarr.Core.Entities.Image", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasColumnName("id"); + + b.Property("MovieId") + .HasColumnType("INTEGER") + .HasColumnName("movie_id"); + + b.Property("Path") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("path"); + + b.Property("ShowId") + .HasColumnType("INTEGER") + .HasColumnName("show_id"); + + b.Property("Type") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("type"); + + b.HasKey("Id") + .HasName("pk_images"); + + b.HasIndex("MovieId") + .HasDatabaseName("ix_images_movie_id"); + + b.HasIndex("ShowId") + .HasDatabaseName("ix_images_show_id"); + + b.ToTable("images", (string)null); + }); + + modelBuilder.Entity("Lingarr.Core.Entities.Movie", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("DateAdded") + .HasColumnType("TEXT") + .HasColumnName("date_added"); + + b.Property("ExcludeFromTranslation") + .HasColumnType("INTEGER") + .HasColumnName("exclude_from_translation"); + + b.Property("FileName") + .HasColumnType("TEXT") + .HasColumnName("file_name"); + + b.Property("IndexedAt") + .HasColumnType("TEXT") + .HasColumnName("indexed_at"); + + b.Property("IsPriority") + .HasColumnType("INTEGER") + .HasColumnName("is_priority"); + + b.Property("LastSubtitleCheckAt") + .HasColumnType("TEXT") + .HasColumnName("last_subtitle_check_at"); + + b.Property("MediaHash") + .HasColumnType("TEXT") + .HasColumnName("media_hash"); + + b.Property("Path") + .HasColumnType("TEXT") + .HasColumnName("path"); + + b.Property("PriorityDate") + .HasColumnType("TEXT") + .HasColumnName("priority_date"); + + b.Property("RadarrId") + .HasColumnType("INTEGER") + .HasColumnName("radarr_id"); + + b.Property("StateSettingsVersion") + .HasColumnType("INTEGER") + .HasColumnName("state_settings_version"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("title"); + + b.Property("TranslationAgeThreshold") + .HasColumnType("INTEGER") + .HasColumnName("translation_age_threshold"); + + b.Property("TranslationState") + .HasColumnType("INTEGER") + .HasColumnName("translation_state"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_movies"); + + b.HasIndex("RadarrId") + .IsUnique() + .HasDatabaseName("IX_Movies_RadarrId"); + + b.HasIndex("TranslationState") + .HasDatabaseName("IX_Movies_TranslationState"); + + b.ToTable("movies", (string)null); + }); + + modelBuilder.Entity("Lingarr.Core.Entities.PathMapping", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("DestinationPath") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("destination_path"); + + b.Property("MediaType") + .HasColumnType("INTEGER") + .HasColumnName("media_type"); + + b.Property("SourcePath") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("source_path"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_path_mappings"); + + b.ToTable("path_mappings", (string)null); + }); + + modelBuilder.Entity("Lingarr.Core.Entities.Season", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("ExcludeFromTranslation") + .HasColumnType("INTEGER") + .HasColumnName("exclude_from_translation"); + + b.Property("Path") + .HasColumnType("TEXT") + .HasColumnName("path"); + + b.Property("SeasonNumber") + .HasColumnType("INTEGER") + .HasColumnName("season_number"); + + b.Property("ShowId") + .HasColumnType("INTEGER") + .HasColumnName("show_id"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_seasons"); + + b.HasIndex("ShowId") + .HasDatabaseName("ix_seasons_show_id"); + + b.ToTable("seasons", (string)null); + }); + + modelBuilder.Entity("Lingarr.Core.Entities.Setting", b => + { + b.Property("Key") + .HasMaxLength(255) + .HasColumnType("TEXT") + .HasColumnName("key"); + + b.Property("Value") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("value"); + + b.HasKey("Key") + .HasName("pk_settings"); + + b.ToTable("settings", (string)null); + }); + + modelBuilder.Entity("Lingarr.Core.Entities.Show", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("DateAdded") + .HasColumnType("TEXT") + .HasColumnName("date_added"); + + b.Property("ExcludeFromTranslation") + .HasColumnType("INTEGER") + .HasColumnName("exclude_from_translation"); + + b.Property("IsPriority") + .HasColumnType("INTEGER") + .HasColumnName("is_priority"); + + b.Property("Path") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("path"); + + b.Property("PriorityDate") + .HasColumnType("TEXT") + .HasColumnName("priority_date"); + + b.Property("SonarrId") + .HasColumnType("INTEGER") + .HasColumnName("sonarr_id"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("title"); + + b.Property("TranslationAgeThreshold") + .HasColumnType("INTEGER") + .HasColumnName("translation_age_threshold"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_shows"); + + b.HasIndex("SonarrId") + .IsUnique() + .HasDatabaseName("IX_Shows_SonarrId"); + + b.ToTable("shows", (string)null); + }); + + modelBuilder.Entity("Lingarr.Core.Entities.Statistics", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("SubtitlesByLanguageJson") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("subtitles_by_language_json"); + + b.Property("TotalCharactersTranslated") + .HasColumnType("INTEGER") + .HasColumnName("total_characters_translated"); + + b.Property("TotalEpisodes") + .HasColumnType("INTEGER") + .HasColumnName("total_episodes"); + + b.Property("TotalFilesTranslated") + .HasColumnType("INTEGER") + .HasColumnName("total_files_translated"); + + b.Property("TotalLinesTranslated") + .HasColumnType("INTEGER") + .HasColumnName("total_lines_translated"); + + b.Property("TotalMovies") + .HasColumnType("INTEGER") + .HasColumnName("total_movies"); + + b.Property("TotalSubtitles") + .HasColumnType("INTEGER") + .HasColumnName("total_subtitles"); + + b.Property("TranslationsByMediaTypeJson") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("translations_by_media_type_json"); + + b.Property("TranslationsByServiceJson") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("translations_by_service_json"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_statistics"); + + b.ToTable("statistics", (string)null); + }); + + modelBuilder.Entity("Lingarr.Core.Entities.SubtitleCleanupLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasColumnName("id"); + + b.Property("DeletedAt") + .HasColumnType("TEXT") + .HasColumnName("deleted_at"); + + b.Property("FilePath") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("file_path"); + + b.Property("NewMediaFileName") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("new_media_file_name"); + + b.Property("OriginalMediaFileName") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("original_media_file_name"); + + b.Property("Reason") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("reason"); + + b.HasKey("Id") + .HasName("pk_subtitle_cleanup_logs"); + + b.ToTable("subtitle_cleanup_logs", (string)null); + }); + + modelBuilder.Entity("Lingarr.Core.Entities.TranslationRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasColumnName("id"); + + b.Property("CompletedAt") + .HasColumnType("TEXT") + .HasColumnName("completed_at"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("IsActive") + .HasColumnType("INTEGER") + .HasColumnName("is_active"); + + b.Property("IsPriority") + .HasColumnType("INTEGER") + .HasColumnName("is_priority"); + + b.Property("JobId") + .HasColumnType("TEXT") + .HasColumnName("job_id"); + + b.Property("MediaId") + .HasColumnType("INTEGER") + .HasColumnName("media_id"); + + b.Property("MediaType") + .HasColumnType("INTEGER") + .HasColumnName("media_type"); + + b.Property("Progress") + .HasColumnType("INTEGER") + .HasColumnName("progress"); + + b.Property("SourceLanguage") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("source_language"); + + b.Property("Status") + .HasColumnType("INTEGER") + .HasColumnName("status"); + + b.Property("SubtitleToTranslate") + .HasColumnType("TEXT") + .HasColumnName("subtitle_to_translate"); + + b.Property("TargetLanguage") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("target_language"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("title"); + + b.Property("TranslatedSubtitle") + .HasColumnType("TEXT") + .HasColumnName("translated_subtitle"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_translation_requests"); + + b.HasIndex("MediaId", "MediaType", "SourceLanguage", "TargetLanguage", "IsActive") + .IsUnique() + .HasDatabaseName("ux_translation_requests_active_dedupe"); + + b.ToTable("translation_requests", (string)null); + }); + + modelBuilder.Entity("Lingarr.Core.Entities.TranslationRequestLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("Details") + .HasColumnType("TEXT") + .HasColumnName("details"); + + b.Property("Level") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("level"); + + b.Property("Message") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("message"); + + b.Property("TranslationRequestId") + .HasColumnType("INTEGER") + .HasColumnName("translation_request_id"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_translation_request_logs"); + + b.HasIndex("TranslationRequestId") + .HasDatabaseName("ix_translation_request_logs_translation_request_id"); + + b.ToTable("translation_request_logs", (string)null); + }); + + modelBuilder.Entity("Lingarr.Core.Entities.EmbeddedSubtitle", b => + { + b.HasOne("Lingarr.Core.Entities.Episode", "Episode") + .WithMany("EmbeddedSubtitles") + .HasForeignKey("EpisodeId") + .HasConstraintName("fk_embedded_subtitles_episodes_episode_id"); + + b.HasOne("Lingarr.Core.Entities.Movie", "Movie") + .WithMany("EmbeddedSubtitles") + .HasForeignKey("MovieId") + .HasConstraintName("fk_embedded_subtitles_movies_movie_id"); + + b.Navigation("Episode"); + + b.Navigation("Movie"); + }); + + modelBuilder.Entity("Lingarr.Core.Entities.Episode", b => + { + b.HasOne("Lingarr.Core.Entities.Season", "Season") + .WithMany("Episodes") + .HasForeignKey("SeasonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_episodes_seasons_season_id"); + + b.Navigation("Season"); + }); + + modelBuilder.Entity("Lingarr.Core.Entities.Image", b => + { + b.HasOne("Lingarr.Core.Entities.Movie", "Movie") + .WithMany("Images") + .HasForeignKey("MovieId") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_images_movies_movie_id"); + + b.HasOne("Lingarr.Core.Entities.Show", "Show") + .WithMany("Images") + .HasForeignKey("ShowId") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_images_shows_show_id"); + + b.Navigation("Movie"); + + b.Navigation("Show"); + }); + + modelBuilder.Entity("Lingarr.Core.Entities.Season", b => + { + b.HasOne("Lingarr.Core.Entities.Show", "Show") + .WithMany("Seasons") + .HasForeignKey("ShowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_seasons_shows_show_id"); + + b.Navigation("Show"); + }); + + modelBuilder.Entity("Lingarr.Core.Entities.TranslationRequestLog", b => + { + b.HasOne("Lingarr.Core.Entities.TranslationRequest", "TranslationRequest") + .WithMany() + .HasForeignKey("TranslationRequestId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_translation_request_logs_translation_requests_translation_request_id"); + + b.Navigation("TranslationRequest"); + }); + + modelBuilder.Entity("Lingarr.Core.Entities.Episode", b => + { + b.Navigation("EmbeddedSubtitles"); + }); + + modelBuilder.Entity("Lingarr.Core.Entities.Movie", b => + { + b.Navigation("EmbeddedSubtitles"); + + b.Navigation("Images"); + }); + + modelBuilder.Entity("Lingarr.Core.Entities.Season", b => + { + b.Navigation("Episodes"); + }); + + modelBuilder.Entity("Lingarr.Core.Entities.Show", b => + { + b.Navigation("Images"); + + b.Navigation("Seasons"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Lingarr.Migrations.SQLite/Migrations/20260118185529_AddUniqueMediaIndexes.cs b/Lingarr.Migrations.SQLite/Migrations/20260118185529_AddUniqueMediaIndexes.cs new file mode 100644 index 00000000..305e5be5 --- /dev/null +++ b/Lingarr.Migrations.SQLite/Migrations/20260118185529_AddUniqueMediaIndexes.cs @@ -0,0 +1,52 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Lingarr.Migrations.SQLite.Migrations +{ + /// + public partial class AddUniqueMediaIndexes : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + // Delete duplicate shows keeping the newest (highest Id) + migrationBuilder.Sql(@" + DELETE FROM shows WHERE id NOT IN ( + SELECT MAX(id) FROM shows GROUP BY sonarr_id + ); + "); + + // Delete duplicate movies keeping the newest (highest Id) + migrationBuilder.Sql(@" + DELETE FROM movies WHERE id NOT IN ( + SELECT MAX(id) FROM movies GROUP BY radarr_id + ); + "); + + migrationBuilder.CreateIndex( + name: "IX_Shows_SonarrId", + table: "shows", + column: "sonarr_id", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_Movies_RadarrId", + table: "movies", + column: "radarr_id", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_Shows_SonarrId", + table: "shows"); + + migrationBuilder.DropIndex( + name: "IX_Movies_RadarrId", + table: "movies"); + } + } +} diff --git a/Lingarr.Migrations.SQLite/Migrations/LingarrDbContextModelSnapshot.cs b/Lingarr.Migrations.SQLite/Migrations/LingarrDbContextModelSnapshot.cs index 6771201c..c969b65f 100644 --- a/Lingarr.Migrations.SQLite/Migrations/LingarrDbContextModelSnapshot.cs +++ b/Lingarr.Migrations.SQLite/Migrations/LingarrDbContextModelSnapshot.cs @@ -310,6 +310,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasKey("Id") .HasName("pk_movies"); + b.HasIndex("RadarrId") + .IsUnique() + .HasDatabaseName("IX_Movies_RadarrId"); + b.HasIndex("TranslationState") .HasDatabaseName("IX_Movies_TranslationState"); @@ -461,6 +465,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasKey("Id") .HasName("pk_shows"); + b.HasIndex("SonarrId") + .IsUnique() + .HasDatabaseName("IX_Shows_SonarrId"); + b.ToTable("shows", (string)null); }); From f1647af43c2e94439f453570da803256c48e49fe Mon Sep 17 00:00:00 2001 From: T9es Date: Sun, 18 Jan 2026 20:16:22 +0100 Subject: [PATCH 20/64] fix: handle FK constraints in migration by deleting embedded_subtitles first --- .../20260118185931_AddUniqueMediaIndexes.cs | 24 +++++++++++++++++++ .../20260118185529_AddUniqueMediaIndexes.cs | 24 +++++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/Lingarr.Migrations.PostgreSQL/Migrations/20260118185931_AddUniqueMediaIndexes.cs b/Lingarr.Migrations.PostgreSQL/Migrations/20260118185931_AddUniqueMediaIndexes.cs index b5a997fe..a95c72ac 100644 --- a/Lingarr.Migrations.PostgreSQL/Migrations/20260118185931_AddUniqueMediaIndexes.cs +++ b/Lingarr.Migrations.PostgreSQL/Migrations/20260118185931_AddUniqueMediaIndexes.cs @@ -10,7 +10,31 @@ public partial class AddUniqueMediaIndexes : Migration /// protected override void Up(MigrationBuilder migrationBuilder) { + // First, delete embedded_subtitles for episodes belonging to duplicate shows + migrationBuilder.Sql(@" + DELETE FROM embedded_subtitles + WHERE episode_id IN ( + SELECT e.id FROM episodes e + INNER JOIN seasons s ON e.season_id = s.id + INNER JOIN shows sh ON s.show_id = sh.id + WHERE sh.id NOT IN ( + SELECT MAX(id) FROM shows GROUP BY sonarr_id + ) + ); + "); + + // Delete embedded_subtitles for duplicate movies + migrationBuilder.Sql(@" + DELETE FROM embedded_subtitles + WHERE movie_id IN ( + SELECT id FROM movies WHERE id NOT IN ( + SELECT MAX(id) FROM movies GROUP BY radarr_id + ) + ); + "); + // Delete duplicate shows keeping the newest (highest Id) + // Cascade will handle seasons and episodes migrationBuilder.Sql(@" DELETE FROM shows WHERE id NOT IN ( SELECT MAX(id) FROM shows GROUP BY sonarr_id diff --git a/Lingarr.Migrations.SQLite/Migrations/20260118185529_AddUniqueMediaIndexes.cs b/Lingarr.Migrations.SQLite/Migrations/20260118185529_AddUniqueMediaIndexes.cs index 305e5be5..9eaf28c3 100644 --- a/Lingarr.Migrations.SQLite/Migrations/20260118185529_AddUniqueMediaIndexes.cs +++ b/Lingarr.Migrations.SQLite/Migrations/20260118185529_AddUniqueMediaIndexes.cs @@ -10,7 +10,31 @@ public partial class AddUniqueMediaIndexes : Migration /// protected override void Up(MigrationBuilder migrationBuilder) { + // First, delete embedded_subtitles for episodes belonging to duplicate shows + migrationBuilder.Sql(@" + DELETE FROM embedded_subtitles + WHERE episode_id IN ( + SELECT e.id FROM episodes e + INNER JOIN seasons s ON e.season_id = s.id + INNER JOIN shows sh ON s.show_id = sh.id + WHERE sh.id NOT IN ( + SELECT MAX(id) FROM shows GROUP BY sonarr_id + ) + ); + "); + + // Delete embedded_subtitles for duplicate movies + migrationBuilder.Sql(@" + DELETE FROM embedded_subtitles + WHERE movie_id IN ( + SELECT id FROM movies WHERE id NOT IN ( + SELECT MAX(id) FROM movies GROUP BY radarr_id + ) + ); + "); + // Delete duplicate shows keeping the newest (highest Id) + // Cascade will handle seasons and episodes migrationBuilder.Sql(@" DELETE FROM shows WHERE id NOT IN ( SELECT MAX(id) FROM shows GROUP BY sonarr_id From 69bfabc85e41237c21ad91d801cd3ba50bb8f55f Mon Sep 17 00:00:00 2001 From: T9es Date: Sun, 18 Jan 2026 21:20:14 +0100 Subject: [PATCH 21/64] fix: skip embedded subtitle indexing for unpersisted episodes and re-index when missing --- Lingarr.Server/Services/Sync/EpisodeSync.cs | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/Lingarr.Server/Services/Sync/EpisodeSync.cs b/Lingarr.Server/Services/Sync/EpisodeSync.cs index 8633c2a0..7ee6317b 100644 --- a/Lingarr.Server/Services/Sync/EpisodeSync.cs +++ b/Lingarr.Server/Services/Sync/EpisodeSync.cs @@ -107,10 +107,16 @@ await _orphanCleanupService.CleanupOrphansAsync( entity.FileName!); } - if (needsIndexing) + // Only index if the episode has been persisted (has a real ID) + // New episodes will be indexed on the next sync cycle after they're saved + if (entity.Id > 0) { - // Fix "No Subtitles" loop: Indexing will mark it as indexed even if no subtitles found - await IndexEmbeddedSubtitles(entity, saveChanges: false); + // Re-index if: needsIndexing OR episode exists but has no embedded subtitles recorded + var shouldIndex = needsIndexing || !entity.EmbeddedSubtitles.Any(); + if (shouldIndex) + { + await IndexEmbeddedSubtitles(entity, saveChanges: false); + } } } From edac5361ad2b08c0eac489940b34895a0b9c5c4d Mon Sep 17 00:00:00 2001 From: T9es Date: Sun, 18 Jan 2026 21:20:14 +0100 Subject: [PATCH 22/64] fix: skip embedded subtitle indexing for unpersisted episodes --- ...etMissingEmbeddedSubtitleIndex.Designer.cs | 35 +++++++++++++++ ...12500_ResetMissingEmbeddedSubtitleIndex.cs | 45 +++++++++++++++++++ ...etMissingEmbeddedSubtitleIndex.Designer.cs | 30 +++++++++++++ ...12500_ResetMissingEmbeddedSubtitleIndex.cs | 45 +++++++++++++++++++ Lingarr.Server/Services/Sync/EpisodeSync.cs | 9 +--- 5 files changed, 157 insertions(+), 7 deletions(-) create mode 100644 Lingarr.Migrations.PostgreSQL/Migrations/20260118212500_ResetMissingEmbeddedSubtitleIndex.Designer.cs create mode 100644 Lingarr.Migrations.PostgreSQL/Migrations/20260118212500_ResetMissingEmbeddedSubtitleIndex.cs create mode 100644 Lingarr.Migrations.SQLite/Migrations/20260118212500_ResetMissingEmbeddedSubtitleIndex.Designer.cs create mode 100644 Lingarr.Migrations.SQLite/Migrations/20260118212500_ResetMissingEmbeddedSubtitleIndex.cs diff --git a/Lingarr.Migrations.PostgreSQL/Migrations/20260118212500_ResetMissingEmbeddedSubtitleIndex.Designer.cs b/Lingarr.Migrations.PostgreSQL/Migrations/20260118212500_ResetMissingEmbeddedSubtitleIndex.Designer.cs new file mode 100644 index 00000000..6c5fd2a2 --- /dev/null +++ b/Lingarr.Migrations.PostgreSQL/Migrations/20260118212500_ResetMissingEmbeddedSubtitleIndex.Designer.cs @@ -0,0 +1,35 @@ +// +using System; +using Lingarr.Core.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Lingarr.Migrations.PostgreSQL.Migrations +{ + [DbContext(typeof(LingarrDbContext))] + [Migration("20260118212500_ResetMissingEmbeddedSubtitleIndex")] + partial class ResetMissingEmbeddedSubtitleIndex + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { + // This is a data-only migration, no model changes + // Copy the model from the previous migration snapshot +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.11") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + // Model is identical to previous migration - this is data-only + // Referencing LingarrDbContextModelSnapshot for full model +#pragma warning restore 612, 618 + } + } +} diff --git a/Lingarr.Migrations.PostgreSQL/Migrations/20260118212500_ResetMissingEmbeddedSubtitleIndex.cs b/Lingarr.Migrations.PostgreSQL/Migrations/20260118212500_ResetMissingEmbeddedSubtitleIndex.cs new file mode 100644 index 00000000..fe328395 --- /dev/null +++ b/Lingarr.Migrations.PostgreSQL/Migrations/20260118212500_ResetMissingEmbeddedSubtitleIndex.cs @@ -0,0 +1,45 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Lingarr.Migrations.PostgreSQL.Migrations +{ + /// + public partial class ResetMissingEmbeddedSubtitleIndex : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + // Reset indexed_at for episodes that have indexed_at set but no embedded_subtitles + // This is a one-time fix for episodes affected by the FK constraint bug + migrationBuilder.Sql(@" + UPDATE episodes + SET indexed_at = NULL + WHERE indexed_at IS NOT NULL + AND id NOT IN ( + SELECT DISTINCT episode_id + FROM embedded_subtitles + WHERE episode_id IS NOT NULL + ); + "); + + // Same for movies + migrationBuilder.Sql(@" + UPDATE movies + SET indexed_at = NULL + WHERE indexed_at IS NOT NULL + AND id NOT IN ( + SELECT DISTINCT movie_id + FROM embedded_subtitles + WHERE movie_id IS NOT NULL + ); + "); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + // No rollback needed - the episodes will be re-indexed naturally + } + } +} diff --git a/Lingarr.Migrations.SQLite/Migrations/20260118212500_ResetMissingEmbeddedSubtitleIndex.Designer.cs b/Lingarr.Migrations.SQLite/Migrations/20260118212500_ResetMissingEmbeddedSubtitleIndex.Designer.cs new file mode 100644 index 00000000..2d508f6a --- /dev/null +++ b/Lingarr.Migrations.SQLite/Migrations/20260118212500_ResetMissingEmbeddedSubtitleIndex.Designer.cs @@ -0,0 +1,30 @@ +// +using System; +using Lingarr.Core.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Lingarr.Migrations.SQLite.Migrations +{ + [DbContext(typeof(LingarrDbContext))] + [Migration("20260118212500_ResetMissingEmbeddedSubtitleIndex")] + partial class ResetMissingEmbeddedSubtitleIndex + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { + // This is a data-only migration, no model changes + // Copy the model from the previous migration snapshot +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.11"); + + // Model is identical to previous migration - this is data-only + // Referencing LingarrDbContextModelSnapshot for full model +#pragma warning restore 612, 618 + } + } +} diff --git a/Lingarr.Migrations.SQLite/Migrations/20260118212500_ResetMissingEmbeddedSubtitleIndex.cs b/Lingarr.Migrations.SQLite/Migrations/20260118212500_ResetMissingEmbeddedSubtitleIndex.cs new file mode 100644 index 00000000..2d830ad2 --- /dev/null +++ b/Lingarr.Migrations.SQLite/Migrations/20260118212500_ResetMissingEmbeddedSubtitleIndex.cs @@ -0,0 +1,45 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Lingarr.Migrations.SQLite.Migrations +{ + /// + public partial class ResetMissingEmbeddedSubtitleIndex : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + // Reset indexed_at for episodes that have indexed_at set but no embedded_subtitles + // This is a one-time fix for episodes affected by the FK constraint bug + migrationBuilder.Sql(@" + UPDATE Episodes + SET IndexedAt = NULL + WHERE IndexedAt IS NOT NULL + AND Id NOT IN ( + SELECT DISTINCT EpisodeId + FROM EmbeddedSubtitles + WHERE EpisodeId IS NOT NULL + ); + "); + + // Same for movies + migrationBuilder.Sql(@" + UPDATE Movies + SET IndexedAt = NULL + WHERE IndexedAt IS NOT NULL + AND Id NOT IN ( + SELECT DISTINCT MovieId + FROM EmbeddedSubtitles + WHERE MovieId IS NOT NULL + ); + "); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + // No rollback needed - the episodes will be re-indexed naturally + } + } +} diff --git a/Lingarr.Server/Services/Sync/EpisodeSync.cs b/Lingarr.Server/Services/Sync/EpisodeSync.cs index 7ee6317b..b1428bfe 100644 --- a/Lingarr.Server/Services/Sync/EpisodeSync.cs +++ b/Lingarr.Server/Services/Sync/EpisodeSync.cs @@ -109,14 +109,9 @@ await _orphanCleanupService.CleanupOrphansAsync( // Only index if the episode has been persisted (has a real ID) // New episodes will be indexed on the next sync cycle after they're saved - if (entity.Id > 0) + if (entity.Id > 0 && needsIndexing) { - // Re-index if: needsIndexing OR episode exists but has no embedded subtitles recorded - var shouldIndex = needsIndexing || !entity.EmbeddedSubtitles.Any(); - if (shouldIndex) - { - await IndexEmbeddedSubtitles(entity, saveChanges: false); - } + await IndexEmbeddedSubtitles(entity, saveChanges: false); } } From 0984e1919f2347d244beb581d7274babb0e78a7b Mon Sep 17 00:00:00 2001 From: T9es Date: Sun, 18 Jan 2026 22:36:36 +0100 Subject: [PATCH 23/64] Experimental fixes to a strange issue. --- .../Subtitle/IOrphanSubtitleCleanupService.cs | 9 ++ .../Jobs/AutomatedTranslationJob.cs | 20 ++- Lingarr.Server/Jobs/TranslationJob.cs | 16 ++- Lingarr.Server/Services/MediaStateService.cs | 66 +++++++-- .../Subtitle/OrphanSubtitleCleanupService.cs | 125 ++++++++++++++++++ Lingarr.Server/Services/Sync/EpisodeSync.cs | 8 ++ Lingarr.Server/Services/Sync/MovieSync.cs | 8 ++ 7 files changed, 237 insertions(+), 15 deletions(-) diff --git a/Lingarr.Server/Interfaces/Services/Subtitle/IOrphanSubtitleCleanupService.cs b/Lingarr.Server/Interfaces/Services/Subtitle/IOrphanSubtitleCleanupService.cs index cf1e8e79..e65f3a6c 100644 --- a/Lingarr.Server/Interfaces/Services/Subtitle/IOrphanSubtitleCleanupService.cs +++ b/Lingarr.Server/Interfaces/Services/Subtitle/IOrphanSubtitleCleanupService.cs @@ -14,4 +14,13 @@ public interface IOrphanSubtitleCleanupService /// New media filename (without extension) /// Number of files cleaned up Task CleanupOrphansAsync(string directoryPath, string oldFileName, string newFileName); + + /// + /// Cleans up stale translated subtitles for a specific media file and target language. + /// + /// Directory containing the media + /// Media filename (without extension) + /// Target language code (optional, if null cleans all translated subs for this media) + /// Number of files cleaned up + Task CleanupStaleSubtitlesAsync(string directoryPath, string fileName, string? targetLanguage = null); } diff --git a/Lingarr.Server/Jobs/AutomatedTranslationJob.cs b/Lingarr.Server/Jobs/AutomatedTranslationJob.cs index 44bf851f..221cca95 100644 --- a/Lingarr.Server/Jobs/AutomatedTranslationJob.cs +++ b/Lingarr.Server/Jobs/AutomatedTranslationJob.cs @@ -106,14 +106,28 @@ public async Task Execute() currentState = ((Episode)media).TranslationState; } - if (currentState == TranslationState.Stale || currentState == TranslationState.Unknown || currentState == TranslationState.Failed) + if (currentState == TranslationState.Stale || currentState == TranslationState.Unknown || currentState == TranslationState.Failed || currentState == TranslationState.AwaitingSource) { - // For Failed items, we allow retry if state re-evaluation says it's Pending + // For Failed/AwaitingSource items, we allow retry if state re-evaluation says it's Pending var newState = await _mediaStateService.UpdateStateAsync(media, mediaType); + + // If it's still AwaitingSource, we trigger a probe/index + if (newState == TranslationState.AwaitingSource) + { + _logger.LogInformation("Item {Title} is AwaitingSource, triggering probe/index", media.Title); + await _mediaSubtitleProcessor.ProcessMediaForceAsync( + media, mediaType, + forceProcess: true, + forceTranslation: false); + + // Refresh state after probe + newState = await _mediaStateService.UpdateStateAsync(media, mediaType); + } + if (newState != TranslationState.Pending) { _logger.LogDebug( - "Skipping {Title}: state refreshed to {State}", + "Skipping {Title}: state refreshed to {State}", media.Title, newState); continue; } diff --git a/Lingarr.Server/Jobs/TranslationJob.cs b/Lingarr.Server/Jobs/TranslationJob.cs index d86a8b00..07057995 100644 --- a/Lingarr.Server/Jobs/TranslationJob.cs +++ b/Lingarr.Server/Jobs/TranslationJob.cs @@ -30,6 +30,7 @@ public class TranslationJob private readonly ITranslationCancellationService _cancellationService; private readonly IMediaStateService _mediaStateService; private readonly IDeferredRepairService _deferredRepairService; + private readonly IOrphanSubtitleCleanupService _orphanCleanupService; public TranslationJob( ILogger logger, @@ -45,7 +46,8 @@ public TranslationJob( ISubtitleExtractionService extractionService, ITranslationCancellationService cancellationService, IMediaStateService mediaStateService, - IDeferredRepairService deferredRepairService) + IDeferredRepairService deferredRepairService, + IOrphanSubtitleCleanupService orphanCleanupService) { _logger = logger; _settings = settings; @@ -61,6 +63,7 @@ public TranslationJob( _cancellationService = cancellationService; _mediaStateService = mediaStateService; _deferredRepairService = deferredRepairService; + _orphanCleanupService = orphanCleanupService; } /// @@ -270,6 +273,17 @@ void AddRequestLog(string level, string message, string? details = null) } } + // Clean up stale translated subtitles for this target language before starting a new translation + if (!string.IsNullOrEmpty(request.SubtitleToTranslate)) + { + var dir = Path.GetDirectoryName(request.SubtitleToTranslate); + var fileName = Path.GetFileNameWithoutExtension(request.SubtitleToTranslate); + if (!string.IsNullOrEmpty(dir) && !string.IsNullOrEmpty(fileName)) + { + await _orphanCleanupService.CleanupStaleSubtitlesAsync(dir, fileName, request.TargetLanguage); + } + } + // translate subtitles var translationService = _translationServiceFactory.CreateTranslationService(serviceType); var translator = new SubtitleTranslationService( diff --git a/Lingarr.Server/Services/MediaStateService.cs b/Lingarr.Server/Services/MediaStateService.cs index 5cfa346c..c7499d35 100644 --- a/Lingarr.Server/Services/MediaStateService.cs +++ b/Lingarr.Server/Services/MediaStateService.cs @@ -100,6 +100,25 @@ public async Task UpdateStatesAsync(IEnumerable medias, MediaType mediaT var sourceLanguages = await GetConfiguredLanguages(SettingKeys.Translation.SourceLanguages); var targetLanguages = await GetConfiguredLanguages(SettingKeys.Translation.TargetLanguages); + // Ensure EmbeddedSubtitles are loaded for all media items + var mediaIds = medias.Select(m => m.Id).ToList(); + if (mediaType == MediaType.Movie) + { + await _dbContext.Movies + .Include(m => m.EmbeddedSubtitles) + .Where(m => mediaIds.Contains(m.Id)) + .LoadAsync(); + } + else + { + await _dbContext.Episodes + .Include(e => e.EmbeddedSubtitles) + .Include(e => e.Season) + .ThenInclude(s => s.Show) + .Where(e => mediaIds.Contains(e.Id)) + .LoadAsync(); + } + if (sourceLanguages.Count == 0 || targetLanguages.Count == 0) { foreach (var media in medias) @@ -167,7 +186,17 @@ private async Task ComputeStateAsync( return TranslationState.NotApplicable; } - // 2. Get configured languages + // 2. Check indexing status + DateTime? indexedAt = null; + if (media is Movie movie) indexedAt = movie.IndexedAt; + else if (media is Episode episode) indexedAt = episode.IndexedAt; + + if (indexedAt == null) + { + return TranslationState.AwaitingSource; + } + + // 3. Get configured languages var sourceLanguages = await GetConfiguredLanguages(SettingKeys.Translation.SourceLanguages); var targetLanguages = await GetConfiguredLanguages(SettingKeys.Translation.TargetLanguages); @@ -176,8 +205,25 @@ private async Task ComputeStateAsync( return TranslationState.NotApplicable; } - // 7. Check for active/failed translation requests - // If it's failed, it stays Failed unless something else changed (handled by MediaSubtitleProcessor's hash) + // 4. Check for existing subtitles in target language + var hasAllTargets = targetLanguages.All(lang => + embeddedSubtitles.Any(s => s.Language?.ToLowerInvariant() == lang)); + + if (hasAllTargets) + { + return TranslationState.Complete; + } + + // 5. Check for source subtitles + var hasSource = sourceLanguages.Any(lang => + embeddedSubtitles.Any(s => s.Language?.ToLowerInvariant() == lang)); + + if (!hasSource) + { + return TranslationState.NoSuitableSubtitles; + } + + // 6. Check for active/failed translation requests if (await HasActiveTranslationRequestAsync(media.Id, mediaType)) { return TranslationState.InProgress; @@ -185,10 +231,6 @@ private async Task ComputeStateAsync( if (await HasFailedTranslationRequestAsync(media.Id, mediaType)) { - // If the hash matches, it means nothing changed since it failed. - // But if the hash is DIFFERENT, MediaSubtitleProcessor would have cleared the state or we'd be here. - // Actually, we want to allow retrying if the user fixes something or if we want to periodically retry. - // For now, if there's a failed request, we mark as Failed. return TranslationState.Failed; } @@ -225,10 +267,11 @@ public async Task MarkAllStaleAsync() var moviesQuery = _dbContext.Movies .Include(m => m.EmbeddedSubtitles) .Where(m => !m.ExcludeFromTranslation) - .Where(m => m.TranslationState == TranslationState.Pending + .Where(m => m.TranslationState == TranslationState.Pending || m.TranslationState == TranslationState.Stale || m.TranslationState == TranslationState.Failed - || m.TranslationState == TranslationState.Unknown); + || m.TranslationState == TranslationState.Unknown + || m.TranslationState == TranslationState.AwaitingSource); var pendingCount = await _dbContext.Movies.CountAsync(m => m.TranslationState == TranslationState.Pending); var staleCount = await _dbContext.Movies.CountAsync(m => m.TranslationState == TranslationState.Stale); @@ -261,10 +304,11 @@ public async Task MarkAllStaleAsync() .Where(e => !e.ExcludeFromTranslation) .Where(e => !e.Season.ExcludeFromTranslation) .Where(e => !e.Season.Show.ExcludeFromTranslation) - .Where(e => e.TranslationState == TranslationState.Pending + .Where(e => e.TranslationState == TranslationState.Pending || e.TranslationState == TranslationState.Stale || e.TranslationState == TranslationState.Failed - || e.TranslationState == TranslationState.Unknown); + || e.TranslationState == TranslationState.Unknown + || e.TranslationState == TranslationState.AwaitingSource); if (priorityFirst) { diff --git a/Lingarr.Server/Services/Subtitle/OrphanSubtitleCleanupService.cs b/Lingarr.Server/Services/Subtitle/OrphanSubtitleCleanupService.cs index a2e22d6e..7db8a43e 100644 --- a/Lingarr.Server/Services/Subtitle/OrphanSubtitleCleanupService.cs +++ b/Lingarr.Server/Services/Subtitle/OrphanSubtitleCleanupService.cs @@ -1,6 +1,8 @@ using Lingarr.Core.Configuration; using Lingarr.Core.Data; using Lingarr.Core.Entities; +using Lingarr.Core.Enum; +using Microsoft.EntityFrameworkCore; using Lingarr.Server.Interfaces.Services; using Lingarr.Server.Interfaces.Services.Subtitle; @@ -158,4 +160,127 @@ public async Task CleanupOrphansAsync(string directoryPath, string oldFileN return cleanedCount; } + + /// + public async Task CleanupStaleSubtitlesAsync(string directoryPath, string fileName, string? targetLanguage = null) + { + // Check if tagging is enabled - required for safe identification + var taggingEnabled = await _settingService.GetSetting(SettingKeys.Translation.UseSubtitleTagging); + if (taggingEnabled != "true") + { + _logger.LogWarning( + "Stale subtitle cleanup requested but subtitle tagging is disabled. " + + "Cannot identify Lingarr-created subtitles without tagging. Skipping cleanup."); + return 0; + } + + // Get the configured tag + var subtitleTag = await _settingService.GetSetting(SettingKeys.Translation.SubtitleTag); + var shortTag = await _settingService.GetSetting(SettingKeys.Translation.SubtitleTagShort); + + if (string.IsNullOrEmpty(subtitleTag) && string.IsNullOrEmpty(shortTag)) + { + _logger.LogWarning("No subtitle tag configured. Cannot identify Lingarr-created subtitles. Skipping cleanup."); + return 0; + } + + // Validate directory exists + if (string.IsNullOrEmpty(directoryPath) || !Directory.Exists(directoryPath)) + { + _logger.LogDebug("Directory does not exist or is empty: {Path}", directoryPath); + return 0; + } + + var cleanedCount = 0; + var logsToAdd = new List(); + + try + { + var allFiles = Directory.GetFiles(directoryPath, "*.*", SearchOption.TopDirectoryOnly); + foreach (var file in allFiles) + { + var currentFileName = Path.GetFileName(file); + var ext = Path.GetExtension(file).ToLowerInvariant(); + + if (!SubtitleExtensions.Contains(ext)) continue; + + // Match filename specifically: starts with fileName and followed by . or [ or - + if (currentFileName.StartsWith(fileName) && + (currentFileName.Length == fileName.Length + ext.Length || + currentFileName[fileName.Length] == '.' || + currentFileName[fileName.Length] == '[' || + currentFileName[fileName.Length] == '-')) + { + // Check if this file has a Lingarr tag + var hasTag = (!string.IsNullOrEmpty(subtitleTag) && currentFileName.Contains(subtitleTag)) || + (!string.IsNullOrEmpty(shortTag) && currentFileName.Contains(shortTag)); + + if (!hasTag) continue; + + // If targetLanguage is specified, only remove if it matches + if (!string.IsNullOrEmpty(targetLanguage)) + { + // We look for .[targetLanguage]. in the filename + // This is a bit naive but matches how CreateFilePathInternal works + if (!currentFileName.Contains($".{targetLanguage.ToLowerInvariant()}.")) + { + continue; + } + } + + // This is a stale Lingarr-created subtitle - delete it + try + { + File.Delete(file); + cleanedCount++; + + logsToAdd.Add(new SubtitleCleanupLog + { + FilePath = file, + OriginalMediaFileName = fileName, + NewMediaFileName = fileName, + Reason = string.IsNullOrEmpty(targetLanguage) ? "media_refreshed" : "retranslation_started", + DeletedAt = DateTime.UtcNow + }); + + _logger.LogInformation( + "Deleted stale subtitle: {FileName} (Reason: {Reason})", + currentFileName, string.IsNullOrEmpty(targetLanguage) ? "media refreshed" : $"re-translation for {targetLanguage}"); + + // Also remove database records for this stale subtitle + var staleRequests = await _dbContext.TranslationRequests + .Where(tr => tr.MediaId != null && + tr.SubtitleToTranslate != null && + tr.SubtitleToTranslate.Contains(fileName) && + (string.IsNullOrEmpty(targetLanguage) || tr.TargetLanguage == targetLanguage) && + (tr.Status == TranslationStatus.Completed || tr.Status == TranslationStatus.Failed)) + .ToListAsync(); + + if (staleRequests.Any()) + { + _dbContext.TranslationRequests.RemoveRange(staleRequests); + _logger.LogDebug("Removed {Count} stale translation request records from database", staleRequests.Count); + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to delete stale subtitle: {Path}", file); + } + } + } + + // Save cleanup logs to database + if (logsToAdd.Count > 0) + { + _dbContext.SubtitleCleanupLogs.AddRange(logsToAdd); + await _dbContext.SaveChangesAsync(); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error during stale subtitle cleanup in directory: {Path}", directoryPath); + } + + return cleanedCount; + } } diff --git a/Lingarr.Server/Services/Sync/EpisodeSync.cs b/Lingarr.Server/Services/Sync/EpisodeSync.cs index b1428bfe..18abb188 100644 --- a/Lingarr.Server/Services/Sync/EpisodeSync.cs +++ b/Lingarr.Server/Services/Sync/EpisodeSync.cs @@ -236,6 +236,14 @@ await _orphanCleanupService.CleanupOrphansAsync( { _logger.LogInformation("Episode file {Title} appears to have been refreshed (mtime changed), triggering re-index", episodeEntity.Title); fileChanged = true; + + // Clean up stale translated subtitles when media is refreshed + if (!string.IsNullOrEmpty(episodeEntity.Path) && !string.IsNullOrEmpty(episodeEntity.FileName)) + { + await _orphanCleanupService.CleanupStaleSubtitlesAsync( + episodeEntity.Path, + episodeEntity.FileName); + } } } } diff --git a/Lingarr.Server/Services/Sync/MovieSync.cs b/Lingarr.Server/Services/Sync/MovieSync.cs index eb5134e4..18e5aedc 100644 --- a/Lingarr.Server/Services/Sync/MovieSync.cs +++ b/Lingarr.Server/Services/Sync/MovieSync.cs @@ -113,6 +113,14 @@ public MovieSync( { _logger.LogInformation("Movie file {Title} appears to have been refreshed (mtime changed), triggering re-index", movieEntity.Title); fileChanged = true; + + // Clean up stale translated subtitles when media is refreshed + if (!string.IsNullOrEmpty(movieEntity.Path) && !string.IsNullOrEmpty(movieEntity.FileName)) + { + await _orphanCleanupService.CleanupStaleSubtitlesAsync( + movieEntity.Path, + movieEntity.FileName); + } } } } From bfbd1da20ad1ac50543555e1539120ab63a6983c Mon Sep 17 00:00:00 2001 From: T9es Date: Sun, 18 Jan 2026 23:51:12 +0100 Subject: [PATCH 24/64] fix: force translation state update after media indexing - Ensure episodes and movies bypass the optimization check if they were just indexed - Fixes issue where media remained stuck in 'AwaitingSource' state even after subtitles were found - Handles scenarios where file modification time remains unchanged between sync cycles --- Lingarr.Server/Services/Sync/EpisodeSync.cs | 3 ++- Lingarr.Server/Services/Sync/MovieSync.cs | 6 ++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/Lingarr.Server/Services/Sync/EpisodeSync.cs b/Lingarr.Server/Services/Sync/EpisodeSync.cs index 18abb188..095953b3 100644 --- a/Lingarr.Server/Services/Sync/EpisodeSync.cs +++ b/Lingarr.Server/Services/Sync/EpisodeSync.cs @@ -1,4 +1,4 @@ -using Lingarr.Core.Data; +using Lingarr.Core.Data; using Lingarr.Core.Entities; using Lingarr.Core.Enum; using Lingarr.Core.Interfaces; @@ -119,6 +119,7 @@ await _orphanCleanupService.CleanupOrphansAsync( var episodesToUpdateState = syncedEpisodes .Where(x => { var entity = x.Entity; + if (x.NeedsIndexing) return true; if (entity.TranslationState != TranslationState.AwaitingSource) return true; if (string.IsNullOrEmpty(entity.Path)) return true; diff --git a/Lingarr.Server/Services/Sync/MovieSync.cs b/Lingarr.Server/Services/Sync/MovieSync.cs index 18e5aedc..49e06593 100644 --- a/Lingarr.Server/Services/Sync/MovieSync.cs +++ b/Lingarr.Server/Services/Sync/MovieSync.cs @@ -1,4 +1,4 @@ -using Lingarr.Core.Data; +using Lingarr.Core.Data; using Lingarr.Core.Entities; using Lingarr.Core.Enum; using Lingarr.Server.Interfaces.Services; @@ -171,11 +171,13 @@ await _orphanCleanupService.CleanupOrphansAsync( // Update translation state // For AwaitingSource: only re-check if directory mtime changed (reduces I/O) + // Unless we just indexed it (needsIndexing), then always update try { var shouldUpdateState = true; - if (movieEntity.TranslationState == TranslationState.AwaitingSource && + if (!needsIndexing && + movieEntity.TranslationState == TranslationState.AwaitingSource && !string.IsNullOrEmpty(movieEntity.Path)) { var dirInfo = new DirectoryInfo(movieEntity.Path); From 88a801dd1d08799bdf460282d9226bc7c3edb1eb Mon Sep 17 00:00:00 2001 From: T9es Date: Mon, 19 Jan 2026 00:12:49 +0100 Subject: [PATCH 25/64] feat: include 'AwaitingSource' items in Bulk Integrity Check - Updated BulkIntegrityCheckJob to query for media in both 'Complete' and 'AwaitingSource' states - Allows users to force a re-probe and state update for items stuck in 'Not yet analysed' via the Integrity UI - Provides a safer alternative to manual database or file manipulation for recovering stuck items --- Lingarr.Server/Jobs/BulkIntegrityCheckJob.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Lingarr.Server/Jobs/BulkIntegrityCheckJob.cs b/Lingarr.Server/Jobs/BulkIntegrityCheckJob.cs index a14c0c6f..cf63cd6d 100644 --- a/Lingarr.Server/Jobs/BulkIntegrityCheckJob.cs +++ b/Lingarr.Server/Jobs/BulkIntegrityCheckJob.cs @@ -45,15 +45,15 @@ public async Task Execute() try { - // Get all Complete-state movies + // Get all Complete-state or AwaitingSource-state movies var completedMovieIds = await _dbContext.Movies - .Where(m => m.TranslationState == TranslationState.Complete) + .Where(m => m.TranslationState == TranslationState.Complete || m.TranslationState == TranslationState.AwaitingSource) .Select(m => m.Id) .ToListAsync(); - // Get all Complete-state episodes + // Get all Complete-state or AwaitingSource-state episodes var completedEpisodeIds = await _dbContext.Episodes - .Where(e => e.TranslationState == TranslationState.Complete) + .Where(e => e.TranslationState == TranslationState.Complete || e.TranslationState == TranslationState.AwaitingSource) .Select(e => e.Id) .ToListAsync(); From 3b5822d7e0fad75b7eb3f43971013c527ca6e16e Mon Sep 17 00:00:00 2001 From: T9es Date: Mon, 19 Jan 2026 00:48:52 +0100 Subject: [PATCH 26/64] fix: include 'Unknown' items in Bulk Integrity Check - Updated BulkIntegrityCheckJob to query for media in 'Complete', 'AwaitingSource', and 'Unknown' states - Ensures that items stuck in 'Not yet analysed' (which maps to the Unknown state) are included in the re-probing process - Fixes the issue where the job reported '0 movies, 0 episodes' despite many unanalyzed items --- Lingarr.Server/Jobs/BulkIntegrityCheckJob.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Lingarr.Server/Jobs/BulkIntegrityCheckJob.cs b/Lingarr.Server/Jobs/BulkIntegrityCheckJob.cs index cf63cd6d..501ca88d 100644 --- a/Lingarr.Server/Jobs/BulkIntegrityCheckJob.cs +++ b/Lingarr.Server/Jobs/BulkIntegrityCheckJob.cs @@ -45,15 +45,15 @@ public async Task Execute() try { - // Get all Complete-state or AwaitingSource-state movies + // Get all Complete-state, AwaitingSource-state, or Unknown-state movies var completedMovieIds = await _dbContext.Movies - .Where(m => m.TranslationState == TranslationState.Complete || m.TranslationState == TranslationState.AwaitingSource) + .Where(m => m.TranslationState == TranslationState.Complete || m.TranslationState == TranslationState.AwaitingSource || m.TranslationState == TranslationState.Unknown) .Select(m => m.Id) .ToListAsync(); - // Get all Complete-state or AwaitingSource-state episodes + // Get all Complete-state, AwaitingSource-state, or Unknown-state episodes var completedEpisodeIds = await _dbContext.Episodes - .Where(e => e.TranslationState == TranslationState.Complete || e.TranslationState == TranslationState.AwaitingSource) + .Where(e => e.TranslationState == TranslationState.Complete || e.TranslationState == TranslationState.AwaitingSource || e.TranslationState == TranslationState.Unknown) .Select(e => e.Id) .ToListAsync(); From 83dee7d69219a8ba7a6dbbab3942200497db52ef Mon Sep 17 00:00:00 2001 From: T9es Date: Mon, 19 Jan 2026 01:25:38 +0100 Subject: [PATCH 27/64] Fix up more stuff. --- Lingarr.Server/Extensions/ServiceCollectionExtensions.cs | 3 +-- Lingarr.Server/Jobs/BulkIntegrityCheckJob.cs | 8 ++++---- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/Lingarr.Server/Extensions/ServiceCollectionExtensions.cs b/Lingarr.Server/Extensions/ServiceCollectionExtensions.cs index c539826f..05044b3a 100644 --- a/Lingarr.Server/Extensions/ServiceCollectionExtensions.cs +++ b/Lingarr.Server/Extensions/ServiceCollectionExtensions.cs @@ -1,4 +1,4 @@ -using System.Reflection; +using System.Reflection; using System.Text.Json; using System.Text.Json.Serialization; using GTranslate.Translators; @@ -34,7 +34,6 @@ public static void Configure(this WebApplicationBuilder builder) { builder.Services.AddControllers().AddJsonOptions(options => { - options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter()); options.JsonSerializerOptions.ReferenceHandler = ReferenceHandler.IgnoreCycles; options.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase; options.JsonSerializerOptions.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull; diff --git a/Lingarr.Server/Jobs/BulkIntegrityCheckJob.cs b/Lingarr.Server/Jobs/BulkIntegrityCheckJob.cs index 501ca88d..e59e296f 100644 --- a/Lingarr.Server/Jobs/BulkIntegrityCheckJob.cs +++ b/Lingarr.Server/Jobs/BulkIntegrityCheckJob.cs @@ -45,15 +45,15 @@ public async Task Execute() try { - // Get all Complete-state, AwaitingSource-state, or Unknown-state movies + // Get all media items for integrity check, except those explicitly excluded or in progress var completedMovieIds = await _dbContext.Movies - .Where(m => m.TranslationState == TranslationState.Complete || m.TranslationState == TranslationState.AwaitingSource || m.TranslationState == TranslationState.Unknown) + .Where(m => m.TranslationState != TranslationState.InProgress && m.TranslationState != TranslationState.NotApplicable) .Select(m => m.Id) .ToListAsync(); - // Get all Complete-state, AwaitingSource-state, or Unknown-state episodes + // Get all media items for integrity check, except those explicitly excluded or in progress var completedEpisodeIds = await _dbContext.Episodes - .Where(e => e.TranslationState == TranslationState.Complete || e.TranslationState == TranslationState.AwaitingSource || e.TranslationState == TranslationState.Unknown) + .Where(e => e.TranslationState != TranslationState.InProgress && e.TranslationState != TranslationState.NotApplicable) .Select(e => e.Id) .ToListAsync(); From 584f93d80e8c9579bed35d8a22c5e4e21eb0e887 Mon Sep 17 00:00:00 2001 From: T9es Date: Mon, 19 Jan 2026 02:04:53 +0100 Subject: [PATCH 28/64] Revert "Fix up more stuff." This reverts commit 83dee7d69219a8ba7a6dbbab3942200497db52ef. --- Lingarr.Server/Extensions/ServiceCollectionExtensions.cs | 3 ++- Lingarr.Server/Jobs/BulkIntegrityCheckJob.cs | 8 ++++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/Lingarr.Server/Extensions/ServiceCollectionExtensions.cs b/Lingarr.Server/Extensions/ServiceCollectionExtensions.cs index 05044b3a..c539826f 100644 --- a/Lingarr.Server/Extensions/ServiceCollectionExtensions.cs +++ b/Lingarr.Server/Extensions/ServiceCollectionExtensions.cs @@ -1,4 +1,4 @@ -using System.Reflection; +using System.Reflection; using System.Text.Json; using System.Text.Json.Serialization; using GTranslate.Translators; @@ -34,6 +34,7 @@ public static void Configure(this WebApplicationBuilder builder) { builder.Services.AddControllers().AddJsonOptions(options => { + options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter()); options.JsonSerializerOptions.ReferenceHandler = ReferenceHandler.IgnoreCycles; options.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase; options.JsonSerializerOptions.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull; diff --git a/Lingarr.Server/Jobs/BulkIntegrityCheckJob.cs b/Lingarr.Server/Jobs/BulkIntegrityCheckJob.cs index e59e296f..501ca88d 100644 --- a/Lingarr.Server/Jobs/BulkIntegrityCheckJob.cs +++ b/Lingarr.Server/Jobs/BulkIntegrityCheckJob.cs @@ -45,15 +45,15 @@ public async Task Execute() try { - // Get all media items for integrity check, except those explicitly excluded or in progress + // Get all Complete-state, AwaitingSource-state, or Unknown-state movies var completedMovieIds = await _dbContext.Movies - .Where(m => m.TranslationState != TranslationState.InProgress && m.TranslationState != TranslationState.NotApplicable) + .Where(m => m.TranslationState == TranslationState.Complete || m.TranslationState == TranslationState.AwaitingSource || m.TranslationState == TranslationState.Unknown) .Select(m => m.Id) .ToListAsync(); - // Get all media items for integrity check, except those explicitly excluded or in progress + // Get all Complete-state, AwaitingSource-state, or Unknown-state episodes var completedEpisodeIds = await _dbContext.Episodes - .Where(e => e.TranslationState != TranslationState.InProgress && e.TranslationState != TranslationState.NotApplicable) + .Where(e => e.TranslationState == TranslationState.Complete || e.TranslationState == TranslationState.AwaitingSource || e.TranslationState == TranslationState.Unknown) .Select(e => e.Id) .ToListAsync(); From 826dc6248b6d9ff06d0c0312e33c7cbf68107565 Mon Sep 17 00:00:00 2001 From: T9es Date: Mon, 19 Jan 2026 02:04:55 +0100 Subject: [PATCH 29/64] Revert "fix: include 'Unknown' items in Bulk Integrity Check" This reverts commit 3b5822d7e0fad75b7eb3f43971013c527ca6e16e. --- Lingarr.Server/Jobs/BulkIntegrityCheckJob.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Lingarr.Server/Jobs/BulkIntegrityCheckJob.cs b/Lingarr.Server/Jobs/BulkIntegrityCheckJob.cs index 501ca88d..cf63cd6d 100644 --- a/Lingarr.Server/Jobs/BulkIntegrityCheckJob.cs +++ b/Lingarr.Server/Jobs/BulkIntegrityCheckJob.cs @@ -45,15 +45,15 @@ public async Task Execute() try { - // Get all Complete-state, AwaitingSource-state, or Unknown-state movies + // Get all Complete-state or AwaitingSource-state movies var completedMovieIds = await _dbContext.Movies - .Where(m => m.TranslationState == TranslationState.Complete || m.TranslationState == TranslationState.AwaitingSource || m.TranslationState == TranslationState.Unknown) + .Where(m => m.TranslationState == TranslationState.Complete || m.TranslationState == TranslationState.AwaitingSource) .Select(m => m.Id) .ToListAsync(); - // Get all Complete-state, AwaitingSource-state, or Unknown-state episodes + // Get all Complete-state or AwaitingSource-state episodes var completedEpisodeIds = await _dbContext.Episodes - .Where(e => e.TranslationState == TranslationState.Complete || e.TranslationState == TranslationState.AwaitingSource || e.TranslationState == TranslationState.Unknown) + .Where(e => e.TranslationState == TranslationState.Complete || e.TranslationState == TranslationState.AwaitingSource) .Select(e => e.Id) .ToListAsync(); From c129fd30e748d0953eb8f7982aad64d9427c494f Mon Sep 17 00:00:00 2001 From: T9es Date: Mon, 19 Jan 2026 02:05:08 +0100 Subject: [PATCH 30/64] Revert "feat: include 'AwaitingSource' items in Bulk Integrity Check" This reverts commit 88a801dd1d08799bdf460282d9226bc7c3edb1eb. --- Lingarr.Server/Jobs/BulkIntegrityCheckJob.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Lingarr.Server/Jobs/BulkIntegrityCheckJob.cs b/Lingarr.Server/Jobs/BulkIntegrityCheckJob.cs index cf63cd6d..a14c0c6f 100644 --- a/Lingarr.Server/Jobs/BulkIntegrityCheckJob.cs +++ b/Lingarr.Server/Jobs/BulkIntegrityCheckJob.cs @@ -45,15 +45,15 @@ public async Task Execute() try { - // Get all Complete-state or AwaitingSource-state movies + // Get all Complete-state movies var completedMovieIds = await _dbContext.Movies - .Where(m => m.TranslationState == TranslationState.Complete || m.TranslationState == TranslationState.AwaitingSource) + .Where(m => m.TranslationState == TranslationState.Complete) .Select(m => m.Id) .ToListAsync(); - // Get all Complete-state or AwaitingSource-state episodes + // Get all Complete-state episodes var completedEpisodeIds = await _dbContext.Episodes - .Where(e => e.TranslationState == TranslationState.Complete || e.TranslationState == TranslationState.AwaitingSource) + .Where(e => e.TranslationState == TranslationState.Complete) .Select(e => e.Id) .ToListAsync(); From 84259cf247db7c625b62a4854c3f4b046b865106 Mon Sep 17 00:00:00 2001 From: T9es Date: Mon, 19 Jan 2026 02:08:57 +0100 Subject: [PATCH 31/64] Revert all changes since yesterday (restoring to agents.md update) --- Lingarr.Client/package-lock.json | 12 + .../Configuration/MovieConfiguration.cs | 4 - .../Configuration/ShowConfiguration.cs | 4 - ...18185931_AddUniqueMediaIndexes.Designer.cs | 824 --------------- .../20260118185931_AddUniqueMediaIndexes.cs | 76 -- ...etMissingEmbeddedSubtitleIndex.Designer.cs | 35 - ...12500_ResetMissingEmbeddedSubtitleIndex.cs | 45 - .../LingarrDbContextModelSnapshot.cs | 8 - ...18185529_AddUniqueMediaIndexes.Designer.cs | 795 --------------- .../20260118185529_AddUniqueMediaIndexes.cs | 76 -- ...etMissingEmbeddedSubtitleIndex.Designer.cs | 30 - ...12500_ResetMissingEmbeddedSubtitleIndex.cs | 45 - .../LingarrDbContextModelSnapshot.cs | 8 - .../Extensions/LazyServiceWrapper.cs | 2 +- .../Interfaces/Services/IMediaStateService.cs | 9 - .../Services/ITranslationRequestService.cs | 8 - .../Subtitle/IOrphanSubtitleCleanupService.cs | 9 - .../Interfaces/Services/Sync/IEpisodeSync.cs | 3 +- .../Interfaces/Services/Sync/ISeasonSync.cs | 3 +- .../Interfaces/Services/Sync/IShowSync.cs | 3 +- .../Jobs/AutomatedTranslationJob.cs | 21 +- Lingarr.Server/Jobs/SyncMovieJob.cs | 1 - Lingarr.Server/Jobs/SyncShowJob.cs | 11 - Lingarr.Server/Jobs/TranslationJob.cs | 16 +- Lingarr.Server/Services/MediaStateService.cs | 179 +--- .../Services/MediaSubtitleProcessor.cs | 936 ++++++++++++++---- Lingarr.Server/Services/ScheduleService.cs | 26 +- .../Subtitle/OrphanSubtitleCleanupService.cs | 204 +--- Lingarr.Server/Services/Sync/EpisodeSync.cs | 185 +--- Lingarr.Server/Services/Sync/MovieSync.cs | 52 +- Lingarr.Server/Services/Sync/SeasonSync.cs | 63 +- Lingarr.Server/Services/Sync/ShowSync.cs | 7 +- .../Services/Sync/ShowSyncService.cs | 112 +-- .../Services/TranslationRequestService.cs | 20 - 34 files changed, 914 insertions(+), 2918 deletions(-) delete mode 100644 Lingarr.Migrations.PostgreSQL/Migrations/20260118185931_AddUniqueMediaIndexes.Designer.cs delete mode 100644 Lingarr.Migrations.PostgreSQL/Migrations/20260118185931_AddUniqueMediaIndexes.cs delete mode 100644 Lingarr.Migrations.PostgreSQL/Migrations/20260118212500_ResetMissingEmbeddedSubtitleIndex.Designer.cs delete mode 100644 Lingarr.Migrations.PostgreSQL/Migrations/20260118212500_ResetMissingEmbeddedSubtitleIndex.cs delete mode 100644 Lingarr.Migrations.SQLite/Migrations/20260118185529_AddUniqueMediaIndexes.Designer.cs delete mode 100644 Lingarr.Migrations.SQLite/Migrations/20260118185529_AddUniqueMediaIndexes.cs delete mode 100644 Lingarr.Migrations.SQLite/Migrations/20260118212500_ResetMissingEmbeddedSubtitleIndex.Designer.cs delete mode 100644 Lingarr.Migrations.SQLite/Migrations/20260118212500_ResetMissingEmbeddedSubtitleIndex.cs diff --git a/Lingarr.Client/package-lock.json b/Lingarr.Client/package-lock.json index f3dd641f..ee3765e0 100644 --- a/Lingarr.Client/package-lock.json +++ b/Lingarr.Client/package-lock.json @@ -1395,6 +1395,7 @@ "version": "24.10.1", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -1431,6 +1432,7 @@ "version": "8.48.1", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.48.1", "@typescript-eslint/types": "8.48.1", @@ -1814,6 +1816,7 @@ "version": "8.15.0", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1945,6 +1948,7 @@ "node_modules/chart.js": { "version": "4.5.1", "license": "MIT", + "peer": true, "dependencies": { "@kurkle/color": "^0.3.0" }, @@ -2197,6 +2201,7 @@ "version": "9.39.1", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -2255,6 +2260,7 @@ "version": "10.1.8", "dev": true, "license": "MIT", + "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -3339,6 +3345,7 @@ "version": "4.0.3", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -3415,6 +3422,7 @@ "version": "3.7.4", "dev": true, "license": "MIT", + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -3763,6 +3771,7 @@ "version": "5.9.3", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -3808,6 +3817,7 @@ "version": "7.2.6", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -3887,6 +3897,7 @@ "node_modules/vue": { "version": "3.5.25", "license": "MIT", + "peer": true, "dependencies": { "@vue/compiler-dom": "3.5.25", "@vue/compiler-sfc": "3.5.25", @@ -3915,6 +3926,7 @@ "version": "10.2.0", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "debug": "^4.4.0", "eslint-scope": "^8.2.0", diff --git a/Lingarr.Core/Configuration/MovieConfiguration.cs b/Lingarr.Core/Configuration/MovieConfiguration.cs index 7f10c70c..e7478ab2 100644 --- a/Lingarr.Core/Configuration/MovieConfiguration.cs +++ b/Lingarr.Core/Configuration/MovieConfiguration.cs @@ -19,9 +19,5 @@ public void Configure(EntityTypeBuilder builder) builder.HasIndex(m => m.TranslationState) .HasDatabaseName("IX_Movies_TranslationState"); - - builder.HasIndex(m => m.RadarrId) - .IsUnique() - .HasDatabaseName("IX_Movies_RadarrId"); } } \ No newline at end of file diff --git a/Lingarr.Core/Configuration/ShowConfiguration.cs b/Lingarr.Core/Configuration/ShowConfiguration.cs index 6afa961e..8cec126e 100644 --- a/Lingarr.Core/Configuration/ShowConfiguration.cs +++ b/Lingarr.Core/Configuration/ShowConfiguration.cs @@ -21,9 +21,5 @@ public void Configure(EntityTypeBuilder builder) .OnDelete(DeleteBehavior.Cascade); builder.Navigation(s => s.Seasons).AutoInclude(); - - builder.HasIndex(s => s.SonarrId) - .IsUnique() - .HasDatabaseName("IX_Shows_SonarrId"); } } \ No newline at end of file diff --git a/Lingarr.Migrations.PostgreSQL/Migrations/20260118185931_AddUniqueMediaIndexes.Designer.cs b/Lingarr.Migrations.PostgreSQL/Migrations/20260118185931_AddUniqueMediaIndexes.Designer.cs deleted file mode 100644 index 6d59801c..00000000 --- a/Lingarr.Migrations.PostgreSQL/Migrations/20260118185931_AddUniqueMediaIndexes.Designer.cs +++ /dev/null @@ -1,824 +0,0 @@ -// -using System; -using Lingarr.Core.Data; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; - -#nullable disable - -namespace Lingarr.Migrations.PostgreSQL.Migrations -{ - [DbContext(typeof(LingarrDbContext))] - [Migration("20260118185931_AddUniqueMediaIndexes")] - partial class AddUniqueMediaIndexes - { - /// - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "9.0.11") - .HasAnnotation("Relational:MaxIdentifierLength", 63); - - NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - - modelBuilder.Entity("Lingarr.Core.Entities.DailyStatistics", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("integer") - .HasColumnName("id"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone") - .HasColumnName("created_at"); - - b.Property("Date") - .HasColumnType("timestamp with time zone") - .HasColumnName("date"); - - b.Property("TranslationCount") - .HasColumnType("integer") - .HasColumnName("translation_count"); - - b.Property("UpdatedAt") - .HasColumnType("timestamp with time zone") - .HasColumnName("updated_at"); - - b.HasKey("Id") - .HasName("pk_daily_statistics"); - - b.ToTable("daily_statistics", (string)null); - }); - - modelBuilder.Entity("Lingarr.Core.Entities.EmbeddedSubtitle", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("integer") - .HasColumnName("id"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); - - b.Property("CodecName") - .IsRequired() - .HasColumnType("text") - .HasColumnName("codec_name"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone") - .HasColumnName("created_at"); - - b.Property("EpisodeId") - .HasColumnType("integer") - .HasColumnName("episode_id"); - - b.Property("ExtractedPath") - .HasColumnType("text") - .HasColumnName("extracted_path"); - - b.Property("IsDefault") - .HasColumnType("boolean") - .HasColumnName("is_default"); - - b.Property("IsExtracted") - .HasColumnType("boolean") - .HasColumnName("is_extracted"); - - b.Property("IsForced") - .HasColumnType("boolean") - .HasColumnName("is_forced"); - - b.Property("IsTextBased") - .HasColumnType("boolean") - .HasColumnName("is_text_based"); - - b.Property("Language") - .HasColumnType("text") - .HasColumnName("language"); - - b.Property("MovieId") - .HasColumnType("integer") - .HasColumnName("movie_id"); - - b.Property("StreamIndex") - .HasColumnType("integer") - .HasColumnName("stream_index"); - - b.Property("Title") - .HasColumnType("text") - .HasColumnName("title"); - - b.Property("UpdatedAt") - .HasColumnType("timestamp with time zone") - .HasColumnName("updated_at"); - - b.HasKey("Id") - .HasName("pk_embedded_subtitles"); - - b.HasIndex("EpisodeId") - .HasDatabaseName("ix_embedded_subtitles_episode_id"); - - b.HasIndex("MovieId") - .HasDatabaseName("ix_embedded_subtitles_movie_id"); - - b.ToTable("embedded_subtitles", (string)null); - }); - - modelBuilder.Entity("Lingarr.Core.Entities.Episode", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("integer") - .HasColumnName("id"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone") - .HasColumnName("created_at"); - - b.Property("DateAdded") - .HasColumnType("timestamp with time zone") - .HasColumnName("date_added"); - - b.Property("EpisodeNumber") - .HasColumnType("integer") - .HasColumnName("episode_number"); - - b.Property("ExcludeFromTranslation") - .HasColumnType("boolean") - .HasColumnName("exclude_from_translation"); - - b.Property("FileName") - .HasColumnType("text") - .HasColumnName("file_name"); - - b.Property("IndexedAt") - .HasColumnType("timestamp with time zone") - .HasColumnName("indexed_at"); - - b.Property("LastSubtitleCheckAt") - .HasColumnType("timestamp with time zone") - .HasColumnName("last_subtitle_check_at"); - - b.Property("MediaHash") - .HasColumnType("text") - .HasColumnName("media_hash"); - - b.Property("Path") - .HasColumnType("text") - .HasColumnName("path"); - - b.Property("SeasonId") - .HasColumnType("integer") - .HasColumnName("season_id"); - - b.Property("SonarrId") - .HasColumnType("integer") - .HasColumnName("sonarr_id"); - - b.Property("StateSettingsVersion") - .HasColumnType("integer") - .HasColumnName("state_settings_version"); - - b.Property("Title") - .IsRequired() - .HasColumnType("text") - .HasColumnName("title"); - - b.Property("TranslationState") - .HasColumnType("integer") - .HasColumnName("translation_state"); - - b.Property("UpdatedAt") - .HasColumnType("timestamp with time zone") - .HasColumnName("updated_at"); - - b.HasKey("Id") - .HasName("pk_episodes"); - - b.HasIndex("SeasonId") - .HasDatabaseName("ix_episodes_season_id"); - - b.HasIndex("TranslationState") - .HasDatabaseName("IX_Episodes_TranslationState"); - - b.ToTable("episodes", (string)null); - }); - - modelBuilder.Entity("Lingarr.Core.Entities.Image", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("integer") - .HasColumnName("id"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); - - b.Property("MovieId") - .HasColumnType("integer") - .HasColumnName("movie_id"); - - b.Property("Path") - .IsRequired() - .HasColumnType("text") - .HasColumnName("path"); - - b.Property("ShowId") - .HasColumnType("integer") - .HasColumnName("show_id"); - - b.Property("Type") - .IsRequired() - .HasColumnType("text") - .HasColumnName("type"); - - b.HasKey("Id") - .HasName("pk_images"); - - b.HasIndex("MovieId") - .HasDatabaseName("ix_images_movie_id"); - - b.HasIndex("ShowId") - .HasDatabaseName("ix_images_show_id"); - - b.ToTable("images", (string)null); - }); - - modelBuilder.Entity("Lingarr.Core.Entities.Movie", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("integer") - .HasColumnName("id"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone") - .HasColumnName("created_at"); - - b.Property("DateAdded") - .HasColumnType("timestamp with time zone") - .HasColumnName("date_added"); - - b.Property("ExcludeFromTranslation") - .HasColumnType("boolean") - .HasColumnName("exclude_from_translation"); - - b.Property("FileName") - .HasColumnType("text") - .HasColumnName("file_name"); - - b.Property("IndexedAt") - .HasColumnType("timestamp with time zone") - .HasColumnName("indexed_at"); - - b.Property("IsPriority") - .HasColumnType("boolean") - .HasColumnName("is_priority"); - - b.Property("LastSubtitleCheckAt") - .HasColumnType("timestamp with time zone") - .HasColumnName("last_subtitle_check_at"); - - b.Property("MediaHash") - .HasColumnType("text") - .HasColumnName("media_hash"); - - b.Property("Path") - .HasColumnType("text") - .HasColumnName("path"); - - b.Property("PriorityDate") - .HasColumnType("timestamp with time zone") - .HasColumnName("priority_date"); - - b.Property("RadarrId") - .HasColumnType("integer") - .HasColumnName("radarr_id"); - - b.Property("StateSettingsVersion") - .HasColumnType("integer") - .HasColumnName("state_settings_version"); - - b.Property("Title") - .IsRequired() - .HasColumnType("text") - .HasColumnName("title"); - - b.Property("TranslationAgeThreshold") - .HasColumnType("integer") - .HasColumnName("translation_age_threshold"); - - b.Property("TranslationState") - .HasColumnType("integer") - .HasColumnName("translation_state"); - - b.Property("UpdatedAt") - .HasColumnType("timestamp with time zone") - .HasColumnName("updated_at"); - - b.HasKey("Id") - .HasName("pk_movies"); - - b.HasIndex("RadarrId") - .IsUnique() - .HasDatabaseName("IX_Movies_RadarrId"); - - b.HasIndex("TranslationState") - .HasDatabaseName("IX_Movies_TranslationState"); - - b.ToTable("movies", (string)null); - }); - - modelBuilder.Entity("Lingarr.Core.Entities.PathMapping", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("integer") - .HasColumnName("id"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone") - .HasColumnName("created_at"); - - b.Property("DestinationPath") - .IsRequired() - .HasColumnType("text") - .HasColumnName("destination_path"); - - b.Property("MediaType") - .HasColumnType("integer") - .HasColumnName("media_type"); - - b.Property("SourcePath") - .IsRequired() - .HasColumnType("text") - .HasColumnName("source_path"); - - b.Property("UpdatedAt") - .HasColumnType("timestamp with time zone") - .HasColumnName("updated_at"); - - b.HasKey("Id") - .HasName("pk_path_mappings"); - - b.ToTable("path_mappings", (string)null); - }); - - modelBuilder.Entity("Lingarr.Core.Entities.Season", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("integer") - .HasColumnName("id"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone") - .HasColumnName("created_at"); - - b.Property("ExcludeFromTranslation") - .HasColumnType("boolean") - .HasColumnName("exclude_from_translation"); - - b.Property("Path") - .HasColumnType("text") - .HasColumnName("path"); - - b.Property("SeasonNumber") - .HasColumnType("integer") - .HasColumnName("season_number"); - - b.Property("ShowId") - .HasColumnType("integer") - .HasColumnName("show_id"); - - b.Property("UpdatedAt") - .HasColumnType("timestamp with time zone") - .HasColumnName("updated_at"); - - b.HasKey("Id") - .HasName("pk_seasons"); - - b.HasIndex("ShowId") - .HasDatabaseName("ix_seasons_show_id"); - - b.ToTable("seasons", (string)null); - }); - - modelBuilder.Entity("Lingarr.Core.Entities.Setting", b => - { - b.Property("Key") - .HasMaxLength(255) - .HasColumnType("character varying(255)") - .HasColumnName("key"); - - b.Property("Value") - .IsRequired() - .HasColumnType("text") - .HasColumnName("value"); - - b.HasKey("Key") - .HasName("pk_settings"); - - b.ToTable("settings", (string)null); - }); - - modelBuilder.Entity("Lingarr.Core.Entities.Show", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("integer") - .HasColumnName("id"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone") - .HasColumnName("created_at"); - - b.Property("DateAdded") - .HasColumnType("timestamp with time zone") - .HasColumnName("date_added"); - - b.Property("ExcludeFromTranslation") - .HasColumnType("boolean") - .HasColumnName("exclude_from_translation"); - - b.Property("IsPriority") - .HasColumnType("boolean") - .HasColumnName("is_priority"); - - b.Property("Path") - .IsRequired() - .HasColumnType("text") - .HasColumnName("path"); - - b.Property("PriorityDate") - .HasColumnType("timestamp with time zone") - .HasColumnName("priority_date"); - - b.Property("SonarrId") - .HasColumnType("integer") - .HasColumnName("sonarr_id"); - - b.Property("Title") - .IsRequired() - .HasColumnType("text") - .HasColumnName("title"); - - b.Property("TranslationAgeThreshold") - .HasColumnType("integer") - .HasColumnName("translation_age_threshold"); - - b.Property("UpdatedAt") - .HasColumnType("timestamp with time zone") - .HasColumnName("updated_at"); - - b.HasKey("Id") - .HasName("pk_shows"); - - b.HasIndex("SonarrId") - .IsUnique() - .HasDatabaseName("IX_Shows_SonarrId"); - - b.ToTable("shows", (string)null); - }); - - modelBuilder.Entity("Lingarr.Core.Entities.Statistics", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("integer") - .HasColumnName("id"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone") - .HasColumnName("created_at"); - - b.Property("SubtitlesByLanguageJson") - .IsRequired() - .HasColumnType("text") - .HasColumnName("subtitles_by_language_json"); - - b.Property("TotalCharactersTranslated") - .HasColumnType("bigint") - .HasColumnName("total_characters_translated"); - - b.Property("TotalEpisodes") - .HasColumnType("integer") - .HasColumnName("total_episodes"); - - b.Property("TotalFilesTranslated") - .HasColumnType("bigint") - .HasColumnName("total_files_translated"); - - b.Property("TotalLinesTranslated") - .HasColumnType("bigint") - .HasColumnName("total_lines_translated"); - - b.Property("TotalMovies") - .HasColumnType("integer") - .HasColumnName("total_movies"); - - b.Property("TotalSubtitles") - .HasColumnType("integer") - .HasColumnName("total_subtitles"); - - b.Property("TranslationsByMediaTypeJson") - .IsRequired() - .HasColumnType("text") - .HasColumnName("translations_by_media_type_json"); - - b.Property("TranslationsByServiceJson") - .IsRequired() - .HasColumnType("text") - .HasColumnName("translations_by_service_json"); - - b.Property("UpdatedAt") - .HasColumnType("timestamp with time zone") - .HasColumnName("updated_at"); - - b.HasKey("Id") - .HasName("pk_statistics"); - - b.ToTable("statistics", (string)null); - }); - - modelBuilder.Entity("Lingarr.Core.Entities.SubtitleCleanupLog", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("integer") - .HasColumnName("id"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); - - b.Property("DeletedAt") - .HasColumnType("timestamp with time zone") - .HasColumnName("deleted_at"); - - b.Property("FilePath") - .IsRequired() - .HasColumnType("text") - .HasColumnName("file_path"); - - b.Property("NewMediaFileName") - .IsRequired() - .HasColumnType("text") - .HasColumnName("new_media_file_name"); - - b.Property("OriginalMediaFileName") - .IsRequired() - .HasColumnType("text") - .HasColumnName("original_media_file_name"); - - b.Property("Reason") - .IsRequired() - .HasColumnType("text") - .HasColumnName("reason"); - - b.HasKey("Id") - .HasName("pk_subtitle_cleanup_logs"); - - b.ToTable("subtitle_cleanup_logs", (string)null); - }); - - modelBuilder.Entity("Lingarr.Core.Entities.TranslationRequest", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("integer") - .HasColumnName("id"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); - - b.Property("CompletedAt") - .HasColumnType("timestamp with time zone") - .HasColumnName("completed_at"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone") - .HasColumnName("created_at"); - - b.Property("IsActive") - .HasColumnType("boolean") - .HasColumnName("is_active"); - - b.Property("IsPriority") - .HasColumnType("boolean") - .HasColumnName("is_priority"); - - b.Property("JobId") - .HasColumnType("text") - .HasColumnName("job_id"); - - b.Property("MediaId") - .HasColumnType("integer") - .HasColumnName("media_id"); - - b.Property("MediaType") - .HasColumnType("integer") - .HasColumnName("media_type"); - - b.Property("Progress") - .HasColumnType("integer") - .HasColumnName("progress"); - - b.Property("SourceLanguage") - .IsRequired() - .HasColumnType("text") - .HasColumnName("source_language"); - - b.Property("Status") - .HasColumnType("integer") - .HasColumnName("status"); - - b.Property("SubtitleToTranslate") - .HasColumnType("text") - .HasColumnName("subtitle_to_translate"); - - b.Property("TargetLanguage") - .IsRequired() - .HasColumnType("text") - .HasColumnName("target_language"); - - b.Property("Title") - .IsRequired() - .HasColumnType("text") - .HasColumnName("title"); - - b.Property("TranslatedSubtitle") - .HasColumnType("text") - .HasColumnName("translated_subtitle"); - - b.Property("UpdatedAt") - .HasColumnType("timestamp with time zone") - .HasColumnName("updated_at"); - - b.HasKey("Id") - .HasName("pk_translation_requests"); - - b.HasIndex("MediaId", "MediaType", "SourceLanguage", "TargetLanguage", "IsActive") - .IsUnique() - .HasDatabaseName("ux_translation_requests_active_dedupe"); - - b.ToTable("translation_requests", (string)null); - }); - - modelBuilder.Entity("Lingarr.Core.Entities.TranslationRequestLog", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("integer") - .HasColumnName("id"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone") - .HasColumnName("created_at"); - - b.Property("Details") - .HasColumnType("text") - .HasColumnName("details"); - - b.Property("Level") - .IsRequired() - .HasColumnType("text") - .HasColumnName("level"); - - b.Property("Message") - .IsRequired() - .HasColumnType("text") - .HasColumnName("message"); - - b.Property("TranslationRequestId") - .HasColumnType("integer") - .HasColumnName("translation_request_id"); - - b.Property("UpdatedAt") - .HasColumnType("timestamp with time zone") - .HasColumnName("updated_at"); - - b.HasKey("Id") - .HasName("pk_translation_request_logs"); - - b.HasIndex("TranslationRequestId") - .HasDatabaseName("ix_translation_request_logs_translation_request_id"); - - b.ToTable("translation_request_logs", (string)null); - }); - - modelBuilder.Entity("Lingarr.Core.Entities.EmbeddedSubtitle", b => - { - b.HasOne("Lingarr.Core.Entities.Episode", "Episode") - .WithMany("EmbeddedSubtitles") - .HasForeignKey("EpisodeId") - .HasConstraintName("fk_embedded_subtitles_episodes_episode_id"); - - b.HasOne("Lingarr.Core.Entities.Movie", "Movie") - .WithMany("EmbeddedSubtitles") - .HasForeignKey("MovieId") - .HasConstraintName("fk_embedded_subtitles_movies_movie_id"); - - b.Navigation("Episode"); - - b.Navigation("Movie"); - }); - - modelBuilder.Entity("Lingarr.Core.Entities.Episode", b => - { - b.HasOne("Lingarr.Core.Entities.Season", "Season") - .WithMany("Episodes") - .HasForeignKey("SeasonId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired() - .HasConstraintName("fk_episodes_seasons_season_id"); - - b.Navigation("Season"); - }); - - modelBuilder.Entity("Lingarr.Core.Entities.Image", b => - { - b.HasOne("Lingarr.Core.Entities.Movie", "Movie") - .WithMany("Images") - .HasForeignKey("MovieId") - .OnDelete(DeleteBehavior.Cascade) - .HasConstraintName("fk_images_movies_movie_id"); - - b.HasOne("Lingarr.Core.Entities.Show", "Show") - .WithMany("Images") - .HasForeignKey("ShowId") - .OnDelete(DeleteBehavior.Cascade) - .HasConstraintName("fk_images_shows_show_id"); - - b.Navigation("Movie"); - - b.Navigation("Show"); - }); - - modelBuilder.Entity("Lingarr.Core.Entities.Season", b => - { - b.HasOne("Lingarr.Core.Entities.Show", "Show") - .WithMany("Seasons") - .HasForeignKey("ShowId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired() - .HasConstraintName("fk_seasons_shows_show_id"); - - b.Navigation("Show"); - }); - - modelBuilder.Entity("Lingarr.Core.Entities.TranslationRequestLog", b => - { - b.HasOne("Lingarr.Core.Entities.TranslationRequest", "TranslationRequest") - .WithMany() - .HasForeignKey("TranslationRequestId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired() - .HasConstraintName("fk_translation_request_logs_translation_requests_translation_r"); - - b.Navigation("TranslationRequest"); - }); - - modelBuilder.Entity("Lingarr.Core.Entities.Episode", b => - { - b.Navigation("EmbeddedSubtitles"); - }); - - modelBuilder.Entity("Lingarr.Core.Entities.Movie", b => - { - b.Navigation("EmbeddedSubtitles"); - - b.Navigation("Images"); - }); - - modelBuilder.Entity("Lingarr.Core.Entities.Season", b => - { - b.Navigation("Episodes"); - }); - - modelBuilder.Entity("Lingarr.Core.Entities.Show", b => - { - b.Navigation("Images"); - - b.Navigation("Seasons"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/Lingarr.Migrations.PostgreSQL/Migrations/20260118185931_AddUniqueMediaIndexes.cs b/Lingarr.Migrations.PostgreSQL/Migrations/20260118185931_AddUniqueMediaIndexes.cs deleted file mode 100644 index a95c72ac..00000000 --- a/Lingarr.Migrations.PostgreSQL/Migrations/20260118185931_AddUniqueMediaIndexes.cs +++ /dev/null @@ -1,76 +0,0 @@ -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace Lingarr.Migrations.PostgreSQL.Migrations -{ - /// - public partial class AddUniqueMediaIndexes : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - // First, delete embedded_subtitles for episodes belonging to duplicate shows - migrationBuilder.Sql(@" - DELETE FROM embedded_subtitles - WHERE episode_id IN ( - SELECT e.id FROM episodes e - INNER JOIN seasons s ON e.season_id = s.id - INNER JOIN shows sh ON s.show_id = sh.id - WHERE sh.id NOT IN ( - SELECT MAX(id) FROM shows GROUP BY sonarr_id - ) - ); - "); - - // Delete embedded_subtitles for duplicate movies - migrationBuilder.Sql(@" - DELETE FROM embedded_subtitles - WHERE movie_id IN ( - SELECT id FROM movies WHERE id NOT IN ( - SELECT MAX(id) FROM movies GROUP BY radarr_id - ) - ); - "); - - // Delete duplicate shows keeping the newest (highest Id) - // Cascade will handle seasons and episodes - migrationBuilder.Sql(@" - DELETE FROM shows WHERE id NOT IN ( - SELECT MAX(id) FROM shows GROUP BY sonarr_id - ); - "); - - // Delete duplicate movies keeping the newest (highest Id) - migrationBuilder.Sql(@" - DELETE FROM movies WHERE id NOT IN ( - SELECT MAX(id) FROM movies GROUP BY radarr_id - ); - "); - - migrationBuilder.CreateIndex( - name: "IX_Shows_SonarrId", - table: "shows", - column: "sonarr_id", - unique: true); - - migrationBuilder.CreateIndex( - name: "IX_Movies_RadarrId", - table: "movies", - column: "radarr_id", - unique: true); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropIndex( - name: "IX_Shows_SonarrId", - table: "shows"); - - migrationBuilder.DropIndex( - name: "IX_Movies_RadarrId", - table: "movies"); - } - } -} diff --git a/Lingarr.Migrations.PostgreSQL/Migrations/20260118212500_ResetMissingEmbeddedSubtitleIndex.Designer.cs b/Lingarr.Migrations.PostgreSQL/Migrations/20260118212500_ResetMissingEmbeddedSubtitleIndex.Designer.cs deleted file mode 100644 index 6c5fd2a2..00000000 --- a/Lingarr.Migrations.PostgreSQL/Migrations/20260118212500_ResetMissingEmbeddedSubtitleIndex.Designer.cs +++ /dev/null @@ -1,35 +0,0 @@ -// -using System; -using Lingarr.Core.Data; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; - -#nullable disable - -namespace Lingarr.Migrations.PostgreSQL.Migrations -{ - [DbContext(typeof(LingarrDbContext))] - [Migration("20260118212500_ResetMissingEmbeddedSubtitleIndex")] - partial class ResetMissingEmbeddedSubtitleIndex - { - /// - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { - // This is a data-only migration, no model changes - // Copy the model from the previous migration snapshot -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "9.0.11") - .HasAnnotation("Relational:MaxIdentifierLength", 63); - - NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - - // Model is identical to previous migration - this is data-only - // Referencing LingarrDbContextModelSnapshot for full model -#pragma warning restore 612, 618 - } - } -} diff --git a/Lingarr.Migrations.PostgreSQL/Migrations/20260118212500_ResetMissingEmbeddedSubtitleIndex.cs b/Lingarr.Migrations.PostgreSQL/Migrations/20260118212500_ResetMissingEmbeddedSubtitleIndex.cs deleted file mode 100644 index fe328395..00000000 --- a/Lingarr.Migrations.PostgreSQL/Migrations/20260118212500_ResetMissingEmbeddedSubtitleIndex.cs +++ /dev/null @@ -1,45 +0,0 @@ -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace Lingarr.Migrations.PostgreSQL.Migrations -{ - /// - public partial class ResetMissingEmbeddedSubtitleIndex : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - // Reset indexed_at for episodes that have indexed_at set but no embedded_subtitles - // This is a one-time fix for episodes affected by the FK constraint bug - migrationBuilder.Sql(@" - UPDATE episodes - SET indexed_at = NULL - WHERE indexed_at IS NOT NULL - AND id NOT IN ( - SELECT DISTINCT episode_id - FROM embedded_subtitles - WHERE episode_id IS NOT NULL - ); - "); - - // Same for movies - migrationBuilder.Sql(@" - UPDATE movies - SET indexed_at = NULL - WHERE indexed_at IS NOT NULL - AND id NOT IN ( - SELECT DISTINCT movie_id - FROM embedded_subtitles - WHERE movie_id IS NOT NULL - ); - "); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - // No rollback needed - the episodes will be re-indexed naturally - } - } -} diff --git a/Lingarr.Migrations.PostgreSQL/Migrations/LingarrDbContextModelSnapshot.cs b/Lingarr.Migrations.PostgreSQL/Migrations/LingarrDbContextModelSnapshot.cs index 9c4b6551..0bf3437b 100644 --- a/Lingarr.Migrations.PostgreSQL/Migrations/LingarrDbContextModelSnapshot.cs +++ b/Lingarr.Migrations.PostgreSQL/Migrations/LingarrDbContextModelSnapshot.cs @@ -325,10 +325,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasKey("Id") .HasName("pk_movies"); - b.HasIndex("RadarrId") - .IsUnique() - .HasDatabaseName("IX_Movies_RadarrId"); - b.HasIndex("TranslationState") .HasDatabaseName("IX_Movies_TranslationState"); @@ -486,10 +482,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasKey("Id") .HasName("pk_shows"); - b.HasIndex("SonarrId") - .IsUnique() - .HasDatabaseName("IX_Shows_SonarrId"); - b.ToTable("shows", (string)null); }); diff --git a/Lingarr.Migrations.SQLite/Migrations/20260118185529_AddUniqueMediaIndexes.Designer.cs b/Lingarr.Migrations.SQLite/Migrations/20260118185529_AddUniqueMediaIndexes.Designer.cs deleted file mode 100644 index 4edfc9d8..00000000 --- a/Lingarr.Migrations.SQLite/Migrations/20260118185529_AddUniqueMediaIndexes.Designer.cs +++ /dev/null @@ -1,795 +0,0 @@ -// -using System; -using Lingarr.Core.Data; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; - -#nullable disable - -namespace Lingarr.Migrations.SQLite.Migrations -{ - [DbContext(typeof(LingarrDbContext))] - [Migration("20260118185529_AddUniqueMediaIndexes")] - partial class AddUniqueMediaIndexes - { - /// - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder.HasAnnotation("ProductVersion", "9.0.11"); - - modelBuilder.Entity("Lingarr.Core.Entities.DailyStatistics", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasColumnName("id"); - - b.Property("CreatedAt") - .HasColumnType("TEXT") - .HasColumnName("created_at"); - - b.Property("Date") - .HasColumnType("TEXT") - .HasColumnName("date"); - - b.Property("TranslationCount") - .HasColumnType("INTEGER") - .HasColumnName("translation_count"); - - b.Property("UpdatedAt") - .HasColumnType("TEXT") - .HasColumnName("updated_at"); - - b.HasKey("Id") - .HasName("pk_daily_statistics"); - - b.ToTable("daily_statistics", (string)null); - }); - - modelBuilder.Entity("Lingarr.Core.Entities.EmbeddedSubtitle", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasColumnName("id"); - - b.Property("CodecName") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("codec_name"); - - b.Property("CreatedAt") - .HasColumnType("TEXT") - .HasColumnName("created_at"); - - b.Property("EpisodeId") - .HasColumnType("INTEGER") - .HasColumnName("episode_id"); - - b.Property("ExtractedPath") - .HasColumnType("TEXT") - .HasColumnName("extracted_path"); - - b.Property("IsDefault") - .HasColumnType("INTEGER") - .HasColumnName("is_default"); - - b.Property("IsExtracted") - .HasColumnType("INTEGER") - .HasColumnName("is_extracted"); - - b.Property("IsForced") - .HasColumnType("INTEGER") - .HasColumnName("is_forced"); - - b.Property("IsTextBased") - .HasColumnType("INTEGER") - .HasColumnName("is_text_based"); - - b.Property("Language") - .HasColumnType("TEXT") - .HasColumnName("language"); - - b.Property("MovieId") - .HasColumnType("INTEGER") - .HasColumnName("movie_id"); - - b.Property("StreamIndex") - .HasColumnType("INTEGER") - .HasColumnName("stream_index"); - - b.Property("Title") - .HasColumnType("TEXT") - .HasColumnName("title"); - - b.Property("UpdatedAt") - .HasColumnType("TEXT") - .HasColumnName("updated_at"); - - b.HasKey("Id") - .HasName("pk_embedded_subtitles"); - - b.HasIndex("EpisodeId") - .HasDatabaseName("ix_embedded_subtitles_episode_id"); - - b.HasIndex("MovieId") - .HasDatabaseName("ix_embedded_subtitles_movie_id"); - - b.ToTable("embedded_subtitles", (string)null); - }); - - modelBuilder.Entity("Lingarr.Core.Entities.Episode", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasColumnName("id"); - - b.Property("CreatedAt") - .HasColumnType("TEXT") - .HasColumnName("created_at"); - - b.Property("DateAdded") - .HasColumnType("TEXT") - .HasColumnName("date_added"); - - b.Property("EpisodeNumber") - .HasColumnType("INTEGER") - .HasColumnName("episode_number"); - - b.Property("ExcludeFromTranslation") - .HasColumnType("INTEGER") - .HasColumnName("exclude_from_translation"); - - b.Property("FileName") - .HasColumnType("TEXT") - .HasColumnName("file_name"); - - b.Property("IndexedAt") - .HasColumnType("TEXT") - .HasColumnName("indexed_at"); - - b.Property("LastSubtitleCheckAt") - .HasColumnType("TEXT") - .HasColumnName("last_subtitle_check_at"); - - b.Property("MediaHash") - .HasColumnType("TEXT") - .HasColumnName("media_hash"); - - b.Property("Path") - .HasColumnType("TEXT") - .HasColumnName("path"); - - b.Property("SeasonId") - .HasColumnType("INTEGER") - .HasColumnName("season_id"); - - b.Property("SonarrId") - .HasColumnType("INTEGER") - .HasColumnName("sonarr_id"); - - b.Property("StateSettingsVersion") - .HasColumnType("INTEGER") - .HasColumnName("state_settings_version"); - - b.Property("Title") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("title"); - - b.Property("TranslationState") - .HasColumnType("INTEGER") - .HasColumnName("translation_state"); - - b.Property("UpdatedAt") - .HasColumnType("TEXT") - .HasColumnName("updated_at"); - - b.HasKey("Id") - .HasName("pk_episodes"); - - b.HasIndex("SeasonId") - .HasDatabaseName("ix_episodes_season_id"); - - b.HasIndex("TranslationState") - .HasDatabaseName("IX_Episodes_TranslationState"); - - b.ToTable("episodes", (string)null); - }); - - modelBuilder.Entity("Lingarr.Core.Entities.Image", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasColumnName("id"); - - b.Property("MovieId") - .HasColumnType("INTEGER") - .HasColumnName("movie_id"); - - b.Property("Path") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("path"); - - b.Property("ShowId") - .HasColumnType("INTEGER") - .HasColumnName("show_id"); - - b.Property("Type") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("type"); - - b.HasKey("Id") - .HasName("pk_images"); - - b.HasIndex("MovieId") - .HasDatabaseName("ix_images_movie_id"); - - b.HasIndex("ShowId") - .HasDatabaseName("ix_images_show_id"); - - b.ToTable("images", (string)null); - }); - - modelBuilder.Entity("Lingarr.Core.Entities.Movie", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasColumnName("id"); - - b.Property("CreatedAt") - .HasColumnType("TEXT") - .HasColumnName("created_at"); - - b.Property("DateAdded") - .HasColumnType("TEXT") - .HasColumnName("date_added"); - - b.Property("ExcludeFromTranslation") - .HasColumnType("INTEGER") - .HasColumnName("exclude_from_translation"); - - b.Property("FileName") - .HasColumnType("TEXT") - .HasColumnName("file_name"); - - b.Property("IndexedAt") - .HasColumnType("TEXT") - .HasColumnName("indexed_at"); - - b.Property("IsPriority") - .HasColumnType("INTEGER") - .HasColumnName("is_priority"); - - b.Property("LastSubtitleCheckAt") - .HasColumnType("TEXT") - .HasColumnName("last_subtitle_check_at"); - - b.Property("MediaHash") - .HasColumnType("TEXT") - .HasColumnName("media_hash"); - - b.Property("Path") - .HasColumnType("TEXT") - .HasColumnName("path"); - - b.Property("PriorityDate") - .HasColumnType("TEXT") - .HasColumnName("priority_date"); - - b.Property("RadarrId") - .HasColumnType("INTEGER") - .HasColumnName("radarr_id"); - - b.Property("StateSettingsVersion") - .HasColumnType("INTEGER") - .HasColumnName("state_settings_version"); - - b.Property("Title") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("title"); - - b.Property("TranslationAgeThreshold") - .HasColumnType("INTEGER") - .HasColumnName("translation_age_threshold"); - - b.Property("TranslationState") - .HasColumnType("INTEGER") - .HasColumnName("translation_state"); - - b.Property("UpdatedAt") - .HasColumnType("TEXT") - .HasColumnName("updated_at"); - - b.HasKey("Id") - .HasName("pk_movies"); - - b.HasIndex("RadarrId") - .IsUnique() - .HasDatabaseName("IX_Movies_RadarrId"); - - b.HasIndex("TranslationState") - .HasDatabaseName("IX_Movies_TranslationState"); - - b.ToTable("movies", (string)null); - }); - - modelBuilder.Entity("Lingarr.Core.Entities.PathMapping", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasColumnName("id"); - - b.Property("CreatedAt") - .HasColumnType("TEXT") - .HasColumnName("created_at"); - - b.Property("DestinationPath") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("destination_path"); - - b.Property("MediaType") - .HasColumnType("INTEGER") - .HasColumnName("media_type"); - - b.Property("SourcePath") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("source_path"); - - b.Property("UpdatedAt") - .HasColumnType("TEXT") - .HasColumnName("updated_at"); - - b.HasKey("Id") - .HasName("pk_path_mappings"); - - b.ToTable("path_mappings", (string)null); - }); - - modelBuilder.Entity("Lingarr.Core.Entities.Season", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasColumnName("id"); - - b.Property("CreatedAt") - .HasColumnType("TEXT") - .HasColumnName("created_at"); - - b.Property("ExcludeFromTranslation") - .HasColumnType("INTEGER") - .HasColumnName("exclude_from_translation"); - - b.Property("Path") - .HasColumnType("TEXT") - .HasColumnName("path"); - - b.Property("SeasonNumber") - .HasColumnType("INTEGER") - .HasColumnName("season_number"); - - b.Property("ShowId") - .HasColumnType("INTEGER") - .HasColumnName("show_id"); - - b.Property("UpdatedAt") - .HasColumnType("TEXT") - .HasColumnName("updated_at"); - - b.HasKey("Id") - .HasName("pk_seasons"); - - b.HasIndex("ShowId") - .HasDatabaseName("ix_seasons_show_id"); - - b.ToTable("seasons", (string)null); - }); - - modelBuilder.Entity("Lingarr.Core.Entities.Setting", b => - { - b.Property("Key") - .HasMaxLength(255) - .HasColumnType("TEXT") - .HasColumnName("key"); - - b.Property("Value") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("value"); - - b.HasKey("Key") - .HasName("pk_settings"); - - b.ToTable("settings", (string)null); - }); - - modelBuilder.Entity("Lingarr.Core.Entities.Show", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasColumnName("id"); - - b.Property("CreatedAt") - .HasColumnType("TEXT") - .HasColumnName("created_at"); - - b.Property("DateAdded") - .HasColumnType("TEXT") - .HasColumnName("date_added"); - - b.Property("ExcludeFromTranslation") - .HasColumnType("INTEGER") - .HasColumnName("exclude_from_translation"); - - b.Property("IsPriority") - .HasColumnType("INTEGER") - .HasColumnName("is_priority"); - - b.Property("Path") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("path"); - - b.Property("PriorityDate") - .HasColumnType("TEXT") - .HasColumnName("priority_date"); - - b.Property("SonarrId") - .HasColumnType("INTEGER") - .HasColumnName("sonarr_id"); - - b.Property("Title") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("title"); - - b.Property("TranslationAgeThreshold") - .HasColumnType("INTEGER") - .HasColumnName("translation_age_threshold"); - - b.Property("UpdatedAt") - .HasColumnType("TEXT") - .HasColumnName("updated_at"); - - b.HasKey("Id") - .HasName("pk_shows"); - - b.HasIndex("SonarrId") - .IsUnique() - .HasDatabaseName("IX_Shows_SonarrId"); - - b.ToTable("shows", (string)null); - }); - - modelBuilder.Entity("Lingarr.Core.Entities.Statistics", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasColumnName("id"); - - b.Property("CreatedAt") - .HasColumnType("TEXT") - .HasColumnName("created_at"); - - b.Property("SubtitlesByLanguageJson") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("subtitles_by_language_json"); - - b.Property("TotalCharactersTranslated") - .HasColumnType("INTEGER") - .HasColumnName("total_characters_translated"); - - b.Property("TotalEpisodes") - .HasColumnType("INTEGER") - .HasColumnName("total_episodes"); - - b.Property("TotalFilesTranslated") - .HasColumnType("INTEGER") - .HasColumnName("total_files_translated"); - - b.Property("TotalLinesTranslated") - .HasColumnType("INTEGER") - .HasColumnName("total_lines_translated"); - - b.Property("TotalMovies") - .HasColumnType("INTEGER") - .HasColumnName("total_movies"); - - b.Property("TotalSubtitles") - .HasColumnType("INTEGER") - .HasColumnName("total_subtitles"); - - b.Property("TranslationsByMediaTypeJson") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("translations_by_media_type_json"); - - b.Property("TranslationsByServiceJson") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("translations_by_service_json"); - - b.Property("UpdatedAt") - .HasColumnType("TEXT") - .HasColumnName("updated_at"); - - b.HasKey("Id") - .HasName("pk_statistics"); - - b.ToTable("statistics", (string)null); - }); - - modelBuilder.Entity("Lingarr.Core.Entities.SubtitleCleanupLog", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasColumnName("id"); - - b.Property("DeletedAt") - .HasColumnType("TEXT") - .HasColumnName("deleted_at"); - - b.Property("FilePath") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("file_path"); - - b.Property("NewMediaFileName") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("new_media_file_name"); - - b.Property("OriginalMediaFileName") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("original_media_file_name"); - - b.Property("Reason") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("reason"); - - b.HasKey("Id") - .HasName("pk_subtitle_cleanup_logs"); - - b.ToTable("subtitle_cleanup_logs", (string)null); - }); - - modelBuilder.Entity("Lingarr.Core.Entities.TranslationRequest", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasColumnName("id"); - - b.Property("CompletedAt") - .HasColumnType("TEXT") - .HasColumnName("completed_at"); - - b.Property("CreatedAt") - .HasColumnType("TEXT") - .HasColumnName("created_at"); - - b.Property("IsActive") - .HasColumnType("INTEGER") - .HasColumnName("is_active"); - - b.Property("IsPriority") - .HasColumnType("INTEGER") - .HasColumnName("is_priority"); - - b.Property("JobId") - .HasColumnType("TEXT") - .HasColumnName("job_id"); - - b.Property("MediaId") - .HasColumnType("INTEGER") - .HasColumnName("media_id"); - - b.Property("MediaType") - .HasColumnType("INTEGER") - .HasColumnName("media_type"); - - b.Property("Progress") - .HasColumnType("INTEGER") - .HasColumnName("progress"); - - b.Property("SourceLanguage") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("source_language"); - - b.Property("Status") - .HasColumnType("INTEGER") - .HasColumnName("status"); - - b.Property("SubtitleToTranslate") - .HasColumnType("TEXT") - .HasColumnName("subtitle_to_translate"); - - b.Property("TargetLanguage") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("target_language"); - - b.Property("Title") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("title"); - - b.Property("TranslatedSubtitle") - .HasColumnType("TEXT") - .HasColumnName("translated_subtitle"); - - b.Property("UpdatedAt") - .HasColumnType("TEXT") - .HasColumnName("updated_at"); - - b.HasKey("Id") - .HasName("pk_translation_requests"); - - b.HasIndex("MediaId", "MediaType", "SourceLanguage", "TargetLanguage", "IsActive") - .IsUnique() - .HasDatabaseName("ux_translation_requests_active_dedupe"); - - b.ToTable("translation_requests", (string)null); - }); - - modelBuilder.Entity("Lingarr.Core.Entities.TranslationRequestLog", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasColumnName("id"); - - b.Property("CreatedAt") - .HasColumnType("TEXT") - .HasColumnName("created_at"); - - b.Property("Details") - .HasColumnType("TEXT") - .HasColumnName("details"); - - b.Property("Level") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("level"); - - b.Property("Message") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("message"); - - b.Property("TranslationRequestId") - .HasColumnType("INTEGER") - .HasColumnName("translation_request_id"); - - b.Property("UpdatedAt") - .HasColumnType("TEXT") - .HasColumnName("updated_at"); - - b.HasKey("Id") - .HasName("pk_translation_request_logs"); - - b.HasIndex("TranslationRequestId") - .HasDatabaseName("ix_translation_request_logs_translation_request_id"); - - b.ToTable("translation_request_logs", (string)null); - }); - - modelBuilder.Entity("Lingarr.Core.Entities.EmbeddedSubtitle", b => - { - b.HasOne("Lingarr.Core.Entities.Episode", "Episode") - .WithMany("EmbeddedSubtitles") - .HasForeignKey("EpisodeId") - .HasConstraintName("fk_embedded_subtitles_episodes_episode_id"); - - b.HasOne("Lingarr.Core.Entities.Movie", "Movie") - .WithMany("EmbeddedSubtitles") - .HasForeignKey("MovieId") - .HasConstraintName("fk_embedded_subtitles_movies_movie_id"); - - b.Navigation("Episode"); - - b.Navigation("Movie"); - }); - - modelBuilder.Entity("Lingarr.Core.Entities.Episode", b => - { - b.HasOne("Lingarr.Core.Entities.Season", "Season") - .WithMany("Episodes") - .HasForeignKey("SeasonId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired() - .HasConstraintName("fk_episodes_seasons_season_id"); - - b.Navigation("Season"); - }); - - modelBuilder.Entity("Lingarr.Core.Entities.Image", b => - { - b.HasOne("Lingarr.Core.Entities.Movie", "Movie") - .WithMany("Images") - .HasForeignKey("MovieId") - .OnDelete(DeleteBehavior.Cascade) - .HasConstraintName("fk_images_movies_movie_id"); - - b.HasOne("Lingarr.Core.Entities.Show", "Show") - .WithMany("Images") - .HasForeignKey("ShowId") - .OnDelete(DeleteBehavior.Cascade) - .HasConstraintName("fk_images_shows_show_id"); - - b.Navigation("Movie"); - - b.Navigation("Show"); - }); - - modelBuilder.Entity("Lingarr.Core.Entities.Season", b => - { - b.HasOne("Lingarr.Core.Entities.Show", "Show") - .WithMany("Seasons") - .HasForeignKey("ShowId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired() - .HasConstraintName("fk_seasons_shows_show_id"); - - b.Navigation("Show"); - }); - - modelBuilder.Entity("Lingarr.Core.Entities.TranslationRequestLog", b => - { - b.HasOne("Lingarr.Core.Entities.TranslationRequest", "TranslationRequest") - .WithMany() - .HasForeignKey("TranslationRequestId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired() - .HasConstraintName("fk_translation_request_logs_translation_requests_translation_request_id"); - - b.Navigation("TranslationRequest"); - }); - - modelBuilder.Entity("Lingarr.Core.Entities.Episode", b => - { - b.Navigation("EmbeddedSubtitles"); - }); - - modelBuilder.Entity("Lingarr.Core.Entities.Movie", b => - { - b.Navigation("EmbeddedSubtitles"); - - b.Navigation("Images"); - }); - - modelBuilder.Entity("Lingarr.Core.Entities.Season", b => - { - b.Navigation("Episodes"); - }); - - modelBuilder.Entity("Lingarr.Core.Entities.Show", b => - { - b.Navigation("Images"); - - b.Navigation("Seasons"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/Lingarr.Migrations.SQLite/Migrations/20260118185529_AddUniqueMediaIndexes.cs b/Lingarr.Migrations.SQLite/Migrations/20260118185529_AddUniqueMediaIndexes.cs deleted file mode 100644 index 9eaf28c3..00000000 --- a/Lingarr.Migrations.SQLite/Migrations/20260118185529_AddUniqueMediaIndexes.cs +++ /dev/null @@ -1,76 +0,0 @@ -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace Lingarr.Migrations.SQLite.Migrations -{ - /// - public partial class AddUniqueMediaIndexes : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - // First, delete embedded_subtitles for episodes belonging to duplicate shows - migrationBuilder.Sql(@" - DELETE FROM embedded_subtitles - WHERE episode_id IN ( - SELECT e.id FROM episodes e - INNER JOIN seasons s ON e.season_id = s.id - INNER JOIN shows sh ON s.show_id = sh.id - WHERE sh.id NOT IN ( - SELECT MAX(id) FROM shows GROUP BY sonarr_id - ) - ); - "); - - // Delete embedded_subtitles for duplicate movies - migrationBuilder.Sql(@" - DELETE FROM embedded_subtitles - WHERE movie_id IN ( - SELECT id FROM movies WHERE id NOT IN ( - SELECT MAX(id) FROM movies GROUP BY radarr_id - ) - ); - "); - - // Delete duplicate shows keeping the newest (highest Id) - // Cascade will handle seasons and episodes - migrationBuilder.Sql(@" - DELETE FROM shows WHERE id NOT IN ( - SELECT MAX(id) FROM shows GROUP BY sonarr_id - ); - "); - - // Delete duplicate movies keeping the newest (highest Id) - migrationBuilder.Sql(@" - DELETE FROM movies WHERE id NOT IN ( - SELECT MAX(id) FROM movies GROUP BY radarr_id - ); - "); - - migrationBuilder.CreateIndex( - name: "IX_Shows_SonarrId", - table: "shows", - column: "sonarr_id", - unique: true); - - migrationBuilder.CreateIndex( - name: "IX_Movies_RadarrId", - table: "movies", - column: "radarr_id", - unique: true); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropIndex( - name: "IX_Shows_SonarrId", - table: "shows"); - - migrationBuilder.DropIndex( - name: "IX_Movies_RadarrId", - table: "movies"); - } - } -} diff --git a/Lingarr.Migrations.SQLite/Migrations/20260118212500_ResetMissingEmbeddedSubtitleIndex.Designer.cs b/Lingarr.Migrations.SQLite/Migrations/20260118212500_ResetMissingEmbeddedSubtitleIndex.Designer.cs deleted file mode 100644 index 2d508f6a..00000000 --- a/Lingarr.Migrations.SQLite/Migrations/20260118212500_ResetMissingEmbeddedSubtitleIndex.Designer.cs +++ /dev/null @@ -1,30 +0,0 @@ -// -using System; -using Lingarr.Core.Data; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; - -#nullable disable - -namespace Lingarr.Migrations.SQLite.Migrations -{ - [DbContext(typeof(LingarrDbContext))] - [Migration("20260118212500_ResetMissingEmbeddedSubtitleIndex")] - partial class ResetMissingEmbeddedSubtitleIndex - { - /// - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { - // This is a data-only migration, no model changes - // Copy the model from the previous migration snapshot -#pragma warning disable 612, 618 - modelBuilder.HasAnnotation("ProductVersion", "9.0.11"); - - // Model is identical to previous migration - this is data-only - // Referencing LingarrDbContextModelSnapshot for full model -#pragma warning restore 612, 618 - } - } -} diff --git a/Lingarr.Migrations.SQLite/Migrations/20260118212500_ResetMissingEmbeddedSubtitleIndex.cs b/Lingarr.Migrations.SQLite/Migrations/20260118212500_ResetMissingEmbeddedSubtitleIndex.cs deleted file mode 100644 index 2d830ad2..00000000 --- a/Lingarr.Migrations.SQLite/Migrations/20260118212500_ResetMissingEmbeddedSubtitleIndex.cs +++ /dev/null @@ -1,45 +0,0 @@ -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace Lingarr.Migrations.SQLite.Migrations -{ - /// - public partial class ResetMissingEmbeddedSubtitleIndex : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - // Reset indexed_at for episodes that have indexed_at set but no embedded_subtitles - // This is a one-time fix for episodes affected by the FK constraint bug - migrationBuilder.Sql(@" - UPDATE Episodes - SET IndexedAt = NULL - WHERE IndexedAt IS NOT NULL - AND Id NOT IN ( - SELECT DISTINCT EpisodeId - FROM EmbeddedSubtitles - WHERE EpisodeId IS NOT NULL - ); - "); - - // Same for movies - migrationBuilder.Sql(@" - UPDATE Movies - SET IndexedAt = NULL - WHERE IndexedAt IS NOT NULL - AND Id NOT IN ( - SELECT DISTINCT MovieId - FROM EmbeddedSubtitles - WHERE MovieId IS NOT NULL - ); - "); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - // No rollback needed - the episodes will be re-indexed naturally - } - } -} diff --git a/Lingarr.Migrations.SQLite/Migrations/LingarrDbContextModelSnapshot.cs b/Lingarr.Migrations.SQLite/Migrations/LingarrDbContextModelSnapshot.cs index c969b65f..6771201c 100644 --- a/Lingarr.Migrations.SQLite/Migrations/LingarrDbContextModelSnapshot.cs +++ b/Lingarr.Migrations.SQLite/Migrations/LingarrDbContextModelSnapshot.cs @@ -310,10 +310,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasKey("Id") .HasName("pk_movies"); - b.HasIndex("RadarrId") - .IsUnique() - .HasDatabaseName("IX_Movies_RadarrId"); - b.HasIndex("TranslationState") .HasDatabaseName("IX_Movies_TranslationState"); @@ -465,10 +461,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasKey("Id") .HasName("pk_shows"); - b.HasIndex("SonarrId") - .IsUnique() - .HasDatabaseName("IX_Shows_SonarrId"); - b.ToTable("shows", (string)null); }); diff --git a/Lingarr.Server/Extensions/LazyServiceWrapper.cs b/Lingarr.Server/Extensions/LazyServiceWrapper.cs index 115e69b8..f4506fc5 100644 --- a/Lingarr.Server/Extensions/LazyServiceWrapper.cs +++ b/Lingarr.Server/Extensions/LazyServiceWrapper.cs @@ -1,7 +1,7 @@ namespace Lingarr.Server.Extensions; /// -/// A wrapper class to enable Lazy<T> resolution from the DI container. +/// A wrapper class to enable Lazy resolution from the DI container. /// This is used to break circular dependencies by deferring service resolution until first use. /// /// The service type to lazily resolve diff --git a/Lingarr.Server/Interfaces/Services/IMediaStateService.cs b/Lingarr.Server/Interfaces/Services/IMediaStateService.cs index c083e6fb..21cdcecb 100644 --- a/Lingarr.Server/Interfaces/Services/IMediaStateService.cs +++ b/Lingarr.Server/Interfaces/Services/IMediaStateService.cs @@ -18,15 +18,6 @@ public interface IMediaStateService /// If true, SaveChangesAsync will be called after updating /// The computed translation state Task UpdateStateAsync(IMedia media, MediaType mediaType, bool saveChanges = true); - - /// - /// Computes and updates TranslationStates for multiple media items in a single operation. - /// This is significantly more efficient than individual updates during sync. - /// - /// The list of media items to update - /// Type of media (Movie or Episode) - /// If true, SaveChangesAsync will be called after updating - Task UpdateStatesAsync(IEnumerable medias, MediaType mediaType, bool saveChanges = true); /// /// Marks all media as Stale. Used when language settings change. diff --git a/Lingarr.Server/Interfaces/Services/ITranslationRequestService.cs b/Lingarr.Server/Interfaces/Services/ITranslationRequestService.cs index 7a8e4f21..f3df1671 100644 --- a/Lingarr.Server/Interfaces/Services/ITranslationRequestService.cs +++ b/Lingarr.Server/Interfaces/Services/ITranslationRequestService.cs @@ -116,14 +116,6 @@ Task> GetTranslationRequests( TranslationRequest cancelRequest ); - /// - /// Removes all failed translation requests for a specific media item. - /// - /// The ID of the media item - /// The type of media (Movie or Episode) - /// The number of failed requests removed - Task RemoveFailedRequestsAsync(int mediaId, MediaType mediaType); - /// /// Retries all translation requests with Failed status /// diff --git a/Lingarr.Server/Interfaces/Services/Subtitle/IOrphanSubtitleCleanupService.cs b/Lingarr.Server/Interfaces/Services/Subtitle/IOrphanSubtitleCleanupService.cs index e65f3a6c..cf1e8e79 100644 --- a/Lingarr.Server/Interfaces/Services/Subtitle/IOrphanSubtitleCleanupService.cs +++ b/Lingarr.Server/Interfaces/Services/Subtitle/IOrphanSubtitleCleanupService.cs @@ -14,13 +14,4 @@ public interface IOrphanSubtitleCleanupService /// New media filename (without extension) /// Number of files cleaned up Task CleanupOrphansAsync(string directoryPath, string oldFileName, string newFileName); - - /// - /// Cleans up stale translated subtitles for a specific media file and target language. - /// - /// Directory containing the media - /// Media filename (without extension) - /// Target language code (optional, if null cleans all translated subs for this media) - /// Number of files cleaned up - Task CleanupStaleSubtitlesAsync(string directoryPath, string fileName, string? targetLanguage = null); } diff --git a/Lingarr.Server/Interfaces/Services/Sync/IEpisodeSync.cs b/Lingarr.Server/Interfaces/Services/Sync/IEpisodeSync.cs index 709fee03..c9301e40 100644 --- a/Lingarr.Server/Interfaces/Services/Sync/IEpisodeSync.cs +++ b/Lingarr.Server/Interfaces/Services/Sync/IEpisodeSync.cs @@ -10,7 +10,6 @@ public interface IEpisodeSync /// /// The Sonarr show containing the episodes /// The season to sync episodes for - /// Optional pre-loaded episode entities to update /// A task representing the asynchronous operation - Task SyncEpisodes(SonarrShow show, Season season, List? existingEpisodes = null); + Task SyncEpisodes(SonarrShow show, Season season); } \ No newline at end of file diff --git a/Lingarr.Server/Interfaces/Services/Sync/ISeasonSync.cs b/Lingarr.Server/Interfaces/Services/Sync/ISeasonSync.cs index a575d759..70c46b24 100644 --- a/Lingarr.Server/Interfaces/Services/Sync/ISeasonSync.cs +++ b/Lingarr.Server/Interfaces/Services/Sync/ISeasonSync.cs @@ -11,7 +11,6 @@ public interface ISeasonSync /// The show entity the season belongs to /// The Sonarr show containing the season /// The Sonarr season to sync - /// Optional pre-loaded season entity to update /// The synchronized season entity - Task SyncSeason(Show show, SonarrShow sonarrShow, SonarrSeason season, Season? existingSeason = null); + Task SyncSeason(Show show, SonarrShow sonarrShow, SonarrSeason season); } \ No newline at end of file diff --git a/Lingarr.Server/Interfaces/Services/Sync/IShowSync.cs b/Lingarr.Server/Interfaces/Services/Sync/IShowSync.cs index 1cc0eb59..690d9527 100644 --- a/Lingarr.Server/Interfaces/Services/Sync/IShowSync.cs +++ b/Lingarr.Server/Interfaces/Services/Sync/IShowSync.cs @@ -9,7 +9,6 @@ public interface IShowSync /// Synchronizes a single show from Sonarr /// /// The Sonarr show to sync - /// Optional pre-loaded show entity to update /// The synchronized show entity - Task SyncShow(SonarrShow show, Show? existingShow = null); + Task SyncShow(SonarrShow show); } \ No newline at end of file diff --git a/Lingarr.Server/Jobs/AutomatedTranslationJob.cs b/Lingarr.Server/Jobs/AutomatedTranslationJob.cs index 221cca95..db8d1319 100644 --- a/Lingarr.Server/Jobs/AutomatedTranslationJob.cs +++ b/Lingarr.Server/Jobs/AutomatedTranslationJob.cs @@ -95,7 +95,7 @@ public async Task Execute() processedCount++; - // For stale/unknown/failed items, refresh state first + // For stale/unknown items, refresh state first TranslationState currentState; if (mediaType == MediaType.Movie) { @@ -106,28 +106,13 @@ public async Task Execute() currentState = ((Episode)media).TranslationState; } - if (currentState == TranslationState.Stale || currentState == TranslationState.Unknown || currentState == TranslationState.Failed || currentState == TranslationState.AwaitingSource) + if (currentState == TranslationState.Stale || currentState == TranslationState.Unknown) { - // For Failed/AwaitingSource items, we allow retry if state re-evaluation says it's Pending var newState = await _mediaStateService.UpdateStateAsync(media, mediaType); - - // If it's still AwaitingSource, we trigger a probe/index - if (newState == TranslationState.AwaitingSource) - { - _logger.LogInformation("Item {Title} is AwaitingSource, triggering probe/index", media.Title); - await _mediaSubtitleProcessor.ProcessMediaForceAsync( - media, mediaType, - forceProcess: true, - forceTranslation: false); - - // Refresh state after probe - newState = await _mediaStateService.UpdateStateAsync(media, mediaType); - } - if (newState != TranslationState.Pending) { _logger.LogDebug( - "Skipping {Title}: state refreshed to {State}", + "Skipping {Title}: state refreshed to {State}", media.Title, newState); continue; } diff --git a/Lingarr.Server/Jobs/SyncMovieJob.cs b/Lingarr.Server/Jobs/SyncMovieJob.cs index 6d96d43d..f525caff 100644 --- a/Lingarr.Server/Jobs/SyncMovieJob.cs +++ b/Lingarr.Server/Jobs/SyncMovieJob.cs @@ -69,7 +69,6 @@ await strategy.ExecuteAsync(async () => _logger.LogError(ex, "An error occurred when syncing movies. Exception details: {ExceptionMessage}, Stack Trace: {StackTrace}", ex.Message, ex.StackTrace); - throw; } } } \ No newline at end of file diff --git a/Lingarr.Server/Jobs/SyncShowJob.cs b/Lingarr.Server/Jobs/SyncShowJob.cs index 73cbd851..5e3432c2 100644 --- a/Lingarr.Server/Jobs/SyncShowJob.cs +++ b/Lingarr.Server/Jobs/SyncShowJob.cs @@ -49,16 +49,6 @@ public async Task Execute() _logger.LogInformation("Fetched {ShowCount} shows from Sonarr", shows.Count); - var jjk = shows.FirstOrDefault(s => s.Title.Equals("Jujutsu Kaisen", StringComparison.OrdinalIgnoreCase)); - if (jjk != null) - { - _logger.LogInformation("DEBUG: Jujutsu Kaisen found in Sonarr response. Id: {Id}, Path: {Path}", jjk.Id, jjk.Path); - } - else - { - _logger.LogWarning("DEBUG: Jujutsu Kaisen NOT found in Sonarr response!"); - } - // Sync shows incrementally - each batch commits independently for UI visibility await _showSyncService.SyncShows(shows); @@ -79,7 +69,6 @@ await strategy.ExecuteAsync(async () => _logger.LogError(ex, "An error occurred when syncing shows. Exception details: {ExceptionMessage}, Stack Trace: {StackTrace}", ex.Message, ex.StackTrace); - throw; } } } \ No newline at end of file diff --git a/Lingarr.Server/Jobs/TranslationJob.cs b/Lingarr.Server/Jobs/TranslationJob.cs index 07057995..d86a8b00 100644 --- a/Lingarr.Server/Jobs/TranslationJob.cs +++ b/Lingarr.Server/Jobs/TranslationJob.cs @@ -30,7 +30,6 @@ public class TranslationJob private readonly ITranslationCancellationService _cancellationService; private readonly IMediaStateService _mediaStateService; private readonly IDeferredRepairService _deferredRepairService; - private readonly IOrphanSubtitleCleanupService _orphanCleanupService; public TranslationJob( ILogger logger, @@ -46,8 +45,7 @@ public TranslationJob( ISubtitleExtractionService extractionService, ITranslationCancellationService cancellationService, IMediaStateService mediaStateService, - IDeferredRepairService deferredRepairService, - IOrphanSubtitleCleanupService orphanCleanupService) + IDeferredRepairService deferredRepairService) { _logger = logger; _settings = settings; @@ -63,7 +61,6 @@ public TranslationJob( _cancellationService = cancellationService; _mediaStateService = mediaStateService; _deferredRepairService = deferredRepairService; - _orphanCleanupService = orphanCleanupService; } /// @@ -273,17 +270,6 @@ void AddRequestLog(string level, string message, string? details = null) } } - // Clean up stale translated subtitles for this target language before starting a new translation - if (!string.IsNullOrEmpty(request.SubtitleToTranslate)) - { - var dir = Path.GetDirectoryName(request.SubtitleToTranslate); - var fileName = Path.GetFileNameWithoutExtension(request.SubtitleToTranslate); - if (!string.IsNullOrEmpty(dir) && !string.IsNullOrEmpty(fileName)) - { - await _orphanCleanupService.CleanupStaleSubtitlesAsync(dir, fileName, request.TargetLanguage); - } - } - // translate subtitles var translationService = _translationServiceFactory.CreateTranslationService(serviceType); var translator = new SubtitleTranslationService( diff --git a/Lingarr.Server/Services/MediaStateService.cs b/Lingarr.Server/Services/MediaStateService.cs index c7499d35..4802b406 100644 --- a/Lingarr.Server/Services/MediaStateService.cs +++ b/Lingarr.Server/Services/MediaStateService.cs @@ -93,85 +93,6 @@ public async Task UpdateStateAsync(IMedia media, MediaType med return state; } - /// - public async Task UpdateStatesAsync(IEnumerable medias, MediaType mediaType, bool saveChanges = true) - { - var currentVersion = await GetSettingsVersionAsync(); - var sourceLanguages = await GetConfiguredLanguages(SettingKeys.Translation.SourceLanguages); - var targetLanguages = await GetConfiguredLanguages(SettingKeys.Translation.TargetLanguages); - - // Ensure EmbeddedSubtitles are loaded for all media items - var mediaIds = medias.Select(m => m.Id).ToList(); - if (mediaType == MediaType.Movie) - { - await _dbContext.Movies - .Include(m => m.EmbeddedSubtitles) - .Where(m => mediaIds.Contains(m.Id)) - .LoadAsync(); - } - else - { - await _dbContext.Episodes - .Include(e => e.EmbeddedSubtitles) - .Include(e => e.Season) - .ThenInclude(s => s.Show) - .Where(e => mediaIds.Contains(e.Id)) - .LoadAsync(); - } - - if (sourceLanguages.Count == 0 || targetLanguages.Count == 0) - { - foreach (var media in medias) - { - if (media is Movie m) { m.TranslationState = TranslationState.NotApplicable; m.StateSettingsVersion = currentVersion; } - else if (media is Episode e) { e.TranslationState = TranslationState.NotApplicable; e.StateSettingsVersion = currentVersion; } - } - if (saveChanges) await _dbContext.SaveChangesAsync(); - return; - } - - foreach (var media in medias) - { - List embeddedSubtitles; - bool mediaExcluded; - bool seasonExcluded = false; - bool showExcluded = false; - - if (media is Movie movie) - { - embeddedSubtitles = movie.EmbeddedSubtitles; - mediaExcluded = movie.ExcludeFromTranslation; - } - else if (media is Episode episode) - { - embeddedSubtitles = episode.EmbeddedSubtitles; - mediaExcluded = episode.ExcludeFromTranslation; - seasonExcluded = episode.Season?.ExcludeFromTranslation ?? false; - showExcluded = episode.Season?.Show?.ExcludeFromTranslation ?? false; - } - else - { - continue; - } - - var state = await ComputeStateAsync( - media, - mediaType, - embeddedSubtitles, - mediaExcluded, - seasonExcluded, - showExcluded); - - if (media is Movie m) { m.TranslationState = state; m.StateSettingsVersion = currentVersion; } - else if (media is Episode e) { e.TranslationState = state; e.StateSettingsVersion = currentVersion; } - } - - if (saveChanges) - { - await _dbContext.SaveChangesAsync(); - } - } - private async Task ComputeStateAsync( IMedia media, MediaType mediaType, @@ -186,17 +107,7 @@ private async Task ComputeStateAsync( return TranslationState.NotApplicable; } - // 2. Check indexing status - DateTime? indexedAt = null; - if (media is Movie movie) indexedAt = movie.IndexedAt; - else if (media is Episode episode) indexedAt = episode.IndexedAt; - - if (indexedAt == null) - { - return TranslationState.AwaitingSource; - } - - // 3. Get configured languages + // 2. Get configured languages var sourceLanguages = await GetConfiguredLanguages(SettingKeys.Translation.SourceLanguages); var targetLanguages = await GetConfiguredLanguages(SettingKeys.Translation.TargetLanguages); @@ -205,40 +116,70 @@ private async Task ComputeStateAsync( return TranslationState.NotApplicable; } - // 4. Check for existing subtitles in target language - var hasAllTargets = targetLanguages.All(lang => - embeddedSubtitles.Any(s => s.Language?.ToLowerInvariant() == lang)); - - if (hasAllTargets) + // 3. Check for active translation request + if (await HasActiveTranslationRequestAsync(media.Id, mediaType)) { - return TranslationState.Complete; + return TranslationState.InProgress; } - // 5. Check for source subtitles - var hasSource = sourceLanguages.Any(lang => - embeddedSubtitles.Any(s => s.Language?.ToLowerInvariant() == lang)); + // 3b. Check for failed translation request + if (await HasFailedTranslationRequestAsync(media.Id, mediaType)) + { + return TranslationState.Failed; + } - if (!hasSource) + // 4. Get external subtitles + var externalSubtitles = new List(); + if (!string.IsNullOrEmpty(media.Path)) { - return TranslationState.NoSuitableSubtitles; + try + { + var allSubs = await _subtitleService.GetAllSubtitles(media.Path); + var mediaNameNoExt = Path.GetFileNameWithoutExtension(media.FileName); + externalSubtitles = allSubs + .Where(s => !string.IsNullOrEmpty(media.FileName) && + (s.FileName.StartsWith(media.FileName + ".") || + s.FileName == media.FileName || + (!string.IsNullOrEmpty(mediaNameNoExt) && s.FileName.StartsWith(mediaNameNoExt + ".")))) + .ToList(); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Failed to get external subtitles for {Title}", media.Title); + } } - // 6. Check for active/failed translation requests - if (await HasActiveTranslationRequestAsync(media.Id, mediaType)) + // 5. Check for source subtitle + var hasExternalSource = externalSubtitles + .Any(s => sourceLanguages.Any(sl => SubtitleLanguageHelper.LanguageMatches(s.Language, sl))); + var hasEmbeddedSource = embeddedSubtitles + .Any(e => e.IsTextBased && + !string.IsNullOrEmpty(e.Language) && + sourceLanguages.Any(sl => SubtitleLanguageHelper.LanguageMatches(e.Language, sl))); + + if (!hasExternalSource && !hasEmbeddedSource) { - return TranslationState.InProgress; + return TranslationState.AwaitingSource; } - if (await HasFailedTranslationRequestAsync(media.Id, mediaType)) + // 6. Check which targets are satisfied + var existingTargetLanguages = externalSubtitles + .Select(s => s.Language.ToLowerInvariant()) + .ToHashSet(); + + var missingTargets = targetLanguages + .Where(t => !existingTargetLanguages.Contains(t)) + .ToList(); + + if (missingTargets.Count == 0) { - return TranslationState.Failed; + return TranslationState.Complete; } - // Has source, missing targets, no active/failed request = Pending + // Has source, missing targets, no active request = Pending return TranslationState.Pending; } - /// public async Task MarkAllStaleAsync() { @@ -261,25 +202,13 @@ public async Task MarkAllStaleAsync() var result = new List<(IMedia Media, MediaType Type)>(); var halfLimit = Math.Max(limit / 2, 1); - _logger.LogInformation("Querying for media needing translation. Limit: {Limit}", limit); - // Query movies needing work var moviesQuery = _dbContext.Movies .Include(m => m.EmbeddedSubtitles) .Where(m => !m.ExcludeFromTranslation) - .Where(m => m.TranslationState == TranslationState.Pending + .Where(m => m.TranslationState == TranslationState.Pending || m.TranslationState == TranslationState.Stale - || m.TranslationState == TranslationState.Failed - || m.TranslationState == TranslationState.Unknown - || m.TranslationState == TranslationState.AwaitingSource); - - var pendingCount = await _dbContext.Movies.CountAsync(m => m.TranslationState == TranslationState.Pending); - var staleCount = await _dbContext.Movies.CountAsync(m => m.TranslationState == TranslationState.Stale); - var unknownCount = await _dbContext.Movies.CountAsync(m => m.TranslationState == TranslationState.Unknown); - var failedCount = await _dbContext.Movies.CountAsync(m => m.TranslationState == TranslationState.Failed); - - _logger.LogInformation("Movie states in DB: Pending={Pending}, Stale={Stale}, Unknown={Unknown}, Failed={Failed}", - pendingCount, staleCount, unknownCount, failedCount); + || (m.TranslationState == TranslationState.Unknown && m.IndexedAt != null)); if (priorityFirst) { @@ -304,11 +233,9 @@ public async Task MarkAllStaleAsync() .Where(e => !e.ExcludeFromTranslation) .Where(e => !e.Season.ExcludeFromTranslation) .Where(e => !e.Season.Show.ExcludeFromTranslation) - .Where(e => e.TranslationState == TranslationState.Pending + .Where(e => e.TranslationState == TranslationState.Pending || e.TranslationState == TranslationState.Stale - || e.TranslationState == TranslationState.Failed - || e.TranslationState == TranslationState.Unknown - || e.TranslationState == TranslationState.AwaitingSource); + || (e.TranslationState == TranslationState.Unknown && e.IndexedAt != null)); if (priorityFirst) { diff --git a/Lingarr.Server/Services/MediaSubtitleProcessor.cs b/Lingarr.Server/Services/MediaSubtitleProcessor.cs index 0f9b873d..271abc77 100644 --- a/Lingarr.Server/Services/MediaSubtitleProcessor.cs +++ b/Lingarr.Server/Services/MediaSubtitleProcessor.cs @@ -1,4 +1,4 @@ -using System.Security.Cryptography; +using System.Security.Cryptography; using Lingarr.Core.Configuration; using Lingarr.Core.Data; using Lingarr.Core.Entities; @@ -23,7 +23,6 @@ public class MediaSubtitleProcessor : IMediaSubtitleProcessor private readonly ISubtitleExtractionService _extractionService; private readonly LingarrDbContext _dbContext; private readonly ISubtitleIntegrityService _integrityService; - private static readonly string[] VideoExtensions = { ".mkv", ".mp4", ".avi", ".m4v", ".mov", ".wmv", ".flv", ".webm" }; private string _hash = string.Empty; private IMedia _media = null!; private MediaType _mediaType; @@ -51,234 +50,334 @@ public async Task ProcessMedia( IMedia media, MediaType mediaType) { - var count = await ProcessInternalAsync(media, mediaType, forceProcess: false, forceTranslation: false, forcePriority: false); - return count > 0; - } - - /// - public async Task ProcessMediaForceAsync( - IMedia media, - MediaType mediaType, - bool forceProcess = true, - bool forceTranslation = true, - bool forcePriority = false) - { - return await ProcessInternalAsync(media, mediaType, forceProcess, forceTranslation, forcePriority); - } - - private async Task ProcessInternalAsync( - IMedia media, - MediaType mediaType, - bool forceProcess, - bool forceTranslation, - bool forcePriority) - { - if (string.IsNullOrEmpty(media.Path) || string.IsNullOrEmpty(media.FileName)) + if (media.Path == null) { - return 0; + return false; } - - _media = media; - _mediaType = mediaType; - var allSubtitles = await _subtitleService.GetAllSubtitles(media.Path); var matchingSubtitles = allSubtitles .Where(s => s.FileName.StartsWith(media.FileName + ".") || s.FileName == media.FileName) .ToList(); - var sourceLanguages = await GetLanguagesSetting(SettingKeys.Translation.SourceLanguages); - var targetLanguages = await GetLanguagesSetting(SettingKeys.Translation.TargetLanguages); - var ignoreCaptions = await _settingService.GetSetting(SettingKeys.Translation.IgnoreCaptions) ?? "false"; - - if (sourceLanguages.Count == 0 || targetLanguages.Count == 0) + if (!matchingSubtitles.Any()) { - _logger.LogWarning("Source or target languages are empty for {Title}.", media.Title); - return 0; + return false; } - // 1. Check external subtitles first - var existingLanguages = ExtractLanguageCodes(matchingSubtitles); - var sourceLanguage = existingLanguages.FirstOrDefault(lang => sourceLanguages.Contains(lang)); - Subtitles? sourceSubtitle = null; + var sourceLanguages = await GetLanguagesSetting(SettingKeys.Translation.SourceLanguages); + var targetLanguages = await GetLanguagesSetting(SettingKeys.Translation.TargetLanguages); + var ignoreCaptions = await _settingService.GetSetting(SettingKeys.Translation.IgnoreCaptions); - if (sourceLanguage != null) + _media = media; + _mediaType = mediaType; + _hash = CreateHash(matchingSubtitles, sourceLanguages, targetLanguages, ignoreCaptions ?? ""); + if (!string.IsNullOrEmpty(media.MediaHash) && media.MediaHash == _hash) { - sourceSubtitle = ignoreCaptions == "true" - ? (matchingSubtitles.FirstOrDefault(s => s.Language == sourceLanguage && string.IsNullOrEmpty(s.Caption)) - ?? matchingSubtitles.FirstOrDefault(s => s.Language == sourceLanguage)) - : matchingSubtitles.FirstOrDefault(s => s.Language == sourceLanguage); + return false; } + + _logger.LogInformation("Initiating subtitle processing."); + return await ProcessSubtitles(matchingSubtitles, sourceLanguages, targetLanguages, ignoreCaptions ?? ""); + } - // 2. Compute Hash (Robust & Relative) - _hash = CreateHash(media, matchingSubtitles, sourceLanguages, targetLanguages, ignoreCaptions); - - if (!forceProcess && !string.IsNullOrEmpty(media.MediaHash) && media.MediaHash == _hash) - { - _logger.LogDebug("Skipping {Title}: hash matches and not forcing", media.Title); - return 0; - } + /// + /// Processes subtitle files for translation based on configured languages. + /// + /// List of subtitle files to process. + /// The source languages. + /// The target languages. + /// The ignore captions setting. + /// True if new translation requests were created, false otherwise. + private async Task ProcessSubtitles( + List subtitles, + HashSet sourceLanguages, + HashSet targetLanguages, + string ignoreCaptions) + { + var existingLanguages = ExtractLanguageCodes(subtitles); - // 3. If no external source, try embedded - if (sourceSubtitle == null) + if (sourceLanguages.Count == 0 || targetLanguages.Count == 0) { - _logger.LogInformation("No suitable external source found for {Title}, trying embedded...", media.Title); - return await TryQueueEmbeddedSubtitleTranslation(media, mediaType, forceTranslation, forceProcess, forcePriority); + _logger.LogWarning( + "Source or target languages are empty. Source languages: {SourceCount}, Target languages: {TargetCount}", + sourceLanguages.Count, targetLanguages.Count); + await UpdateHash(); + return false; } - // 4. Process External Subtitles - _logger.LogInformation("Processing external subtitles for {Title} (forceTranslation={Force}).", media.Title, forceTranslation); - - var languagesToTranslate = forceTranslation - ? targetLanguages.ToList() - : targetLanguages.Except(existingLanguages).ToList(); - - if (ignoreCaptions == "true" && !forceTranslation) + string? tempSourcePath = null; + try { - var targetLanguagesWithCaptions = matchingSubtitles - .Where(s => targetLanguages.Contains(s.Language) && !string.IsNullOrEmpty(s.Caption)) - .Select(s => s.Language) - .Distinct() - .ToList(); + var sourceLanguage = existingLanguages.FirstOrDefault(lang => sourceLanguages.Contains(lang)); + Subtitles? sourceSubtitle = null; - if (targetLanguagesWithCaptions.Any()) + if (sourceLanguage != null) { - languagesToTranslate = languagesToTranslate.Except(targetLanguagesWithCaptions).ToList(); + sourceSubtitle = ignoreCaptions == "true" + ? subtitles.FirstOrDefault(s => s.Language == sourceLanguage && string.IsNullOrEmpty(s.Caption)) + ?? subtitles.FirstOrDefault(s => s.Language == sourceLanguage) + : subtitles.FirstOrDefault(s => s.Language == sourceLanguage); } - } - var corruptLanguages = new List(); - if (!forceTranslation) - { - foreach (var targetLang in targetLanguages.Intersect(existingLanguages)) + // Check if the found external subtitle is sparse (likely Signs/Songs from previous extraction) + // If it was extracted by Lingarr and is sparse, skip it and fall back to embedded extraction + if (sourceSubtitle != null) { - var targetSubtitle = matchingSubtitles.FirstOrDefault(s => s.Language == targetLang); - if (targetSubtitle != null) + var isSparse = SubtitleExtractionService.IsSparseSubtitle(sourceSubtitle.Path); + var isExtracted = SubtitleExtractionService.IsLingarrExtracted(sourceSubtitle.Path); + + if (isSparse) { - var isValid = await _integrityService.ValidateIntegrityAsync(sourceSubtitle.Path, targetSubtitle.Path); - if (!isValid) + var entryCount = SubtitleExtractionService.CountSubtitleEntries(sourceSubtitle.Path); + _logger.LogWarning( + "External subtitle {Path} is sparse ({Count} entries, minimum: {Min}). {Action}", + sourceSubtitle.Path, + entryCount, + SubtitleExtractionService.MinimumDialogueEntries, + isExtracted ? "Lingarr-extracted file will be skipped, trying embedded fallback..." : "User-provided file will still be used."); + + // Only skip Lingarr-extracted sparse files; user-provided sparse files may be intentional + if (isExtracted) { - _logger.LogWarning("Integrity check failed for {TargetLang} subtitle: {Path}", targetLang, targetSubtitle.Path); - corruptLanguages.Add(targetLang); + sourceSubtitle = null; } } } - languagesToTranslate = languagesToTranslate.Union(corruptLanguages).ToList(); - } - var queuedCount = 0; - foreach (var targetLanguage in languagesToTranslate) - { - if (await HasActiveRequestAsync(media.Id, mediaType, sourceLanguage!, targetLanguage)) continue; + // Fallback: If no external source found (even if sourceLanguage was detected but file missing?) + // Actually, existingLanguages comes from files. So if sourceLanguage != null, the file exists. + // But if existingLanguages DOES NOT contain sourceLanguage, sourceLanguage is null. + // So we check if validSource is missing. + + if (sourceSubtitle == null) + { + _logger.LogInformation("No external source subtitle found for {FileName}. Checking for embedded subtitles for validation...", _media.FileName); + + // Logic to extract embedded subtitle + var sourceLanguageModels = await _settingService.GetSettingAsJson(SettingKeys.Translation.SourceLanguages); + var configuredSourceLanguages = sourceLanguageModels.Select(lang => lang.Code).Where(c => !string.IsNullOrWhiteSpace(c)).ToList(); - // Clean up any failed requests for this media/language pair before creating a new one - await _translationRequestService.RemoveFailedRequestsAsync(media.Id, mediaType); + var embeddedSubtitles = await ProbeEmbeddedSubtitlesForCurrentMedia(); + + if (embeddedSubtitles != null && embeddedSubtitles.Any()) + { + var textBasedSubs = embeddedSubtitles.Where(s => s.IsTextBased).ToList(); + var bestMatch = SubtitleLanguageHelper.FindBestMatch(textBasedSubs, configuredSourceLanguages); + + if (bestMatch.Subtitle != null) + { + var tempDir = Path.GetTempPath(); + var tempFileName = $"lingarr_temp_source_{Guid.NewGuid()}.{bestMatch.MatchedLanguage}.srt"; + + tempSourcePath = await _extractionService.ExtractSubtitle( + Path.Combine(_media.Path!, _media.FileName!), + bestMatch.Subtitle.StreamIndex, + tempDir, + "srt", + bestMatch.MatchedLanguage); + + if (tempSourcePath != null) + { + // Create a temporary Subtitles object + sourceSubtitle = new Subtitles + { + Path = tempSourcePath, + Language = bestMatch.MatchedLanguage, + Format = "srt", + FileName = Path.GetFileName(tempSourcePath) + }; + sourceLanguage = bestMatch.MatchedLanguage; + _logger.LogInformation("Extracted temporary source subtitle for validation: {TempPath}", tempSourcePath); + } + } + } + } - await _translationRequestService.CreateRequest(new TranslateAbleSubtitle + if (sourceSubtitle != null) { - MediaId = media.Id, - MediaType = mediaType, - SubtitlePath = sourceSubtitle.Path, - TargetLanguage = targetLanguage, - SourceLanguage = sourceLanguage!, - SubtitleFormat = sourceSubtitle.Format - }, forcePriority); - queuedCount++; - } + // Get languages that don't yet exist to validate whether captions in those languages are available + var languagesToTranslate = targetLanguages.Except(existingLanguages).ToList(); + + // Check integrity of existing target subtitles and add corrupt ones for re-translation + var corruptLanguages = new List(); + foreach (var targetLang in targetLanguages.Intersect(existingLanguages)) + { + var targetSubtitle = subtitles.FirstOrDefault(s => s.Language == targetLang); + if (targetSubtitle != null) + { + var isValid = await _integrityService.ValidateIntegrityAsync( + sourceSubtitle.Path, + targetSubtitle.Path); + if (!isValid) + { + _logger.LogWarning( + "Integrity check failed for {TargetLang} subtitle: {Path} - scheduling re-translation", + targetLang, targetSubtitle.Path); + corruptLanguages.Add(targetLang); + } + } + } + + // Add corrupt languages to the translation queue + languagesToTranslate = languagesToTranslate.Union(corruptLanguages).ToList(); + var foundCorruption = corruptLanguages.Count > 0; + + if (ignoreCaptions == "true") + { + var targetLanguagesWithCaptions = subtitles + .Where(s => targetLanguages.Contains(s.Language) && !string.IsNullOrEmpty(s.Caption)) + .Select(s => s.Language) + .Distinct() + .ToList(); - if (corruptLanguages.Count == 0 && queuedCount > 0) - { - await UpdateHash(); - } - else if (corruptLanguages.Count == 0 && languagesToTranslate.Count == 0) - { - // If nothing to translate and nothing corrupt, we can also update hash - await UpdateHash(); - return 1; // Signal that we "processed" it (nothing needed) - } + if (targetLanguagesWithCaptions.Any()) + { + // Remove languages that have captions from languagesToTranslate if ignoreCaptions is true + // Actually logic above just returns. But if we have corrupt languages, maybe we want to continue? + // Original logic returns if ANY valid caption exists? No, it returns if target exists w/ caption. + // Let's keep original logic strictness for now. + + // BUT: corruptLanguages might NEED re-translation. If a corrupt subtitle has a caption, do we skip it? + // If it's corrupt, it's corrupt. + + var skipped = targetLanguagesWithCaptions.Except(corruptLanguages).ToList(); + if (skipped.Any()) + { + _logger.LogInformation( + "Translation skipped because captions exist for target languages: |Green|{CaptionLanguages}|/Green| and ignoreCaptions is disabled", + string.Join(", ", skipped)); + + // If all targets are skipped, return. + if (!languagesToTranslate.Except(skipped).Any()) + { + if (!foundCorruption) + { + await UpdateHash(); + } + return false; + } + } + } + } - return queuedCount; - } + foreach (var targetLanguage in languagesToTranslate) + { + if (sourceLanguage == null || await HasActiveRequestAsync(_media.Id, _mediaType, sourceLanguage, targetLanguage)) + { + _logger.LogInformation( + "Skipping enqueue for {FileName} {Source}->{Target}: translation request already active.", + _media.FileName, + sourceLanguage, + targetLanguage); + continue; + } - private string CreateHash( - IMedia media, - List subtitles, - HashSet sourceLanguages, - HashSet targetLanguages, - string ignoreCaptions) - { - using var sha256 = SHA256.Create(); - - // We no longer include media file size/mtime in the hash for external subtitles - // to avoid re-translations when Tdarr/remuxing changes the container/video/audio - // but leaves external .srt files untouched. - - var subtitleTokens = subtitles - .OrderBy(s => s.Path) - .Select(s => { - var fileInfo = new FileInfo(s.Path); - var size = fileInfo.Exists ? fileInfo.Length : 0; - var mtime = fileInfo.Exists ? fileInfo.LastWriteTimeUtc.Ticks : 0; - var relativePath = Path.GetFileName(s.Path); - return $"{relativePath}:{size}:{mtime}"; - }); - - var hashInput = $"{string.Join("|", subtitleTokens)}|{string.Join(",", sourceLanguages.OrderBy(l => l))}|{string.Join(",", targetLanguages.OrderBy(l => l))}|{ignoreCaptions}|v6"; - return Convert.ToBase64String(sha256.ComputeHash(System.Text.Encoding.UTF8.GetBytes(hashInput))); - } + await _translationRequestService.CreateRequest(new TranslateAbleSubtitle + { + MediaId = _media.Id, + MediaType = _mediaType, + SubtitlePath = (tempSourcePath != null) ? null : sourceSubtitle.Path, // If temp, use NULL so Job extracts fresh? Or use temp? + // IMPORTANT: If we use temp path, the Job might fail if temp is deleted. + // Ideally, we pass NULL so the Job does its own extraction. + // We ONLY extracted temp for VALIDATION. + TargetLanguage = targetLanguage, + SourceLanguage = sourceLanguage, + SubtitleFormat = sourceSubtitle.Format + }); + _logger.LogInformation( + "Initiating translation from |Orange|{sourceLanguage}|/Orange| to |Orange|{targetLanguage}|/Orange| for |Green|{subtitleFile}|/Green|", + sourceLanguage, + targetLanguage, + sourceSubtitle.Path); + } - private string CreateEmbeddedHash( - IMedia media, - IReadOnlyCollection embeddedSubtitles, - IEnumerable configuredSourceLanguages, - IEnumerable targetLanguages, - string ignoreCaptions) - { - using var sha256 = SHA256.Create(); + // Only update hash if no corruption was found - ensures re-validation if translation fails + if (!foundCorruption) + { + await UpdateHash(); + } + else + { + _logger.LogDebug("Skipping hash update for {FileName} due to corruption found - will re-validate next run", _media.FileName); + } + return true; + } - // For embedded subtitles, we still need to know if the media file changed - // because we might need to re-extract. However, Tdarr often changes the file - // without changing the subtitle content. - // We'll exclude mediaSize/mtime if we have embedded subtitles to track, - // relying on the stream properties instead. If the streams change (e.g. re-ordered, - // different codec), we'll still re-process. - - long mediaSize = 0; - long mediaMtime = 0; - - // If no embedded subtitles found yet, we include media info to ensure - // we re-probe when the file is replaced/updated. - if (embeddedSubtitles.Count == 0) + _logger.LogWarning("No source subtitle file found for language: |Green|{SourceLanguage}|/Green|", + sourceLanguage); + + await UpdateHash(); + return false; + } + finally { - try + if (tempSourcePath != null && File.Exists(tempSourcePath)) { - var dirInfo = new DirectoryInfo(media.Path!); - if (dirInfo.Exists) + try + { + File.Delete(tempSourcePath); + _logger.LogDebug("Deleted temporary validation subtitle: {TempPath}", tempSourcePath); + } + catch (Exception ex) { - var fileInfo = dirInfo.GetFiles(media.FileName + ".*") - .FirstOrDefault(f => VideoExtensions.Contains(f.Extension.ToLowerInvariant())); - - if (fileInfo != null) - { - mediaSize = fileInfo.Length; - mediaMtime = fileInfo.LastWriteTimeUtc.Ticks; - } + _logger.LogWarning(ex, "Failed to delete temporary validation subtitle: {TempPath}", tempSourcePath); } - } catch {} + } } - - var streamTokens = embeddedSubtitles - .OrderBy(s => s.StreamIndex) - .Select(s => - $"{s.StreamIndex}:{s.Language?.ToLowerInvariant()}:{s.CodecName}:{s.IsTextBased}:{s.IsDefault}:{s.IsForced}"); - - var sources = string.Join(",", configuredSourceLanguages.OrderBy(l => l)); - var targets = string.Join(",", targetLanguages.OrderBy(l => l)); - - var hashInput = $"{mediaSize}:{mediaMtime}|{string.Join("|", streamTokens)}|{sources}|{targets}|{ignoreCaptions}|v7"; - var hashBytes = sha256.ComputeHash(System.Text.Encoding.UTF8.GetBytes(hashInput)); - return Convert.ToBase64String(hashBytes); } + /// + /// Creates a hash of the current subtitle file state. + /// + /// List of subtitle file paths to include in the hash. + /// The source languages. + /// The target languages. + /// The ignore captions setting. + /// A Base64 encoded string representing the hash of the current subtitle state. + private string CreateHash( + List subtitles, + HashSet sourceLanguages, + HashSet targetLanguages, + string ignoreCaptions) + { + using var sha256 = SHA256.Create(); + var subtitlePaths = string.Join("|", subtitles.Select(subtitle => subtitle.Path) + .ToList() + .OrderBy(f => f)); + + var sourceLangs = string.Join(",", sourceLanguages.OrderBy(l => l)); + var targetLangs = string.Join(",", targetLanguages.OrderBy(l => l)); + + var hashInput = $"{subtitlePaths}|{sourceLangs}|{targetLangs}|{ignoreCaptions}|v2"; + var hashBytes = sha256.ComputeHash(System.Text.Encoding.UTF8.GetBytes(hashInput)); + return Convert.ToBase64String(hashBytes); + } + + private string CreateEmbeddedHash( + IReadOnlyCollection embeddedSubtitles, + IEnumerable configuredSourceLanguages, + IEnumerable targetLanguages) + { + using var sha256 = SHA256.Create(); + + var streamTokens = embeddedSubtitles + .OrderBy(s => s.StreamIndex) + .Select(s => + $"{s.StreamIndex}:{s.Language?.ToLowerInvariant()}:{s.CodecName}:{s.IsTextBased}:{s.IsDefault}:{s.IsForced}"); + + var sources = string.Join(",", configuredSourceLanguages.OrderBy(l => l)); + var targets = string.Join(",", targetLanguages.OrderBy(l => l)); + + var hashInput = $"{string.Join("|", streamTokens)}|{sources}|{targets}|v2"; + var hashBytes = sha256.ComputeHash(System.Text.Encoding.UTF8.GetBytes(hashInput)); + return Convert.ToBase64String(hashBytes); + } + + /// + /// Extracts language codes from subtitle file names. + /// + /// List of subtitle file paths to process. + /// A HashSet of valid language codes found in the file names. private HashSet ExtractLanguageCodes(List subtitles) { return subtitles @@ -286,6 +385,12 @@ private HashSet ExtractLanguageCodes(List subtitles) .ToHashSet(); } + /// + /// Retrieves language settings from the application configuration. + /// + /// The type of language setting to retrieve (Source or Target). + /// The name of the setting to retrieve. + /// A HashSet of language codes from the configuration. private async Task> GetLanguagesSetting(string settingName) where T : class, ILanguage { var languages = await _settingService.GetSettingAsJson(settingName); @@ -294,6 +399,10 @@ private async Task> GetLanguagesSetting(string settingName) w .ToHashSet(); } + /// + /// Updates the media hash in the database. + /// + /// A task representing the asynchronous operation. private async Task UpdateHash() { _media.MediaHash = _hash; @@ -301,13 +410,248 @@ private async Task UpdateHash() await _dbContext.SaveChangesAsync(); } + /// + public async Task ProcessMediaForceAsync( + IMedia media, + MediaType mediaType, + bool forceProcess = true, + bool forceTranslation = true, + bool forcePriority = false) + { + if (media.Path == null) + { + return 0; + } + + var allSubtitles = await _subtitleService.GetAllSubtitles(media.Path); + var matchingSubtitles = allSubtitles + .Where(s => s.FileName.StartsWith(media.FileName + ".") || s.FileName == media.FileName) + .ToList(); + + _logger.LogDebug( + "ProcessMediaForceAsync for {FileName}: Found {AllCount} subtitles in directory, {MatchCount} matching media file", + media.FileName, allSubtitles.Count, matchingSubtitles.Count); + + if (!matchingSubtitles.Any()) + { + _logger.LogInformation( + "No external subtitles found for {FileName}. Checking for embedded subtitles...", + media.FileName); + + // Try to queue translation jobs for embedded subtitle extraction + return await TryQueueEmbeddedSubtitleTranslation(media, mediaType, forceTranslation, forceProcess, forcePriority); + } + + var sourceLanguages = await GetLanguagesSetting(SettingKeys.Translation.SourceLanguages); + var targetLanguages = await GetLanguagesSetting(SettingKeys.Translation.TargetLanguages); + var ignoreCaptions = await _settingService.GetSetting(SettingKeys.Translation.IgnoreCaptions); + + _logger.LogDebug( + "Language settings for {FileName}: Sources=[{Sources}], Targets=[{Targets}], IgnoreCaptions={IgnoreCaptions}", + media.FileName, + string.Join(", ", sourceLanguages), + string.Join(", ", targetLanguages), + ignoreCaptions); + + _logger.LogDebug( + "Matching subtitles for {FileName}: [{Subtitles}]", + media.FileName, + string.Join(", ", matchingSubtitles.Select(s => $"{s.Language}:{s.FileName}"))); + + _media = media; + _mediaType = mediaType; + _hash = CreateHash(matchingSubtitles, sourceLanguages, targetLanguages, ignoreCaptions ?? ""); + + // If not forcing and hash matches, skip processing + if (!forceProcess && !string.IsNullOrEmpty(media.MediaHash) && media.MediaHash == _hash) + { + _logger.LogDebug("Skipping {FileName}: hash matches and not forcing", media.FileName); + return 0; + } + + _logger.LogInformation("Initiating manual subtitle processing for {FileName} (forceProcess={Force}, forceTranslation={ForceTrans}, forcePriority={Priority}).", media.FileName, forceProcess, forceTranslation, forcePriority); + return await ProcessSubtitlesWithCount(media, mediaType, matchingSubtitles, sourceLanguages, targetLanguages, ignoreCaptions ?? "", forceTranslation, forceProcess, forcePriority); + // return 0; + } + + /// + /// Processes subtitle files for translation and returns the count of translations queued. + /// + /// If true, translates to all target languages even if they already exist. + private async Task ProcessSubtitlesWithCount( + IMedia media, + MediaType mediaType, + List subtitles, + HashSet sourceLanguages, + HashSet targetLanguages, + string ignoreCaptions, + bool forceTranslation = false, + bool forceProcess = false, + bool forcePriority = false) + { + var existingLanguages = ExtractLanguageCodes(subtitles); + var translationsQueued = 0; + + _logger.LogDebug( + "ProcessSubtitlesWithCount: ExistingLanguages=[{Existing}], SourceLanguages=[{Sources}], TargetLanguages=[{Targets}], ForceTranslation={Force}", + string.Join(", ", existingLanguages), + string.Join(", ", sourceLanguages), + string.Join(", ", targetLanguages), + forceTranslation); + + if (sourceLanguages.Count == 0 || targetLanguages.Count == 0) + { + _logger.LogWarning( + "Source or target languages are empty. Source languages: {SourceCount}, Target languages: {TargetCount}", + sourceLanguages.Count, targetLanguages.Count); + await UpdateHash(); + return 0; + } + + var sourceLanguage = existingLanguages.FirstOrDefault(lang => sourceLanguages.Contains(lang)); + _logger.LogDebug("Source language match result: {SourceLanguage}", sourceLanguage ?? "NONE"); + + if (sourceLanguage != null && targetLanguages.Any()) + + { + var sourceSubtitle = ignoreCaptions == "true" + ? subtitles.FirstOrDefault(s => s.Language == sourceLanguage && string.IsNullOrEmpty(s.Caption)) + ?? subtitles.FirstOrDefault(s => s.Language == sourceLanguage) + : subtitles.FirstOrDefault(s => s.Language == sourceLanguage); + + if (sourceSubtitle != null) + { + // When forceTranslation is true, translate to all target languages even if they exist + var languagesToTranslate = forceTranslation + ? targetLanguages.ToList() + : targetLanguages.Except(existingLanguages).ToList(); + + // Check integrity of existing target subtitles and add corrupt ones for re-translation + var foundCorruption = false; + if (!forceTranslation) + { + var corruptLanguages = new List(); + foreach (var targetLang in targetLanguages.Intersect(existingLanguages)) + { + var targetSubtitle = subtitles.FirstOrDefault(s => s.Language == targetLang); + if (targetSubtitle != null) + { + var isValid = await _integrityService.ValidateIntegrityAsync( + sourceSubtitle.Path, + targetSubtitle.Path); + if (!isValid) + { + _logger.LogWarning( + "Integrity check failed for {TargetLang} subtitle: {Path} - scheduling re-translation", + targetLang, targetSubtitle.Path); + corruptLanguages.Add(targetLang); + } + } + } + + if (corruptLanguages.Count > 0) + { + foundCorruption = true; + } + + // Add corrupt languages to the translation queue + languagesToTranslate = languagesToTranslate.Union(corruptLanguages).ToList(); + } + + if (ignoreCaptions == "true") + { + var targetLanguagesWithCaptions = subtitles + .Where(s => targetLanguages.Contains(s.Language) && !string.IsNullOrEmpty(s.Caption)) + .Select(s => s.Language) + .Distinct() + .ToList(); + + if (targetLanguagesWithCaptions.Any()) + { + _logger.LogInformation( + "Translation skipped because captions exist for target languages: |Green|{CaptionLanguages}|/Green|", + string.Join(", ", targetLanguagesWithCaptions)); + if (!foundCorruption) + { + await UpdateHash(); + } + return 0; + } + } + + foreach (var targetLanguage in languagesToTranslate) + { + if (await HasActiveRequestAsync(media.Id, mediaType, sourceLanguage, targetLanguage)) + { + _logger.LogInformation( + "Skipping enqueue for {FileName} {Source}->{Target}: translation request already active.", + media.FileName, + sourceLanguage, + targetLanguage); + continue; + } + + await _translationRequestService.CreateRequest(new TranslateAbleSubtitle + { + MediaId = media.Id, + MediaType = mediaType, + SubtitlePath = sourceSubtitle.Path, + TargetLanguage = targetLanguage, + SourceLanguage = sourceLanguage, + SubtitleFormat = sourceSubtitle.Format + }, forcePriority); + translationsQueued++; + _logger.LogInformation( + "Initiating translation from |Orange|{sourceLanguage}|/Orange| to |Orange|{targetLanguage}|/Orange| for |Green|{subtitleFile}|/Green|", + sourceLanguage, + targetLanguage, + sourceSubtitle.Path); + } + + // Only update hash if no corruption was found - ensures re-validation if translation fails + if (!foundCorruption) + { + await UpdateHash(); + } + else + { + _logger.LogDebug("Skipping hash update for {FileName} due to corruption found - will re-validate next run", media.FileName); + } + return translationsQueued; + } + + _logger.LogWarning("No source subtitle file found for language: |Green|{SourceLanguage}|/Green|", + sourceLanguage); + + _logger.LogInformation( + "No external source subtitle found for {FileName}. Checking for embedded subtitles...", + media.FileName); + } + + // Final fallback: try embedded + return await TryQueueEmbeddedSubtitleTranslation(media, mediaType, forceTranslation, forceProcess, forcePriority); + } + + /// + /// Attempts to queue translation jobs for media with embedded subtitles but no external subtitles. + /// + /// The media item to process + /// The type of media (Movie or Episode) + /// If true, translates to all target languages even if they already exist. + /// If true, bypasses the media hash check + /// If true, forces jobs to use the priority queue + /// The number of translation requests queued private async Task TryQueueEmbeddedSubtitleTranslation(IMedia media, MediaType mediaType, bool forceTranslation, bool forceProcess, bool forcePriority = false) { - if (string.IsNullOrEmpty(media.Path) || string.IsNullOrEmpty(media.FileName)) + if (media.Path == null) { return 0; } + // Preserve the order of configured source languages so we can treat + // them as a priority list (e.g. [en, ja] => prefer English when both + // are good candidates, but fall back to Japanese when English only + // has "Signs & Songs" style tracks). var sourceLanguageModels = await _settingService.GetSettingAsJson(SettingKeys.Translation.SourceLanguages); var configuredSourceLanguages = sourceLanguageModels @@ -321,8 +665,6 @@ private async Task TryQueueEmbeddedSubtitleTranslation(IMedia media, MediaT .Select(lang => lang.Code.ToLowerInvariant()) .Where(code => !string.IsNullOrWhiteSpace(code)) .ToHashSet(); - - var ignoreCaptions = await _settingService.GetSetting(SettingKeys.Translation.IgnoreCaptions) ?? "false"; if (configuredSourceLanguages.Count == 0 || targetLanguages.Count == 0) { @@ -332,6 +674,12 @@ private async Task TryQueueEmbeddedSubtitleTranslation(IMedia media, MediaT return 0; } + // ============================================================================ + // OPTIMISTIC SKIP: Check if we already have cached embedded subtitle data + // from the sync job. If hash matches, skip the expensive ffprobe call entirely. + // This is the key optimization that prevents the automation job from scanning + // all media files every run. + // ============================================================================ if (!forceProcess) { List? cachedEmbedded = null; @@ -353,10 +701,11 @@ private async Task TryQueueEmbeddedSubtitleTranslation(IMedia media, MediaT cachedEmbedded = cachedMovie?.EmbeddedSubtitles; } + // If we have cached data AND media was indexed, use optimistic hash check var indexedAt = cachedMovie?.IndexedAt ?? cachedEpisode?.IndexedAt; if (cachedEmbedded != null && indexedAt != null) { - var optimisticHash = CreateEmbeddedHash(media, cachedEmbedded, configuredSourceLanguages, targetLanguages, ignoreCaptions); + var optimisticHash = CreateEmbeddedHash(cachedEmbedded, configuredSourceLanguages, targetLanguages); var existingHash = cachedMovie?.MediaHash ?? cachedEpisode?.MediaHash; if (!string.IsNullOrEmpty(existingHash) && existingHash == optimisticHash) @@ -368,17 +717,22 @@ private async Task TryQueueEmbeddedSubtitleTranslation(IMedia media, MediaT } } } + // ============================================================================ + // Sync embedded subtitles from the media file List? embeddedSubtitles = null; IMedia? trackedMedia = null; if (mediaType == MediaType.Episode) { + // Don't use Include here - we're syncing immediately after, and Include+ExecuteDeleteAsync + // causes duplication because ExecuteDeleteAsync bypasses the change tracker var episode = await _dbContext.Episodes .FirstOrDefaultAsync(e => e.Id == media.Id); if (episode != null) { + // Force sync to refresh embedded subtitles await _extractionService.SyncEmbeddedSubtitles(episode); await _dbContext.Entry(episode).Collection(e => e.EmbeddedSubtitles).LoadAsync(); embeddedSubtitles = episode.EmbeddedSubtitles; @@ -387,11 +741,14 @@ private async Task TryQueueEmbeddedSubtitleTranslation(IMedia media, MediaT } else if (mediaType == MediaType.Movie) { + // Don't use Include here - we're syncing immediately after, and Include+ExecuteDeleteAsync + // causes duplication because ExecuteDeleteAsync bypasses the change tracker var movie = await _dbContext.Movies .FirstOrDefaultAsync(m => m.Id == media.Id); if (movie != null) { + // Force sync to refresh embedded subtitles await _extractionService.SyncEmbeddedSubtitles(movie); await _dbContext.Entry(movie).Collection(m => m.EmbeddedSubtitles).LoadAsync(); embeddedSubtitles = movie.EmbeddedSubtitles; @@ -405,16 +762,19 @@ private async Task TryQueueEmbeddedSubtitleTranslation(IMedia media, MediaT "No embedded subtitles found for {FileName}. Cannot translate.", media.FileName); + // Update hash so we don't retry constantly unless streams or settings change _media = trackedMedia ?? media; _mediaType = mediaType; - _hash = CreateEmbeddedHash(_media, [], configuredSourceLanguages, targetLanguages, ignoreCaptions); + _hash = CreateEmbeddedHash([], configuredSourceLanguages, targetLanguages); await UpdateHash(); return 0; } var mediaForHash = trackedMedia ?? media; - var embeddedHash = CreateEmbeddedHash(mediaForHash, embeddedSubtitles, configuredSourceLanguages, targetLanguages, ignoreCaptions); + + // Compute embedded hash based on current streams and settings + var embeddedHash = CreateEmbeddedHash(embeddedSubtitles, configuredSourceLanguages, targetLanguages); if (!forceProcess && !string.IsNullOrEmpty(mediaForHash.MediaHash) && mediaForHash.MediaHash == embeddedHash) { @@ -426,38 +786,59 @@ private async Task TryQueueEmbeddedSubtitleTranslation(IMedia media, MediaT _mediaType = mediaType; _hash = embeddedHash; - var textBasedSubs = embeddedSubtitles.Where(s => s.IsTextBased).ToList(); - if (textBasedSubs.Count == 0) - { - _logger.LogWarning( - "No text-based embedded subtitles found for {FileName}.", - media.FileName); - await UpdateHash(); - return 0; - } + _logger.LogInformation( + "Found {Count} embedded subtitles for {FileName}: [{Subtitles}]", + embeddedSubtitles.Count, media.FileName, + string.Join(", ", embeddedSubtitles.Select(s => $"{s.Language ?? "unknown"}:{s.CodecName}"))); + // Work only with text-based streams; image-based subtitles require OCR + var textBasedSubs = embeddedSubtitles.Where(s => s.IsTextBased).ToList(); + if (textBasedSubs.Count == 0) + { + _logger.LogWarning( + "No text-based embedded subtitles found for {FileName}. Only image-based subtitles available.", + media.FileName); + await UpdateHash(); + return 0; + } + + // Score candidates across all configured source languages. + // We only consider streams whose language matches one of the + // configured languages (via tolerant matching), and apply a small + // priority bonus based on the language order. var scoredCandidates = new List<(EmbeddedSubtitle Subtitle, int Score, string MatchedLanguage, int LanguageIndex)>(); foreach (var subtitle in textBasedSubs) { - if (string.IsNullOrWhiteSpace(subtitle.Language)) continue; + if (string.IsNullOrWhiteSpace(subtitle.Language)) + { + continue; + } var bestIndex = -1; string? matchedLanguage = null; for (var i = 0; i < configuredSourceLanguages.Count; i++) { - if (SubtitleLanguageHelper.LanguageMatches(subtitle.Language, configuredSourceLanguages[i])) + var configuredLanguage = configuredSourceLanguages[i]; + if (SubtitleLanguageHelper.LanguageMatches(subtitle.Language, configuredLanguage)) { bestIndex = i; - matchedLanguage = configuredSourceLanguages[i]; + matchedLanguage = configuredLanguage; break; } } - if (bestIndex == -1 || matchedLanguage == null) continue; + if (bestIndex == -1 || matchedLanguage == null) + { + // This subtitle is in a language the user didn't configure; + // we'll surface it in logging but won't auto-translate from it. + continue; + } var baseScore = SubtitleLanguageHelper.ScoreSubtitleCandidate(subtitle, matchedLanguage); + // Earlier languages in the list get a small priority boost, + // but content quality (full vs signs/karaoke) dominates. var priorityBonus = (configuredSourceLanguages.Count - bestIndex) * 5; var totalScore = baseScore + priorityBonus; @@ -466,10 +847,23 @@ private async Task TryQueueEmbeddedSubtitleTranslation(IMedia media, MediaT if (!scoredCandidates.Any()) { - _logger.LogWarning("No embedded subtitle matches configured source languages for {FileName}.", media.FileName); - await UpdateHash(); - return 0; - } + var availableLanguages = textBasedSubs + .GroupBy(s => SubtitleLanguageHelper.NormalizeLanguageCode(s.Language)) + .Select(g => g.Key ?? "unknown") + .Distinct() + .ToList(); + + _logger.LogWarning( + "No embedded subtitle matches configured source languages [{Sources}] for {FileName}. " + + "Available embedded subtitle languages: [{Available}]. " + + "Update your source languages on the Services page if you want to translate from one of these.", + string.Join(", ", configuredSourceLanguages), + media.FileName, + string.Join(", ", availableLanguages)); + + await UpdateHash(); + return 0; + } var bestCandidate = scoredCandidates .OrderByDescending(c => c.Score) @@ -479,6 +873,15 @@ private async Task TryQueueEmbeddedSubtitleTranslation(IMedia media, MediaT var selectedSubtitle = bestCandidate.Subtitle; var selectedSourceLanguage = bestCandidate.MatchedLanguage; + _logger.LogInformation( + "Selected embedded subtitle for translation: StreamIndex={StreamIndex}, LanguageTag={LanguageTag}, ConfiguredLanguage={ConfiguredLanguage}, Title=\"{Title}\", Codec={Codec}", + selectedSubtitle.StreamIndex, + selectedSubtitle.Language ?? "unknown", + selectedSourceLanguage, + selectedSubtitle.Title ?? "", + selectedSubtitle.CodecName); + + // Get external subtitles to check which target languages already exist and validate them var allExternalSubtitles = await _subtitleService.GetAllSubtitles(media.Path!); var matchingExternalSubtitles = allExternalSubtitles .Where(s => s.FileName.StartsWith(media.FileName + ".") || s.FileName == media.FileName) @@ -487,18 +890,31 @@ private async Task TryQueueEmbeddedSubtitleTranslation(IMedia media, MediaT .Select(s => s.Language.ToLowerInvariant()) .ToHashSet(); + // Determine which languages need translation (missing or corrupt) var languagesToTranslate = forceTranslation ? targetLanguages.ToList() : targetLanguages.Except(existingExternalLanguages).ToList(); + // For integrity validation (forceTranslation=false), we need to extract temp source and check existing targets string? tempSourcePath = null; var foundCorruption = false; try { + // Debug logging to trace validation check + _logger.LogInformation( + "Validation check - forceTranslation={ForceTranslation}, matchingExternalSubtitles=[{Subtitles}], existingExternalLanguages=[{ExistingLangs}], targetLanguages=[{TargetLangs}]", + forceTranslation, + string.Join(", ", matchingExternalSubtitles.Select(s => $"{s.FileName}:{s.Language}")), + string.Join(", ", existingExternalLanguages), + string.Join(", ", targetLanguages)); + var hasMatchingTarget = existingExternalLanguages.Any(lang => targetLanguages.Contains(lang)); + _logger.LogInformation("Validation gate check: !forceTranslation={NotForce}, hasMatchingTarget={HasMatch}, willValidate={WillValidate}", + !forceTranslation, hasMatchingTarget, !forceTranslation && hasMatchingTarget); if (!forceTranslation && hasMatchingTarget) { + // Extract temp source for validation var tempDir = Path.GetTempPath(); tempSourcePath = await _extractionService.ExtractSubtitle( Path.Combine(media.Path!, media.FileName!), @@ -516,53 +932,135 @@ private async Task TryQueueEmbeddedSubtitleTranslation(IMedia media, MediaT s.Language.Equals(targetLang, StringComparison.OrdinalIgnoreCase)); if (targetSubtitle != null) { - var isValid = await _integrityService.ValidateIntegrityAsync(tempSourcePath, targetSubtitle.Path); + var isValid = await _integrityService.ValidateIntegrityAsync( + tempSourcePath, + targetSubtitle.Path); if (!isValid) { - _logger.LogWarning("Integrity check failed for {TargetLang} subtitle: {Path} - scheduling re-translation", targetLang, targetSubtitle.Path); + _logger.LogWarning( + "Integrity check failed for {TargetLang} subtitle: {Path} - scheduling re-translation (embedded source)", + targetLang, targetSubtitle.Path); corruptLanguages.Add(targetLang); } } } - if (corruptLanguages.Count > 0) foundCorruption = true; + if (corruptLanguages.Count > 0) + { + foundCorruption = true; + } + + // Add corrupt languages to the translation queue languagesToTranslate = languagesToTranslate.Union(corruptLanguages).ToList(); } } + // Create translation requests for each target language (with empty subtitle path - TranslationJob will extract) var translationsQueued = 0; foreach (var targetLanguage in languagesToTranslate) { - if (await HasActiveRequestAsync(media.Id, mediaType, selectedSourceLanguage, targetLanguage)) continue; - - // Clean up any failed requests for this media/language pair before creating a new one - await _translationRequestService.RemoveFailedRequestsAsync(media.Id, mediaType); + if (await HasActiveRequestAsync(media.Id, mediaType, selectedSourceLanguage, targetLanguage)) + { + _logger.LogInformation( + "Skipping embedded enqueue for {FileName} {Source}->{Target}: translation request already active.", + media.FileName, + selectedSourceLanguage, + targetLanguage); + continue; + } await _translationRequestService.CreateRequest(new TranslateAbleSubtitle { MediaId = media.Id, MediaType = mediaType, - SubtitlePath = null, + SubtitlePath = null, // Will trigger embedded extraction in TranslationJob TargetLanguage = targetLanguage, SourceLanguage = selectedSourceLanguage, SubtitleFormat = null }, forcePriority); translationsQueued++; + _logger.LogInformation( + "Queued embedded subtitle translation from |Orange|{sourceLanguage}|/Orange| to |Orange|{targetLanguage}|/Orange| for |Green|{FileName}|/Green|", + selectedSourceLanguage, + targetLanguage, + media.FileName); } - if (!foundCorruption) await UpdateHash(); + // Only update hash if no corruption was found - this ensures re-validation on next run + // if translation job fails or app crashes before completing + if (!foundCorruption) + { + await UpdateHash(); + } + else + { + _logger.LogDebug("Skipping hash update for {FileName} due to corruption found - will re-validate next run", media.FileName); + } return translationsQueued; } finally { if (tempSourcePath != null && File.Exists(tempSourcePath)) { - try { File.Delete(tempSourcePath); } catch {} + try + { + File.Delete(tempSourcePath); + _logger.LogDebug("Deleted temporary validation subtitle: {TempPath}", tempSourcePath); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to delete temporary validation subtitle: {TempPath}", tempSourcePath); + } } } + } + + /// + /// Probes and retrieves embedded subtitles for the currently processing media. + /// Ensures the database is synced with the file's current state. + /// + private async Task?> ProbeEmbeddedSubtitlesForCurrentMedia() + { + if (_media == null) return null; + + if (_mediaType == MediaType.Episode) + { + // Don't use Include here - we're syncing immediately after, and Include+ExecuteDeleteAsync + // causes duplication because ExecuteDeleteAsync bypasses the change tracker + var episode = await _dbContext.Episodes + .FirstOrDefaultAsync(e => e.Id == _media.Id); + + if (episode != null) + { + await _extractionService.SyncEmbeddedSubtitles(episode); + await _dbContext.Entry(episode).Collection(e => e.EmbeddedSubtitles).LoadAsync(); + return episode.EmbeddedSubtitles; + } + } + else if (_mediaType == MediaType.Movie) + { + // Don't use Include here - we're syncing immediately after, and Include+ExecuteDeleteAsync + // causes duplication because ExecuteDeleteAsync bypasses the change tracker + var movie = await _dbContext.Movies + .FirstOrDefaultAsync(m => m.Id == _media.Id); + + if (movie != null) + { + await _extractionService.SyncEmbeddedSubtitles(movie); + await _dbContext.Entry(movie).Collection(m => m.EmbeddedSubtitles).LoadAsync(); + return movie.EmbeddedSubtitles; + } + } + + return null; } - private async Task HasActiveRequestAsync(int mediaId, MediaType mediaType, string sourceLanguage, string targetLanguage) + + private async Task HasActiveRequestAsync( + int mediaId, + MediaType mediaType, + string sourceLanguage, + string targetLanguage) { return await _dbContext.TranslationRequests.AnyAsync(tr => tr.MediaId == mediaId && diff --git a/Lingarr.Server/Services/ScheduleService.cs b/Lingarr.Server/Services/ScheduleService.cs index 3ce8b25b..d9d39066 100644 --- a/Lingarr.Server/Services/ScheduleService.cs +++ b/Lingarr.Server/Services/ScheduleService.cs @@ -104,29 +104,15 @@ public string GetJobState(string jobId) { var monitor = JobStorage.Current.GetMonitoringApi(); - // Check Processing first (most important) - if (monitor.ProcessingJobs(0, 100).Any(j => j.Key == jobId)) - return JobStatus.Processing.GetDisplayName(); - - // Check Enqueued in all queues - var queues = monitor.Queues(); - foreach (var queue in queues) - { - if (monitor.EnqueuedJobs(queue.Name, 0, 100).Any(j => j.Key == jobId)) - return JobStatus.Enqueued.GetDisplayName(); - } - - // Check Succeeded (check more than 1) - if (monitor.SucceededJobs(0, 50).Any(j => j.Key == jobId)) + // Check each possible state + if (monitor.SucceededJobs(0, 1).Any(j => j.Key == jobId)) return JobStatus.Succeeded.GetDisplayName(); - - // Check Failed - if (monitor.FailedJobs(0, 50).Any(j => j.Key == jobId)) + if (monitor.FailedJobs(0, 1).Any(j => j.Key == jobId)) return JobStatus.Failed.GetDisplayName(); - - // Check Scheduled - if (monitor.ScheduledJobs(0, 50).Any(j => j.Key == jobId)) + if (monitor.ScheduledJobs(0, 1).Any(j => j.Key == jobId)) return JobStatus.Scheduled.GetDisplayName(); + if (monitor.EnqueuedJobs("default", 0, 1).Any(j => j.Key == jobId)) + return JobStatus.Enqueued.GetDisplayName(); return JobStatus.Planned.GetDisplayName(); } diff --git a/Lingarr.Server/Services/Subtitle/OrphanSubtitleCleanupService.cs b/Lingarr.Server/Services/Subtitle/OrphanSubtitleCleanupService.cs index 7db8a43e..b4d280dc 100644 --- a/Lingarr.Server/Services/Subtitle/OrphanSubtitleCleanupService.cs +++ b/Lingarr.Server/Services/Subtitle/OrphanSubtitleCleanupService.cs @@ -1,8 +1,6 @@ using Lingarr.Core.Configuration; using Lingarr.Core.Data; using Lingarr.Core.Entities; -using Lingarr.Core.Enum; -using Microsoft.EntityFrameworkCore; using Lingarr.Server.Interfaces.Services; using Lingarr.Server.Interfaces.Services.Subtitle; @@ -74,161 +72,37 @@ public async Task CleanupOrphansAsync(string directoryPath, string oldFileN try { - // First, find all subtitles matching the OLD filename (orphans from upgrade) - if (oldFileName != newFileName) + foreach (var ext in SubtitleExtensions) { - var allFiles = Directory.GetFiles(directoryPath, "*.*", SearchOption.TopDirectoryOnly); - foreach (var file in allFiles) + var pattern = $"{oldFileName}*{ext}"; + var files = Directory.GetFiles(directoryPath, pattern, SearchOption.TopDirectoryOnly); + + foreach (var file in files) { var fileName = Path.GetFileName(file); - var ext = Path.GetExtension(file).ToLowerInvariant(); - if (!SubtitleExtensions.Contains(ext)) continue; - - // Match old filename specifically: starts with oldFileName and followed by . or [ or - (standard Radarr/Sonarr naming) - // We avoid glob patterns like * to be more precise - if (fileName.StartsWith(oldFileName) && - (fileName.Length == oldFileName.Length + ext.Length || - fileName[oldFileName.Length] == '.' || - fileName[oldFileName.Length] == '[' || - fileName[oldFileName.Length] == '-')) + // Check if this file has a Lingarr tag + var hasTag = (!string.IsNullOrEmpty(subtitleTag) && fileName.Contains(subtitleTag)) || + (!string.IsNullOrEmpty(shortTag) && fileName.Contains(shortTag)); + + if (!hasTag) { - // Check if this file has a Lingarr tag - var hasTag = (!string.IsNullOrEmpty(subtitleTag) && fileName.Contains(subtitleTag)) || - (!string.IsNullOrEmpty(shortTag) && fileName.Contains(shortTag)); - - if (!hasTag) continue; - - // Double check it doesn't match the new filename - if (fileName.StartsWith(newFileName) && - (fileName.Length == newFileName.Length + ext.Length || - fileName[newFileName.Length] == '.' || - fileName[newFileName.Length] == '[' || - fileName[newFileName.Length] == '-')) - { - continue; - } - - // This is an orphaned Lingarr-created subtitle - delete it - try - { - File.Delete(file); - cleanedCount++; - - logsToAdd.Add(new SubtitleCleanupLog - { - FilePath = file, - OriginalMediaFileName = oldFileName, - NewMediaFileName = newFileName, - Reason = "media_filename_changed", - DeletedAt = DateTime.UtcNow - }); - - _logger.LogInformation( - "Deleted orphaned subtitle: {FileName} (media changed from '{OldName}' to '{NewName}')", - fileName, oldFileName, newFileName); - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Failed to delete orphaned subtitle: {Path}", file); - } + _logger.LogDebug( + "Subtitle file {FileName} does not contain Lingarr tag, skipping.", + fileName); + continue; } - } - } - - // Remove clean slate logic for same-filename mtime changes - MediaSubtitleProcessor handles it via hashing - // and only re-translates if necessary. Aggressive wipe here is dangerous. - - // Save cleanup logs to database - if (logsToAdd.Count > 0) - { - _dbContext.SubtitleCleanupLogs.AddRange(logsToAdd); - await _dbContext.SaveChangesAsync(); - } - } - catch (Exception ex) - { - _logger.LogError(ex, "Error during orphan subtitle cleanup in directory: {Path}", directoryPath); - } - - if (cleanedCount > 0) - { - _logger.LogInformation( - "Cleaned up {Count} orphaned subtitle(s) in {Directory}", - cleanedCount, directoryPath); - } - - return cleanedCount; - } - - /// - public async Task CleanupStaleSubtitlesAsync(string directoryPath, string fileName, string? targetLanguage = null) - { - // Check if tagging is enabled - required for safe identification - var taggingEnabled = await _settingService.GetSetting(SettingKeys.Translation.UseSubtitleTagging); - if (taggingEnabled != "true") - { - _logger.LogWarning( - "Stale subtitle cleanup requested but subtitle tagging is disabled. " + - "Cannot identify Lingarr-created subtitles without tagging. Skipping cleanup."); - return 0; - } - - // Get the configured tag - var subtitleTag = await _settingService.GetSetting(SettingKeys.Translation.SubtitleTag); - var shortTag = await _settingService.GetSetting(SettingKeys.Translation.SubtitleTagShort); - - if (string.IsNullOrEmpty(subtitleTag) && string.IsNullOrEmpty(shortTag)) - { - _logger.LogWarning("No subtitle tag configured. Cannot identify Lingarr-created subtitles. Skipping cleanup."); - return 0; - } - - // Validate directory exists - if (string.IsNullOrEmpty(directoryPath) || !Directory.Exists(directoryPath)) - { - _logger.LogDebug("Directory does not exist or is empty: {Path}", directoryPath); - return 0; - } - - var cleanedCount = 0; - var logsToAdd = new List(); - - try - { - var allFiles = Directory.GetFiles(directoryPath, "*.*", SearchOption.TopDirectoryOnly); - foreach (var file in allFiles) - { - var currentFileName = Path.GetFileName(file); - var ext = Path.GetExtension(file).ToLowerInvariant(); - - if (!SubtitleExtensions.Contains(ext)) continue; - // Match filename specifically: starts with fileName and followed by . or [ or - - if (currentFileName.StartsWith(fileName) && - (currentFileName.Length == fileName.Length + ext.Length || - currentFileName[fileName.Length] == '.' || - currentFileName[fileName.Length] == '[' || - currentFileName[fileName.Length] == '-')) - { - // Check if this file has a Lingarr tag - var hasTag = (!string.IsNullOrEmpty(subtitleTag) && currentFileName.Contains(subtitleTag)) || - (!string.IsNullOrEmpty(shortTag) && currentFileName.Contains(shortTag)); - - if (!hasTag) continue; - - // If targetLanguage is specified, only remove if it matches - if (!string.IsNullOrEmpty(targetLanguage)) + // Check if this file would match the NEW filename (if so, it's not orphaned) + if (fileName.StartsWith(newFileName + ".") || fileName.StartsWith(newFileName + "[")) { - // We look for .[targetLanguage]. in the filename - // This is a bit naive but matches how CreateFilePathInternal works - if (!currentFileName.Contains($".{targetLanguage.ToLowerInvariant()}.")) - { - continue; - } + _logger.LogDebug( + "Subtitle file {FileName} matches new media filename, keeping.", + fileName); + continue; } - // This is a stale Lingarr-created subtitle - delete it + // This is an orphaned Lingarr-created subtitle - delete it try { File.Delete(file); @@ -237,34 +111,19 @@ public async Task CleanupStaleSubtitlesAsync(string directoryPath, string f logsToAdd.Add(new SubtitleCleanupLog { FilePath = file, - OriginalMediaFileName = fileName, - NewMediaFileName = fileName, - Reason = string.IsNullOrEmpty(targetLanguage) ? "media_refreshed" : "retranslation_started", + OriginalMediaFileName = oldFileName, + NewMediaFileName = newFileName, + Reason = "media_filename_changed", DeletedAt = DateTime.UtcNow }); _logger.LogInformation( - "Deleted stale subtitle: {FileName} (Reason: {Reason})", - currentFileName, string.IsNullOrEmpty(targetLanguage) ? "media refreshed" : $"re-translation for {targetLanguage}"); - - // Also remove database records for this stale subtitle - var staleRequests = await _dbContext.TranslationRequests - .Where(tr => tr.MediaId != null && - tr.SubtitleToTranslate != null && - tr.SubtitleToTranslate.Contains(fileName) && - (string.IsNullOrEmpty(targetLanguage) || tr.TargetLanguage == targetLanguage) && - (tr.Status == TranslationStatus.Completed || tr.Status == TranslationStatus.Failed)) - .ToListAsync(); - - if (staleRequests.Any()) - { - _dbContext.TranslationRequests.RemoveRange(staleRequests); - _logger.LogDebug("Removed {Count} stale translation request records from database", staleRequests.Count); - } + "Deleted orphaned subtitle: {FileName} (media changed from '{OldName}' to '{NewName}')", + fileName, oldFileName, newFileName); } catch (Exception ex) { - _logger.LogWarning(ex, "Failed to delete stale subtitle: {Path}", file); + _logger.LogWarning(ex, "Failed to delete orphaned subtitle: {Path}", file); } } } @@ -278,7 +137,14 @@ public async Task CleanupStaleSubtitlesAsync(string directoryPath, string f } catch (Exception ex) { - _logger.LogError(ex, "Error during stale subtitle cleanup in directory: {Path}", directoryPath); + _logger.LogError(ex, "Error during orphan subtitle cleanup in directory: {Path}", directoryPath); + } + + if (cleanedCount > 0) + { + _logger.LogInformation( + "Cleaned up {Count} orphaned subtitle(s) in {Directory}", + cleanedCount, directoryPath); } return cleanedCount; diff --git a/Lingarr.Server/Services/Sync/EpisodeSync.cs b/Lingarr.Server/Services/Sync/EpisodeSync.cs index 095953b3..33755ace 100644 --- a/Lingarr.Server/Services/Sync/EpisodeSync.cs +++ b/Lingarr.Server/Services/Sync/EpisodeSync.cs @@ -1,7 +1,6 @@ -using Lingarr.Core.Data; +using Lingarr.Core.Data; using Lingarr.Core.Entities; using Lingarr.Core.Enum; -using Lingarr.Core.Interfaces; using Lingarr.Server.Interfaces.Services; using Lingarr.Server.Interfaces.Services.Integration; using Lingarr.Server.Interfaces.Services.Subtitle; @@ -42,63 +41,35 @@ public EpisodeSync( } /// - public async Task SyncEpisodes(SonarrShow show, Season season, List? existingEpisodes = null) + public async Task SyncEpisodes(SonarrShow show, Season season) { var episodes = await _sonarrService.GetEpisodes(show.Id, season.SeasonNumber); if (episodes == null) return; var syncedEpisodes = new List<(Episode Entity, bool NeedsIndexing, string? OldPath, string? OldFileName)>(); - - // Optimization: Perform a single directory listing per season and use it in memory - FileInfo[]? seasonFiles = null; - if (!string.IsNullOrEmpty(season.Path)) - { - try - { - var dirInfo = new DirectoryInfo(season.Path); - if (dirInfo.Exists) - { - seasonFiles = dirInfo.GetFiles(); - } - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Failed to list files for season directory: {Path}", season.Path); - } - } foreach (var episode in episodes.Where(e => e.HasFile)) { - try - { - var episodePathResult = await _sonarrService.GetEpisodePath(episode.Id); - if (episodePathResult == null) - { - _logger.LogWarning("Failed to get episode path for episode {EpisodeId} ({Title})", episode.Id, episode.Title); - continue; - } - - var episodePath = _pathConversionService.ConvertAndMapPath( - episodePathResult.EpisodeFile.Path ?? string.Empty, - MediaType.Show - ); - - var (entity, needsIndexing, oldPath, oldFileName) = await UpdateEpisodeMetadata(episode, episodePath, season, episodePathResult.EpisodeFile.DateAdded, existingEpisodes, seasonFiles); - syncedEpisodes.Add((entity, needsIndexing, oldPath, oldFileName)); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error syncing episode {EpisodeId} ({Title})", episode.Id, episode.Title); - } + var episodePathResult = await _sonarrService.GetEpisodePath(episode.Id); + var episodePath = _pathConversionService.ConvertAndMapPath( + episodePathResult?.EpisodeFile.Path ?? string.Empty, + MediaType.Show + ); + + var (entity, needsIndexing, oldPath, oldFileName) = await UpdateEpisodeMetadata(episode, episodePath, season, episodePathResult?.EpisodeFile.DateAdded); + syncedEpisodes.Add((entity, needsIndexing, oldPath, oldFileName)); } // Batch save all metadata updates/additions - // No longer saving here, deferred to the end of the show or batch + if (_dbContext.ChangeTracker.HasChanges()) + { + await _dbContext.SaveChangesAsync(); + } - // Now that IDs might be assigned (if already saved or existing), perform indexing and state updates + // Now that IDs are assigned for new entities, perform indexing and state updates foreach (var (entity, needsIndexing, oldPath, oldFileName) in syncedEpisodes) { - // Clean up orphaned subtitles when the filename actually changes (media upgraded) + // Clean up orphaned subtitles when the filename changes (e.g., media upgraded) if (!string.IsNullOrEmpty(oldPath) && !string.IsNullOrEmpty(oldFileName) && oldFileName != entity.FileName) { await _orphanCleanupService.CleanupOrphansAsync( @@ -107,76 +78,54 @@ await _orphanCleanupService.CleanupOrphansAsync( entity.FileName!); } - // Only index if the episode has been persisted (has a real ID) - // New episodes will be indexed on the next sync cycle after they're saved - if (entity.Id > 0 && needsIndexing) + if (needsIndexing) { - await IndexEmbeddedSubtitles(entity, saveChanges: false); + await IndexEmbeddedSubtitles(entity); } - } - // Use batch state update for all synced episodes - var episodesToUpdateState = syncedEpisodes - .Where(x => { - var entity = x.Entity; - if (x.NeedsIndexing) return true; - if (entity.TranslationState != TranslationState.AwaitingSource) return true; - if (string.IsNullOrEmpty(entity.Path)) return true; + // Update state - for AwaitingSource, check mtime first (reduces I/O) + try + { + var shouldUpdateState = true; - // I/O Caching / Mtime check - try + if (entity.TranslationState == TranslationState.AwaitingSource && + !string.IsNullOrEmpty(entity.Path)) { var dirInfo = new DirectoryInfo(entity.Path); if (dirInfo.Exists) { var dirMtime = dirInfo.LastWriteTimeUtc; - if (entity.LastSubtitleCheckAt.HasValue && dirMtime <= entity.LastSubtitleCheckAt.Value) + if (entity.LastSubtitleCheckAt.HasValue && + dirMtime <= entity.LastSubtitleCheckAt.Value) { - return false; + shouldUpdateState = false; + _logger.LogDebug("Skipping subtitle check for {Title}: directory unchanged", entity.Title); } } } - catch { /* ignored */ } - return true; - }) - .Select(x => (IMedia)x.Entity) - .ToList(); - - if (episodesToUpdateState.Any()) - { - await _mediaStateService.UpdateStatesAsync(episodesToUpdateState, MediaType.Episode, saveChanges: false); - foreach (var media in episodesToUpdateState) + + if (shouldUpdateState) + { + await _mediaStateService.UpdateStateAsync(entity, MediaType.Episode, saveChanges: false); + entity.LastSubtitleCheckAt = DateTime.UtcNow; + } + } + catch (Exception ex) { - if (media is Episode e) e.LastSubtitleCheckAt = DateTime.UtcNow; + _logger.LogWarning(ex, "Failed to update translation state for episode {Title}", entity.Title); } } RemoveNonExistentEpisodes(season, episodes); - - var duplicateEpisodes = season.Episodes.GroupBy(e => e.SonarrId).Where(g => g.Count() > 1).ToList(); - if (duplicateEpisodes.Any()) - { - foreach (var dup in duplicateEpisodes) - { - _logger.LogWarning("Duplicate episode SonarrId found in DB for season {SeasonNumber}: {SonarrId}. Count: {Count}", season.SeasonNumber, dup.Key, dup.Count()); - } - } } /// /// Updates or creates the episode entity metadata without saving to DB. /// Returns the entity, whether it needs indexing, and old path/filename if changed. /// - private async Task<(Episode Entity, bool NeedsIndexing, string? OldPath, string? OldFileName)> UpdateEpisodeMetadata( - SonarrEpisode episode, - string episodePath, - Season season, - DateTime? dateAdded, - List? existingEpisodes = null, - FileInfo[]? seasonFiles = null) + private async Task<(Episode Entity, bool NeedsIndexing, string? OldPath, string? OldFileName)> UpdateEpisodeMetadata(SonarrEpisode episode, string episodePath, Season season, DateTime? dateAdded) { - var episodeEntity = existingEpisodes?.FirstOrDefault(se => se.SonarrId == episode.Id) - ?? season.Episodes.FirstOrDefault(se => se.SonarrId == episode.Id); + var episodeEntity = season.Episodes.FirstOrDefault(se => se.SonarrId == episode.Id); var isNew = episodeEntity == null; var oldPath = episodeEntity?.Path; @@ -209,71 +158,21 @@ await _orphanCleanupService.CleanupOrphansAsync( oldPath != episodeEntity.Path || oldFileName != episodeEntity.FileName); - if (!isNew && !fileChanged && !string.IsNullOrEmpty(episodeEntity.Path) && !string.IsNullOrEmpty(episodeEntity.FileName)) - { - try - { - // Optimization: Use pre-loaded season files if available - FileInfo? fileInfo = null; - if (seasonFiles != null) - { - fileInfo = seasonFiles.FirstOrDefault(f => - f.Name.StartsWith(episodeEntity.FileName!) && - !SubtitleExtensions.Contains(f.Extension.ToLowerInvariant())); - } - else - { - var dirInfo = new DirectoryInfo(episodeEntity.Path); - if (dirInfo.Exists) - { - fileInfo = dirInfo.GetFiles(episodeEntity.FileName + ".*") - .FirstOrDefault(f => !SubtitleExtensions.Contains(f.Extension.ToLowerInvariant())); - } - } - - if (fileInfo != null) - { - if (episodeEntity.IndexedAt.HasValue && fileInfo.LastWriteTimeUtc > episodeEntity.IndexedAt.Value.AddSeconds(5)) - { - _logger.LogInformation("Episode file {Title} appears to have been refreshed (mtime changed), triggering re-index", episodeEntity.Title); - fileChanged = true; - - // Clean up stale translated subtitles when media is refreshed - if (!string.IsNullOrEmpty(episodeEntity.Path) && !string.IsNullOrEmpty(episodeEntity.FileName)) - { - await _orphanCleanupService.CleanupStaleSubtitlesAsync( - episodeEntity.Path, - episodeEntity.FileName); - } - } - } - } - catch (Exception ex) - { - _logger.LogDebug(ex, "Failed to check mtime for episode {Title}", episodeEntity.Title); - } - } - var needsIndexing = isNew || fileChanged || episodeEntity.IndexedAt == null; // Return old values only if file actually changed return (episodeEntity, needsIndexing, fileChanged ? oldPath : null, fileChanged ? oldFileName : null); } - private static readonly string[] SubtitleExtensions = { ".srt", ".ass", ".ssa", ".sub" }; - - private async Task IndexEmbeddedSubtitles(Episode episodeEntity, bool saveChanges = true) + private async Task IndexEmbeddedSubtitles(Episode episodeEntity) { try { await _extractionService.SyncEmbeddedSubtitles(episodeEntity); episodeEntity.IndexedAt = DateTime.UtcNow; - // Persist the indexing status - if (saveChanges) - { - await _dbContext.SaveChangesAsync(); - } + // Persist the indexing status immediately + await _dbContext.SaveChangesAsync(); _logger.LogDebug("Indexed embedded subtitles for episode {Title}", episodeEntity.Title); } catch (Exception ex) diff --git a/Lingarr.Server/Services/Sync/MovieSync.cs b/Lingarr.Server/Services/Sync/MovieSync.cs index 49e06593..138bd890 100644 --- a/Lingarr.Server/Services/Sync/MovieSync.cs +++ b/Lingarr.Server/Services/Sync/MovieSync.cs @@ -1,4 +1,4 @@ -using Lingarr.Core.Data; +using Lingarr.Core.Data; using Lingarr.Core.Entities; using Lingarr.Core.Enum; using Lingarr.Server.Interfaces.Services; @@ -48,7 +48,6 @@ public MovieSync( } var movieEntity = await _dbContext.Movies - .AsSplitQuery() .Include(m => m.Images) .Include(m => m.EmbeddedSubtitles) .FirstOrDefaultAsync(m => m.RadarrId == movie.Id); @@ -90,46 +89,9 @@ public MovieSync( } // Determine if we need to re-index embedded subtitles - // Safe detection: only trigger if the filename actually changes (media upgraded) - var fileChanged = !isNew && (oldFileName != movieEntity.FileName); - - if (!isNew && !fileChanged && !string.IsNullOrEmpty(movieEntity.Path) && !string.IsNullOrEmpty(movieEntity.FileName)) - { - // If path changed but filename is the same, it's just a move. - // We update the path but don't need to re-index or clean orphans unless we're paranoid. - // But if mtime changed on the same file, we might need re-indexing. - - try - { - var dirInfo = new DirectoryInfo(movieEntity.Path); - if (dirInfo.Exists) - { - var fileInfo = dirInfo.GetFiles(movieEntity.FileName + ".*") - .FirstOrDefault(f => !SubtitleExtensions.Contains(f.Extension.ToLowerInvariant())); - - if (fileInfo != null) - { - if (movieEntity.IndexedAt.HasValue && fileInfo.LastWriteTimeUtc > movieEntity.IndexedAt.Value.AddSeconds(5)) - { - _logger.LogInformation("Movie file {Title} appears to have been refreshed (mtime changed), triggering re-index", movieEntity.Title); - fileChanged = true; - - // Clean up stale translated subtitles when media is refreshed - if (!string.IsNullOrEmpty(movieEntity.Path) && !string.IsNullOrEmpty(movieEntity.FileName)) - { - await _orphanCleanupService.CleanupStaleSubtitlesAsync( - movieEntity.Path, - movieEntity.FileName); - } - } - } - } - } - catch (Exception ex) - { - _logger.LogDebug(ex, "Failed to check mtime for movie {Title}", movieEntity.Title); - } - } + var fileChanged = !isNew && ( + oldPath != movieEntity.Path || + oldFileName != movieEntity.FileName); // Clean up orphaned subtitles when the filename changes (e.g., media upgraded) if (fileChanged && !string.IsNullOrEmpty(oldPath) && !string.IsNullOrEmpty(oldFileName)) @@ -171,13 +133,11 @@ await _orphanCleanupService.CleanupOrphansAsync( // Update translation state // For AwaitingSource: only re-check if directory mtime changed (reduces I/O) - // Unless we just indexed it (needsIndexing), then always update try { var shouldUpdateState = true; - if (!needsIndexing && - movieEntity.TranslationState == TranslationState.AwaitingSource && + if (movieEntity.TranslationState == TranslationState.AwaitingSource && !string.IsNullOrEmpty(movieEntity.Path)) { var dirInfo = new DirectoryInfo(movieEntity.Path); @@ -207,6 +167,4 @@ await _orphanCleanupService.CleanupOrphansAsync( return movieEntity; } - - private static readonly string[] SubtitleExtensions = { ".srt", ".ass", ".ssa", ".sub" }; } diff --git a/Lingarr.Server/Services/Sync/SeasonSync.cs b/Lingarr.Server/Services/Sync/SeasonSync.cs index 5aacc205..aa8538e5 100644 --- a/Lingarr.Server/Services/Sync/SeasonSync.cs +++ b/Lingarr.Server/Services/Sync/SeasonSync.cs @@ -28,11 +28,13 @@ public SeasonSync( } /// - public async Task SyncSeason(Show show, SonarrShow sonarrShow, SonarrSeason season, Season? existingSeason = null) + public async Task SyncSeason(Show show, SonarrShow sonarrShow, SonarrSeason season) { var seasonPath = await GetSeasonPath(sonarrShow, season); - var seasonEntity = existingSeason; + var seasonEntity = await _dbContext.Seasons + .Include(s => s.Episodes) + .FirstOrDefaultAsync(s => s.ShowId == show.Id && s.SeasonNumber == season.SeasonNumber); if (seasonEntity == null) { @@ -62,45 +64,14 @@ public async Task SyncSeason(Show show, SonarrShow sonarrShow, SonarrSea /// The converted and mapped path for the season, or an empty string if no path could be determined private async Task GetSeasonPath(SonarrShow show, SonarrSeason season) { - if (show.Title.Equals("Jujutsu Kaisen", StringComparison.OrdinalIgnoreCase)) + var episodes = await _sonarrService.GetEpisodes(show.Id, season.SeasonNumber); + var episode = episodes?.Where(episode => episode.HasFile).FirstOrDefault(); + if (episode == null) { - _logger.LogInformation("DEBUG: Getting season path for Jujutsu Kaisen Season {SeasonNumber}", season.SeasonNumber); - } - - // Optimization: Derive season path locally from show path if possible - if (!string.IsNullOrEmpty(show.Path)) - { - var localShowPath = _pathConversionService.NormalizePath(show.Path); - var folderName = season.SeasonNumber == 0 ? "Specials" : $"Season {season.SeasonNumber}"; - var potentialPath = Path.Combine(localShowPath, folderName); - - // We don't check if it exists here because it might be a remote path or mapped path - // But we can use it as a very good guess to avoid API calls - _logger.LogDebug("Derived potential season path locally: {Path}", potentialPath); - - // However, Sonarr might use different naming schemes. - // To be safe, we still want to verify or use Sonarr's path if we can't be sure. - // For now, let's try to use the local derivation as a primary source if show path is available. + return string.Empty; } - - try - { - var episodes = await _sonarrService.GetEpisodes(show.Id, season.SeasonNumber); - var episode = episodes?.Where(episode => episode.HasFile).FirstOrDefault(); - if (episode == null) - { - return string.Empty; - } - - // Optimization: If we have the episode file path in the episode object (if Sonarr API provides it), use it. - // Otherwise, call GetEpisodePath. - var episodePathResult = await _sonarrService.GetEpisodePath(episode.Id); - if (episodePathResult == null) - { - _logger.LogWarning("Failed to get episode path for episode {EpisodeId} in season {SeasonNumber} of show {ShowTitle}", - episode.Id, season.SeasonNumber, show.Title); - return string.Empty; - } + + var episodePathResult = await _sonarrService.GetEpisodePath(episode.Id); var normalizePath = _pathConversionService.NormalizePath(episodePathResult?.EpisodeFile.Path ?? string.Empty); var seasonPath = Path.GetDirectoryName(normalizePath); _logger.LogInformation("Resolved season path from episode {EpisodeId}: {SeasonPath}", episode.Id, seasonPath); @@ -117,15 +88,9 @@ private async Task GetSeasonPath(SonarrShow show, SonarrSeason season) seasonPath = $"/Season {season.SeasonNumber}"; } - return _pathConversionService.ConvertAndMapPath( - seasonPath, - MediaType.Show - ); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error resolving season path for show {ShowTitle} season {SeasonNumber}", show.Title, season.SeasonNumber); - return string.Empty; - } + return _pathConversionService.ConvertAndMapPath( + seasonPath, + MediaType.Show + ); } } \ No newline at end of file diff --git a/Lingarr.Server/Services/Sync/ShowSync.cs b/Lingarr.Server/Services/Sync/ShowSync.cs index b82fb77e..480aca8b 100644 --- a/Lingarr.Server/Services/Sync/ShowSync.cs +++ b/Lingarr.Server/Services/Sync/ShowSync.cs @@ -20,9 +20,12 @@ public ShowSync( } /// - public async Task SyncShow(SonarrShow sonarrShow, Show? existingShow = null) + public async Task SyncShow(SonarrShow sonarrShow) { - var showEntity = existingShow; + var showEntity = await _dbContext.Shows + .Include(s => s.Images) + .Include(s => s.Seasons) + .FirstOrDefaultAsync(s => s.SonarrId == sonarrShow.Id); if (showEntity == null) { diff --git a/Lingarr.Server/Services/Sync/ShowSyncService.cs b/Lingarr.Server/Services/Sync/ShowSyncService.cs index 4ce35997..8c824358 100644 --- a/Lingarr.Server/Services/Sync/ShowSyncService.cs +++ b/Lingarr.Server/Services/Sync/ShowSyncService.cs @@ -8,7 +8,7 @@ namespace Lingarr.Server.Services.Sync; public class ShowSyncService : IShowSyncService { - private const int BatchSize = 25; + private const int BatchSize = 100; private readonly LingarrDbContext _dbContext; private readonly IShowSync _showSync; @@ -35,117 +35,39 @@ public async Task SyncShows(List shows) { var processedCount = 0; - // Process in batches to optimize database lookups and memory usage - for (int i = 0; i < shows.Count; i += BatchSize) + foreach (var show in shows) { - var batch = shows.Skip(i).Take(BatchSize).ToList(); - var sonarrIds = batch.Select(s => s.Id).ToList(); + var showEntity = await _showSync.SyncShow(show); - // Pre-load the entire hierarchy for the current batch - List existingShows; - try + foreach (var season in show.Seasons) { - _logger.LogInformation("Pre-loading batch of {Count} shows from database (Batch start: {Index})", batch.Count, i); - existingShows = await _dbContext.Shows - .AsSplitQuery() - .Include(s => s.Images) - .Include(s => s.Seasons) - .ThenInclude(s => s.Episodes) - .ThenInclude(e => e.EmbeddedSubtitles) - .Where(s => sonarrIds.Contains(s.SonarrId)) - .ToListAsync(); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to pre-load batch of shows from database. Attempting to continue without pre-loading."); - existingShows = new List(); - } - - var duplicates = existingShows.GroupBy(s => s.SonarrId).Where(g => g.Count() > 1).ToList(); - if (duplicates.Any()) - { - foreach (var dup in duplicates) - { - _logger.LogWarning("Duplicate SonarrId found in database: {SonarrId}. Count: {Count}", dup.Key, dup.Count()); - } + var seasonEntity = await _seasonSync.SyncSeason(showEntity, show, season); + await _episodeSync.SyncEpisodes(show, seasonEntity); } + + processedCount++; - var showsBySonarrId = existingShows - .GroupBy(s => s.SonarrId) - .ToDictionary(g => g.Key, g => g.First()); - - foreach (var sonarrShow in batch) - { - try - { - if (sonarrShow.Title.Equals("Jujutsu Kaisen", StringComparison.OrdinalIgnoreCase)) - { - _logger.LogInformation("DEBUG: Syncing Jujutsu Kaisen. SonarrId: {Id}, Path: {Path}, Seasons: {SeasonCount}", - sonarrShow.Id, sonarrShow.Path, sonarrShow.Seasons?.Count ?? 0); - } - - showsBySonarrId.TryGetValue(sonarrShow.Id, out var showEntity); - showEntity = await _showSync.SyncShow(sonarrShow, showEntity); - - if (sonarrShow.Seasons == null) - { - _logger.LogWarning("Show {Title} has no seasons in Sonarr response.", sonarrShow.Title); - processedCount++; - continue; - } - - foreach (var sonarrSeason in sonarrShow.Seasons) - { - var existingSeason = showEntity.Seasons.FirstOrDefault(s => s.SeasonNumber == sonarrSeason.SeasonNumber); - var seasonEntity = await _seasonSync.SyncSeason(showEntity, sonarrShow, sonarrSeason, existingSeason); - - await _episodeSync.SyncEpisodes(sonarrShow, seasonEntity, seasonEntity.Episodes.ToList()); - } - - processedCount++; - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to sync show {Title} (SonarrId: {Id}). Skipping to next show.", - sonarrShow.Title, sonarrShow.Id); - } - } - - // Deferred saving: Save once per batch - try + if (processedCount % BatchSize == 0) { await SaveChanges(processedCount, shows.Count); } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to save batch of shows to database. Changes for this batch may be lost."); - } - finally - { - _dbContext.ChangeTracker.Clear(); - } + } + + if (processedCount % BatchSize != 0) + { + await SaveChanges(processedCount, shows.Count); } } /// public async Task SyncShow(SonarrShow show) { - // Pre-load hierarchy for single show - var showEntity = await _dbContext.Shows - .AsSplitQuery() - .Include(s => s.Images) - .Include(s => s.Seasons) - .ThenInclude(s => s.Episodes) - .ThenInclude(e => e.EmbeddedSubtitles) - .FirstOrDefaultAsync(s => s.SonarrId == show.Id); - - showEntity = await _showSync.SyncShow(show, showEntity); + var showEntity = await _showSync.SyncShow(show); foreach (var season in show.Seasons) { - var existingSeason = showEntity.Seasons.FirstOrDefault(s => s.SeasonNumber == season.SeasonNumber); - var seasonEntity = await _seasonSync.SyncSeason(showEntity, show, season, existingSeason); - await _episodeSync.SyncEpisodes(show, seasonEntity, seasonEntity.Episodes.ToList()); + var seasonEntity = await _seasonSync.SyncSeason(showEntity, show, season); + await _episodeSync.SyncEpisodes(show, seasonEntity); } await _dbContext.SaveChangesAsync(); diff --git a/Lingarr.Server/Services/TranslationRequestService.cs b/Lingarr.Server/Services/TranslationRequestService.cs index e5c7c3c6..f858127a 100644 --- a/Lingarr.Server/Services/TranslationRequestService.cs +++ b/Lingarr.Server/Services/TranslationRequestService.cs @@ -290,26 +290,6 @@ public async Task UpdateActiveCount() return $"Translation request with id {cancelRequest.Id} has been removed"; } - /// - public async Task RemoveFailedRequestsAsync(int mediaId, MediaType mediaType) - { - var failedRequests = await _dbContext.TranslationRequests - .Where(tr => tr.MediaId == mediaId && tr.MediaType == mediaType && tr.Status == TranslationStatus.Failed) - .ToListAsync(); - - if (!failedRequests.Any()) - { - return 0; - } - - _dbContext.TranslationRequests.RemoveRange(failedRequests); - var count = await _dbContext.SaveChangesAsync(); - - _logger.LogInformation("Removed {Count} failed translation requests for {MediaType} {MediaId}", count, mediaType, mediaId); - - return count; - } - /// /// /// From f23892511df46ce09f2c7d7088724f4b2571c338 Mon Sep 17 00:00:00 2001 From: T9es Date: Mon, 19 Jan 2026 02:24:53 +0100 Subject: [PATCH 32/64] Update package-lock.json --- Lingarr.Client/package-lock.json | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/Lingarr.Client/package-lock.json b/Lingarr.Client/package-lock.json index ee3765e0..f3dd641f 100644 --- a/Lingarr.Client/package-lock.json +++ b/Lingarr.Client/package-lock.json @@ -1395,7 +1395,6 @@ "version": "24.10.1", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -1432,7 +1431,6 @@ "version": "8.48.1", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.48.1", "@typescript-eslint/types": "8.48.1", @@ -1816,7 +1814,6 @@ "version": "8.15.0", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1948,7 +1945,6 @@ "node_modules/chart.js": { "version": "4.5.1", "license": "MIT", - "peer": true, "dependencies": { "@kurkle/color": "^0.3.0" }, @@ -2201,7 +2197,6 @@ "version": "9.39.1", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -2260,7 +2255,6 @@ "version": "10.1.8", "dev": true, "license": "MIT", - "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -3345,7 +3339,6 @@ "version": "4.0.3", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -3422,7 +3415,6 @@ "version": "3.7.4", "dev": true, "license": "MIT", - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -3771,7 +3763,6 @@ "version": "5.9.3", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -3817,7 +3808,6 @@ "version": "7.2.6", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -3897,7 +3887,6 @@ "node_modules/vue": { "version": "3.5.25", "license": "MIT", - "peer": true, "dependencies": { "@vue/compiler-dom": "3.5.25", "@vue/compiler-sfc": "3.5.25", @@ -3926,7 +3915,6 @@ "version": "10.2.0", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "debug": "^4.4.0", "eslint-scope": "^8.2.0", From 9b3c6c40435ca49b4c561d276e17d353e7019ebc Mon Sep 17 00:00:00 2001 From: T9es Date: Tue, 20 Jan 2026 00:05:24 +0100 Subject: [PATCH 33/64] Experimental. --- .gitignore | 1 + Lingarr.Server/Jobs/RetryFailedRequestsJob.cs | 74 +++++++++++++++++++ Lingarr.Server/Services/MediaStateService.cs | 4 +- Lingarr.Server/Services/ScheduleService.cs | 6 ++ 4 files changed, 83 insertions(+), 2 deletions(-) create mode 100644 Lingarr.Server/Jobs/RetryFailedRequestsJob.cs diff --git a/.gitignore b/.gitignore index 914fd12f..18b20b40 100644 --- a/.gitignore +++ b/.gitignore @@ -66,3 +66,4 @@ EXAMPLE SUBTITLES/Jujutsu Kaisen (2020) - S01E13 - Tomorrow \[v2 Bluray-1080p Pr EXAMPLE SUBTITLES/Jujutsu Kaisen (2020) - S01E13 - Tomorrow \[v2 Bluray-1080p Proper\]\[Opus 2.0\]\[x265\]-Vodes.eng.English-\[Kaizoku\].srt EXAMPLE EPISODES/Jujutsu Kaisen (2020) - S02E02 - Hidden Inventory 2 \[Bluray-1080p\]\[Opus 2.0\]\[x265\]-Vodes.mkv .opencode/opencode.json +Debug.txt diff --git a/Lingarr.Server/Jobs/RetryFailedRequestsJob.cs b/Lingarr.Server/Jobs/RetryFailedRequestsJob.cs new file mode 100644 index 00000000..afc11650 --- /dev/null +++ b/Lingarr.Server/Jobs/RetryFailedRequestsJob.cs @@ -0,0 +1,74 @@ +using Hangfire; +using Lingarr.Core.Configuration; +using Lingarr.Core.Enum; +using Lingarr.Server.Filters; +using Lingarr.Server.Interfaces.Services; +using Microsoft.OpenApi.Extensions; + +namespace Lingarr.Server.Jobs; + +/// +/// Background job that periodically retries failed translation requests. +/// +public class RetryFailedRequestsJob +{ + private readonly ITranslationRequestService _translationRequestService; + private readonly ILogger _logger; + private readonly IScheduleService _scheduleService; + private readonly ISettingService _settingService; + + public RetryFailedRequestsJob( + ITranslationRequestService translationRequestService, + ILogger logger, + IScheduleService scheduleService, + ISettingService settingService) + { + _translationRequestService = translationRequestService; + _logger = logger; + _scheduleService = scheduleService; + _settingService = settingService; + } + + [DisableConcurrentExecution(timeoutInSeconds: 30 * 60)] + [AutomaticRetry(Attempts = 0)] + [Queue("system")] + public async Task Execute() + { + var jobName = JobContextFilter.GetCurrentJobTypeName(); + await _scheduleService.UpdateJobState(jobName, JobStatus.Processing.GetDisplayName()); + + try + { + // Check if automation is enabled - we probably only want to auto-retry if automation is on + var automationEnabled = await _settingService.GetSetting(SettingKeys.Automation.AutomationEnabled); + if (automationEnabled != "true") + { + _logger.LogInformation("Automation is disabled, skipping retry job"); + await _scheduleService.UpdateJobState(jobName, JobStatus.Succeeded.GetDisplayName()); + return; + } + + _logger.LogInformation("Starting scheduled retry of failed translation requests..."); + + // This service method processes in batches + var count = await _translationRequestService.RetryAllFailedRequests(); + + if (count > 0) + { + _logger.LogInformation("Successfully requeued {Count} failed requests", count); + } + else + { + _logger.LogInformation("No failed requests found to retry"); + } + + await _scheduleService.UpdateJobState(jobName, JobStatus.Succeeded.GetDisplayName()); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to retry translation requests"); + await _scheduleService.UpdateJobState(jobName, JobStatus.Failed.GetDisplayName()); + throw; + } + } +} diff --git a/Lingarr.Server/Services/MediaStateService.cs b/Lingarr.Server/Services/MediaStateService.cs index 4802b406..b9a36c9f 100644 --- a/Lingarr.Server/Services/MediaStateService.cs +++ b/Lingarr.Server/Services/MediaStateService.cs @@ -208,7 +208,7 @@ public async Task MarkAllStaleAsync() .Where(m => !m.ExcludeFromTranslation) .Where(m => m.TranslationState == TranslationState.Pending || m.TranslationState == TranslationState.Stale - || (m.TranslationState == TranslationState.Unknown && m.IndexedAt != null)); + || m.TranslationState == TranslationState.Unknown); if (priorityFirst) { @@ -235,7 +235,7 @@ public async Task MarkAllStaleAsync() .Where(e => !e.Season.Show.ExcludeFromTranslation) .Where(e => e.TranslationState == TranslationState.Pending || e.TranslationState == TranslationState.Stale - || (e.TranslationState == TranslationState.Unknown && e.IndexedAt != null)); + || e.TranslationState == TranslationState.Unknown); if (priorityFirst) { diff --git a/Lingarr.Server/Services/ScheduleService.cs b/Lingarr.Server/Services/ScheduleService.cs index d9d39066..a72373e9 100644 --- a/Lingarr.Server/Services/ScheduleService.cs +++ b/Lingarr.Server/Services/ScheduleService.cs @@ -85,6 +85,12 @@ public async Task Initialize() Cron.Daily, new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc }); + RecurringJob.AddOrUpdate( + "RetryFailedRequestsJob", + job => job.Execute(), + Cron.Hourly, + new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc }); + _logger.LogInformation("Starting pending translation requests."); await translationRequestService.ResumeTranslationRequests(); } From 256274327529096e38c1c36b27cbba4f68eef20a Mon Sep 17 00:00:00 2001 From: T9es Date: Tue, 20 Jan 2026 00:39:13 +0100 Subject: [PATCH 34/64] More experimental stuff. --- .gitignore | 1 + Lingarr.Server/Jobs/AutomatedTranslationJob.cs | 15 +++++++++++---- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/.gitignore b/.gitignore index 18b20b40..e5757ba0 100644 --- a/.gitignore +++ b/.gitignore @@ -67,3 +67,4 @@ EXAMPLE SUBTITLES/Jujutsu Kaisen (2020) - S01E13 - Tomorrow \[v2 Bluray-1080p Pr EXAMPLE EPISODES/Jujutsu Kaisen (2020) - S02E02 - Hidden Inventory 2 \[Bluray-1080p\]\[Opus 2.0\]\[x265\]-Vodes.mkv .opencode/opencode.json Debug.txt +DEBUG/Debug2.txt diff --git a/Lingarr.Server/Jobs/AutomatedTranslationJob.cs b/Lingarr.Server/Jobs/AutomatedTranslationJob.cs index db8d1319..7c9ef2bf 100644 --- a/Lingarr.Server/Jobs/AutomatedTranslationJob.cs +++ b/Lingarr.Server/Jobs/AutomatedTranslationJob.cs @@ -109,11 +109,14 @@ public async Task Execute() if (currentState == TranslationState.Stale || currentState == TranslationState.Unknown) { var newState = await _mediaStateService.UpdateStateAsync(media, mediaType); - if (newState != TranslationState.Pending) + // Allow proceeding if Pending, OR if AwaitingSource but unindexed (needs scan) + var isUnindexed = media.IndexedAt == null; + + if (newState != TranslationState.Pending && !(newState == TranslationState.AwaitingSource && isUnindexed)) { - _logger.LogDebug( - "Skipping {Title}: state refreshed to {State}", - media.Title, newState); + _logger.LogInformation( + "Skipping {Title}: state refreshed to {State} (was {OldState})", + media.Title, newState, currentState); continue; } } @@ -163,6 +166,10 @@ public async Task Execute() "Queued {Count} translation(s) for {Title}", count, media.Title); } + else + { + _logger.LogInformation("Processed {Title} but no translations were queued (Count=0). MediaId: {Id}", media.Title, media.Id); + } } catch (DirectoryNotFoundException) { From b878acf572d885cbfb665a82fafab719b39d7ab8 Mon Sep 17 00:00:00 2001 From: T9es Date: Tue, 20 Jan 2026 00:41:23 +0100 Subject: [PATCH 35/64] Update AutomatedTranslationJob.cs --- Lingarr.Server/Jobs/AutomatedTranslationJob.cs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Lingarr.Server/Jobs/AutomatedTranslationJob.cs b/Lingarr.Server/Jobs/AutomatedTranslationJob.cs index 7c9ef2bf..09bcad01 100644 --- a/Lingarr.Server/Jobs/AutomatedTranslationJob.cs +++ b/Lingarr.Server/Jobs/AutomatedTranslationJob.cs @@ -110,7 +110,11 @@ public async Task Execute() { var newState = await _mediaStateService.UpdateStateAsync(media, mediaType); // Allow proceeding if Pending, OR if AwaitingSource but unindexed (needs scan) - var isUnindexed = media.IndexedAt == null; + DateTime? indexedAt = null; + if (mediaType == MediaType.Movie && media is Movie m) indexedAt = m.IndexedAt; + else if (mediaType == MediaType.Episode && media is Episode e) indexedAt = e.IndexedAt; + + var isUnindexed = indexedAt == null; if (newState != TranslationState.Pending && !(newState == TranslationState.AwaitingSource && isUnindexed)) { From 96d0534d7f32d12b642d27caff380e57a0a6962f Mon Sep 17 00:00:00 2001 From: T9es Date: Tue, 20 Jan 2026 01:18:40 +0100 Subject: [PATCH 36/64] Rotate queue. --- .../Interfaces/Services/IMediaStateService.cs | 6 ++++ .../Jobs/AutomatedTranslationJob.cs | 4 +++ Lingarr.Server/Services/MediaStateService.cs | 34 ++++++++++++++++--- 3 files changed, 40 insertions(+), 4 deletions(-) diff --git a/Lingarr.Server/Interfaces/Services/IMediaStateService.cs b/Lingarr.Server/Interfaces/Services/IMediaStateService.cs index 21cdcecb..cf510e61 100644 --- a/Lingarr.Server/Interfaces/Services/IMediaStateService.cs +++ b/Lingarr.Server/Interfaces/Services/IMediaStateService.cs @@ -56,4 +56,10 @@ public interface IMediaStateService /// Checks if a media item has any failed translation requests. /// Task HasFailedTranslationRequestAsync(int mediaId, MediaType mediaType); + + /// + /// Updates the LastSubtitleCheckAt timestamp for a media item. + /// Used for queue rotation to prevent starvation. + /// + Task UpdateLastSubtitleCheckAt(int mediaId, MediaType mediaType); } diff --git a/Lingarr.Server/Jobs/AutomatedTranslationJob.cs b/Lingarr.Server/Jobs/AutomatedTranslationJob.cs index 09bcad01..c3d7cdd1 100644 --- a/Lingarr.Server/Jobs/AutomatedTranslationJob.cs +++ b/Lingarr.Server/Jobs/AutomatedTranslationJob.cs @@ -154,6 +154,10 @@ public async Task Execute() // Queue translation try { + // Update rotation timestamp FIRST to ensure it goes to back of queue + // even if processing crashes. + await _mediaStateService.UpdateLastSubtitleCheckAt(media.Id, mediaType); + var count = await _mediaSubtitleProcessor.ProcessMediaForceAsync( media, mediaType, forceProcess: false, diff --git a/Lingarr.Server/Services/MediaStateService.cs b/Lingarr.Server/Services/MediaStateService.cs index b9a36c9f..d9054ecc 100644 --- a/Lingarr.Server/Services/MediaStateService.cs +++ b/Lingarr.Server/Services/MediaStateService.cs @@ -208,18 +208,22 @@ public async Task MarkAllStaleAsync() .Where(m => !m.ExcludeFromTranslation) .Where(m => m.TranslationState == TranslationState.Pending || m.TranslationState == TranslationState.Stale - || m.TranslationState == TranslationState.Unknown); + || m.TranslationState == TranslationState.Unknown + || (m.TranslationState == TranslationState.AwaitingSource && m.IndexedAt == null)); if (priorityFirst) { moviesQuery = moviesQuery .OrderByDescending(m => m.IsPriority) .ThenBy(m => m.PriorityDate) + .ThenBy(m => m.LastSubtitleCheckAt) // Oldest check first .ThenBy(m => m.DateAdded); } else { - moviesQuery = moviesQuery.OrderBy(m => m.DateAdded); + moviesQuery = moviesQuery + .OrderBy(m => m.LastSubtitleCheckAt) // Oldest check first + .ThenBy(m => m.DateAdded); } var movies = await moviesQuery.Take(halfLimit).ToListAsync(); @@ -235,18 +239,22 @@ public async Task MarkAllStaleAsync() .Where(e => !e.Season.Show.ExcludeFromTranslation) .Where(e => e.TranslationState == TranslationState.Pending || e.TranslationState == TranslationState.Stale - || e.TranslationState == TranslationState.Unknown); + || e.TranslationState == TranslationState.Unknown + || (e.TranslationState == TranslationState.AwaitingSource && e.IndexedAt == null)); if (priorityFirst) { episodesQuery = episodesQuery .OrderByDescending(e => e.Season.Show.IsPriority) .ThenBy(e => e.Season.Show.PriorityDate) + .ThenBy(e => e.LastSubtitleCheckAt) // Oldest check first (nulls first usually) .ThenBy(e => e.DateAdded); } else { - episodesQuery = episodesQuery.OrderBy(e => e.DateAdded); + episodesQuery = episodesQuery + .OrderBy(e => e.LastSubtitleCheckAt) // Oldest check first + .ThenBy(e => e.DateAdded); } var episodes = await episodesQuery.Take(limit - movies.Count).ToListAsync(); @@ -301,4 +309,22 @@ private async Task> GetConfiguredLanguages(string settingKey) return new HashSet(); } } + + /// + public async Task UpdateLastSubtitleCheckAt(int mediaId, MediaType mediaType) + { + var now = DateTime.UtcNow; + if (mediaType == MediaType.Movie) + { + await _dbContext.Movies + .Where(m => m.Id == mediaId) + .ExecuteUpdateAsync(s => s.SetProperty(m => m.LastSubtitleCheckAt, now)); + } + else + { + await _dbContext.Episodes + .Where(e => e.Id == mediaId) + .ExecuteUpdateAsync(s => s.SetProperty(e => e.LastSubtitleCheckAt, now)); + } + } } From 9cc6d5d41e1babb50dc7ee18e6f0099591233942 Mon Sep 17 00:00:00 2001 From: T9es Date: Tue, 20 Jan 2026 01:26:23 +0100 Subject: [PATCH 37/64] Resolve API issue. --- Lingarr.Core/Entities/Episode.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Lingarr.Core/Entities/Episode.cs b/Lingarr.Core/Entities/Episode.cs index 3664d151..e4566043 100644 --- a/Lingarr.Core/Entities/Episode.cs +++ b/Lingarr.Core/Entities/Episode.cs @@ -1,4 +1,5 @@ using System.ComponentModel.DataAnnotations.Schema; +using System.Text.Json.Serialization; using Lingarr.Core.Enum; using Lingarr.Core.Interfaces; @@ -23,6 +24,7 @@ public class Episode : BaseEntity, IMedia /// /// Current translation state for efficient querying. /// + [JsonConverter(typeof(JsonNumberEnumConverter))] public TranslationState TranslationState { get; set; } = TranslationState.Unknown; /// From 94c5a42dd4abf15615bded87cc0cafffd93a7306 Mon Sep 17 00:00:00 2001 From: T9es Date: Tue, 20 Jan 2026 20:42:18 +0100 Subject: [PATCH 38/64] fix(dashboard,logs,test): resolve multiple UI/UX and logic issues - Fix dashboard media stats accuracy by fetching counts directly from DB - Fix 'Update Available' always green by fixing badge condition - Enhance System Logs with rolling window, pause/resume, and animations - Overhaul Translation Test page with media search help, split-view comparison, and download options - Refactor TestTranslationService to return full preview data --- .../src/components/layout/AsideNavigation.vue | 4 +- .../src/pages/TranslationTestPage.vue | 76 +++- .../src/pages/settings/LogsPage.vue | 171 +++++--- Lingarr.Server/Services/StatisticsService.cs | 5 +- .../Services/TestTranslationService.cs | 3 +- agents.md | 371 ------------------ 6 files changed, 202 insertions(+), 428 deletions(-) delete mode 100644 agents.md diff --git a/Lingarr.Client/src/components/layout/AsideNavigation.vue b/Lingarr.Client/src/components/layout/AsideNavigation.vue index f5accc85..db3773f3 100644 --- a/Lingarr.Client/src/components/layout/AsideNavigation.vue +++ b/Lingarr.Client/src/components/layout/AsideNavigation.vue @@ -1,4 +1,4 @@ -