From cdec96ffe0e84663b50a956e9f0cf67188e7356a Mon Sep 17 00:00:00 2001 From: CircleCrop <54088075+CircleCrop@users.noreply.github.com> Date: Sat, 11 Jul 2026 23:14:36 +0800 Subject: [PATCH 1/2] fix: sort upload volumes numerically --- .../web-uploader/source-scanner.test.ts | 36 ++++++++++++++++++ .../components/web-uploader/source-scanner.ts | 37 ++++++++++++++++++- 2 files changed, 72 insertions(+), 1 deletion(-) 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..488ab21 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,42 @@ 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("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..6545878 100644 --- a/apps/internal-site/components/web-uploader/source-scanner.ts +++ b/apps/internal-site/components/web-uploader/source-scanner.ts @@ -65,6 +65,12 @@ const MATCH_KEY_SUFFIX_RE = new RegExp( const NON_ALNUM_RE = /[^0-9a-z]+/g; const MAX_AFTER_ASSETS = 4; +interface VolumeSortKey { + kind: "VOL" | "BOX"; + number: number; + label: string; +} + interface SourceCandidate { entry: BrowserUploadFile; originalName: string; @@ -125,6 +131,28 @@ function titleCase(input: string) { .replace(/\b\w/g, (letter) => letter.toUpperCase()); } +function volumeSortKey(input: string): VolumeSortKey | null { + const match = input.match(/(?:^|[^a-z0-9])(VOL|BOX)[ _-]*(\d+)(?=$|[^a-z0-9])/i); + 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 variantLabel(variant: string) { const normalized = variant.toLowerCase(); if (PRIMARY_AFTER_VARIANTS.has(normalized)) { @@ -576,6 +604,7 @@ function buildFlatPlan(sourceRootName: string, entries: BrowserUploadFile[], ign return ( Number(leftCandidate.episode) - Number(rightCandidate.episode) || leftCandidate.frameNumber - rightCandidate.frameNumber || + compareVolumeHints(leftCandidate.rootHint, rightCandidate.rootHint) || leftCandidate.rootHint.localeCompare(rightCandidate.rootHint) ); }); @@ -643,7 +672,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 || + compareVolumeHints(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[] = []; From cd7580987ba556f5ea849d511a0b81a60af92c82 Mon Sep 17 00:00:00 2001 From: CircleCrop <54088075+CircleCrop@users.noreply.github.com> Date: Mon, 13 Jul 2026 22:28:39 +0800 Subject: [PATCH 2/2] fix: preserve upload title ordering --- .../web-uploader/source-scanner.test.ts | 18 +++++++++++++ .../components/web-uploader/source-scanner.ts | 25 ++++++++++++++++--- 2 files changed, 39 insertions(+), 4 deletions(-) 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 488ab21..4f7dbcf 100644 --- a/apps/internal-site/components/web-uploader/source-scanner.test.ts +++ b/apps/internal-site/components/web-uploader/source-scanner.test.ts @@ -183,6 +183,24 @@ describe("scanBrowserUploadFiles", () => { ]); }); + 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( [ diff --git a/apps/internal-site/components/web-uploader/source-scanner.ts b/apps/internal-site/components/web-uploader/source-scanner.ts index 6545878..fe22087 100644 --- a/apps/internal-site/components/web-uploader/source-scanner.ts +++ b/apps/internal-site/components/web-uploader/source-scanner.ts @@ -63,6 +63,7 @@ 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 { @@ -132,7 +133,7 @@ function titleCase(input: string) { } function volumeSortKey(input: string): VolumeSortKey | null { - const match = input.match(/(?:^|[^a-z0-9])(VOL|BOX)[ _-]*(\d+)(?=$|[^a-z0-9])/i); + const match = input.match(VOLUME_HINT_RE); if (!match) { return null; } @@ -153,6 +154,23 @@ function compareVolumeHints(left: string, right: string) { ); } +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)) { @@ -604,8 +622,7 @@ function buildFlatPlan(sourceRootName: string, entries: BrowserUploadFile[], ign return ( Number(leftCandidate.episode) - Number(rightCandidate.episode) || leftCandidate.frameNumber - rightCandidate.frameNumber || - compareVolumeHints(leftCandidate.rootHint, rightCandidate.rootHint) || - leftCandidate.rootHint.localeCompare(rightCandidate.rootHint) + compareRootHints(leftCandidate.rootHint, rightCandidate.rootHint) ); }); const fallbackWidth = Math.max( @@ -676,7 +693,7 @@ function buildNestedPlan(sourceRootName: string, entries: BrowserUploadFile[], i (left, right) => Number(left[1].episode) - Number(right[1].episode) || left[1].frameNumber - right[1].frameNumber || - compareVolumeHints(left[1].rootHint, right[1].rootHint) || + 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);