From 1b9a16095b2bdfcef0cec8de4ff57f3ea011667a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 10 Oct 2026 00:22:18 +0000 Subject: [PATCH 1/3] build(deps): bump the npm_and_yarn group across 2 directories with 1 update Bumps the npm_and_yarn group with 1 update in the /landing directory: [next](https://github.com/vercel/next.js). Bumps the npm_and_yarn group with 1 update in the /web directory: [next](https://github.com/vercel/next.js). Updates `next` from 16.3.3 to 16.3.8 - [Release notes](https://github.com/vercel/next.js/releases) - [Commits](https://github.com/vercel/next.js/compare/v16.3.3...v16.3.8) Updates `next` from 16.3.3 to 16.3.8 - [Release notes](https://github.com/vercel/next.js/releases) - [Commits](https://github.com/vercel/next.js/compare/v16.3.3...v16.3.8) --- updated-dependencies: - dependency-name: next dependency-version: 16.3.8 dependency-type: direct:production dependency-group: npm_and_yarn - dependency-name: next dependency-version: 16.3.8 dependency-type: direct:production dependency-group: npm_and_yarn ... Signed-off-by: dependabot[bot] --- landing/package.json | 2 +- web/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/landing/package.json b/landing/package.json index 011f34e9..d4f7fa38 100644 --- a/landing/package.json +++ b/landing/package.json @@ -19,7 +19,7 @@ "@types/three": "^0.184.1", "framer-motion": "^12.40.0", "lucide-react": "^1.17.0", - "next": "16.3.3", + "next": "16.3.8", "react": "19.2.4", "react-dom": "19.2.4", "three": "^0.184.0" diff --git a/web/package.json b/web/package.json index 1b7204f5..fd15fa25 100644 --- a/web/package.json +++ b/web/package.json @@ -25,7 +25,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^0.561.0", - "next": "16.3.3", + "next": "16.3.8", "next-themes": "^0.4.6", "react": "19.2.1", "react-dom": "19.2.1", From ae8347bb1123ac5b8b660988dffc0846a1a423a5 Mon Sep 17 00:00:00 2001 From: samtruman <39094339+samtruman@users.noreply.github.com> Date: Sun, 11 Oct 2026 23:30:20 +0200 Subject: [PATCH 2/3] fix(organizer): reconcile filename mode symlinks --- src/services/organizer.ts | 88 ++++++++++++++++++- .../unit/services/organizer-filename.test.ts | 20 +++++ 2 files changed, 107 insertions(+), 1 deletion(-) diff --git a/src/services/organizer.ts b/src/services/organizer.ts index 7f4424ed..7673e59f 100644 --- a/src/services/organizer.ts +++ b/src/services/organizer.ts @@ -793,7 +793,8 @@ export async function resolveCollisionTarget(src: string, dst: string): Promise< if (!st) return candidate; if (st.isSymbolicLink()) { const current = await fsp.readlink(candidate).catch(() => ""); - if (path.resolve(path.dirname(candidate), current) === src) return candidate; + const currentTarget = path.resolve(path.dirname(candidate), current); + if (await organizerSourcesMatch(src, currentTarget)) return candidate; } const suffix = index === 0 ? ` - ${fingerprint}` : ` - ${fingerprint}-${index}`; candidate = path.join(dstDir, `${stem}${suffix}${ext}`); @@ -802,6 +803,24 @@ export async function resolveCollisionTarget(src: string, dst: string): Promise< throw new Error(`unable to allocate collision-safe organizer target for ${dst}`); } +/** Compare sources across symlinked/container mount aliases. */ +async function organizerSourcesMatch(left: string, right: string): Promise { + const leftAbsolute = path.normalize(path.resolve(left)); + const rightAbsolute = path.normalize(path.resolve(right)); + if (leftAbsolute === rightAbsolute) return true; + + const [leftReal, rightReal] = await Promise.all([ + fsp.realpath(leftAbsolute).catch(() => leftAbsolute), + fsp.realpath(rightAbsolute).catch(() => rightAbsolute), + ]); + return path.normalize(leftReal) === path.normalize(rightReal); +} + +async function organizerSourceKey(source: string): Promise { + const absolute = path.normalize(path.resolve(source)); + return fsp.realpath(absolute).then((resolved) => path.normalize(resolved)).catch(() => absolute); +} + /** * Recursively walks a directory tree, collecting absolute paths of video files. * Handles symlinks by falling back to `stat` when `withFileTypes` doesn't resolve. @@ -905,6 +924,53 @@ async function pruneStaleSymlinks(dir: string): Promise<{ removedLinks: number; return { removedLinks, removedDirs }; } +/** Remove valid links left behind when naming or classification changes. */ +async function reconcileObsoleteSymlinks( + dir: string, + desiredBySource: Map, +): Promise<{ removedLinks: number; removedDirs: number }> { + let removedLinks = 0; + let removedDirs = 0; + let entries: fs.Dirent[]; + try { + entries = await fsp.readdir(dir, { withFileTypes: true }); + } catch { + return { removedLinks, removedDirs }; + } + + for (const ent of entries) { + const full = path.join(dir, ent.name); + if (ent.isDirectory()) { + const sub = await reconcileObsoleteSymlinks(full, desiredBySource); + removedLinks += sub.removedLinks; + removedDirs += sub.removedDirs; + try { + if ((await fsp.readdir(full)).length === 0) { + await fsp.rmdir(full); + removedDirs++; + } + } catch { /* directory may have disappeared */ } + continue; + } + if (!ent.isSymbolicLink()) continue; + + const current = await fsp.readlink(full).catch(() => ""); + const source = path.resolve(path.dirname(full), current); + const desired = desiredBySource.get(await organizerSourceKey(source)); + if (!desired || path.normalize(desired) === path.normalize(full)) continue; + + console.log(`[${new Date().toISOString()}][organize] obsolete organized symlink`, { + path: full, + target: source, + replacement: desired, + }); + await fsp.unlink(full); + removedLinks++; + } + + return { removedLinks, removedDirs }; +} + // =========================================================================== // Main Orchestration // =========================================================================== @@ -1046,6 +1112,7 @@ export async function organizeOnce(opts?: { dryRun?: boolean; limit?: number }) let tvCount = 0; let unknownCount = 0; const unknownSamples: string[] = []; + const desiredBySource = new Map(); for (const src of files) { const base = path.basename(src); const structuredIdentity = parseMediaFilename(base, src); @@ -1130,6 +1197,7 @@ export async function organizeOnce(opts?: { dryRun?: boolean; limit?: number }) const dst = computeTarget(parsed, base, src); if (!dst) continue; + let organizedTarget = dst; if (config.organizerFilenameMode === "original") { // Original release filenames: never silently replace a different // symlink on collision — keep the existing one. @@ -1139,10 +1207,28 @@ export async function organizeOnce(opts?: { dryRun?: boolean; limit?: number }) // deterministic alternate target instead of replacing them. const safeDst = dryRun ? dst : await resolveCollisionTarget(src, dst); await makeSymlink(src, safeDst, dryRun); + organizedTarget = safeDst; } + if (!dryRun) desiredBySource.set(await organizerSourceKey(src), organizedTarget); processed++; } + if (!dryRun && desiredBySource.size > 0) { + let removedLinks = 0; + let removedDirs = 0; + for (const dir of [movieDir, tvDir, animeDir]) { + const result = await reconcileObsoleteSymlinks(dir, desiredBySource); + removedLinks += result.removedLinks; + removedDirs += result.removedDirs; + } + if (removedLinks || removedDirs) { + console.log(`[${new Date().toISOString()}][organize] reconciled obsolete content`, { + removedSymlinks: removedLinks, + removedEmptyDirs: removedDirs, + }); + } + } + console.log( `[${new Date().toISOString()}][organize] complete`, { count: processed, movies: movieCount, tv: tvCount, unknown: unknownCount, dryRun, organizedBase: config.organizedBase, unknownSamples } diff --git a/tests/unit/services/organizer-filename.test.ts b/tests/unit/services/organizer-filename.test.ts index 86bd4ab5..ae6820a7 100644 --- a/tests/unit/services/organizer-filename.test.ts +++ b/tests/unit/services/organizer-filename.test.ts @@ -6,6 +6,7 @@ import { config } from "../../../src/core/config"; import { computeTarget, makeSymlink, + resolveCollisionTarget, selectOrganizerFilename, type Parsed, } from "../../../src/services/organizer"; @@ -65,4 +66,23 @@ describe("Organizer filename mode", () => { await fs.rm(root, { recursive: true, force: true }); } }); + + test("recognizes the same source through a mount alias", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "schro-organizer-")); + try { + const sourceDir = path.join(root, "source"); + const aliasDir = path.join(root, "alias"); + const source = path.join(sourceDir, "release.mkv"); + const aliasedSource = path.join(aliasDir, "release.mkv"); + const destination = path.join(root, "organized", "Canonical.mkv"); + await fs.mkdir(sourceDir, { recursive: true }); + await fs.writeFile(source, "test"); + await fs.symlink(sourceDir, aliasDir, "dir"); + await makeSymlink(source, destination, false, true); + + expect(await resolveCollisionTarget(aliasedSource, destination)).toBe(destination); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } + }); }); From 0cc64b0b4cebeac2d197d2e1e79aaae4196dfe41 Mon Sep 17 00:00:00 2001 From: samtruman Date: Tue, 13 Oct 2026 10:22:36 +0200 Subject: [PATCH 3/3] fix(organizer): avoid FUSE I/O during source reconciliation --- src/services/organizer.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/services/organizer.ts b/src/services/organizer.ts index 7673e59f..f06c1896 100644 --- a/src/services/organizer.ts +++ b/src/services/organizer.ts @@ -809,6 +809,14 @@ async function organizerSourcesMatch(left: string, right: string): Promise value === mountBase || value.startsWith(mountBase + path.sep); + if (isMountedPath(leftAbsolute) || isMountedPath(rightAbsolute)) return false; + const [leftReal, rightReal] = await Promise.all([ fsp.realpath(leftAbsolute).catch(() => leftAbsolute), fsp.realpath(rightAbsolute).catch(() => rightAbsolute), @@ -818,6 +826,8 @@ async function organizerSourcesMatch(left: string, right: string): Promise { const absolute = path.normalize(path.resolve(source)); + const mountBase = path.normalize(path.resolve(config.mountBase)); + if (absolute === mountBase || absolute.startsWith(mountBase + path.sep)) return absolute; return fsp.realpath(absolute).then((resolved) => path.normalize(resolved)).catch(() => absolute); }