Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 3 additions & 29 deletions backend/internal/service/metadata.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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") }
14 changes: 10 additions & 4 deletions backend/internal/service/metadata_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package service

import (
"context"
"strings"
"testing"
"time"

Expand Down Expand Up @@ -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{
Expand Down Expand Up @@ -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)
Expand Down
8 changes: 4 additions & 4 deletions frontend/src/i18n/resources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
},
Expand Down Expand Up @@ -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',
},
Expand Down