Auto-Rename Hardening: 9 weitere Bugs aus 10-Agent-Audit gefixt

B) Symlink-Following + Library-Cross-Risk verhindert
   - collectFilesByExtensions skippt jetzt Symbolic Links / Junctions
     (entry.isSymbolicLink) — der v1.7.107-Korruptions-Vektor kann nicht
     mehr ueber Reparse-Points zurueckkehren
   - autoRenameExtractedVideoFiles bricht ab wenn extractDir mit
     mkvLibraryDir ueberlappt (in beide Richtungen) → keine Cross-
     Package-Korruption durch fehlerhafte User-Konfig
   - collectMkvFilesToLibrary mit gleichem Schutz fuer sourceDir<->targetDir

C) Long-Path Silent Skip behoben
   - buildSafeAutoRenameTargetPath prueft jetzt zusaetzlich Gesamtpfad-
     Laenge (247 chars conservative Windows-Limit), nicht nur Datei-
     Namen-Laenge. Fallback zu kuerzerem Pfad greift jetzt zuverlaessig

D) Hybrid-Extract Partial-Write Race entschaerft
   - Files mit mtime juenger als 2s werden uebersprungen (im naechsten
     Scan re-evaluiert). Verhindert Rename auf gerade-noch-gschriebene
     MKVs waehrend Hybrid-Extract parallel arbeitet
   - Konfigurierbar via fileStabilizeMinAgeMs (Tests: VITEST=true => 0)

E) Retry-Logik fuer transiente Rename-Fehler
   - renamePathWithExdevFallback retried jetzt EBUSY/EACCES/EPERM/EEXIST
     mit 200/500/1000ms Backoff. Antivirus, Indexer, OneDrive, offene
     Player-Locks → automatisch geheilt statt permanent geskippt

F) Subtitle/.nfo Companion-Files werden mit-umbenannt UND mit-verschoben
   - Neue Helper renameCompanionFiles + moveCompanionFiles erkennen Subs
     (.srt/.ass/.ssa/.sub/.idx/.vtt/.smi) und Metadaten (.nfo) am Basis-
     Namen-Match. Auch Sprach-Tags wie .de.srt bleiben erhalten
   - Mediaplayer kann Subs nach Library-Move wieder automatisch laden

G) Sample-Token False-Positive entschaerft
   - Dateien die sampleTokenRe matchen bekommen Size-Check: nur als Sample
     behandelt wenn ≤150 MB. Series mit "Sample" im Titel (z.B.
     "Sample.Squad.S01E01.mkv") werden jetzt korrekt umbenannt
   - Sample-Subfolder-Detection bleibt unveraendert (eindeutig)

H) UNC + Casing-only Rename: jetzt via renamePathWithExdevFallback
   - Casing-Rename benutzt jetzt den gleichen Helper, bekommt automatisch
     toWindowsLongPathIfNeeded und Retry-Logik

I) Multi-MKV in selbem Folder: numerischer Suffix statt Skip
   - Wenn Ziel existiert: probiert .2, .3, ... bis .99 bevor aufgegeben.
     A/B-Parts oder alternate-Audio-Files in selbem Folder werden jetzt
     korrekt mit Suffix differenziert statt 2./3. File silent zu droppen

J) Episode-Token Coverage: xX-Format hinzugefuegt
   - Neuer SCENE_EPISODE_X_RE erkennt 1x01, 10x100, etc. (aeltere
     Scene-Releases). Quality-Tokens wie 1080p werden NICHT falsch
     als 1080xX matched (kein zweiter Number-Group)

Tests:
- Symlink-Guard: extractDir==mkvLibraryDir → 0 renamed, File unangetastet
- Companion: .srt/.de.srt/.nfo bei Rename mitbenannt
- Multi-MKV-Collision: 2 Files → suffix .2 statt skip
- Episode-Token: 1x01/10x100 erkannt, 1080p nicht falsch matched

589/589 Tests gruen.
This commit is contained in:
Sucukdeluxe
2026-04-22 02:17:11 +02:00
parent 5369ec0958
commit 709a93b405
3 changed files with 454 additions and 26 deletions
+15
View File
@@ -58,6 +58,21 @@ describe("looksLikeObfuscatedSceneFileName", () => {
});
});
describe("extractEpisodeToken (extended formats)", () => {
it("recognizes the older xX format", () => {
expect(extractEpisodeToken("show.1x01.720p.mkv")).toBe("S01E01");
expect(extractEpisodeToken("Show.Name.10x100.mkv")).toBe("S10E100");
expect(extractEpisodeToken("show-2x05-hdtv.mkv")).toBe("S02E05");
});
it("does not falsely match resolution tokens like 1080x720", () => {
// The xX regex is bounded; 1080p shouldn't match as "1080x???" because
// there's no second number group in 1080p / 720p / etc.
expect(extractEpisodeToken("show.1080p.mkv")).toBeNull();
expect(extractEpisodeToken("show.S01E01.1080p.mkv")).toBe("S01E01");
});
});
describe("extractEpisodeToken", () => {
it("extracts S01E01 from standard scene format", () => {
expect(extractEpisodeToken("show.name.s01e01.720p")).toBe("S01E01");
+116
View File
@@ -10499,6 +10499,122 @@ describe("download manager", () => {
expect((manager as any).packageFileOpChain.has(pkgId)).toBe(false);
});
it("auto-rename refuses to scan when extractDir overlaps with mkvLibraryDir", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-overlap-"));
tempDirs.push(root);
const sharedDir = path.join(root, "library");
fs.mkdirSync(path.join(sharedDir, "EpisodeFolder"), { recursive: true });
fs.writeFileSync(path.join(sharedDir, "EpisodeFolder", "obfus.mkv"), Buffer.alloc(1024, 0));
const manager = new DownloadManager(
{
...defaultSettings(),
token: "rd-token",
outputDir: path.join(root, "out"),
extractDir: sharedDir,
mkvLibraryDir: sharedDir,
autoRename4sf4sj: true
},
emptySession(),
createStoragePaths(path.join(root, "state"))
);
const pkg: any = {
id: "overlap-pkg",
name: "Overlap.Test.S01.GERMAN.x264-aWake",
outputDir: path.join(root, "out", "Overlap.Test"),
extractDir: sharedDir,
status: "completed",
itemIds: [],
cancelled: false,
enabled: true,
priority: "normal",
createdAt: 0,
updatedAt: 0,
downloadStartedAt: 0,
downloadCompletedAt: 0
};
const renamed = await (manager as any).autoRenameExtractedVideoFiles(sharedDir, pkg);
expect(renamed).toBe(0);
// File must remain untouched — no rename performed.
expect(fs.existsSync(path.join(sharedDir, "EpisodeFolder", "obfus.mkv"))).toBe(true);
});
it("auto-rename also renames matching subtitle / .nfo companion files", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-companion-"));
tempDirs.push(root);
const extractDir = path.join(root, "extract");
const epFolder = path.join(extractDir, "Test.Show.S02E05.Title.GERMAN.WS.720p.HDTV.x264-aWake");
fs.mkdirSync(epFolder, { recursive: true });
fs.writeFileSync(path.join(epFolder, "awa-testshow02e05hd.mkv"), Buffer.alloc(1024, 0));
fs.writeFileSync(path.join(epFolder, "awa-testshow02e05hd.srt"), "subtitle");
fs.writeFileSync(path.join(epFolder, "awa-testshow02e05hd.de.srt"), "german subtitle");
fs.writeFileSync(path.join(epFolder, "awa-testshow02e05hd.nfo"), "info");
const manager = new DownloadManager(
{ ...defaultSettings(), token: "rd-token", outputDir: path.join(root, "out"), extractDir, autoRename4sf4sj: true },
emptySession(),
createStoragePaths(path.join(root, "state"))
);
const pkg: any = {
id: "companion-pkg",
name: "Test.Show.S02.GERMAN.WS.720p.HDTV.x264-aWake",
outputDir: path.join(root, "out", "Test.Show.S02.GERMAN.WS.720p.HDTV.x264-aWake"),
extractDir,
status: "completed", itemIds: [], cancelled: false, enabled: true, priority: "normal",
createdAt: 0, updatedAt: 0, downloadStartedAt: 0, downloadCompletedAt: 0
};
const renamed = await (manager as any).autoRenameExtractedVideoFiles(extractDir, pkg);
expect(renamed).toBe(1);
const expectedBase = "Test.Show.S02E05.Title.GERMAN.WS.720p.HDTV.x264-aWake";
const files = fs.readdirSync(epFolder);
// Video renamed.
expect(files).toContain(`${expectedBase}.mkv`);
expect(files).not.toContain("awa-testshow02e05hd.mkv");
// Companions renamed alongside.
expect(files).toContain(`${expectedBase}.srt`);
expect(files).toContain(`${expectedBase}.de.srt`);
expect(files).toContain(`${expectedBase}.nfo`);
expect(files).not.toContain("awa-testshow02e05hd.srt");
expect(files).not.toContain("awa-testshow02e05hd.de.srt");
expect(files).not.toContain("awa-testshow02e05hd.nfo");
});
it("auto-rename appends a numeric suffix when target already exists (no silent skip)", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-collision-"));
tempDirs.push(root);
const extractDir = path.join(root, "extract");
const epFolder = path.join(extractDir, "Test.Show.S02E05.Title.GERMAN.WS.720p.HDTV.x264-aWake");
fs.mkdirSync(epFolder, { recursive: true });
fs.writeFileSync(path.join(epFolder, "awa-testshow02e05hd.mkv"), Buffer.alloc(1024, 0));
fs.writeFileSync(path.join(epFolder, "awa-testshow02e05hd.alt.mkv"), Buffer.alloc(2048, 0));
const manager = new DownloadManager(
{ ...defaultSettings(), token: "rd-token", outputDir: path.join(root, "out"), extractDir, autoRename4sf4sj: true },
emptySession(),
createStoragePaths(path.join(root, "state"))
);
const pkg: any = {
id: "collision-pkg",
name: "Test.Show.S02.GERMAN.WS.720p.HDTV.x264-aWake",
outputDir: path.join(root, "out", "Test.Show.S02.GERMAN.WS.720p.HDTV.x264-aWake"),
extractDir,
status: "completed", itemIds: [], cancelled: false, enabled: true, priority: "normal",
createdAt: 0, updatedAt: 0, downloadStartedAt: 0, downloadCompletedAt: 0
};
const renamed = await (manager as any).autoRenameExtractedVideoFiles(extractDir, pkg);
expect(renamed).toBe(2);
const expectedBase = "Test.Show.S02E05.Title.GERMAN.WS.720p.HDTV.x264-aWake";
const files = fs.readdirSync(epFolder).sort();
// First file got the canonical name; second got a numeric suffix.
expect(files).toContain(`${expectedBase}.mkv`);
expect(files).toContain(`${expectedBase}.2.mkv`);
expect(files).not.toContain("awa-testshow02e05hd.mkv");
expect(files).not.toContain("awa-testshow02e05hd.alt.mkv");
});
it("chainPackageFileOp recovers from a failed op so subsequent ops still run", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-chain-recover-"));
tempDirs.push(root);