diff --git a/backend/internal/service/metadata.go b/backend/internal/service/metadata.go index 4a5c590..04ec8f4 100644 --- a/backend/internal/service/metadata.go +++ b/backend/internal/service/metadata.go @@ -53,9 +53,9 @@ func expandEpisodeArtifacts(ctx context.Context, provider TMDBSeasonCatalog, con for _, file := range plan.EpisodeFiles { episode, found := episodes[[2]int{file.Season, file.Episode}] if !found { - // Episodes TMDB does not know about stay untouched instead of blocking - // the whole plan: no rename, no generated metadata for them. - skipEpisode(plan, file) + // Episodes TMDB does not know about are still renamed, but no + // episode metadata is generated for them. + plan.SkippedEpisodes = append(plan.SkippedEpisodes, file) continue } kept = append(kept, file) @@ -79,30 +79,4 @@ func expandEpisodeArtifacts(ctx context.Context, provider TMDBSeasonCatalog, con return nil } -// skipEpisode drops one episode and its companion assets from the rename plan so -// the executor never touches them. -func skipEpisode(plan *PreviewPlan, file EpisodeFilePlan) { - plan.SkippedEpisodes = append(plan.SkippedEpisodes, file) - videoBase := strings.TrimSuffix(path.Base(file.SourcePath), path.Ext(file.SourcePath)) - directory := path.Dir(file.SourcePath) - kept := make([]RenameItem, 0, len(plan.ProposedFileRenames)) - for _, rename := range plan.ProposedFileRenames { - sameDirectory := path.Dir(rename.SourcePath) == directory - isSkippedVideo := rename.SourcePath == file.SourcePath - isSkippedCompanion := sameDirectory && rename.AssetType != "video" && isCompanionOf(rename.SourcePath, videoBase) - if isSkippedVideo || isSkippedCompanion { - continue - } - kept = append(kept, rename) - } - plan.ProposedFileRenames = kept -} - -// isCompanionOf mirrors the planner's companion matching: same video base plus a -// separator or exact base match. -func isCompanionOf(sourcePath, videoBase string) bool { - base := strings.TrimSuffix(path.Base(sourcePath), path.Ext(sourcePath)) - return base == videoBase || strings.HasPrefix(base, videoBase+".") || strings.HasPrefix(base, videoBase+"-") || strings.HasPrefix(base, videoBase+"_") -} - func isNFOArtifact(kind string) bool { return kind == "nfo" || strings.HasSuffix(kind, "_nfo") } diff --git a/backend/internal/service/metadata_test.go b/backend/internal/service/metadata_test.go index 8e3119a..4cb6c0d 100644 --- a/backend/internal/service/metadata_test.go +++ b/backend/internal/service/metadata_test.go @@ -2,6 +2,7 @@ package service import ( "context" + "strings" "testing" "time" @@ -31,7 +32,7 @@ func TestEpisodeArtifactsUseFinalVideoBaseName(t *testing.T) { } } -func TestMissingEpisodesAreSkippedInsteadOfBlocking(t *testing.T) { +func TestMissingEpisodesRenameButGenerateNoMetadata(t *testing.T) { plan := PreviewPlan{ Ready: true, Artifacts: []PreviewArtifact{}, GeneratedFiles: []string{}, Warnings: []string{}, EpisodeFiles: []EpisodeFilePlan{ @@ -61,11 +62,16 @@ func TestMissingEpisodesAreSkippedInsteadOfBlocking(t *testing.T) { if len(plan.EpisodeFiles) != 1 || plan.EpisodeFiles[0].Episode != 1 { t.Fatalf("unexpected remaining episode files: %#v", plan.EpisodeFiles) } - if len(plan.ProposedFileRenames) != 3 || plan.ProposedFileRenames[0].SourcePath != "/tv/Show/raw1.mkv" || plan.ProposedFileRenames[1].SourcePath != "/tv/Show/raw1.jpg" || plan.ProposedFileRenames[2].SourcePath != "/tv/Show/raw1.nfo" { - t.Fatalf("skipped episode renames were not dropped: %#v", plan.ProposedFileRenames) + if len(plan.ProposedFileRenames) != 6 { + t.Fatalf("skipped episodes must still be renamed: %#v", plan.ProposedFileRenames) } if len(plan.Artifacts) != 2 || plan.Artifacts[0].Path != "/tv/Show/Season 01/Show - S01E01.nfo" { - t.Fatalf("unexpected artifacts: %#v", plan.Artifacts) + t.Fatalf("metadata must only be generated for matched episodes: %#v", plan.Artifacts) + } + for _, artifact := range plan.Artifacts { + if strings.Contains(artifact.Path, "S01E21") { + t.Fatalf("metadata was generated for the skipped episode: %#v", artifact) + } } if len(plan.Warnings) != 1 || plan.Warnings[0] != "episodes_skipped" { t.Fatalf("episodes_skipped warning was not added: %#v", plan.Warnings) diff --git a/frontend/src/i18n/resources.ts b/frontend/src/i18n/resources.ts index 61c144f..2c16ef9 100644 --- a/frontend/src/i18n/resources.ts +++ b/frontend/src/i18n/resources.ts @@ -40,8 +40,8 @@ const en = { scan: 'Scan media', scanning: 'Scanning…', scanTitle: 'Media scan', scanDescription: 'Read-only discovery; no media files are modified.', scanningDescription: 'Reading the target recursively and identifying media candidates…', scanStatus: 'Status', scanSucceeded: 'Completed', candidates: 'Candidates', videoFiles: 'Video files', scrapedCandidates: 'Scraped', scraped: 'Scraped', notScraped: 'Not scraped', scrapeFilter: 'Filter by scrape status', allScrapeStates: 'All media', onlyScraped: 'Scraped only', onlyUnscraped: 'Not scraped only', noCandidates: 'No media candidates were found.', noFilteredCandidates: 'No media matches this filter.', ready: 'Ready', needs_review: 'Needs review', confidence: '{{value}}% confidence', videoCount: '{{count}} video', videoCount_other: '{{count}} videos', scanError: 'Could not scan the target', tmdbPreview: 'TMDB preview', rescrape: 'Scrape again', searchTitle: 'Search title', searchYear: 'Year', searchTMDB: 'Search TMDB', searching: 'Searching…', noTMDBResults: 'TMDB returned no matching results.', noOverview: 'No overview is available.', selectMatch: 'Use this match', matchError: 'Could not search TMDB', previewError: 'Could not create the scrape preview', previewReady: 'Plan ready', previewBlocked: 'Blocked', readOnly: 'Read only', renamePlan: 'Rename plan', directoryCreates: 'Directories to create', directoryRenames: 'Directories to rename', fileRenames: 'Files to rename', noRenames: 'No media rename is planned.', generatedFiles: 'Generated metadata', nfoPreview: 'Preview NFO XML', previewWarnings: 'Review required', previewConflicts: 'Blocking conflicts', previewExpires: 'This immutable preview expires at {{value}}.', execute: 'Confirm and execute', submittingJob: 'Submitting…', executeError: 'Could not submit the scrape job', executeConfirm: 'Execute {{renames}} media operations and upload {{files}} metadata files? The server will stop rather than overwrite conflicting media.', scrapeAll: 'Scrape all', batchConfirmTitle: 'Scrape all media', batchConfirmDescription: '{{total}} media will be matched against TMDB one by one. Media with exactly one match is scraped and renamed automatically; media without a match, with several matches, or with a blocked plan is skipped.', batchIncludeScraped: 'Also re-scrape media that is already scraped', batchRunning: 'Matching media {{done}}/{{total}}…', batchProgress: 'Submitted {{submitted}} · Skipped {{skipped}} · Failed {{failed}}', batchStop: 'Stop', batchDoneNote: 'Submitted jobs keep running on the Jobs page. Rescan the target afterwards to refresh the scrape states.', batchError: 'Could not start the scrape batch', batchCancelError: 'Could not stop the scrape batch', batchStart: 'Start scrape', batchItemPending: 'Queued', batchSubmitted: 'Job submitted', batchSkipped: 'Skipped', batchFailed: 'Failed', batchSkip: { no_match: 'No TMDB result', multiple_matches: 'Multiple TMDB results', plan_conflicts: 'Plan has conflicts', stale: 'Directory changed, rescan needed', already_active: 'A job is already running', canceled: 'Canceled' }, - warnings: { rename_disabled: 'Media rename is disabled for this target.', year_missing: 'TMDB did not provide a release year; execution will remain blocked.', episodes_skipped: 'Episodes without TMDB metadata stay untouched: they are not renamed and no metadata is generated for them.' }, - skippedEpisodes: 'Episodes left untouched', + warnings: { rename_disabled: 'Media rename is disabled for this target.', year_missing: 'TMDB did not provide a release year; execution will remain blocked.', episodes_skipped: 'Episodes without TMDB metadata are still renamed, but no metadata is generated for them.' }, + skippedEpisodes: 'Episodes without metadata', conflicts: { target_exists: 'A destination path already exists.', duplicate_target: 'Multiple source items map to the same destination.', video_missing: 'The candidate no longer contains a video file.', multiple_movie_videos: 'A movie candidate contains multiple video files and cannot be renamed safely.', season_ambiguous: 'A season directory contains conflicting season markers.', season_invalid: 'A season directory contains an invalid season marker.', duplicate_season_directory: 'Multiple directories map to the same season.', episode_unrecognized: 'A video file has no unambiguous season and episode marker.', episode_metadata_missing: 'TMDB has no matching metadata for this season and episode.' }, saving: 'Saving…', saved: 'Target saved', deleted: 'Target deleted', deleteConfirm: 'Delete target “{{name}}”?', formError: 'Could not save the target', deleteError: 'Could not delete the target', browserError: 'Could not read the directory', placeholderName: 'Movies', placeholderPath: '/media/movies', }, @@ -118,8 +118,8 @@ const zhCN = { scan: '扫描媒体', scanning: '扫描中…', scanTitle: '媒体扫描', scanDescription: '仅进行只读发现,不会修改媒体文件。', scanningDescription: '正在递归读取目标目录并识别媒体候选…', scanStatus: '状态', scanSucceeded: '已完成', candidates: '候选', videoFiles: '视频文件', scrapedCandidates: '已刮削', scraped: '已刮削', notScraped: '未刮削', scrapeFilter: '按刮削状态筛选', allScrapeStates: '全部媒体', onlyScraped: '仅已刮削', onlyUnscraped: '仅未刮削', noCandidates: '未发现媒体候选。', noFilteredCandidates: '当前筛选条件下没有媒体。', ready: '可预览', needs_review: '需要检查', confidence: '置信度 {{value}}%', videoCount: '{{count}} 个视频', videoCount_other: '{{count}} 个视频', scanError: '扫描目标失败', tmdbPreview: 'TMDB 预览', rescrape: '重新刮削', searchTitle: '搜索标题', searchYear: '年份', searchTMDB: '搜索 TMDB', searching: '搜索中…', noTMDBResults: 'TMDB 未返回匹配结果。', noOverview: '暂无简介。', selectMatch: '使用该匹配', matchError: '搜索 TMDB 失败', previewError: '创建刮削预览失败', previewReady: '计划就绪', previewBlocked: '存在阻断', readOnly: '只读', renamePlan: '重命名计划', directoryCreates: '将创建的目录', directoryRenames: '将重命名的目录', fileRenames: '将重命名的文件', noRenames: '不会重命名媒体。', generatedFiles: '将生成的元数据', nfoPreview: '预览 NFO XML', previewWarnings: '需要确认', previewConflicts: '阻断冲突', previewExpires: '该不可变预览将在 {{value}} 过期。', execute: '确认并执行', submittingJob: '提交中…', executeError: '提交刮削作业失败', executeConfirm: '将执行 {{renames}} 项媒体操作并上传 {{files}} 个元数据文件。若发现媒体冲突,服务端会停止而不是覆盖,是否继续?', scrapeAll: '全部刮削', batchConfirmTitle: '全部刮削', batchConfirmDescription: '将对 {{total}} 个媒体逐个匹配 TMDB:只有唯一匹配结果的媒体会自动刮削并重命名;没有匹配、多个匹配或计划存在冲突的媒体将被跳过。', batchIncludeScraped: '同时重新刮削已刮削的媒体', batchRunning: '正在匹配媒体 {{done}}/{{total}}…', batchProgress: '已提交 {{submitted}} · 已跳过 {{skipped}} · 失败 {{failed}}', batchStop: '停止', batchDoneNote: '已提交的作业会在任务页继续执行。完成后请重新扫描以刷新刮削状态。', batchError: '启动批量刮削失败', batchCancelError: '停止批量刮削失败', batchStart: '开始刮削', batchItemPending: '排队中', batchSubmitted: '已提交作业', batchSkipped: '已跳过', batchFailed: '失败', batchSkip: { no_match: 'TMDB 无匹配结果', multiple_matches: '多个 TMDB 匹配结果', plan_conflicts: '计划存在冲突', stale: '目录已变化,请重新扫描', already_active: '该媒体已有进行中的作业', canceled: '已取消' }, - warnings: { rename_disabled: '该目标未启用媒体重命名。', year_missing: 'TMDB 未提供发行年份,执行将保持禁用。', episodes_skipped: '在 TMDB 中没有对应集元数据的媒体将保持原样:不重命名,也不生成元数据。' }, - skippedEpisodes: '保持原样的剧集', + warnings: { rename_disabled: '该目标未启用媒体重命名。', year_missing: 'TMDB 未提供发行年份,执行将保持禁用。', episodes_skipped: '在 TMDB 中没有对应集元数据的媒体仍会重命名,但不会生成它们的集元数据。' }, + skippedEpisodes: '缺少元数据的剧集', conflicts: { target_exists: '目标路径已经存在。', duplicate_target: '多个源项目映射到了同一个目标。', video_missing: '候选中已没有视频文件。', multiple_movie_videos: '电影候选包含多个视频,无法安全自动重命名。', season_ambiguous: '季目录中包含互相冲突的季号。', season_invalid: '季目录中包含无效季号。', duplicate_season_directory: '多个目录将映射到同一季目录。', episode_unrecognized: '视频文件缺少明确的季集标记。', episode_metadata_missing: 'TMDB 中没有匹配的季集元数据。' }, saving: '保存中…', saved: '目标已保存', deleted: '目标已删除', deleteConfirm: '确定删除目标“{{name}}”吗?', formError: '保存目标失败', deleteError: '删除目标失败', browserError: '读取目录失败', placeholderName: '电影库', placeholderPath: '/media/movies', },