diff --git a/apps/internal-site/components/web-uploader/source-scanner.test.ts b/apps/internal-site/components/web-uploader/source-scanner.test.ts index 7ed1082..4f7dbcf 100644 --- a/apps/internal-site/components/web-uploader/source-scanner.test.ts +++ b/apps/internal-site/components/web-uploader/source-scanner.test.ts @@ -165,6 +165,60 @@ describe("scanBrowserUploadFiles", () => { expect(plan.frames.map((frame) => frame.title)).toEqual(["00-2183", "12-27240"]); }); + it("sorts structured flat volumes numerically when episode and frame match", () => { + const plan = scanBrowserUploadFiles( + [ + image("TITLE_VOL10_00000.m2ts-27240-src.png"), + image("TITLE_VOL10_00000.m2ts-27240-output.png"), + image("TITLE_VOL2_00000.m2ts-27240-src.png"), + image("TITLE_VOL2_00000.m2ts-27240-output.png"), + ], + "20260702", + ); + + expect(plan.frames).toHaveLength(2); + expect(plan.frames.map((frame) => frame.before.source.relativePath)).toEqual([ + "TITLE_VOL2_00000.m2ts-27240-src.png", + "TITLE_VOL10_00000.m2ts-27240-src.png", + ]); + }); + + it("keeps unrelated flat titles grouped before comparing volume numbers", () => { + const plan = scanBrowserUploadFiles( + [ + image("BBB_VOL2_00000.m2ts-27240-src.png"), + image("BBB_VOL2_00000.m2ts-27240-output.png"), + image("AAA_VOL10_00000.m2ts-27240-src.png"), + image("AAA_VOL10_00000.m2ts-27240-output.png"), + ], + "20260702", + ); + + expect(plan.frames).toHaveLength(2); + expect(plan.frames.map((frame) => frame.before.source.relativePath)).toEqual([ + "AAA_VOL10_00000.m2ts-27240-src.png", + "BBB_VOL2_00000.m2ts-27240-src.png", + ]); + }); + + it("sorts structured nested volumes numerically when episode and frame match", () => { + const plan = scanBrowserUploadFiles( + [ + image("case/before/TITLE_VOL10_00000.m2ts-27240.png"), + image("case/after/TITLE_VOL10_00000.m2ts-27240.png"), + image("case/before/TITLE_VOL2_00000.m2ts-27240.png"), + image("case/after/TITLE_VOL2_00000.m2ts-27240.png"), + ], + "case", + ); + + expect(plan.frames).toHaveLength(2); + expect(plan.frames.map((frame) => frame.before.source.relativePath)).toEqual([ + "before/TITLE_VOL2_00000.m2ts-27240.png", + "before/TITLE_VOL10_00000.m2ts-27240.png", + ]); + }); + it("recognizes structured encode filenames with m2ts markers and no fps prefix", () => { const plan = scanBrowserUploadFiles( [ diff --git a/apps/internal-site/components/web-uploader/source-scanner.ts b/apps/internal-site/components/web-uploader/source-scanner.ts index 3725219..fe22087 100644 --- a/apps/internal-site/components/web-uploader/source-scanner.ts +++ b/apps/internal-site/components/web-uploader/source-scanner.ts @@ -63,8 +63,15 @@ const MATCH_KEY_SUFFIX_RE = new RegExp( "i", ); const NON_ALNUM_RE = /[^0-9a-z]+/g; +const VOLUME_HINT_RE = /(?:^|[^a-zA-Z0-9])(VOL|BOX)[ _-]*(\d+)(?=$|[^a-zA-Z0-9])/i; const MAX_AFTER_ASSETS = 4; +interface VolumeSortKey { + kind: "VOL" | "BOX"; + number: number; + label: string; +} + interface SourceCandidate { entry: BrowserUploadFile; originalName: string; @@ -125,6 +132,45 @@ function titleCase(input: string) { .replace(/\b\w/g, (letter) => letter.toUpperCase()); } +function volumeSortKey(input: string): VolumeSortKey | null { + const match = input.match(VOLUME_HINT_RE); + if (!match) { + return null; + } + + const kind = match[1].toUpperCase() as VolumeSortKey["kind"]; + const number = Number(match[2]); + return { kind, number, label: `${kind}${number}` }; +} + +function compareVolumeHints(left: string, right: string) { + const leftKey = volumeSortKey(left); + const rightKey = volumeSortKey(right); + const kindRank = (key: VolumeSortKey | null) => key ? (key.kind === "VOL" ? 0 : 1) : 2; + + return ( + kindRank(leftKey) - kindRank(rightKey) || + (leftKey?.number ?? Number.MAX_SAFE_INTEGER) - (rightKey?.number ?? Number.MAX_SAFE_INTEGER) + ); +} + +function rootHintWithoutVolume(input: string) { + return input + .replace(VOLUME_HINT_RE, " ") + .toLowerCase() + .replace(NON_ALNUM_RE, " ") + .trim(); +} + +function compareRootHints(left: string, right: string) { + // Keep unrelated titles grouped first; volume numbers only break ties within the same root title. + return ( + rootHintWithoutVolume(left).localeCompare(rootHintWithoutVolume(right)) || + compareVolumeHints(left, right) || + left.localeCompare(right) + ); +} + function variantLabel(variant: string) { const normalized = variant.toLowerCase(); if (PRIMARY_AFTER_VARIANTS.has(normalized)) { @@ -576,7 +622,7 @@ function buildFlatPlan(sourceRootName: string, entries: BrowserUploadFile[], ign return ( Number(leftCandidate.episode) - Number(rightCandidate.episode) || leftCandidate.frameNumber - rightCandidate.frameNumber || - leftCandidate.rootHint.localeCompare(rightCandidate.rootHint) + compareRootHints(leftCandidate.rootHint, rightCandidate.rootHint) ); }); const fallbackWidth = Math.max( @@ -643,7 +689,13 @@ function buildNestedPlan(sourceRootName: string, entries: BrowserUploadFile[], i const miscAssignments = assignCandidatesToBeforeKeys(miscParsed, beforeByKey); const allBefore = [...beforeByKey.entries()] .map(([matchKey, candidates]) => [matchKey, candidates[0]] as const) - .sort((left, right) => Number(left[1].episode) - Number(right[1].episode) || left[1].frameNumber - right[1].frameNumber || left[0].localeCompare(right[0])); + .sort( + (left, right) => + Number(left[1].episode) - Number(right[1].episode) || + left[1].frameNumber - right[1].frameNumber || + compareRootHints(left[1].rootHint, right[1].rootHint) || + left[0].localeCompare(right[0]), + ); const fallbackWidth = Math.max(4, String(Math.max(0, ...allBefore.map(([, candidate]) => candidate.frameNumber))).length); const episodeWidth = structuredEpisodeWidth(beforeParsed.candidates); const frames: WebUploadFramePlan[] = [];