diff --git a/package.json b/package.json index 97b6b02..2bc2b11 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "real-debrid-downloader", - "version": "1.3.6", + "version": "1.3.7", "description": "Real-Debrid Downloader Desktop (Electron + React + TypeScript)", "main": "build/main/main/main.js", "author": "Sucukdeluxe", diff --git a/src/main/extractor.ts b/src/main/extractor.ts index ada94f0..447ccf9 100644 --- a/src/main/extractor.ts +++ b/src/main/extractor.ts @@ -245,11 +245,76 @@ function extractZipArchive(archivePath: string, targetDir: string, conflictMode: } } +function escapeRegex(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +export function collectArchiveCleanupTargets(sourceArchivePath: string): string[] { + const targets = new Set([sourceArchivePath]); + const dir = path.dirname(sourceArchivePath); + const fileName = path.basename(sourceArchivePath); + + let filesInDir: string[] = []; + try { + filesInDir = fs.readdirSync(dir, { withFileTypes: true }) + .filter((entry) => entry.isFile()) + .map((entry) => entry.name); + } catch { + return Array.from(targets); + } + + const addMatching = (pattern: RegExp): void => { + for (const candidate of filesInDir) { + if (pattern.test(candidate)) { + targets.add(path.join(dir, candidate)); + } + } + }; + + const multipartRar = fileName.match(/^(.*)\.part\d+\.rar$/i); + if (multipartRar) { + const prefix = escapeRegex(multipartRar[1]); + addMatching(new RegExp(`^${prefix}\\.part\\d+\\.rar$`, "i")); + return Array.from(targets); + } + + if (/\.rar$/i.test(fileName)) { + const stem = escapeRegex(fileName.replace(/\.rar$/i, "")); + addMatching(new RegExp(`^${stem}\\.rar$`, "i")); + addMatching(new RegExp(`^${stem}\\.r\\d{2}$`, "i")); + return Array.from(targets); + } + + if (/\.zip$/i.test(fileName)) { + const stem = escapeRegex(fileName.replace(/\.zip$/i, "")); + addMatching(new RegExp(`^${stem}\\.zip$`, "i")); + addMatching(new RegExp(`^${stem}\\.z\\d{2}$`, "i")); + return Array.from(targets); + } + + if (/\.7z$/i.test(fileName)) { + const stem = escapeRegex(fileName.replace(/\.7z$/i, "")); + addMatching(new RegExp(`^${stem}\\.7z$`, "i")); + addMatching(new RegExp(`^${stem}\\.7z\\.\\d{3}$`, "i")); + return Array.from(targets); + } + + return Array.from(targets); +} + function cleanupArchives(sourceFiles: string[], cleanupMode: CleanupMode): void { if (cleanupMode === "none") { return; } - for (const filePath of sourceFiles) { + + const targets = new Set(); + for (const sourceFile of sourceFiles) { + for (const target of collectArchiveCleanupTargets(sourceFile)) { + targets.add(target); + } + } + + for (const filePath of targets) { try { fs.rmSync(filePath, { force: true }); } catch { diff --git a/tests/extractor.test.ts b/tests/extractor.test.ts index 42f3fd1..1c4f277 100644 --- a/tests/extractor.test.ts +++ b/tests/extractor.test.ts @@ -3,7 +3,7 @@ import os from "node:os"; import path from "node:path"; import AdmZip from "adm-zip"; import { afterEach, describe, expect, it } from "vitest"; -import { buildExternalExtractArgs, extractPackageArchives } from "../src/main/extractor"; +import { buildExternalExtractArgs, collectArchiveCleanupTargets, extractPackageArchives } from "../src/main/extractor"; const tempDirs: string[] = []; @@ -71,6 +71,66 @@ describe("extractor", () => { expect(fs.existsSync(path.join(targetDir, "release.txt"))).toBe(true); }); + it("collects companion rar parts for cleanup", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-")); + tempDirs.push(root); + const packageDir = path.join(root, "pkg"); + fs.mkdirSync(packageDir, { recursive: true }); + + const part1 = path.join(packageDir, "show.s01e01.part01.rar"); + const part2 = path.join(packageDir, "show.s01e01.part02.rar"); + const part3 = path.join(packageDir, "show.s01e01.part03.rar"); + const other = path.join(packageDir, "other.s01e01.part01.rar"); + + fs.writeFileSync(part1, "a", "utf8"); + fs.writeFileSync(part2, "b", "utf8"); + fs.writeFileSync(part3, "c", "utf8"); + fs.writeFileSync(other, "x", "utf8"); + + const targets = new Set(collectArchiveCleanupTargets(part1)); + expect(targets.has(part1)).toBe(true); + expect(targets.has(part2)).toBe(true); + expect(targets.has(part3)).toBe(true); + expect(targets.has(other)).toBe(false); + }); + + it("deletes split zip companion parts when cleanup is enabled", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-")); + tempDirs.push(root); + const packageDir = path.join(root, "pkg"); + const targetDir = path.join(root, "out"); + fs.mkdirSync(packageDir, { recursive: true }); + + const zipPath = path.join(packageDir, "season.zip"); + const z01Path = path.join(packageDir, "season.z01"); + const z02Path = path.join(packageDir, "season.z02"); + const otherPath = path.join(packageDir, "other.z01"); + + const zip = new AdmZip(); + zip.addFile("episode.txt", Buffer.from("ok")); + zip.writeZip(zipPath); + fs.writeFileSync(z01Path, "part1", "utf8"); + fs.writeFileSync(z02Path, "part2", "utf8"); + fs.writeFileSync(otherPath, "keep", "utf8"); + + const result = await extractPackageArchives({ + packageDir, + targetDir, + cleanupMode: "delete", + conflictMode: "overwrite", + removeLinks: false, + removeSamples: false + }); + + expect(result.extracted).toBe(1); + expect(result.failed).toBe(0); + expect(fs.existsSync(zipPath)).toBe(false); + expect(fs.existsSync(z01Path)).toBe(false); + expect(fs.existsSync(z02Path)).toBe(false); + expect(fs.existsSync(otherPath)).toBe(true); + expect(fs.existsSync(path.join(targetDir, "episode.txt"))).toBe(true); + }); + it("treats ask conflict mode as skip in zip extraction", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-")); tempDirs.push(root);