chore: remove all source code comments and internal artifacts
Strip every comment from the source (parsed with the TypeScript compiler so strings, template literals, regex literals and JSX are never touched), and drop internal/working artifacts that do not belong in the public repository (design mockups, internal analysis docs, a stray backup file and an old log). No functional change: build is green, the full test suite passes.
This commit is contained in:
@@ -21,7 +21,7 @@ function mockFetchOnce(status: number, body: unknown): void {
|
||||
})) as unknown as typeof fetch);
|
||||
}
|
||||
|
||||
const NOW = 1_700_000_000_000; // fixed epoch ms
|
||||
const NOW = 1_700_000_000_000;
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
@@ -29,7 +29,7 @@ afterEach(() => {
|
||||
|
||||
describe("checkMegaDebridAccount", () => {
|
||||
it("reports valid + premium from vip_end (future Unix ts)", async () => {
|
||||
const futureSec = Math.floor(NOW / 1000) + 30 * 24 * 60 * 60; // +30 days
|
||||
const futureSec = Math.floor(NOW / 1000) + 30 * 24 * 60 * 60;
|
||||
mockFetchOnce(200, { response_code: "ok", response_text: "User logged", token: "t", vip_end: String(futureSec), email: "a@b.de" });
|
||||
const st = await checkMegaDebridAccount(megaAccount(), undefined, NOW);
|
||||
expect(st.valid).toBe(true);
|
||||
@@ -80,7 +80,7 @@ describe("checkMegaDebridAccount", () => {
|
||||
|
||||
describe("checkDebridLinkKey", () => {
|
||||
it("reports valid + premium from premiumLeft seconds", async () => {
|
||||
const premiumLeft = 60 * 24 * 60 * 60; // 60 days in seconds
|
||||
const premiumLeft = 60 * 24 * 60 * 60;
|
||||
mockFetchOnce(200, { success: true, value: { username: "u", accountType: 1, premiumLeft } });
|
||||
const st = await checkDebridLinkKey(debridLinkKey(), undefined, NOW);
|
||||
expect(st.valid).toBe(true);
|
||||
@@ -118,7 +118,6 @@ describe("checkAllDebridAccounts", () => {
|
||||
});
|
||||
|
||||
it("checks every configured mega account + debrid-link key", async () => {
|
||||
// All requests succeed as valid premium
|
||||
const futureSec = Math.floor(Date.now() / 1000) + 1000;
|
||||
vi.stubGlobal("fetch", vi.fn(async (url: string) => {
|
||||
if (String(url).includes("mega-debrid")) {
|
||||
@@ -134,7 +133,7 @@ describe("checkAllDebridAccounts", () => {
|
||||
} as unknown as AppSettings;
|
||||
|
||||
const result = await checkAllDebridAccounts(settings);
|
||||
expect(result).toHaveLength(5); // 2 mega + 3 debrid-link
|
||||
expect(result).toHaveLength(5);
|
||||
expect(result.filter((r) => r.provider === "megadebrid")).toHaveLength(2);
|
||||
expect(result.filter((r) => r.provider === "debridlink")).toHaveLength(3);
|
||||
expect(result.every((r) => r.valid)).toBe(true);
|
||||
|
||||
@@ -10,7 +10,6 @@ describe("rotation item-sink (AsyncLocalStorage)", () => {
|
||||
logAccountRotation("WARN", "Mega-Debrid Web", "Account 1/3 (ab**xy)", "FAILED", { reason: "Timeout", cooldownSec: 30, next: "Account 2/3 (cd**zw)" });
|
||||
logAccountRotation("INFO", "Mega-Debrid Web", "Account 2/3 (cd**zw)", "TEST", { link: "x" });
|
||||
logAccountRotation("INFO", "Mega-Debrid Web", "Account 2/3 (cd**zw)", "OK", { fileName: "f.mkv" });
|
||||
// simulate an await boundary — ALS must survive it
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
@@ -23,7 +22,6 @@ describe("rotation item-sink (AsyncLocalStorage)", () => {
|
||||
|
||||
it("does not leak events to the sink outside the run() scope", () => {
|
||||
const captured: RotationEvent[] = [];
|
||||
// No active sink here
|
||||
logAccountRotation("INFO", "Debrid-Link", "Key 1/2 (k1)", "OK");
|
||||
expect(captured).toHaveLength(0);
|
||||
});
|
||||
@@ -43,7 +41,6 @@ describe("rotation item-sink (AsyncLocalStorage)", () => {
|
||||
logAccountRotation("WARN", "Debrid-Link", "Key 1 (b)", "FAILED", { reason: "badToken" });
|
||||
})
|
||||
]);
|
||||
// Each sink only saw its own provider's events
|
||||
expect(a.every((e) => e.provider === "Mega-Debrid Web")).toBe(true);
|
||||
expect(b.every((e) => e.provider === "Debrid-Link")).toBe(true);
|
||||
expect(a.map((e) => e.event)).toEqual(["TEST", "OK"]);
|
||||
@@ -54,7 +51,6 @@ describe("rotation item-sink (AsyncLocalStorage)", () => {
|
||||
logAccountRotation("INFO", "Mega-Debrid API", "Account 9 (zz)", "TEST");
|
||||
logAccountRotation("INFO", "Mega-Debrid API", "Account 9 (zz)", "OK", { fileName: "ring.mkv" });
|
||||
const ring = getRecentRotationEvents(10);
|
||||
// OK is in the ring; the TEST marker is filtered out of the panel
|
||||
expect(ring.some((e) => e.event === "OK" && e.accountLabel === "Account 9 (zz)")).toBe(true);
|
||||
expect(ring.some((e) => e.event === "TEST" && e.accountLabel === "Account 9 (zz)")).toBe(false);
|
||||
});
|
||||
|
||||
@@ -14,10 +14,6 @@ import {
|
||||
} from "../src/main/download-manager";
|
||||
|
||||
describe("decideAutoRenameBaseName (shared naming decision — used by auto-rename AND mkv-collect)", () => {
|
||||
// Characterization corpus: pins the EXACT decision for the real failures from
|
||||
// rename-session_2026-06-02 (17 raw files that mkv-move moved un-renamed) plus
|
||||
// the guard cases. mkv-collect now routes through this same function so a file
|
||||
// auto-rename missed still lands clean in the library.
|
||||
|
||||
it("derives the clean name for a Herzflimmern episode from the per-episode folder (S07E12 — the reported failure)", () => {
|
||||
const source = "tvarchiv.herzflimmern.die.klinik.am.see.s07e12-720.mkv";
|
||||
@@ -78,7 +74,6 @@ describe("decideAutoRenameBaseName (shared naming decision — used by auto-rena
|
||||
});
|
||||
|
||||
it("GUARD: lets the parent folder token override an OBFUSCATED source filename (anti-piracy scramble)", () => {
|
||||
// Obfuscated file (E16) inside an explicitly-named E01 folder → trust the folder.
|
||||
const decision = decideAutoRenameBaseName(
|
||||
["Die.Thundermans.S02E01.Der.Thunder.Van.GERMAN.x264-aWake"],
|
||||
"awa-diethundermans02e16hd.mkv",
|
||||
@@ -91,7 +86,6 @@ describe("decideAutoRenameBaseName (shared naming decision — used by auto-rena
|
||||
});
|
||||
|
||||
it("GUARD: a CLEAN scene source is NEVER overridden by a mismatching folder token (folder is wrong, not the file)", () => {
|
||||
// Clean source S01E09 in a folder that says E08 → must NOT rename to E08.
|
||||
const decision = decideAutoRenameBaseName(
|
||||
["The.Royals.2015.S01E08.German.DL.720p.BluRay.x264-iNTENTiON"],
|
||||
"the.royals.2015.s01e09.german.dl.720p.bluray.x264-j4f.mkv",
|
||||
@@ -115,9 +109,6 @@ describe("decideAutoRenameBaseName (shared naming decision — used by auto-rena
|
||||
});
|
||||
|
||||
it("uses the CLEAN per-episode folder (scene group WITH underscore, e.g. -idTV_iNT) — not the obfuscated package folder", () => {
|
||||
// User-Report v1.7.178: castle.s08e02....mkv im sauberen Ordner "Castle.S08E02...H264-idTV_iNT"
|
||||
// (Paket: "scn2-cstl7") wurde zu "scn2-cstl7.S08E02" verschlimmbessert, weil hasSceneGroupSuffix
|
||||
// die Unterstrich-Gruppe "-idTV_iNT" nicht erkannte und auf den Paketordner zurueckfiel.
|
||||
const epFolder = "Castle.S08E02.GERMAN.DL.720p.WEB.H264-idTV_iNT";
|
||||
const decision = decideAutoRenameBaseName(
|
||||
[epFolder, "scn2-cstl7"],
|
||||
@@ -131,9 +122,6 @@ describe("decideAutoRenameBaseName (shared naming decision — used by auto-rena
|
||||
});
|
||||
|
||||
it("uses the complete per-episode folder when the SOURCE has no SxxExx token (bare 'Folge 01' format)", () => {
|
||||
// User-Report: "Kreuzfahrt ins Glück" — Datei "bet_kig_01_hdt.mkv" (kein SxxExx-Token),
|
||||
// Episoden-Ordner nummeriert mit "01" statt S01E01. Frueher "kein Zielname" -> roh in die
|
||||
// Library. Jetzt: der vollstaendige Release-Ordnername wird direkt verwendet.
|
||||
const folders = [
|
||||
"Kreuzfahrt.ins.Glueck.01.Hochzeitsreise.nach.Burma.2007.German.720p.HDTV.x264-BET",
|
||||
"kig.hdtv.7p-001",
|
||||
@@ -189,14 +177,12 @@ describe("hasMeaningfulSeriesPrefix", () => {
|
||||
|
||||
describe("looksLikeObfuscatedSceneFileName", () => {
|
||||
it("flags hoster-obfuscated names with no scene markers as obfuscated", () => {
|
||||
// No 720p / german / x264 / bluray, no dot-separated structure
|
||||
expect(looksLikeObfuscatedSceneFileName("awa-diethundermans02e16hd.mkv")).toBe(true);
|
||||
expect(looksLikeObfuscatedSceneFileName("scn-dthund7-S02E06.mkv")).toBe(true);
|
||||
expect(looksLikeObfuscatedSceneFileName("4sj-blue-bloods-s08e21-720p.mkv")).toBe(true);
|
||||
});
|
||||
|
||||
it("treats clean scene releases with multiple markers as NOT obfuscated", () => {
|
||||
// Has 720p + german + bluray + x264 — clearly a clean scene file
|
||||
expect(looksLikeObfuscatedSceneFileName("the.royals.2015.s01e09.german.dl.720p.bluray.x264-j4f.mkv")).toBe(false);
|
||||
expect(looksLikeObfuscatedSceneFileName("Die.Thundermans.S02E06.Tickets.und.Shreddy.GERMAN.WS.720p.HDTV.x264-aWake.mkv")).toBe(false);
|
||||
expect(looksLikeObfuscatedSceneFileName("Desperate.Housewives.S01E01.German.Synced.DL.720p.WEB-DL.AC3.h264.mkv")).toBe(false);
|
||||
@@ -208,7 +194,6 @@ describe("looksLikeObfuscatedSceneFileName", () => {
|
||||
});
|
||||
|
||||
it("treats long dotted names as scene-style even with few markers", () => {
|
||||
// 6+ dots → looks like scene structure even without quality/codec markers
|
||||
expect(looksLikeObfuscatedSceneFileName("Some.Show.With.Many.Tokens.S01E01.mkv")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -218,31 +203,22 @@ describe("extractEpisodeToken (extended formats)", () => {
|
||||
expect(extractEpisodeToken("show.1x01.720p.mkv")).toBe("S01E01");
|
||||
expect(extractEpisodeToken("show-2x05-hdtv.mkv")).toBe("S02E05");
|
||||
expect(extractEpisodeToken("Show.Name.10x99.mkv")).toBe("S10E99");
|
||||
// 3-digit episode in xX format is intentionally NOT supported — would
|
||||
// collide with codec tokens (x264/x265/x266). 3-digit episodes still
|
||||
// work in the modern SxxEnnn format which has explicit S/E delimiters.
|
||||
expect(extractEpisodeToken("Show.Name.10x100.mkv")).toBeNull();
|
||||
expect(extractEpisodeToken("Show.Name.S10E100.mkv")).toBe("S10E100");
|
||||
});
|
||||
|
||||
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");
|
||||
});
|
||||
|
||||
it("does not falsely match codec tokens like x264 / x265 (caps episode digits)", () => {
|
||||
// First number 5, second number capped to 2 digits → "5x265" CANNOT
|
||||
// match because 265 has 3 digits. Same for x264, x266, h264, h265.
|
||||
expect(extractEpisodeToken("Movie.x264-GROUP.mkv")).toBeNull();
|
||||
expect(extractEpisodeToken("Movie.5x265.x265.mkv")).toBeNull();
|
||||
// SxxExx still wins ahead of phantom xX matches.
|
||||
expect(extractEpisodeToken("Show.S01E01.x265-GROUP.mkv")).toBe("S01E01");
|
||||
});
|
||||
|
||||
it("does not falsely match common aspect ratios like 1920x1080", () => {
|
||||
// 1920 has 4 digits, first group capped at 2 → no match.
|
||||
expect(extractEpisodeToken("Movie.1920x1080.mkv")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -491,7 +467,6 @@ describe("buildAutoRenameBaseName", () => {
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
// Edge cases
|
||||
it("handles 2160p quality token", () => {
|
||||
const result = buildAutoRenameBaseName("Show.S01.2160p-4sf", "show.s01e01.rp.2160p.mkv");
|
||||
expect(result).toBe("Show.S01E01.REPACK.2160p-4sf");
|
||||
@@ -509,12 +484,10 @@ describe("buildAutoRenameBaseName", () => {
|
||||
|
||||
it("handles high season and episode numbers", () => {
|
||||
const result = buildAutoRenameBaseName("Show.S99.720p-4sf", "show.s99e999.720p.mkv");
|
||||
// SCENE_EPISODE_RE allows up to 3-digit episodes and 2-digit seasons
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!).toContain("S99E999");
|
||||
});
|
||||
|
||||
// Real-world scene release patterns
|
||||
it("real-world: German series with dots", () => {
|
||||
const result = buildAutoRenameBaseName(
|
||||
"Der.Bergdoktor.S18.German.720p.WEB.x264-4SJ",
|
||||
@@ -579,18 +552,13 @@ describe("buildAutoRenameBaseName", () => {
|
||||
expect(result).toBe("Cobra.Kai.S06E14.720p.NF.WEB-DL.DDP5.1.x264-4SF");
|
||||
});
|
||||
|
||||
// Bug-hunting edge cases
|
||||
it("source filename extension is not included in episode detection", () => {
|
||||
// The sourceFileName passed to buildAutoRenameBaseName is the basename without extension
|
||||
// so .mkv should not interfere, but let's verify with an actual extension
|
||||
const result = buildAutoRenameBaseName("Show.S01-4sf", "show.s01e01.mkv");
|
||||
// "mkv" should not be treated as part of the filename match
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!).toContain("S01E01");
|
||||
});
|
||||
|
||||
it("does not match episode-like patterns in codec strings", () => {
|
||||
// h.265 has digits but should not be confused with episode tokens
|
||||
const token = extractEpisodeToken("show.s01e01.h.265");
|
||||
expect(token).toBe("S01E01");
|
||||
});
|
||||
@@ -608,23 +576,19 @@ describe("buildAutoRenameBaseName", () => {
|
||||
"Show.S01E05.720p-4sf",
|
||||
"show.s01e05.720p"
|
||||
);
|
||||
// Must NOT produce "Show.S01E05.720p.S01E05-4sf" (double episode bug)
|
||||
expect(result).toBe("Show.S01E05.720p-4sf");
|
||||
});
|
||||
|
||||
it("handles folder with only -4sf suffix (edge case)", () => {
|
||||
const result = buildAutoRenameBaseName("-4sf", "show.s01e01.mkv");
|
||||
// Extreme edge case - sanitizeFilename trims leading dots
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!).toContain("S01E01");
|
||||
expect(result!).toContain("-4sf");
|
||||
expect(result!).not.toContain(".S01E01.S01E01"); // no duplication
|
||||
expect(result!).not.toContain(".S01E01.S01E01");
|
||||
});
|
||||
|
||||
it("sanitizes special characters from result", () => {
|
||||
// sanitizeFilename should strip dangerous chars
|
||||
const result = buildAutoRenameBaseName("Show:Name.S01-4sf", "show.s01e01.mkv");
|
||||
// The colon should be sanitized away
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!).not.toContain(":");
|
||||
});
|
||||
@@ -888,7 +852,6 @@ describe("buildAutoRenameBaseNameFromFolders", () => {
|
||||
expect(result).toBe("Mammon.S01E05E06.German.1080P.Bluray.x264-SMAHD");
|
||||
});
|
||||
|
||||
// Last-resort fallback: folder has season but no scene group suffix (user-renamed packages)
|
||||
it("renames when folder has season but no scene group suffix (Mystery Road case)", () => {
|
||||
const result = buildAutoRenameBaseNameFromFoldersWithOptions(
|
||||
["Mystery Road S02"],
|
||||
@@ -916,7 +879,6 @@ describe("buildAutoRenameBaseNameFromFolders", () => {
|
||||
"myst.road.de.dl.hdtv.7p-s02e05",
|
||||
{ forceEpisodeForSeasonFolder: true }
|
||||
);
|
||||
// Should use the scene-group folder (hrs), not the custom one
|
||||
expect(result).toBe("Mystery.Road.S02E05.GERMAN.DL.AC3.720p.HDTV.x264-hrs");
|
||||
});
|
||||
|
||||
@@ -1002,11 +964,6 @@ describe("buildAutoRenameBaseNameFromFolders", () => {
|
||||
});
|
||||
|
||||
it("documents malformed package name (S01GERMAN) limitation", () => {
|
||||
// Real-world: "Drei.Meter.ueber.dem.Himmel.S01GERMAN.DL.720P.WEB.X264-WAYNE"
|
||||
// is malformed (no separator between S01 and GERMAN). SCENE_SEASON_ONLY_RE
|
||||
// doesn't match this, so the helper falls back to the package name as-is.
|
||||
// The download-manager autoRenameExtractedVideoFiles safety net repairs
|
||||
// this at runtime by inserting the source's episode token.
|
||||
const result = buildAutoRenameBaseNameFromFoldersWithOptions(
|
||||
[
|
||||
"3MH.web.7p-101",
|
||||
@@ -1015,8 +972,6 @@ describe("buildAutoRenameBaseNameFromFolders", () => {
|
||||
"Drei.Meter.ueber.dem.Himmel.S01E01.GERMAN.DL.720P.WEB.X264-WAYNE",
|
||||
{ forceEpisodeForSeasonFolder: true }
|
||||
);
|
||||
// Helper limitation: returns the malformed folder name unchanged.
|
||||
// The download-manager safety net catches this at runtime.
|
||||
if (result !== null) {
|
||||
expect(typeof result).toBe("string");
|
||||
}
|
||||
@@ -1027,7 +982,6 @@ describe("isBonusContent (numbered episodes are never bonus)", () => {
|
||||
const pkgDir = "/pkg/Show.S04.GERMAN.DL.720p.WEB.x264-GRP";
|
||||
|
||||
it("does NOT treat a numbered episode as bonus even when its TITLE is a bonus word", () => {
|
||||
// Der gemeldete Bug: Revenge.2011.S04E19.Interview wurde als Bonus verworfen.
|
||||
const name = "Revenge.2011.S04E19.Interview.GERMAN.DL.720p.WEB.x264-TSCC";
|
||||
const fp = `${pkgDir}/${name}/${name}.mkv`;
|
||||
expect(isBonusContent(fp, pkgDir, name)).toBe(false);
|
||||
@@ -1066,9 +1020,6 @@ describe("complete episode folder WITHOUT group suffix (codec/resolution only)",
|
||||
const hash = "c284d9d9072eaf3ac314d05f951dd115";
|
||||
|
||||
it("uses the clean folder name when it has an episode token + codec but no -GROUP (safari S04E08a)", () => {
|
||||
// Echter Bug (rename-session 2026-06-04): alte deutsche Doku ohne Gruppen-Suffix.
|
||||
// Ordner endet auf ".XviD" (kein "-GROUP") -> buildAutoRenameBaseName lieferte null ->
|
||||
// "kein Zielname" -> Datei landete roh als "safari-fm-s04e08a.avi" in der Library.
|
||||
const folder = "Fluss-Monster.S04E08a.Am.Essequibo.Teil.1.German.DOKU.SATRiP.XviD";
|
||||
const decision = decideAutoRenameBaseName([folder, hash], "safari-fm-s04e08a.avi", "safari-fm-s04e08a", hash, hash);
|
||||
expect(decision).toEqual({ kind: "rename", baseName: folder });
|
||||
@@ -1081,7 +1032,6 @@ describe("complete episode folder WITHOUT group suffix (codec/resolution only)",
|
||||
const db = decideAutoRenameBaseName([fb, hash], "safari-fm-s04e08b.avi", "safari-fm-s04e08b", hash, hash);
|
||||
expect(da).toEqual({ kind: "rename", baseName: fa });
|
||||
expect(db).toEqual({ kind: "rename", baseName: fb });
|
||||
// Verschiedene Zielnamen -> keine "(2)"-Kollision beim Sammeln.
|
||||
expect((da as any).baseName).not.toBe((db as any).baseName);
|
||||
});
|
||||
|
||||
@@ -1092,8 +1042,6 @@ describe("complete episode folder WITHOUT group suffix (codec/resolution only)",
|
||||
});
|
||||
|
||||
it("does NOT use a bare episode folder WITHOUT any codec/resolution marker (stays conservative)", () => {
|
||||
// "Show.S01E01" allein (keine Qualitaets-/Codec-Info, kein -GROUP) ist mehrdeutig ->
|
||||
// weiterhin kein Ableiten (kein Over-Firing auf generische Ordner).
|
||||
const decision = decideAutoRenameBaseName(["Show.S01E01", hash], "abc-s01e01.avi", "abc-s01e01", hash, hash);
|
||||
expect(decision.kind).toBe("skip");
|
||||
});
|
||||
@@ -1106,11 +1054,6 @@ describe("complete episode folder WITHOUT group suffix (codec/resolution only)",
|
||||
|
||||
describe("collect must not mangle an already-clean SxxExx name via an episode-title folder", () => {
|
||||
const hash = "c284d9d9072eaf3ac314d05f951dd115";
|
||||
// Echter Bug (rename-session 2026-06-05): Miniserie "Steven Spielbergs Taken". Auto-Rename
|
||||
// benannte korrekt zu "...S01E01...-GTVG". Der per-Episode-Ordner traegt aber nur einen
|
||||
// Episode-only-Token + Titel ("...E01.Hinter.dem.Himmel...-GTVG", KEIN S01). Der Collect leitete
|
||||
// daraus neu ab und HAENGTE den Quell-Token verkrueppelt an: "...-GTVG.S01E01" → in der Library
|
||||
// stand dann "E01.Titel...S01E01" statt sauber "S01E01".
|
||||
const epFolder = "Steven.Spielbergs.Taken.E01.Hinter.dem.Himmel.German.720p.HDTV.x264-GTVG";
|
||||
const pkgFolder = "Steven.Spielbergs.Taken.S01.German.720p.HDTV.x264-GTVG";
|
||||
const cleanSource = "Steven.Spielbergs.Taken.S01E01.German.720p.HDTV.x264-GTVG";
|
||||
@@ -1118,14 +1061,10 @@ describe("collect must not mangle an already-clean SxxExx name via an episode-ti
|
||||
it("keeps the clean source (skip) instead of appending the token to the episode-title folder", () => {
|
||||
const decision = decideAutoRenameBaseName([epFolder, pkgFolder], cleanSource + ".mkv", cleanSource, epFolder, pkgFolder);
|
||||
expect(decision.kind).toBe("skip");
|
||||
// NICHT der verkrueppelte "...-GTVG.S01E01"-Name.
|
||||
expect(JSON.stringify(decision)).not.toContain("GTVG.S01E01");
|
||||
});
|
||||
|
||||
it("still cleans a JUNK/obfuscated source via an episode-title folder (append path intact, no skip)", () => {
|
||||
// Obfuskierter Hoster-Name (obf=true) → meine Klausel greift NICHT (sie behaelt nur saubere
|
||||
// Quellen). Mit Season-Ordner als Kontext (Root-Guard ok) wird der Token weiter angewandt:
|
||||
// die Quelle wird NICHT roh behalten. Pruegt, dass der Fix nicht zu breit ist.
|
||||
const epFolder = "Show.E05.Die.Sache.German.720p.HDTV.x264-GRP";
|
||||
const seasonFolder = "Show.S01.German.720p.HDTV.x264-GRP";
|
||||
const decision = decideAutoRenameBaseName([epFolder, seasonFolder], "scn-show7-S01E05.mkv", "scn-show7-S01E05", epFolder, seasonFolder);
|
||||
@@ -1140,9 +1079,6 @@ describe("collect must not mangle an already-clean SxxExx name via an episode-ti
|
||||
});
|
||||
|
||||
it("keeps a clean SHORT-prefix series source (ER) instead of the crippled token append", () => {
|
||||
// Adversarial-Befund: die Praefix-Laenge darf KEIN Kriterium sein. Kurze Serien (ER, V, 24, Yu)
|
||||
// sind genauso autoritativ; mit dem alten `hasMeaningfulSeriesPrefix`-Konjunkt (>=3 Alpha vor S0x)
|
||||
// waere ER durchgefallen -> selber verkrueppelter Name wie der gemeldete Bug.
|
||||
const epFolder = "ER.E01.Tag.und.Nacht.German.720p.HDTV.x264-GROUP";
|
||||
const seasonFolder = "ER.S01.German.720p.HDTV.x264-GROUP";
|
||||
const cleanSource = "ER.S01E01.German.720p.HDTV.x264-GROUP";
|
||||
|
||||
+82
-86
@@ -1,86 +1,82 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { encryptBackup, decryptBackup } from "../src/main/backup-crypto";
|
||||
|
||||
describe("backup-crypto", () => {
|
||||
it("encrypts and decrypts a round-trip correctly", () => {
|
||||
const original = JSON.stringify({
|
||||
version: 2,
|
||||
settings: { token: "my-secret-api-key", outputDir: "C:\\Downloads" },
|
||||
session: { packages: {}, items: {} },
|
||||
history: [{ id: "h1", name: "Test" }]
|
||||
});
|
||||
|
||||
const encrypted = encryptBackup(original);
|
||||
const decrypted = decryptBackup(encrypted);
|
||||
expect(decrypted).toBe(original);
|
||||
});
|
||||
|
||||
it("produces binary output that is not plaintext readable", () => {
|
||||
const secret = "super-secret-token-12345";
|
||||
const plaintext = JSON.stringify({ settings: { token: secret } });
|
||||
const encrypted = encryptBackup(plaintext);
|
||||
|
||||
// The encrypted buffer should NOT contain the secret in plaintext
|
||||
expect(encrypted.toString("utf8")).not.toContain(secret);
|
||||
expect(encrypted.toString("latin1")).not.toContain(secret);
|
||||
});
|
||||
|
||||
it("starts with the MDD1 magic bytes", () => {
|
||||
const encrypted = encryptBackup("test");
|
||||
expect(encrypted.subarray(0, 4).toString("utf8")).toBe("MDD1");
|
||||
});
|
||||
|
||||
it("produces different ciphertext for the same input (random IV)", () => {
|
||||
const plaintext = "same input data";
|
||||
const a = encryptBackup(plaintext);
|
||||
const b = encryptBackup(plaintext);
|
||||
// IVs are different, so full buffers must differ
|
||||
expect(a.equals(b)).toBe(false);
|
||||
// But both decrypt to the same plaintext
|
||||
expect(decryptBackup(a)).toBe(plaintext);
|
||||
expect(decryptBackup(b)).toBe(plaintext);
|
||||
});
|
||||
|
||||
it("throws on truncated data", () => {
|
||||
const encrypted = encryptBackup("test data");
|
||||
const truncated = encrypted.subarray(0, 10);
|
||||
expect(() => decryptBackup(truncated)).toThrow();
|
||||
});
|
||||
|
||||
it("throws on corrupted ciphertext", () => {
|
||||
const encrypted = encryptBackup("test data");
|
||||
// Flip a byte in the ciphertext area
|
||||
const corrupted = Buffer.from(encrypted);
|
||||
corrupted[corrupted.length - 1] ^= 0xff;
|
||||
expect(() => decryptBackup(corrupted)).toThrow();
|
||||
});
|
||||
|
||||
it("throws on wrong magic bytes", () => {
|
||||
const encrypted = encryptBackup("test data");
|
||||
const wrongMagic = Buffer.from(encrypted);
|
||||
wrongMagic[0] = 0x00;
|
||||
expect(() => decryptBackup(wrongMagic)).toThrow(/Signatur/);
|
||||
});
|
||||
|
||||
it("throws on empty buffer", () => {
|
||||
expect(() => decryptBackup(Buffer.alloc(0))).toThrow();
|
||||
});
|
||||
|
||||
it("handles large payloads", () => {
|
||||
const large = JSON.stringify({ data: "x".repeat(1_000_000) });
|
||||
const encrypted = encryptBackup(large);
|
||||
const decrypted = decryptBackup(encrypted);
|
||||
expect(decrypted).toBe(large);
|
||||
});
|
||||
|
||||
it("handles unicode content", () => {
|
||||
const unicode = JSON.stringify({ name: "Ünïcödé 日本語 🎉", path: "C:\\Benutzer\\Ö" });
|
||||
const encrypted = encryptBackup(unicode);
|
||||
expect(decryptBackup(encrypted)).toBe(unicode);
|
||||
});
|
||||
|
||||
it("handles empty string round-trip", () => {
|
||||
const encrypted = encryptBackup("");
|
||||
expect(decryptBackup(encrypted)).toBe("");
|
||||
});
|
||||
});
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { encryptBackup, decryptBackup } from "../src/main/backup-crypto";
|
||||
|
||||
describe("backup-crypto", () => {
|
||||
it("encrypts and decrypts a round-trip correctly", () => {
|
||||
const original = JSON.stringify({
|
||||
version: 2,
|
||||
settings: { token: "my-secret-api-key", outputDir: "C:\\Downloads" },
|
||||
session: { packages: {}, items: {} },
|
||||
history: [{ id: "h1", name: "Test" }]
|
||||
});
|
||||
|
||||
const encrypted = encryptBackup(original);
|
||||
const decrypted = decryptBackup(encrypted);
|
||||
expect(decrypted).toBe(original);
|
||||
});
|
||||
|
||||
it("produces binary output that is not plaintext readable", () => {
|
||||
const secret = "super-secret-token-12345";
|
||||
const plaintext = JSON.stringify({ settings: { token: secret } });
|
||||
const encrypted = encryptBackup(plaintext);
|
||||
|
||||
expect(encrypted.toString("utf8")).not.toContain(secret);
|
||||
expect(encrypted.toString("latin1")).not.toContain(secret);
|
||||
});
|
||||
|
||||
it("starts with the MDD1 magic bytes", () => {
|
||||
const encrypted = encryptBackup("test");
|
||||
expect(encrypted.subarray(0, 4).toString("utf8")).toBe("MDD1");
|
||||
});
|
||||
|
||||
it("produces different ciphertext for the same input (random IV)", () => {
|
||||
const plaintext = "same input data";
|
||||
const a = encryptBackup(plaintext);
|
||||
const b = encryptBackup(plaintext);
|
||||
expect(a.equals(b)).toBe(false);
|
||||
expect(decryptBackup(a)).toBe(plaintext);
|
||||
expect(decryptBackup(b)).toBe(plaintext);
|
||||
});
|
||||
|
||||
it("throws on truncated data", () => {
|
||||
const encrypted = encryptBackup("test data");
|
||||
const truncated = encrypted.subarray(0, 10);
|
||||
expect(() => decryptBackup(truncated)).toThrow();
|
||||
});
|
||||
|
||||
it("throws on corrupted ciphertext", () => {
|
||||
const encrypted = encryptBackup("test data");
|
||||
const corrupted = Buffer.from(encrypted);
|
||||
corrupted[corrupted.length - 1] ^= 0xff;
|
||||
expect(() => decryptBackup(corrupted)).toThrow();
|
||||
});
|
||||
|
||||
it("throws on wrong magic bytes", () => {
|
||||
const encrypted = encryptBackup("test data");
|
||||
const wrongMagic = Buffer.from(encrypted);
|
||||
wrongMagic[0] = 0x00;
|
||||
expect(() => decryptBackup(wrongMagic)).toThrow(/Signatur/);
|
||||
});
|
||||
|
||||
it("throws on empty buffer", () => {
|
||||
expect(() => decryptBackup(Buffer.alloc(0))).toThrow();
|
||||
});
|
||||
|
||||
it("handles large payloads", () => {
|
||||
const large = JSON.stringify({ data: "x".repeat(1_000_000) });
|
||||
const encrypted = encryptBackup(large);
|
||||
const decrypted = decryptBackup(encrypted);
|
||||
expect(decrypted).toBe(large);
|
||||
});
|
||||
|
||||
it("handles unicode content", () => {
|
||||
const unicode = JSON.stringify({ name: "Ünïcödé 日本語 🎉", path: "C:\\Benutzer\\Ö" });
|
||||
const encrypted = encryptBackup(unicode);
|
||||
expect(decryptBackup(encrypted)).toBe(unicode);
|
||||
});
|
||||
|
||||
it("handles empty string round-trip", () => {
|
||||
const encrypted = encryptBackup("");
|
||||
expect(decryptBackup(encrypted)).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -73,7 +73,6 @@ describe("bestdebrid-web", () => {
|
||||
try {
|
||||
fs.rmSync(filePath, { force: true });
|
||||
} catch {
|
||||
// ignore temp cleanup failures
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
+100
-109
@@ -1,109 +1,100 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { cleanupCancelledPackageArtifacts, removeDownloadLinkArtifacts, removeSampleArtifacts } from "../src/main/cleanup";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe("cleanup", () => {
|
||||
it("removes archive artifacts but keeps media", () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-clean-"));
|
||||
tempDirs.push(dir);
|
||||
fs.writeFileSync(path.join(dir, "release.part1.rar"), "x");
|
||||
fs.writeFileSync(path.join(dir, "movie.mkv"), "x");
|
||||
|
||||
const removed = cleanupCancelledPackageArtifacts(dir);
|
||||
expect(removed).toBeGreaterThan(0);
|
||||
expect(fs.existsSync(path.join(dir, "release.part1.rar"))).toBe(false);
|
||||
expect(fs.existsSync(path.join(dir, "movie.mkv"))).toBe(true);
|
||||
});
|
||||
|
||||
it("removes sample artifacts and link files", async () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-clean-"));
|
||||
tempDirs.push(dir);
|
||||
fs.mkdirSync(path.join(dir, "Samples"), { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, "Samples", "demo-sample.mkv"), "x");
|
||||
fs.writeFileSync(path.join(dir, "download_links.txt"), "https://example.com/a\n");
|
||||
|
||||
const links = await removeDownloadLinkArtifacts(dir);
|
||||
const samples = await removeSampleArtifacts(dir);
|
||||
expect(links).toBeGreaterThan(0);
|
||||
expect(samples.files + samples.dirs).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("cleans up archive files in nested directories", () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-clean-"));
|
||||
tempDirs.push(dir);
|
||||
|
||||
// Create nested directory structure with archive files
|
||||
const sub1 = path.join(dir, "season1");
|
||||
const sub2 = path.join(dir, "season1", "extras");
|
||||
fs.mkdirSync(sub2, { recursive: true });
|
||||
|
||||
fs.writeFileSync(path.join(sub1, "episode.part1.rar"), "x");
|
||||
fs.writeFileSync(path.join(sub1, "episode.part2.rar"), "x");
|
||||
fs.writeFileSync(path.join(sub2, "bonus.zip"), "x");
|
||||
fs.writeFileSync(path.join(sub2, "bonus.7z"), "x");
|
||||
// Non-archive files should be kept
|
||||
fs.writeFileSync(path.join(sub1, "video.mkv"), "real content");
|
||||
fs.writeFileSync(path.join(sub2, "subtitle.srt"), "subtitle content");
|
||||
|
||||
const removed = cleanupCancelledPackageArtifacts(dir);
|
||||
expect(removed).toBe(4); // 2 rar parts + zip + 7z
|
||||
expect(fs.existsSync(path.join(sub1, "episode.part1.rar"))).toBe(false);
|
||||
expect(fs.existsSync(path.join(sub1, "episode.part2.rar"))).toBe(false);
|
||||
expect(fs.existsSync(path.join(sub2, "bonus.zip"))).toBe(false);
|
||||
expect(fs.existsSync(path.join(sub2, "bonus.7z"))).toBe(false);
|
||||
// Non-archives kept
|
||||
expect(fs.existsSync(path.join(sub1, "video.mkv"))).toBe(true);
|
||||
expect(fs.existsSync(path.join(sub2, "subtitle.srt"))).toBe(true);
|
||||
});
|
||||
|
||||
it("detects link artifacts by URL content in text files", async () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-clean-"));
|
||||
tempDirs.push(dir);
|
||||
|
||||
// File with link-like name containing URLs should be removed
|
||||
fs.writeFileSync(path.join(dir, "download_links.txt"), "https://rapidgator.net/file/abc123\nhttps://uploaded.net/file/def456\n");
|
||||
// File with link-like name but no URLs should be kept
|
||||
fs.writeFileSync(path.join(dir, "my_downloads.txt"), "Just some random text without URLs");
|
||||
// Regular text file that doesn't match the link pattern should be kept
|
||||
fs.writeFileSync(path.join(dir, "readme.txt"), "https://example.com");
|
||||
// .url files should always be removed
|
||||
fs.writeFileSync(path.join(dir, "bookmark.url"), "[InternetShortcut]\nURL=https://example.com");
|
||||
// .dlc files should always be removed
|
||||
fs.writeFileSync(path.join(dir, "container.dlc"), "encrypted-data");
|
||||
|
||||
const removed = await removeDownloadLinkArtifacts(dir);
|
||||
expect(removed).toBeGreaterThanOrEqual(3); // download_links.txt + bookmark.url + container.dlc
|
||||
expect(fs.existsSync(path.join(dir, "download_links.txt"))).toBe(false);
|
||||
expect(fs.existsSync(path.join(dir, "bookmark.url"))).toBe(false);
|
||||
expect(fs.existsSync(path.join(dir, "container.dlc"))).toBe(false);
|
||||
// Non-matching files should be kept
|
||||
expect(fs.existsSync(path.join(dir, "readme.txt"))).toBe(true);
|
||||
});
|
||||
|
||||
it("does not recurse into sample symlink or junction targets", async () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-clean-"));
|
||||
const external = fs.mkdtempSync(path.join(os.tmpdir(), "rd-clean-ext-"));
|
||||
tempDirs.push(dir, external);
|
||||
|
||||
const outsideFile = path.join(external, "outside-sample.mkv");
|
||||
fs.writeFileSync(outsideFile, "keep", "utf8");
|
||||
|
||||
const linkedSampleDir = path.join(dir, "sample");
|
||||
const linkType: fs.symlink.Type = process.platform === "win32" ? "junction" : "dir";
|
||||
fs.symlinkSync(external, linkedSampleDir, linkType);
|
||||
|
||||
const result = await removeSampleArtifacts(dir);
|
||||
expect(result.files).toBe(0);
|
||||
expect(fs.existsSync(outsideFile)).toBe(true);
|
||||
});
|
||||
});
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { cleanupCancelledPackageArtifacts, removeDownloadLinkArtifacts, removeSampleArtifacts } from "../src/main/cleanup";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe("cleanup", () => {
|
||||
it("removes archive artifacts but keeps media", () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-clean-"));
|
||||
tempDirs.push(dir);
|
||||
fs.writeFileSync(path.join(dir, "release.part1.rar"), "x");
|
||||
fs.writeFileSync(path.join(dir, "movie.mkv"), "x");
|
||||
|
||||
const removed = cleanupCancelledPackageArtifacts(dir);
|
||||
expect(removed).toBeGreaterThan(0);
|
||||
expect(fs.existsSync(path.join(dir, "release.part1.rar"))).toBe(false);
|
||||
expect(fs.existsSync(path.join(dir, "movie.mkv"))).toBe(true);
|
||||
});
|
||||
|
||||
it("removes sample artifacts and link files", async () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-clean-"));
|
||||
tempDirs.push(dir);
|
||||
fs.mkdirSync(path.join(dir, "Samples"), { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, "Samples", "demo-sample.mkv"), "x");
|
||||
fs.writeFileSync(path.join(dir, "download_links.txt"), "https://example.com/a\n");
|
||||
|
||||
const links = await removeDownloadLinkArtifacts(dir);
|
||||
const samples = await removeSampleArtifacts(dir);
|
||||
expect(links).toBeGreaterThan(0);
|
||||
expect(samples.files + samples.dirs).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("cleans up archive files in nested directories", () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-clean-"));
|
||||
tempDirs.push(dir);
|
||||
|
||||
const sub1 = path.join(dir, "season1");
|
||||
const sub2 = path.join(dir, "season1", "extras");
|
||||
fs.mkdirSync(sub2, { recursive: true });
|
||||
|
||||
fs.writeFileSync(path.join(sub1, "episode.part1.rar"), "x");
|
||||
fs.writeFileSync(path.join(sub1, "episode.part2.rar"), "x");
|
||||
fs.writeFileSync(path.join(sub2, "bonus.zip"), "x");
|
||||
fs.writeFileSync(path.join(sub2, "bonus.7z"), "x");
|
||||
fs.writeFileSync(path.join(sub1, "video.mkv"), "real content");
|
||||
fs.writeFileSync(path.join(sub2, "subtitle.srt"), "subtitle content");
|
||||
|
||||
const removed = cleanupCancelledPackageArtifacts(dir);
|
||||
expect(removed).toBe(4);
|
||||
expect(fs.existsSync(path.join(sub1, "episode.part1.rar"))).toBe(false);
|
||||
expect(fs.existsSync(path.join(sub1, "episode.part2.rar"))).toBe(false);
|
||||
expect(fs.existsSync(path.join(sub2, "bonus.zip"))).toBe(false);
|
||||
expect(fs.existsSync(path.join(sub2, "bonus.7z"))).toBe(false);
|
||||
expect(fs.existsSync(path.join(sub1, "video.mkv"))).toBe(true);
|
||||
expect(fs.existsSync(path.join(sub2, "subtitle.srt"))).toBe(true);
|
||||
});
|
||||
|
||||
it("detects link artifacts by URL content in text files", async () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-clean-"));
|
||||
tempDirs.push(dir);
|
||||
|
||||
fs.writeFileSync(path.join(dir, "download_links.txt"), "https://rapidgator.net/file/abc123\nhttps://uploaded.net/file/def456\n");
|
||||
fs.writeFileSync(path.join(dir, "my_downloads.txt"), "Just some random text without URLs");
|
||||
fs.writeFileSync(path.join(dir, "readme.txt"), "https://example.com");
|
||||
fs.writeFileSync(path.join(dir, "bookmark.url"), "[InternetShortcut]\nURL=https://example.com");
|
||||
fs.writeFileSync(path.join(dir, "container.dlc"), "encrypted-data");
|
||||
|
||||
const removed = await removeDownloadLinkArtifacts(dir);
|
||||
expect(removed).toBeGreaterThanOrEqual(3);
|
||||
expect(fs.existsSync(path.join(dir, "download_links.txt"))).toBe(false);
|
||||
expect(fs.existsSync(path.join(dir, "bookmark.url"))).toBe(false);
|
||||
expect(fs.existsSync(path.join(dir, "container.dlc"))).toBe(false);
|
||||
expect(fs.existsSync(path.join(dir, "readme.txt"))).toBe(true);
|
||||
});
|
||||
|
||||
it("does not recurse into sample symlink or junction targets", async () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-clean-"));
|
||||
const external = fs.mkdtempSync(path.join(os.tmpdir(), "rd-clean-ext-"));
|
||||
tempDirs.push(dir, external);
|
||||
|
||||
const outsideFile = path.join(external, "outside-sample.mkv");
|
||||
fs.writeFileSync(outsideFile, "keep", "utf8");
|
||||
|
||||
const linkedSampleDir = path.join(dir, "sample");
|
||||
const linkType: fs.symlink.Type = process.platform === "win32" ? "junction" : "dir";
|
||||
fs.symlinkSync(external, linkedSampleDir, linkType);
|
||||
|
||||
const result = await removeSampleArtifacts(dir);
|
||||
expect(result.files).toBe(0);
|
||||
expect(fs.existsSync(outsideFile)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -22,9 +22,7 @@ describe("container", () => {
|
||||
const oversizedFilePath = path.join(dir, "oversized.dlc");
|
||||
fs.writeFileSync(oversizedFilePath, Buffer.alloc((8 * 1024 * 1024) + 1, 1));
|
||||
|
||||
// Create a valid mockup DLC that would be skipped if an error was thrown
|
||||
const validFilePath = path.join(dir, "valid.dlc");
|
||||
// Just needs to be short enough to pass file limits but fail parsing, triggering dcrypt fallback
|
||||
fs.writeFileSync(validFilePath, Buffer.from("Valid but not real DLC content..."));
|
||||
|
||||
const fetchSpy = vi.fn(async (url: string | URL | Request) => {
|
||||
@@ -38,7 +36,6 @@ describe("container", () => {
|
||||
|
||||
const result = await importDlcContainers([oversizedFilePath, validFilePath]);
|
||||
|
||||
// Expect the oversized to be silently skipped, and valid to be parsed into 1 package with DLC filename
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].name).toBe("valid");
|
||||
expect(result[0].links).toEqual(["http://example.com/file1.rar", "http://example.com/file2.rar"]);
|
||||
@@ -60,17 +57,14 @@ describe("container", () => {
|
||||
tempDirs.push(dir);
|
||||
const filePath = path.join(dir, "fallback.dlc");
|
||||
|
||||
// A file large enough to trigger local decryption attempt (needs > 89 bytes to pass the slice check)
|
||||
fs.writeFileSync(filePath, Buffer.alloc(100, 1).toString("base64"));
|
||||
|
||||
const fetchSpy = vi.fn(async (url: string | URL | Request) => {
|
||||
const urlStr = String(url);
|
||||
if (urlStr.includes("service.jdownloader.org")) {
|
||||
// Mock local RC service failure (returning 404)
|
||||
return new Response("", { status: 404 });
|
||||
}
|
||||
if (urlStr.includes("dcrypt.it/decrypt/upload")) {
|
||||
// Mock dcrypt fallback success
|
||||
return new Response("http://fallback.com/1", { status: 200 });
|
||||
}
|
||||
return new Response("", { status: 404 });
|
||||
@@ -81,7 +75,6 @@ describe("container", () => {
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].name).toBe("fallback");
|
||||
expect(result[0].links).toEqual(["http://fallback.com/1"]);
|
||||
// Should have tried both!
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
@@ -135,7 +128,6 @@ describe("container", () => {
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].name).toBe("big-dlc");
|
||||
expect(result[0].links).toEqual(["http://paste-fallback.com/file1.rar", "http://paste-fallback.com/file2.rar"]);
|
||||
// local RC + upload + paste = 3 calls
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
|
||||
+2105
-2179
File diff suppressed because it is too large
Load Diff
@@ -78,7 +78,6 @@ async function waitForReady(url: string): Promise<void> {
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// retry
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
}
|
||||
@@ -314,7 +313,6 @@ afterEach(() => {
|
||||
try {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// ignore cleanup failures
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -24,7 +24,6 @@ afterEach(() => {
|
||||
try {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
createdTmpDirs.length = 0;
|
||||
@@ -50,8 +49,6 @@ describe("desktop-rename-log", () => {
|
||||
});
|
||||
|
||||
it("self-heals: recreates the whole Downloader-Log FOLDER and file if it is deleted mid-session", () => {
|
||||
// Genau die User-Anforderung: Ordner zur Laufzeit geloescht -> beim naechsten
|
||||
// Rename automatisch wieder da, selbst wenn das Programm offen ist.
|
||||
const desktop = tmpDesktop();
|
||||
initDesktopRenameLog(desktop);
|
||||
const logPath = getDesktopRenameLogPath() as string;
|
||||
@@ -60,7 +57,6 @@ describe("desktop-rename-log", () => {
|
||||
fs.rmSync(path.join(desktop, "Downloader-Log"), { recursive: true, force: true });
|
||||
expect(fs.existsSync(logPath)).toBe(false);
|
||||
|
||||
// Naechster Vorgang muss Ordner UND Datei (mit Header) selbstheilend neu anlegen.
|
||||
logDesktopRename("INFO", "ZeileB");
|
||||
expect(fs.existsSync(path.join(desktop, "Downloader-Log"))).toBe(true);
|
||||
expect(fs.existsSync(logPath)).toBe(true);
|
||||
@@ -80,7 +76,7 @@ describe("desktop-rename-log", () => {
|
||||
const dir = tmpDesktop();
|
||||
const source = path.join(dir, "scn-xyz.part1.rar");
|
||||
const target = path.join(dir, "Movie.2024.German.1080p.part1.rar");
|
||||
fs.writeFileSync(target, "data"); // Post-Rename-Zustand: Ziel da, Quelle weg.
|
||||
fs.writeFileSync(target, "data");
|
||||
|
||||
const v = verifyRename(source, target);
|
||||
expect(v.ok).toBe(true);
|
||||
@@ -102,7 +98,6 @@ describe("desktop-rename-log", () => {
|
||||
});
|
||||
|
||||
it("verifyRename: FAILS (half-done move) when the source still exists next to the target", () => {
|
||||
// EXDEV-Copy gelang, aber rm(source) schlug fehl -> Ziel da, Quelle auch noch.
|
||||
const dir = tmpDesktop();
|
||||
const source = path.join(dir, "src.rar");
|
||||
const target = path.join(dir, "dst.rar");
|
||||
|
||||
@@ -26,8 +26,6 @@ describe("download-completion", () => {
|
||||
const contentLength = (n: number) => ({ expectedTotal: n, source: "content-length" as const, canFinishEarly: true });
|
||||
const providerMeta = (n: number) => ({ expectedTotal: n, source: "provider-metadata" as const, canFinishEarly: false });
|
||||
|
||||
// H3 regression: a stream-end download (no Content-Length, no provider size)
|
||||
// that yielded 0 bytes is a FAILED download, not a valid completion.
|
||||
it("rejects a 0-byte stream-end download (H3)", () => {
|
||||
const result = validateDownloadedFileCompletion({ actualBytes: 0, plan: streamEnd });
|
||||
expect(result.ok).toBe(false);
|
||||
@@ -57,7 +55,6 @@ describe("download-completion", () => {
|
||||
|
||||
it("accepts provider-metadata download and flags size mismatch", () => {
|
||||
const result = validateDownloadedFileCompletion({ actualBytes: 1900, plan: providerMeta(2000), toleranceBytes: 0 });
|
||||
// provider-metadata: shorter than expected -> underflow rejected
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2453,8 +2453,6 @@ describe("download manager", () => {
|
||||
await waitFor(() => !manager.getSnapshot().session.running, 25000);
|
||||
|
||||
const item = Object.values(manager.getSnapshot().session.items)[0];
|
||||
// Content-Length matches actual bytes sent, so completion validation passes
|
||||
// (HTTP-level truth takes priority over provider-metadata filesize).
|
||||
expect(item?.status).toBe("completed");
|
||||
expect(item?.downloadedBytes).toBeGreaterThanOrEqual(actual.length);
|
||||
} finally {
|
||||
@@ -3292,7 +3290,6 @@ describe("download manager", () => {
|
||||
expect(item?.status).toBe("completed");
|
||||
expect(item?.targetPath).toBe(existingTargetPath);
|
||||
expect(sawResumeRange).toBe(true);
|
||||
// Allow ALLOCATION_UNIT_SIZE (4096) tolerance for write-flush timing on Windows
|
||||
const fileSize = fs.statSync(existingTargetPath).size;
|
||||
expect(fileSize).toBeGreaterThanOrEqual(binary.length - 4096);
|
||||
expect(fileSize).toBeLessThanOrEqual(binary.length);
|
||||
@@ -3578,9 +3575,6 @@ describe("download manager", () => {
|
||||
const item = manager.getSnapshot().session.items[itemId];
|
||||
expect(item?.status).toBe("completed");
|
||||
expect(item?.provider).toBe("debridlink");
|
||||
// downloadedBytes may reflect stat.size which can be within ALLOCATION_UNIT_SIZE
|
||||
// tolerance of the expected total, or the original partial size if the settle
|
||||
// recovery finalized the item from disk before retry completed.
|
||||
expect(item?.downloadedBytes).toBeGreaterThanOrEqual(partialSize);
|
||||
expect(item?.downloadedBytes).toBeLessThanOrEqual(binary.length);
|
||||
expect(unrestrictCalls).toBeGreaterThanOrEqual(1);
|
||||
@@ -4401,7 +4395,6 @@ describe("download manager", () => {
|
||||
|
||||
for (const [index, archiveName] of archiveNames.entries()) {
|
||||
const targetPath = path.join(outputDir, archiveName);
|
||||
// Write garbage content (no valid archive signature) — simulates corrupt download
|
||||
fs.writeFileSync(targetPath, Buffer.alloc(archiveSize, 0xAA));
|
||||
session.items[itemIds[index]!] = {
|
||||
id: itemIds[index]!,
|
||||
@@ -4450,7 +4443,6 @@ describe("download manager", () => {
|
||||
"hybrid"
|
||||
);
|
||||
|
||||
// Invalid archive signature = genuine corruption → force re-download
|
||||
expect(changed).toBe(2);
|
||||
for (const itemId of itemIds) {
|
||||
const item = session.items[itemId]!;
|
||||
@@ -4494,7 +4486,6 @@ describe("download manager", () => {
|
||||
|
||||
for (const [index, archiveName] of archiveNames.entries()) {
|
||||
const targetPath = path.join(outputDir, archiveName);
|
||||
// Write file with valid RAR5 signature — simulates wrong password, not corruption
|
||||
const content = Buffer.alloc(archiveSize, 0);
|
||||
Buffer.from([0x52, 0x61, 0x72, 0x21, 0x1a, 0x07, 0x01, 0x00]).copy(content);
|
||||
fs.writeFileSync(targetPath, content);
|
||||
@@ -4545,7 +4536,6 @@ describe("download manager", () => {
|
||||
"hybrid"
|
||||
);
|
||||
|
||||
// Valid RAR signature = file is structurally intact → wrong password, don't re-download
|
||||
expect(changed).toBe(0);
|
||||
for (const itemId of itemIds) {
|
||||
const item = session.items[itemId]!;
|
||||
@@ -6129,9 +6119,6 @@ describe("download manager", () => {
|
||||
|
||||
const item = Object.values(manager.getSnapshot().session.items)[0];
|
||||
expect(item?.status).toBe("completed");
|
||||
// On Windows with pre-allocation, the tiny error-page detection is masked
|
||||
// (stat reports pre-allocated size, not actual written bytes), so the first
|
||||
// download may be accepted without a retry.
|
||||
if (process.platform !== "win32") {
|
||||
expect(directCalls).toBeGreaterThan(1);
|
||||
}
|
||||
@@ -6145,7 +6132,6 @@ describe("download manager", () => {
|
||||
it("accepts small .sfv metadata files without rejecting them as suspicious", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
||||
tempDirs.push(root);
|
||||
// SFV content is just CRC32 checksums — legitimately tiny
|
||||
const sfvContent = Buffer.from("archive.part1.rar 1A2B3C4D\narchive.part2.rar 5E6F7A8B\n", "utf8");
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
@@ -9359,12 +9345,6 @@ describe("download manager", () => {
|
||||
}, 20000);
|
||||
|
||||
it("hybrid collect defers fresh files instead of moving them unrenamed; final pass collects them", async () => {
|
||||
// Regression: User-Report — bei Hybrid-Extraktion blieben 1-2 Dateien pro
|
||||
// Staffel unbenannt (mit Original-Scene-Namen in der Library). Ursache: eine
|
||||
// frisch extrahierte Datei wird vom Auto-Rename absichtlich deferred (noch nicht
|
||||
// stabil), aber der Collect moved sie vorher mit Original-Namen. Fix: der
|
||||
// Hybrid-Collect (deferFreshFiles=true) ueberspringt frische Dateien; der finale
|
||||
// Deferred-Pass (deferFreshFiles=false) sammelt sie nach Stabilisierung ein.
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
||||
tempDirs.push(root);
|
||||
|
||||
@@ -9375,7 +9355,7 @@ describe("download manager", () => {
|
||||
|
||||
const mkvName = "grp-freshshow.s01e07-720p.mkv";
|
||||
const mkvPath = path.join(extractDir, mkvName);
|
||||
fs.writeFileSync(mkvPath, Buffer.alloc(4096, 7)); // mtime = jetzt → "frisch"
|
||||
fs.writeFileSync(mkvPath, Buffer.alloc(4096, 7));
|
||||
|
||||
const session = emptySession();
|
||||
const packageId = `${packageName}-pkg`;
|
||||
@@ -9410,18 +9390,14 @@ describe("download manager", () => {
|
||||
session,
|
||||
createStoragePaths(path.join(root, "state"))
|
||||
);
|
||||
// In Tests ist fileStabilizeMinAgeMs=0 (Frische-Erkennung aus) — fuer diesen
|
||||
// Test aktivieren, damit die gerade erstellte Datei als "frisch" gilt.
|
||||
(manager as any).fileStabilizeMinAgeMs = 30_000;
|
||||
|
||||
const libPath = path.join(mkvLibraryDir, mkvName);
|
||||
|
||||
// Hybrid-Collect (deferFreshFiles=true): frische Datei darf NICHT gemoved werden.
|
||||
await (manager as any).collectMkvFilesToLibrary(packageId, session.packages[packageId], undefined, true);
|
||||
expect(fs.existsSync(libPath)).toBe(false);
|
||||
expect(fs.existsSync(mkvPath)).toBe(true);
|
||||
|
||||
// Finaler Deferred-Pass (deferFreshFiles=false): sammelt die Datei trotzdem ein.
|
||||
await (manager as any).collectMkvFilesToLibrary(packageId, session.packages[packageId], undefined, false);
|
||||
expect(fs.existsSync(libPath)).toBe(true);
|
||||
expect(fs.existsSync(mkvPath)).toBe(false);
|
||||
@@ -9430,19 +9406,12 @@ describe("download manager", () => {
|
||||
}, 20000);
|
||||
|
||||
it("collect CLEANS a raw scene file that auto-rename never processed (the 17-file library bug)", async () => {
|
||||
// Echter Bug aus rename-session_2026-06-02: Auto-Rename verpasste einzelne Dateien
|
||||
// (verpasster Scan / lag ausserhalb der extractDir), der Collect schob sie dann ROH in
|
||||
// die Library ("tvarchiv...s07e12-720.mkv"). Fix: Collect leitet den sauberen Namen
|
||||
// selbst ab (gleiche Logik wie Auto-Rename) — die Library-Datei heisst garantiert sauber,
|
||||
// auch wenn KEIN Auto-Rename-Pass die Datei je angefasst hat (hier: Collect direkt
|
||||
// aufgerufen, ohne vorherigen Auto-Rename-Lauf).
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
||||
tempDirs.push(root);
|
||||
|
||||
const packageName = "Herzflimmern.Die.Klinik.am.See.S07.German.720p.Webrip.x264-TVARCHiV";
|
||||
const outputDir = path.join(root, "downloads", packageName);
|
||||
const extractDir = path.join(root, "extract", packageName);
|
||||
// Per-Episoden-Ordner (vom Release-Group sauber benannt) mit ROHER MKV darin.
|
||||
const episodeFolder = "Herzflimmern.Die.Klinik.am.See.S07E12.German.720p.Webrip.x264-TVARCHiV";
|
||||
const epDir = path.join(extractDir, episodeFolder);
|
||||
fs.mkdirSync(epDir, { recursive: true });
|
||||
@@ -9474,7 +9443,7 @@ describe("download manager", () => {
|
||||
outputDir: path.join(root, "downloads"),
|
||||
extractDir: path.join(root, "extract"),
|
||||
autoExtract: true,
|
||||
autoRename4sf4sj: true, // Umbenennen AN — wie in der echten User-Config
|
||||
autoRename4sf4sj: true,
|
||||
collectMkvToLibrary: true,
|
||||
mkvLibraryDir,
|
||||
enableIntegrityCheck: false,
|
||||
@@ -9484,13 +9453,10 @@ describe("download manager", () => {
|
||||
createStoragePaths(path.join(root, "state"))
|
||||
);
|
||||
|
||||
// Collect DIREKT aufrufen, OHNE vorherigen Auto-Rename-Lauf — simuliert genau die
|
||||
// verpasste Datei. deferFreshFiles=false (finaler Pass).
|
||||
await (manager as any).collectMkvFilesToLibrary(packageId, session.packages[packageId], undefined, false);
|
||||
|
||||
const cleanLibPath = path.join(mkvLibraryDir, `${episodeFolder}.mkv`);
|
||||
const rawLibPath = path.join(mkvLibraryDir, rawName);
|
||||
// Library-Datei heisst SAUBER, nicht roh; Quelle ist weg.
|
||||
expect(fs.existsSync(cleanLibPath)).toBe(true);
|
||||
expect(fs.existsSync(rawLibPath)).toBe(false);
|
||||
expect(fs.existsSync(rawPath)).toBe(false);
|
||||
@@ -9499,16 +9465,12 @@ describe("download manager", () => {
|
||||
}, 20000);
|
||||
|
||||
it("collect cleans a raw file sitting OUTSIDE extractDir (Downloader-Unfertig case) AND its .srt follows the rename", async () => {
|
||||
// Die 5 Fritzie-S04-Dateien lagen in "Downloader Unfertig" (= outputDir-Seite, NICHT
|
||||
// extractDir) — Auto-Rename scannt nur extractDir, sah sie also nie. Collect muss sie
|
||||
// trotzdem aus dem Staffel-Ordner heraus sauber benennen, und der Untertitel muss
|
||||
// mit dem Video mitwandern (auf den GEAENDERTEN Namen).
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
||||
tempDirs.push(root);
|
||||
|
||||
const packageName = "Fritzie.-.Der.Himmel.muss.warten.S04.GERMAN.720p.WEB.AVC-4SF";
|
||||
const outputDir = path.join(root, "downloads", packageName); // = "Unfertig"-Aequivalent
|
||||
const extractDir = path.join(root, "extract", packageName); // bleibt leer/fehlt
|
||||
const outputDir = path.join(root, "downloads", packageName);
|
||||
const extractDir = path.join(root, "extract", packageName);
|
||||
fs.mkdirSync(outputDir, { recursive: true });
|
||||
|
||||
const rawName = "4sf-fritzie.himmel.muss.warten.web.7p-s04e01.mkv";
|
||||
@@ -9555,27 +9517,18 @@ describe("download manager", () => {
|
||||
const cleanBase = "Fritzie.-.Der.Himmel.muss.warten.S04E01.GERMAN.720p.WEB.AVC-4SF";
|
||||
expect(fs.existsSync(path.join(mkvLibraryDir, `${cleanBase}.mkv`))).toBe(true);
|
||||
expect(fs.existsSync(path.join(mkvLibraryDir, rawName))).toBe(false);
|
||||
// Untertitel folgt dem Video auf den sauberen Namen (Sprach-Suffix .de erhalten).
|
||||
expect(fs.existsSync(path.join(mkvLibraryDir, `${cleanBase}.de.srt`))).toBe(true);
|
||||
|
||||
void manager;
|
||||
}, 20000);
|
||||
|
||||
it("collect MOVES a numbered episode whose TITLE is a bonus keyword (Revenge S04E19 'Interview')", async () => {
|
||||
// Echter Bug aus rd-support-bundle 2026-06-04: Revenge.2011.S04E19.Interview blieb roh
|
||||
// in "Downloader Fertig" haengen — nie in die Library verschoben, KEIN Fehler. Ursache:
|
||||
// der Episodentitel "Interview" (UND der Episoden-Ordnername) matcht BONUS_FILENAME_RE /
|
||||
// isInsideBonusDir -> der Collect stufte die Folge als Bonus/Extras ein und skippte sie
|
||||
// (nur logger.info, im Paket-Log unsichtbar). Eine Folge MIT gueltigem SxxExx-Token ist
|
||||
// aber eine echte Episode, niemals Bonus. Betrifft Interview/Outtakes/Special/Featurette-
|
||||
// Titel -> "selten, aber 4-5 Folgen pro grossem Download".
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
||||
tempDirs.push(root);
|
||||
|
||||
const packageName = "Revenge.2011.S04.GERMAN.DL.720p.WEB.x264-TSCC";
|
||||
const outputDir = path.join(root, "downloads", packageName);
|
||||
const extractDir = path.join(root, "extract", packageName);
|
||||
// Per-Episoden-Ordner UND Datei tragen beide das Bonus-Wort "Interview" — exakt der Fall.
|
||||
const episodeFolder = "Revenge.2011.S04E19.Interview.GERMAN.DL.720p.WEB.x264-TSCC";
|
||||
const epDir = path.join(extractDir, episodeFolder);
|
||||
fs.mkdirSync(epDir, { recursive: true });
|
||||
@@ -9618,7 +9571,6 @@ describe("download manager", () => {
|
||||
|
||||
await (manager as any).collectMkvFilesToLibrary(packageId, session.packages[packageId], undefined, false);
|
||||
|
||||
// Die Folge MUSS in der Library liegen (nicht als Bonus verworfen) und die Quelle weg sein.
|
||||
expect(fs.existsSync(path.join(mkvLibraryDir, epName))).toBe(true);
|
||||
expect(fs.existsSync(path.join(epDir, epName))).toBe(false);
|
||||
|
||||
@@ -9626,9 +9578,6 @@ describe("download manager", () => {
|
||||
}, 20000);
|
||||
|
||||
it("collect STILL skips genuine bonus/extras with NO episode token (Making.Of) — proves the filter isn't disabled", async () => {
|
||||
// Guard zum Fix oben: eine echte Bonus-Datei OHNE SxxExx-Token (Making.Of) bleibt Bonus
|
||||
// und darf NICHT in die Library wandern. Sonst haetten wir den Bonus-Filter nur kaputt
|
||||
// gemacht statt praezisiert.
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
||||
tempDirs.push(root);
|
||||
|
||||
@@ -9636,7 +9585,6 @@ describe("download manager", () => {
|
||||
const outputDir = path.join(root, "downloads", packageName);
|
||||
const extractDir = path.join(root, "extract", packageName);
|
||||
fs.mkdirSync(extractDir, { recursive: true });
|
||||
// Echte Episode (mit Token) + echtes Extra (ohne Token) im selben Paket.
|
||||
const epName = "Some.Show.S01E01.GERMAN.720p.WEB.x264-GRP.mkv";
|
||||
const bonusName = "Some.Show.Making.Of.GERMAN.720p.WEB.x264-GRP.mkv";
|
||||
fs.writeFileSync(path.join(extractDir, epName), Buffer.alloc(4096, 1));
|
||||
@@ -9678,7 +9626,6 @@ describe("download manager", () => {
|
||||
|
||||
await (manager as any).collectMkvFilesToLibrary(packageId, session.packages[packageId], undefined, false);
|
||||
|
||||
// Echte Episode wandert in die Library; das Making-Of bleibt liegen (Bonus).
|
||||
expect(fs.existsSync(path.join(mkvLibraryDir, epName))).toBe(true);
|
||||
expect(fs.existsSync(path.join(mkvLibraryDir, bonusName))).toBe(false);
|
||||
expect(fs.existsSync(path.join(extractDir, bonusName))).toBe(true);
|
||||
@@ -9687,10 +9634,6 @@ describe("download manager", () => {
|
||||
}, 20000);
|
||||
|
||||
it("collect CLEANS a raw .avi whose folder is a complete episode name WITHOUT a -GROUP suffix (safari S04E08a)", async () => {
|
||||
// Echter Bug (rename-session 2026-06-04): alte deutsche Doku ohne Gruppen-Suffix
|
||||
// (Ordner endet ".XviD", kein "-GROUP"). buildAutoRenameBaseName lieferte null -> die
|
||||
// Folge landete ROH als "safari-fm-s04e08a.avi" in der Library. Der Part-Buchstabe a/b
|
||||
// muss erhalten bleiben (Teil 1 vs Teil 2 duerfen nicht kollidieren).
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
||||
tempDirs.push(root);
|
||||
|
||||
@@ -9743,7 +9686,6 @@ describe("download manager", () => {
|
||||
|
||||
await (manager as any).collectMkvFilesToLibrary(packageId, session.packages[packageId], undefined, false);
|
||||
|
||||
// Beide Folgen sauber benannt in der Library, Part a/b distinkt, KEINE rohen Namen.
|
||||
expect(fs.existsSync(path.join(mkvLibraryDir, `${folderA}.avi`))).toBe(true);
|
||||
expect(fs.existsSync(path.join(mkvLibraryDir, `${folderB}.avi`))).toBe(true);
|
||||
expect(fs.existsSync(path.join(mkvLibraryDir, "safari-fm-s04e08a.avi"))).toBe(false);
|
||||
@@ -9753,10 +9695,6 @@ describe("download manager", () => {
|
||||
}, 20000);
|
||||
|
||||
it("collect KEEPS the clean SxxExx name and does NOT mangle it via an episode-title folder (Taken S01E01)", async () => {
|
||||
// Echter Bug (rename-session 2026-06-05): Auto-Rename hatte die Datei bereits korrekt zu
|
||||
// "...S01E01...-GTVG.mkv" benannt. Der per-Episode-Ordner traegt nur "E01" + Titel (kein S01).
|
||||
// Der Collect leitete daraus neu ab und haengte den Token verkrueppelt an
|
||||
// ("...-GTVG.S01E01"). Erwartung: der saubere S01E01-Name bleibt unangetastet.
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
||||
tempDirs.push(root);
|
||||
|
||||
@@ -9767,7 +9705,6 @@ describe("download manager", () => {
|
||||
const cleanName = "Steven.Spielbergs.Taken.S01E01.German.720p.HDTV.x264-GTVG.mkv";
|
||||
const epDir = path.join(extractDir, epFolder);
|
||||
fs.mkdirSync(epDir, { recursive: true });
|
||||
// Datei liegt bereits SAUBER benannt vor (so wie Auto-Rename sie hinterlassen hat).
|
||||
fs.writeFileSync(path.join(epDir, cleanName), Buffer.alloc(4096, 5));
|
||||
|
||||
const session = emptySession();
|
||||
@@ -9806,11 +9743,9 @@ describe("download manager", () => {
|
||||
|
||||
await (manager as any).collectMkvFilesToLibrary(packageId, session.packages[packageId], undefined, false);
|
||||
|
||||
// Sauberer S01E01-Name bleibt; KEIN verkrueppelter "...E01.Titel...S01E01"-Name.
|
||||
expect(fs.existsSync(path.join(mkvLibraryDir, cleanName))).toBe(true);
|
||||
const mangled = `${epFolder}.S01E01.mkv`;
|
||||
expect(fs.existsSync(path.join(mkvLibraryDir, mangled))).toBe(false);
|
||||
// Nichts mit dem Episoden-Titel im Library-Ordner.
|
||||
const inLib = fs.readdirSync(mkvLibraryDir);
|
||||
expect(inLib.some((n) => /Hinter\.dem\.Himmel/i.test(n))).toBe(false);
|
||||
|
||||
@@ -9818,30 +9753,18 @@ describe("download manager", () => {
|
||||
}, 20000);
|
||||
|
||||
it("deferred final pass renames fresh files before collecting them (no scene names in library)", async () => {
|
||||
// Folge-Fund zu 18eada9 (verifiziert via Advisor-Gate): 18eada9 schloss den
|
||||
// "frische Datei landet unbenannt"-Bug nur fuer den HYBRID-Pfad (deferFreshFiles=true
|
||||
// + Mehrfach-Pässe). Der finale Deferred-Pass (runDeferredPostExtraction) macht
|
||||
// Rename (treatFilesAsStable? nein) -> Collect (deferFreshFiles=false). Ist eine
|
||||
// Datei beim Deferred-Rename noch "frisch" (< fileStabilizeMinAgeMs) — z.B. eine
|
||||
// gerade per Nested-Extraction (12045) geschriebene Datei — ueberspringt der
|
||||
// Frische-Gate sie, und der Collect moved sie mit Original-Scene-Namen in die
|
||||
// Library. Im Deferred-FINAL-Pass laeuft aber KEIN concurrent Extractor mehr
|
||||
// (Extraktion abgeschlossen/awaited), der Frische-Gate ist dort ein False
|
||||
// Positive. Fix: der Final-Pass-Rename behandelt alle Dateien als stabil
|
||||
// (treatFilesAsStable=true) → benennt um, bevor der Collect sie sammelt.
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
||||
tempDirs.push(root);
|
||||
|
||||
const packageName = "Test.Show.S02.GERMAN.WS.720p.HDTV.x264-aWake";
|
||||
const outputDir = path.join(root, "downloads", packageName);
|
||||
const extractDir = path.join(root, "extract", packageName);
|
||||
// Episoden-Ordner liefert den kanonischen Zielnamen (enthaelt SxxExx).
|
||||
const epFolder = path.join(extractDir, "Test.Show.S02E05.Title.GERMAN.WS.720p.HDTV.x264-aWake");
|
||||
fs.mkdirSync(epFolder, { recursive: true });
|
||||
|
||||
const sceneName = "awa-testshow02e05hd.mkv";
|
||||
const scenePath = path.join(epFolder, sceneName);
|
||||
fs.writeFileSync(scenePath, Buffer.alloc(4096, 5)); // mtime = jetzt → "frisch"
|
||||
fs.writeFileSync(scenePath, Buffer.alloc(4096, 5));
|
||||
|
||||
const session = emptySession();
|
||||
const packageId = `${packageName}-pkg`;
|
||||
@@ -9876,22 +9799,15 @@ describe("download manager", () => {
|
||||
session,
|
||||
createStoragePaths(path.join(root, "state"))
|
||||
);
|
||||
// Produktion: fileStabilizeMinAgeMs=2000. Hier 30s, damit die gerade erstellte
|
||||
// Datei garantiert als "frisch" gilt — wie eine eben extrahierte Datei, die der
|
||||
// Deferred-Pass sofort danach verarbeitet.
|
||||
(manager as any).fileStabilizeMinAgeMs = 30_000;
|
||||
|
||||
const expectedBase = "Test.Show.S02E05.Title.GERMAN.WS.720p.HDTV.x264-aWake";
|
||||
const renamedLibPath = path.join(mkvLibraryDir, `${expectedBase}.mkv`);
|
||||
const sceneLibPath = path.join(mkvLibraryDir, sceneName);
|
||||
|
||||
// Deferred-FINAL-Pass-Sequenz, exakt wie runDeferredPostExtraction:
|
||||
// 1) Rename — treatFilesAsStable=true (Extraktion abgeschlossen, kein Frische-Skip)
|
||||
// 2) Collect — deferFreshFiles=false
|
||||
await (manager as any).autoRenameExtractedVideoFiles(extractDir, session.packages[packageId], undefined, true);
|
||||
await (manager as any).collectMkvFilesToLibrary(packageId, session.packages[packageId], undefined, false);
|
||||
|
||||
// Die Datei landet UMBENANNT in der Library — nicht mit dem Scene-Namen.
|
||||
expect(fs.existsSync(renamedLibPath)).toBe(true);
|
||||
expect(fs.existsSync(sceneLibPath)).toBe(false);
|
||||
|
||||
@@ -9899,11 +9815,6 @@ describe("download manager", () => {
|
||||
}, 20000);
|
||||
|
||||
it("deferred post-extraction wiring renames fresh files end-to-end (treatFilesAsStable reaches the rename)", async () => {
|
||||
// Wiring-Lock zum vorherigen Test: stellt sicher, dass runDeferredPostExtraction
|
||||
// den Rename TATSAECHLICH mit treatFilesAsStable=true aufruft. Wuerde jemand das
|
||||
// `true` an der Call-Site (autoRenameExtractedVideoFiles(..., true)) entfernen,
|
||||
// faellt dieser Test (frische Datei landet wieder unbenannt) — der reine
|
||||
// Mechanism-Test wuerde das NICHT bemerken (er ruft den Rename selbst mit true).
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
||||
tempDirs.push(root);
|
||||
|
||||
@@ -9915,7 +9826,7 @@ describe("download manager", () => {
|
||||
fs.mkdirSync(outputDir, { recursive: true });
|
||||
|
||||
const sceneName = "awa-testshow02e05hd.mkv";
|
||||
fs.writeFileSync(path.join(epFolder, sceneName), Buffer.alloc(4096, 5)); // mtime = jetzt → "frisch"
|
||||
fs.writeFileSync(path.join(epFolder, sceneName), Buffer.alloc(4096, 5));
|
||||
|
||||
const session = emptySession();
|
||||
const packageId = `${packageName}-pkg`;
|
||||
@@ -9953,9 +9864,6 @@ describe("download manager", () => {
|
||||
(manager as any).fileStabilizeMinAgeMs = 30_000;
|
||||
|
||||
const expectedBase = "Test.Show.S02E05.Title.GERMAN.WS.720p.HDTV.x264-aWake";
|
||||
// Treibt den ECHTEN Produktionspfad: runDeferredPostExtraction → Rename
|
||||
// (Call-Site mit treatFilesAsStable=true) → Collect (deferFreshFiles=false).
|
||||
// success=1 (Collect-Gate), alreadyMarkedExtracted=true (Rename-Gate), failed=0.
|
||||
await (manager as any).runDeferredPostExtraction(packageId, session.packages[packageId], 1, 0, true, 1);
|
||||
|
||||
expect(fs.existsSync(path.join(mkvLibraryDir, `${expectedBase}.mkv`))).toBe(true);
|
||||
@@ -9973,7 +9881,6 @@ describe("download manager", () => {
|
||||
const extractDir = path.join(root, "extract", packageName);
|
||||
fs.mkdirSync(outputDir, { recursive: true });
|
||||
|
||||
// Direct .mkv download (no archive) — wie es Mega-Debrid bei mega.nz liefert.
|
||||
const directMkvName = "Direct.Show.S01E01.German.1080p.WEB.x264-DIRECT.mkv";
|
||||
const directMkvPath = path.join(outputDir, directMkvName);
|
||||
fs.writeFileSync(directMkvPath, Buffer.alloc(2048, 1));
|
||||
@@ -10039,20 +9946,13 @@ describe("download manager", () => {
|
||||
await waitFor(() => fs.existsSync(libraryPath), 12000);
|
||||
|
||||
expect(fs.existsSync(libraryPath)).toBe(true);
|
||||
// Filename darf NICHT umbenannt werden (Mega-Files sind oft schon korrekt benannt).
|
||||
expect(fs.readFileSync(libraryPath).length).toBe(directMkvSize);
|
||||
// Quelle ist weg (verschoben).
|
||||
expect(fs.existsSync(directMkvPath)).toBe(false);
|
||||
|
||||
void manager;
|
||||
}, 20000);
|
||||
|
||||
it("does NOT delete pending RAR archive sets in outputDir when collecting MKVs from extractDir", async () => {
|
||||
// Regression v1.7.156: bei einem Multi-Archive-Set-Paket (z.B. S01 + S02 RARs
|
||||
// im selben outputDir) wurde nach dem Extrahieren von S01 die MKV-Collection
|
||||
// getriggert. Diese loeschte als "Restdateien" ALLE Nicht-Video-Files im
|
||||
// outputDir — also auch die noch nicht entpackten S02-RAR-Parts. Folge:
|
||||
// S02 ging verloren ("missing_file" beim spaeteren Extract).
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
||||
tempDirs.push(root);
|
||||
|
||||
@@ -10062,7 +9962,6 @@ describe("download manager", () => {
|
||||
fs.mkdirSync(outputDir, { recursive: true });
|
||||
fs.mkdirSync(extractDir, { recursive: true });
|
||||
|
||||
// outputDir: S02-RAR-Set noch NICHT entpackt (pending). Muss erhalten bleiben.
|
||||
const s02Parts = [
|
||||
"Ugly.Americans.S02.COMPLETE.German.part1.rar",
|
||||
"Ugly.Americans.S02.COMPLETE.German.part2.rar",
|
||||
@@ -10071,10 +9970,8 @@ describe("download manager", () => {
|
||||
for (const part of s02Parts) {
|
||||
fs.writeFileSync(path.join(outputDir, part), Buffer.alloc(1024, 7));
|
||||
}
|
||||
// Auch eine harmlose Nicht-Video-Restdatei im outputDir (z.B. .nfo).
|
||||
fs.writeFileSync(path.join(outputDir, "info.nfo"), Buffer.from("nfo"));
|
||||
|
||||
// extractDir: S01 wurde bereits entpackt → MKVs liegen hier.
|
||||
const s01Mkvs = [
|
||||
"Ugly.Americans.S01E01.German.mkv",
|
||||
"Ugly.Americans.S01E02.German.mkv"
|
||||
@@ -10118,14 +10015,11 @@ describe("download manager", () => {
|
||||
createStoragePaths(path.join(root, "state"))
|
||||
);
|
||||
|
||||
// Direkt aufrufen (umgeht die volle Download/Extract-Pipeline).
|
||||
await (manager as any).collectMkvFilesToLibrary(packageId, session.packages[packageId]);
|
||||
|
||||
// S01-MKVs sind in der Library angekommen.
|
||||
for (const mkv of s01Mkvs) {
|
||||
expect(fs.existsSync(path.join(mkvLibraryDir, mkv))).toBe(true);
|
||||
}
|
||||
// KRITISCH: S02-RAR-Parts im outputDir wurden NICHT geloescht.
|
||||
for (const part of s02Parts) {
|
||||
expect(fs.existsSync(path.join(outputDir, part))).toBe(true);
|
||||
}
|
||||
@@ -10142,7 +10036,6 @@ describe("download manager", () => {
|
||||
const extractDir = path.join(root, "extract", packageName);
|
||||
fs.mkdirSync(outputDir, { recursive: true });
|
||||
|
||||
// Build archive containing one real episode + several bonus files in an Extras subdirectory
|
||||
const zip = new AdmZip();
|
||||
zip.addFile("Breaking.Bad.S04E01.GERMAN.5.1.DL.BluRay.720p.x264-TSCC.mkv", Buffer.from("episode-data"));
|
||||
zip.addFile("Breaking.Bad.S04Extras.720p.BluRay.x264-TSCC/Schrotflinte.mkv", Buffer.from("bonus-1"));
|
||||
@@ -10209,22 +10102,18 @@ describe("download manager", () => {
|
||||
createStoragePaths(path.join(root, "state"))
|
||||
);
|
||||
|
||||
// Wait until the real episode landed in the library
|
||||
const flattenedEpisode = path.join(mkvLibraryDir, "Breaking.Bad.S04E01.GERMAN.5.1.DL.BluRay.720p.x264-TSCC.mkv");
|
||||
await waitFor(() => fs.existsSync(flattenedEpisode), 12000);
|
||||
|
||||
// Bonus files MUST NOT be in the flat library
|
||||
expect(fs.existsSync(path.join(mkvLibraryDir, "Schrotflinte.mkv"))).toBe(false);
|
||||
expect(fs.existsSync(path.join(mkvLibraryDir, "Die.Autoexplosion.mkv"))).toBe(false);
|
||||
expect(fs.existsSync(path.join(mkvLibraryDir, "White.House.mkv"))).toBe(false);
|
||||
|
||||
// Bonus files MUST still exist in the extract dir Extras subfolder
|
||||
const extrasDir = path.join(extractDir, "Breaking.Bad.S04Extras.720p.BluRay.x264-TSCC");
|
||||
expect(fs.existsSync(path.join(extrasDir, "Schrotflinte.mkv"))).toBe(true);
|
||||
expect(fs.existsSync(path.join(extrasDir, "Die.Autoexplosion.mkv"))).toBe(true);
|
||||
expect(fs.existsSync(path.join(extrasDir, "White.House.mkv"))).toBe(true);
|
||||
|
||||
// The real episode must be in the library and removed from extract
|
||||
expect(fs.existsSync(flattenedEpisode)).toBe(true);
|
||||
}, 20000);
|
||||
|
||||
@@ -10237,7 +10126,6 @@ describe("download manager", () => {
|
||||
const extractDir = path.join(root, "extract", packageName);
|
||||
fs.mkdirSync(outputDir, { recursive: true });
|
||||
|
||||
// Mix of dot-separated bonus subdirs - must all be detected as bonus
|
||||
const zip = new AdmZip();
|
||||
zip.addFile("Breaking.Bad.S05E01.GERMAN.DL.720p.BluRay.x264-TSCC.mkv", Buffer.from("real-episode"));
|
||||
zip.addFile("Breaking.Bad.S05.Making.Of/SomeBonusClip.mkv", Buffer.from("bonus-1"));
|
||||
@@ -10308,13 +10196,11 @@ describe("download manager", () => {
|
||||
const flattenedEpisode = path.join(mkvLibraryDir, "Breaking.Bad.S05E01.GERMAN.DL.720p.BluRay.x264-TSCC.mkv");
|
||||
await waitFor(() => fs.existsSync(flattenedEpisode), 12000);
|
||||
|
||||
// None of the bonus files should have landed in the flat library
|
||||
expect(fs.existsSync(path.join(mkvLibraryDir, "SomeBonusClip.mkv"))).toBe(false);
|
||||
expect(fs.existsSync(path.join(mkvLibraryDir, "AnotherClip.mkv"))).toBe(false);
|
||||
expect(fs.existsSync(path.join(mkvLibraryDir, "DeletedClip.mkv"))).toBe(false);
|
||||
expect(fs.existsSync(path.join(mkvLibraryDir, "GagClip.mkv"))).toBe(false);
|
||||
|
||||
// All bonus files must still exist in their respective subfolders
|
||||
expect(fs.existsSync(path.join(extractDir, "Breaking.Bad.S05.Making.Of", "SomeBonusClip.mkv"))).toBe(true);
|
||||
expect(fs.existsSync(path.join(extractDir, "Breaking.Bad.S05.Behind.The.Scenes", "AnotherClip.mkv"))).toBe(true);
|
||||
expect(fs.existsSync(path.join(extractDir, "Breaking.Bad.S05.Deleted.Scenes", "DeletedClip.mkv"))).toBe(true);
|
||||
@@ -10998,7 +10884,6 @@ describe("download manager", () => {
|
||||
tempDirs.push(root);
|
||||
const binary = Buffer.alloc(256 * 1024, 7);
|
||||
|
||||
// Slow server: delivers data in chunks with delay
|
||||
const server = http.createServer((req, res) => {
|
||||
if ((req.url || "") !== "/slow-dl") {
|
||||
res.statusCode = 404;
|
||||
@@ -11008,7 +10893,6 @@ describe("download manager", () => {
|
||||
res.statusCode = 200;
|
||||
res.setHeader("Accept-Ranges", "bytes");
|
||||
res.setHeader("Content-Length", String(binary.length));
|
||||
// Send first half, then delay
|
||||
res.write(binary.subarray(0, Math.floor(binary.length / 4)));
|
||||
const timer = setTimeout(() => {
|
||||
if (!res.writableEnded && !res.destroyed) {
|
||||
@@ -11065,23 +10949,19 @@ describe("download manager", () => {
|
||||
|
||||
manager.addPackages([{ name: "hang-test", links: ["https://dummy/hang-test"] }]);
|
||||
|
||||
// Step 1: Start and wait for download to begin
|
||||
await manager.start();
|
||||
await waitFor(() => {
|
||||
const items = Object.values(manager.getSnapshot().session.items);
|
||||
return items.some((item) => item.status === "downloading");
|
||||
}, 12000);
|
||||
|
||||
// Step 2: Stop — do NOT wait for running=false
|
||||
manager.stop();
|
||||
|
||||
// Step 3: Immediately disable the active provider
|
||||
manager.setSettings({
|
||||
...settings,
|
||||
disabledProviders: ["realdebrid"]
|
||||
});
|
||||
|
||||
// Step 4: Start again immediately — must resolve (not hang)
|
||||
const startPromise = manager.start();
|
||||
const timeout = new Promise<"timeout">((resolve) => setTimeout(() => resolve("timeout"), 8000));
|
||||
const result = await Promise.race([startPromise.then(() => "ok" as const), timeout]);
|
||||
@@ -11112,9 +10992,6 @@ describe("download manager", () => {
|
||||
createStoragePaths(stateDir)
|
||||
);
|
||||
|
||||
// 60 packages with 25 links each = 1500 items. This was freezing the UI
|
||||
// for 1-2 min on slower filesystems because every item triggered
|
||||
// ensurePackageLog + ensureItemLog + multiple sync appendFileSync calls.
|
||||
const packages = Array.from({ length: 60 }, (_, pkgIdx) => ({
|
||||
name: `bulk-pkg-${pkgIdx}`,
|
||||
links: Array.from({ length: 25 }, (_, linkIdx) => `https://dummy/bulk-${pkgIdx}-${linkIdx}.rar`)
|
||||
@@ -11127,25 +11004,14 @@ describe("download manager", () => {
|
||||
expect(result.addedPackages).toBe(60);
|
||||
expect(result.addedLinks).toBe(1500);
|
||||
|
||||
// Hard cap: on any reasonable CI box this should complete well under 5 s.
|
||||
// Before the fix, the same workload produced thousands of sync-FS writes
|
||||
// and took 60-120 s even on fast local disks.
|
||||
expect(elapsedMs).toBeLessThan(5000);
|
||||
|
||||
// No per-item log files should have been created — they're only
|
||||
// initialized lazily when an item gets a real lifecycle event later.
|
||||
// Item log files are named item_<id>.txt.
|
||||
const itemLogsDir = path.join(stateDir, "item-logs");
|
||||
const itemLogFiles = fs.existsSync(itemLogsDir)
|
||||
? fs.readdirSync(itemLogsDir).filter((f) => f.startsWith("item_") && f.endsWith(".txt"))
|
||||
: [];
|
||||
expect(itemLogFiles.length).toBe(0);
|
||||
|
||||
// One package log per package. Package log file names are package_<id>.txt.
|
||||
// The "Links registriert" entry is appended async (batched flush every
|
||||
// ~250ms), so we don't assert content here — just that each package has
|
||||
// been initialized with the startup block (ensurePackageLog wrote the
|
||||
// "Paket-Log Start" header synchronously).
|
||||
const packageLogsDir = path.join(stateDir, "package-logs");
|
||||
const pkgLogFiles = fs.readdirSync(packageLogsDir).filter((f) => f.startsWith("package_") && f.endsWith(".txt"));
|
||||
expect(pkgLogFiles.length).toBe(60);
|
||||
@@ -11160,8 +11026,6 @@ describe("download manager", () => {
|
||||
initItemLogs(stateDir);
|
||||
initRenameLog(stateDir);
|
||||
|
||||
// Build extract tree with 3 episode-folders, each containing 1 obfuscated MKV
|
||||
// mirroring the scene release pattern from the production log.
|
||||
const extractDir = path.join(root, "extracted");
|
||||
const episodes = [
|
||||
{ folder: "Test.Show.S02E01.Pilot.GERMAN.WS.720p.HDTV.x264-aWake", file: "awa-testshow02e01hd.mkv" },
|
||||
@@ -11204,24 +11068,15 @@ describe("download manager", () => {
|
||||
downloadCompletedAt: 0
|
||||
};
|
||||
|
||||
// Fire two scans simultaneously for the SAME package — without
|
||||
// serialization, both would race on the same fileset.
|
||||
const [n1, n2] = await Promise.all([
|
||||
(manager as any).autoRenameExtractedVideoFiles(extractDir, pkg),
|
||||
(manager as any).autoRenameExtractedVideoFiles(extractDir, pkg)
|
||||
]);
|
||||
|
||||
// First scan should rename all 3 files. Second scan, having waited for
|
||||
// the first via the in-flight promise, should find them already
|
||||
// renamed (== 0 fresh renames). What matters is that BOTH calls
|
||||
// resolved cleanly (no thrown ENOENT) and the disk state is correct.
|
||||
expect(typeof n1).toBe("number");
|
||||
expect(typeof n2).toBe("number");
|
||||
expect(n1 + n2).toBe(3);
|
||||
|
||||
// All three episodes should now have the folder-derived name (the
|
||||
// obfuscated source name was overridden via the v1.7.148 logic AND
|
||||
// the rename actually succeeded for ALL of them, not just some).
|
||||
for (const ep of episodes) {
|
||||
const dir = path.join(extractDir, ep.folder);
|
||||
const files = fs.readdirSync(dir);
|
||||
@@ -11243,9 +11098,6 @@ describe("download manager", () => {
|
||||
createStoragePaths(stateDir)
|
||||
);
|
||||
|
||||
// Both rename and mkvMove route through the SAME chain, so any pair of
|
||||
// invocations for the same package must run strictly sequentially —
|
||||
// even when they come from different call sites (hybrid + deferred).
|
||||
const pkgId = "crosspipe-pkg-1";
|
||||
let concurrent = 0;
|
||||
let maxConcurrent = 0;
|
||||
@@ -11268,9 +11120,7 @@ describe("download manager", () => {
|
||||
expect(r2).toBe("done-20");
|
||||
expect(r3).toBe("done-30");
|
||||
expect(r4).toBe("done-10");
|
||||
// Crucial: never more than 1 operation in flight at a time.
|
||||
expect(maxConcurrent).toBe(1);
|
||||
// Chain slot cleared after the last op completed.
|
||||
expect((manager as any).packageFileOpChain.has(pkgId)).toBe(false);
|
||||
});
|
||||
|
||||
@@ -11311,7 +11161,6 @@ describe("download manager", () => {
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -11353,13 +11202,8 @@ describe("download manager", () => {
|
||||
await (manager as any).collectMkvFilesToLibrary("movecomp-pkg", pkg);
|
||||
|
||||
const libFiles = fs.readdirSync(libDir);
|
||||
// Video AND subtitle moved to library.
|
||||
expect(libFiles).toContain("Show.S01E01.GERMAN.x264-GROUP.mkv");
|
||||
expect(libFiles).toContain("Show.S01E01.GERMAN.x264-GROUP.srt");
|
||||
// .nfo MUST NOT end up in the library — that's the user-visible bug
|
||||
// we are fixing. (It gets cleaned up with other residual files in
|
||||
// cleanupNonMkvResidualFiles after the move; we don't care whether it
|
||||
// survives in the extract dir, only that the library stays clean.)
|
||||
expect(libFiles).not.toContain("Show.S01E01.GERMAN.x264-GROUP.nfo");
|
||||
});
|
||||
|
||||
@@ -11392,10 +11236,8 @@ describe("download manager", () => {
|
||||
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`);
|
||||
@@ -11431,7 +11273,6 @@ describe("download manager", () => {
|
||||
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");
|
||||
|
||||
@@ -74,7 +74,6 @@ describe.skipIf(!hasJavaRuntime() || !hasJvmExtractorRuntime())("extractor jvm b
|
||||
const targetDir = path.join(root, "out");
|
||||
fs.mkdirSync(packageDir, { recursive: true });
|
||||
|
||||
// Create a ZIP with some content to trigger progress
|
||||
const zipPath = path.join(packageDir, "progress-test.zip");
|
||||
const zip = new AdmZip();
|
||||
zip.addFile("file1.txt", Buffer.from("Hello World ".repeat(100)));
|
||||
@@ -108,20 +107,16 @@ describe.skipIf(!hasJavaRuntime() || !hasJvmExtractorRuntime())("extractor jvm b
|
||||
expect(result.extracted).toBe(1);
|
||||
expect(result.failed).toBe(0);
|
||||
|
||||
// Should have at least preparing, extracting, and done phases
|
||||
const phases = new Set(progressUpdates.map((u) => u.phase));
|
||||
expect(phases.has("preparing")).toBe(true);
|
||||
expect(phases.has("extracting")).toBe(true);
|
||||
|
||||
// Extracting phase should include the archive name
|
||||
const extracting = progressUpdates.filter((u) => u.phase === "extracting" && u.archiveName === "progress-test.zip");
|
||||
expect(extracting.length).toBeGreaterThan(0);
|
||||
|
||||
// Should end at 100%
|
||||
const lastExtracting = extracting[extracting.length - 1];
|
||||
expect(lastExtracting.archivePercent).toBe(100);
|
||||
|
||||
// Files should exist
|
||||
expect(fs.existsSync(path.join(targetDir, "file1.txt"))).toBe(true);
|
||||
expect(fs.existsSync(path.join(targetDir, "file2.txt"))).toBe(true);
|
||||
});
|
||||
@@ -135,7 +130,6 @@ describe.skipIf(!hasJavaRuntime() || !hasJvmExtractorRuntime())("extractor jvm b
|
||||
const targetDir = path.join(root, "out");
|
||||
fs.mkdirSync(packageDir, { recursive: true });
|
||||
|
||||
// Create two separate ZIP archives
|
||||
const zip1 = new AdmZip();
|
||||
zip1.addFile("episode01.txt", Buffer.from("ep1 content"));
|
||||
zip1.writeZip(path.join(packageDir, "archive1.zip"));
|
||||
@@ -162,10 +156,8 @@ describe.skipIf(!hasJavaRuntime() || !hasJvmExtractorRuntime())("extractor jvm b
|
||||
|
||||
expect(result.extracted).toBe(2);
|
||||
expect(result.failed).toBe(0);
|
||||
// Both archive names should have appeared in progress
|
||||
expect(archiveNames.has("archive1.zip")).toBe(true);
|
||||
expect(archiveNames.has("archive2.zip")).toBe(true);
|
||||
// Both files extracted
|
||||
expect(fs.existsSync(path.join(targetDir, "episode01.txt"))).toBe(true);
|
||||
expect(fs.existsSync(path.join(targetDir, "episode02.txt"))).toBe(true);
|
||||
});
|
||||
|
||||
@@ -865,7 +865,6 @@ describe("extractor", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-sig-"));
|
||||
tempDirs.push(root);
|
||||
const filePath = path.join(root, "test.rar");
|
||||
// RAR5 signature: 52 61 72 21 1A 07
|
||||
fs.writeFileSync(filePath, Buffer.from("526172211a0700", "hex"));
|
||||
const sig = await detectArchiveSignature(filePath);
|
||||
expect(sig).toBe("rar");
|
||||
@@ -942,7 +941,6 @@ describe("extractor", () => {
|
||||
const candidates = await findArchiveCandidates(packageDir);
|
||||
const names = candidates.map((c) => path.basename(c));
|
||||
expect(names).toContain("movie.001");
|
||||
// .002 should NOT be in candidates (only .001 is the entry point)
|
||||
expect(names).not.toContain("movie.002");
|
||||
});
|
||||
|
||||
@@ -957,7 +955,6 @@ describe("extractor", () => {
|
||||
|
||||
const candidates = await findArchiveCandidates(packageDir);
|
||||
const names = candidates.map((c) => path.basename(c));
|
||||
// .zip.001 should appear once from zipSplit detection, not duplicated by genericSplit
|
||||
expect(names.filter((n) => n === "movie.zip.001")).toHaveLength(1);
|
||||
});
|
||||
|
||||
@@ -1100,7 +1097,6 @@ describe("extractor", () => {
|
||||
const targetDir = path.join(root, "out");
|
||||
fs.mkdirSync(packageDir, { recursive: true });
|
||||
|
||||
// Create 3 zip archives
|
||||
for (const name of ["ep01.zip", "ep02.zip", "ep03.zip"]) {
|
||||
const zip = new AdmZip();
|
||||
zip.addFile(`${name}.txt`, Buffer.from(name));
|
||||
@@ -1127,7 +1123,6 @@ describe("extractor", () => {
|
||||
|
||||
expect(result.extracted).toBe(3);
|
||||
expect(result.failed).toBe(0);
|
||||
// First archive should be ep01 (natural order, extracted serially for discovery)
|
||||
expect(seenOrder[0]).toBe("ep01.zip");
|
||||
});
|
||||
|
||||
@@ -1144,7 +1139,6 @@ describe("extractor", () => {
|
||||
zip.writeZip(path.join(packageDir, name));
|
||||
}
|
||||
|
||||
// No passwordList → only empty string → length=1 → no discovery phase
|
||||
const result = await extractPackageArchives({
|
||||
packageDir,
|
||||
targetDir,
|
||||
|
||||
@@ -34,25 +34,20 @@ describe("integrity", () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-int-"));
|
||||
tempDirs.push(dir);
|
||||
|
||||
// Create a .md5 manifest that exceeds the 5MB limit
|
||||
const largeContent = "d41d8cd98f00b204e9800998ecf8427e sample.bin\n".repeat(200000);
|
||||
const manifestPath = path.join(dir, "hashes.md5");
|
||||
fs.writeFileSync(manifestPath, largeContent, "utf8");
|
||||
|
||||
// Verify the file is actually > 5MB
|
||||
const stat = fs.statSync(manifestPath);
|
||||
expect(stat.size).toBeGreaterThan(5 * 1024 * 1024);
|
||||
|
||||
// readHashManifest should skip the oversized file
|
||||
const manifest = readHashManifest(dir);
|
||||
expect(manifest.size).toBe(0);
|
||||
});
|
||||
|
||||
it("does not parse SHA256 (64-char hex) as valid hash", () => {
|
||||
// SHA256 is 64 chars - parseHashLine only supports 32 (MD5) and 40 (SHA1)
|
||||
const sha256Line = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 emptyfile.bin";
|
||||
const result = parseHashLine(sha256Line);
|
||||
// 64-char hex should not match the MD5 (32) or SHA1 (40) pattern
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
|
||||
+12
-14
@@ -8,16 +8,16 @@ describe("link-parser", () => {
|
||||
{ name: "Package A", links: ["http://link1", "http://link2"] },
|
||||
{ name: "Package B", links: ["http://link3"] },
|
||||
{ name: "Package A", links: ["http://link4", "http://link1"] },
|
||||
{ name: "", links: ["http://link5"] } // empty name will be inferred
|
||||
{ name: "", links: ["http://link5"] }
|
||||
];
|
||||
|
||||
const result = mergePackageInputs(input);
|
||||
|
||||
expect(result).toHaveLength(3); // Package A, Package B, and inferred 'Paket'
|
||||
|
||||
|
||||
expect(result).toHaveLength(3);
|
||||
|
||||
const pkgA = result.find(p => p.name === "Package A");
|
||||
expect(pkgA?.links).toEqual(["http://link1", "http://link2", "http://link4"]); // link1 deduplicated
|
||||
|
||||
expect(pkgA?.links).toEqual(["http://link1", "http://link2", "http://link4"]);
|
||||
|
||||
const pkgB = result.find(p => p.name === "Package B");
|
||||
expect(pkgB?.links).toEqual(["http://link3"]);
|
||||
});
|
||||
@@ -29,8 +29,7 @@ describe("link-parser", () => {
|
||||
];
|
||||
|
||||
const result = mergePackageInputs(input);
|
||||
|
||||
// "Valid?Name*" becomes "Valid Name " -> trimmed to "Valid Name"
|
||||
|
||||
expect(result.map(p => p.name).sort()).toEqual(["Valid Name", "Valid_Name"]);
|
||||
});
|
||||
|
||||
@@ -59,24 +58,23 @@ describe("link-parser", () => {
|
||||
Here are some links:
|
||||
http://example.com/part1.rar
|
||||
http://example.com/part2.rar
|
||||
|
||||
|
||||
# package: Custom_Name
|
||||
http://other.com/file1
|
||||
http://other.com/file2
|
||||
`;
|
||||
|
||||
const result = parseCollectorInput(rawText, "DefaultFallback");
|
||||
|
||||
// Should have 2 packages: "DefaultFallback" and "Custom_Name"
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
|
||||
|
||||
const defaultPkg = result.find(p => p.name === "DefaultFallback");
|
||||
expect(defaultPkg?.links).toEqual([
|
||||
"http://example.com/part1.rar",
|
||||
"http://example.com/part2.rar"
|
||||
]);
|
||||
|
||||
const customPkg = result.find(p => p.name === "Custom_Name"); // sanitized!
|
||||
|
||||
const customPkg = result.find(p => p.name === "Custom_Name");
|
||||
expect(customPkg?.links).toEqual([
|
||||
"http://other.com/file1",
|
||||
"http://other.com/file2"
|
||||
|
||||
@@ -6,23 +6,18 @@ describe("logTimestamp", () => {
|
||||
const instant = new Date("2026-05-31T17:29:43.605Z");
|
||||
const formatted = logTimestamp(instant);
|
||||
|
||||
// Shape: YYYY-MM-DDTHH:MM:SS.mmm±HH:MM
|
||||
expect(formatted).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}[+-]\d{2}:\d{2}$/);
|
||||
// The whole point: NOT the old UTC "...Z" format that showed 17:29 instead of 19:29.
|
||||
expect(formatted.endsWith("Z")).toBe(false);
|
||||
});
|
||||
|
||||
it("is parseable back to the exact same instant (offset keeps it unambiguous)", () => {
|
||||
const instant = new Date("2026-05-31T17:29:43.605Z");
|
||||
// Date.parse must still recover the identical instant (trace-log autoDisableAt etc.).
|
||||
expect(new Date(logTimestamp(instant)).getTime()).toBe(instant.getTime());
|
||||
});
|
||||
|
||||
it("shows the LOCAL wall-clock hour (machine-timezone-independent assertion)", () => {
|
||||
const instant = new Date("2026-05-31T17:29:43.605Z");
|
||||
const formatted = logTimestamp(instant);
|
||||
// Hour segment must equal the local getHours() of the same instant — i.e. the
|
||||
// user's wall clock, whatever the server timezone is.
|
||||
expect(formatted.slice(11, 13)).toBe(String(instant.getHours()).padStart(2, "0"));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,7 +17,6 @@ function makeRandomFileKey(): Buffer {
|
||||
|
||||
function encryptAttributes(jsonAttrs: Record<string, unknown>, aesKey: Buffer): string {
|
||||
const plain = "MEGA" + JSON.stringify(jsonAttrs);
|
||||
// Pad to 16-byte boundary with \0 (Mega convention).
|
||||
const padded = Buffer.from(plain, "utf8");
|
||||
const padLen = (16 - (padded.length % 16)) % 16;
|
||||
const buf = Buffer.concat([padded, Buffer.alloc(padLen, 0)]);
|
||||
@@ -59,7 +58,6 @@ describe("mega-public-api", () => {
|
||||
expect(parsed?.rawKey.length).toBe(32);
|
||||
});
|
||||
it("parses legacy-format URL", () => {
|
||||
// Make a valid legacy URL with a 32-byte key.
|
||||
const id = "abcDEF12";
|
||||
const key = makeRandomFileKey();
|
||||
const url = `https://mega.nz/#!${id}!${base64Url(key)}`;
|
||||
@@ -143,7 +141,7 @@ describe("mega-public-api", () => {
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
async json() {
|
||||
return -9; // ENOENT — file not found
|
||||
return -9;
|
||||
}
|
||||
} as unknown as Response);
|
||||
|
||||
@@ -156,7 +154,7 @@ describe("mega-public-api", () => {
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
async json() {
|
||||
return [-16]; // EBLOCKED
|
||||
return [-16];
|
||||
}
|
||||
} as unknown as Response);
|
||||
|
||||
|
||||
@@ -21,19 +21,18 @@ describe("mega-web-fallback", () => {
|
||||
globalThis.fetch = vi.fn(async (url: string | URL | Request) => {
|
||||
const urlStr = String(url);
|
||||
fetchCallCount += 1;
|
||||
|
||||
|
||||
if (urlStr.includes("form=login")) {
|
||||
const headers = new Headers();
|
||||
headers.append("set-cookie", "session=goodcookie; path=/");
|
||||
return new Response("", { headers, status: 200 });
|
||||
}
|
||||
|
||||
|
||||
if (urlStr.includes("page=debrideur")) {
|
||||
return new Response('<form id="debridForm"></form>', { status: 200 });
|
||||
}
|
||||
|
||||
|
||||
if (urlStr.includes("form=debrid")) {
|
||||
// The POST to generate the code
|
||||
return new Response(`
|
||||
<div class="acp-box">
|
||||
<h3>Link: https://mega.debrid/link1</h3>
|
||||
@@ -41,22 +40,20 @@ describe("mega-web-fallback", () => {
|
||||
</div>
|
||||
`, { status: 200 });
|
||||
}
|
||||
|
||||
|
||||
if (urlStr.includes("ajax=debrid")) {
|
||||
// Polling endpoint
|
||||
return new Response(JSON.stringify({ link: "https://mega.direct/123" }), { status: 200 });
|
||||
}
|
||||
|
||||
|
||||
return new Response("Not found", { status: 404 });
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
const fallback = new MegaWebFallback(() => ({ login: "user", password: "pwd" }));
|
||||
|
||||
|
||||
const result = await fallback.unrestrict("https://mega.debrid/link1");
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.directUrl).toBe("https://mega.direct/123");
|
||||
expect(result?.fileName).toBe("link1");
|
||||
// Calls: 1. Login POST, 2. Verify GET, 3. Generate POST, 4. Polling POST
|
||||
expect(fetchCallCount).toBe(4);
|
||||
});
|
||||
|
||||
@@ -83,17 +80,11 @@ describe("mega-web-fallback", () => {
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
const fallback = new MegaWebFallback(() => ({ login: "user", password: "pwd" }));
|
||||
// Muss schnell mit der ECHTEN Meldung scheitern — NICHT null zurückgeben (was
|
||||
// re-Login + erneutes Pollen auslösen würde und das Rotations-Budget frisst).
|
||||
await expect(fallback.unrestrict("https://mega.debrid/l1")).rejects.toThrow(/kein server für diesen hoster/i);
|
||||
expect(ajaxCalls).toBe(1);
|
||||
});
|
||||
|
||||
it("surfaces 'Kein Server für diesen Hoster' from the debrid PAGE (daily limit, no debrid code) instead of empty", async () => {
|
||||
// Tageslimit dieses Accounts: die DEBRID-Seite enthält KEINEN processDebrid-Code,
|
||||
// sondern die Limit-Meldung als Page-Error. Früher -> kein Code -> null -> "Antwort
|
||||
// leer" (auf Message-Ebene nicht als Tageslimit erkennbar). Jetzt muss die Meldung
|
||||
// als Fehler hochkommen, damit die Rotation den Account als limitiert behandelt.
|
||||
let ajaxCalls = 0;
|
||||
globalThis.fetch = vi.fn(async (url: string | URL | Request) => {
|
||||
const urlStr = String(url);
|
||||
@@ -106,7 +97,6 @@ describe("mega-web-fallback", () => {
|
||||
return new Response('<form id="debridForm"></form>', { status: 200 });
|
||||
}
|
||||
if (urlStr.includes("form=debrid")) {
|
||||
// Keine processDebrid(...)-Codes — nur die Tageslimit-Meldung als Page-Error.
|
||||
return new Response('<div class="error">Erreur : Kein Server für diesen Hoster verfügbar. Bitte versuchen Sie es später noch einmal.</div>', { status: 200 });
|
||||
}
|
||||
if (urlStr.includes("ajax=debrid")) {
|
||||
@@ -118,7 +108,6 @@ describe("mega-web-fallback", () => {
|
||||
|
||||
const fallback = new MegaWebFallback(() => ({ login: "user", password: "pwd" }));
|
||||
await expect(fallback.unrestrict("https://mega.debrid/l1")).rejects.toThrow(/kein server für diesen hoster/i);
|
||||
// Ohne Code wird gar nicht erst gepollt — die Meldung kommt direkt von der Seite.
|
||||
expect(ajaxCalls).toBe(0);
|
||||
});
|
||||
|
||||
@@ -145,9 +134,7 @@ describe("mega-web-fallback", () => {
|
||||
return new Response("Not found", { status: 404 });
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
// getCredentials liefert den DEFAULT/Legacy-Account ...
|
||||
const fallback = new MegaWebFallback(() => ({ login: "defaultacc", password: "defpw" }));
|
||||
// ... aber die Rotation übergibt explizit Account 2 — DESSEN Login MUSS verwendet werden.
|
||||
const result = await fallback.unrestrict("https://mega.debrid/l1", undefined, { login: "account2", password: "pw2" });
|
||||
expect(result?.directUrl).toBe("https://mega.direct/ok");
|
||||
expect(loginsUsed).toContain("account2");
|
||||
@@ -158,14 +145,14 @@ describe("mega-web-fallback", () => {
|
||||
globalThis.fetch = vi.fn(async (url: string | URL | Request) => {
|
||||
const urlStr = String(url);
|
||||
if (urlStr.includes("form=login")) {
|
||||
const headers = new Headers(); // No cookie
|
||||
const headers = new Headers();
|
||||
return new Response("", { headers, status: 200 });
|
||||
}
|
||||
return new Response("Not found", { status: 404 });
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
const fallback = new MegaWebFallback(() => ({ login: "bad", password: "bad" }));
|
||||
|
||||
|
||||
await expect(fallback.unrestrict("http://mega.debrid/file"))
|
||||
.rejects.toThrow("Mega-Web Login liefert kein Session-Cookie");
|
||||
});
|
||||
@@ -179,18 +166,17 @@ describe("mega-web-fallback", () => {
|
||||
return new Response("", { headers, status: 200 });
|
||||
}
|
||||
if (urlStr.includes("page=debrideur")) {
|
||||
// Missing form!
|
||||
return new Response('<html><body>Nothing here</body></html>', { status: 200 });
|
||||
}
|
||||
return new Response("Not found", { status: 404 });
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
const fallback = new MegaWebFallback(() => ({ login: "a", password: "b" }));
|
||||
|
||||
|
||||
await expect(fallback.unrestrict("http://mega.debrid/file"))
|
||||
.rejects.toThrow("Mega-Web Login ungültig oder Session blockiert");
|
||||
});
|
||||
|
||||
|
||||
it("returns null if generation fails to find a code", async () => {
|
||||
let callCount = 0;
|
||||
globalThis.fetch = vi.fn(async (url: string | URL | Request) => {
|
||||
@@ -205,7 +191,6 @@ describe("mega-web-fallback", () => {
|
||||
return new Response('<form id="debridForm"></form>', { status: 200 });
|
||||
}
|
||||
if (urlStr.includes("form=debrid")) {
|
||||
// The generate POST returns HTML without any codes
|
||||
return new Response(`<div>No links here</div>`, { status: 200 });
|
||||
}
|
||||
return new Response("Not found", { status: 404 });
|
||||
@@ -213,8 +198,7 @@ describe("mega-web-fallback", () => {
|
||||
|
||||
const fallback = new MegaWebFallback(() => ({ login: "a", password: "b" }));
|
||||
const result = await fallback.unrestrict("http://mega.debrid/file");
|
||||
|
||||
// Generation fails -> resets cookie -> tries again -> fails again -> returns null
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
|
||||
@@ -17,7 +17,6 @@ function makeItems(names: string[]): MinimalItem[] {
|
||||
}
|
||||
|
||||
describe("resolveArchiveItemsFromList", () => {
|
||||
// ── Multipart RAR (.partN.rar) ──
|
||||
|
||||
it("matches multipart .part1.rar archives", () => {
|
||||
const items = makeItems([
|
||||
@@ -46,8 +45,6 @@ describe("resolveArchiveItemsFromList", () => {
|
||||
expect(result).toHaveLength(3);
|
||||
});
|
||||
|
||||
// ── Old-style RAR (.rar + .r00, .r01, etc.) ──
|
||||
|
||||
it("matches old-style .rar + .rNN volumes", () => {
|
||||
const items = makeItems([
|
||||
"Archive.rar",
|
||||
@@ -60,8 +57,6 @@ describe("resolveArchiveItemsFromList", () => {
|
||||
expect(result).toHaveLength(4);
|
||||
});
|
||||
|
||||
// ── Single RAR ──
|
||||
|
||||
it("matches a single .rar file", () => {
|
||||
const items = makeItems(["SingleFile.rar", "Other.mkv"]);
|
||||
const result = resolveArchiveItemsFromList("SingleFile.rar", items as any);
|
||||
@@ -69,8 +64,6 @@ describe("resolveArchiveItemsFromList", () => {
|
||||
expect((result[0] as any).fileName).toBe("SingleFile.rar");
|
||||
});
|
||||
|
||||
// ── Split ZIP ──
|
||||
|
||||
it("matches split .zip.NNN files", () => {
|
||||
const items = makeItems([
|
||||
"Data.zip",
|
||||
@@ -82,8 +75,6 @@ describe("resolveArchiveItemsFromList", () => {
|
||||
expect(result).toHaveLength(4);
|
||||
});
|
||||
|
||||
// ── Split 7z ──
|
||||
|
||||
it("matches split .7z.NNN files", () => {
|
||||
const items = makeItems([
|
||||
"Backup.7z.001",
|
||||
@@ -93,8 +84,6 @@ describe("resolveArchiveItemsFromList", () => {
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
|
||||
// ── Generic .NNN splits ──
|
||||
|
||||
it("matches generic .NNN split files", () => {
|
||||
const items = makeItems([
|
||||
"video.001",
|
||||
@@ -105,8 +94,6 @@ describe("resolveArchiveItemsFromList", () => {
|
||||
expect(result).toHaveLength(3);
|
||||
});
|
||||
|
||||
// ── Exact filename match ──
|
||||
|
||||
it("matches a single .zip by exact name", () => {
|
||||
const items = makeItems(["myarchive.zip", "other.rar"]);
|
||||
const result = resolveArchiveItemsFromList("myarchive.zip", items as any);
|
||||
@@ -114,8 +101,6 @@ describe("resolveArchiveItemsFromList", () => {
|
||||
expect((result[0] as any).fileName).toBe("myarchive.zip");
|
||||
});
|
||||
|
||||
// ── Case insensitivity ──
|
||||
|
||||
it("matches case-insensitively", () => {
|
||||
const items = makeItems([
|
||||
"MOVIE.PART1.RAR",
|
||||
@@ -125,40 +110,26 @@ describe("resolveArchiveItemsFromList", () => {
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
|
||||
// ── Stem-based fallback ──
|
||||
|
||||
it("uses stem-based fallback when exact patterns fail", () => {
|
||||
// Simulate a debrid service that renames "Movie.part1.rar" to "Movie.part1_dl.rar"
|
||||
// but the disk file is "Movie.part1.rar"
|
||||
const items = makeItems([
|
||||
"Movie.rar",
|
||||
]);
|
||||
// The archive on disk is "Movie.part1.rar" but there's no item matching the
|
||||
// .partN pattern. The stem "movie" should match "Movie.rar" via fallback.
|
||||
const result = resolveArchiveItemsFromList("Movie.part1.rar", items as any);
|
||||
// stem fallback: "movie" starts with "movie" and ends with .rar
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
|
||||
// ── Single item fallback ──
|
||||
|
||||
it("returns single archive item when no pattern matches", () => {
|
||||
const items = makeItems(["totally-different-name.rar"]);
|
||||
const result = resolveArchiveItemsFromList("Original.rar", items as any);
|
||||
// Single item in list with archive extension → return it
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
|
||||
// ── Empty when no match ──
|
||||
|
||||
it("returns empty when items have no archive extensions", () => {
|
||||
const items = makeItems(["video.mkv", "subtitle.srt"]);
|
||||
const result = resolveArchiveItemsFromList("Archive.rar", items as any);
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
// ── Items without targetPath ──
|
||||
|
||||
it("falls back to fileName when targetPath is missing", () => {
|
||||
const items = [
|
||||
{ fileName: "Movie.part1.rar", id: "1", status: "completed" },
|
||||
@@ -168,8 +139,6 @@ describe("resolveArchiveItemsFromList", () => {
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
|
||||
// ── Multiple archives, should not cross-match ──
|
||||
|
||||
it("does not cross-match different archive groups", () => {
|
||||
const items = makeItems([
|
||||
"Episode.S01E01.part1.rar",
|
||||
|
||||
@@ -8,9 +8,7 @@ import { setLogListener } from "../src/main/logger";
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
// Ensure session log is shut down between tests
|
||||
shutdownSessionLog();
|
||||
// Ensure listener is cleared between tests
|
||||
setLogListener(null);
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
@@ -42,11 +40,9 @@ describe("session-log", () => {
|
||||
initSessionLog(baseDir);
|
||||
const logPath = getSessionLogPath()!;
|
||||
|
||||
// Simulate a log line via the listener
|
||||
const { logger } = await import("../src/main/logger");
|
||||
logger.info("Test-Nachricht für Session-Log");
|
||||
|
||||
// Wait for flush (200ms interval + margin)
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
|
||||
const content = fs.readFileSync(logPath, "utf8");
|
||||
@@ -77,7 +73,6 @@ describe("session-log", () => {
|
||||
|
||||
shutdownSessionLog();
|
||||
|
||||
// Log after shutdown - should NOT appear in session log
|
||||
const { logger } = await import("../src/main/logger");
|
||||
logger.info("Nach-Shutdown-Nachricht");
|
||||
|
||||
@@ -94,21 +89,16 @@ describe("session-log", () => {
|
||||
const logsDir = path.join(baseDir, "session-logs");
|
||||
fs.mkdirSync(logsDir, { recursive: true });
|
||||
|
||||
// Create a fake old session log
|
||||
const oldFile = path.join(logsDir, "session_2020-01-01_00-00-00.txt");
|
||||
fs.writeFileSync(oldFile, "old session");
|
||||
// Set mtime to 30 days ago
|
||||
const oldTime = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
||||
fs.utimesSync(oldFile, oldTime, oldTime);
|
||||
|
||||
// Create a recent file
|
||||
const newFile = path.join(logsDir, "session_2099-01-01_00-00-00.txt");
|
||||
fs.writeFileSync(newFile, "new session");
|
||||
|
||||
// initSessionLog triggers cleanup
|
||||
initSessionLog(baseDir);
|
||||
|
||||
// Wait for async cleanup
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
|
||||
expect(fs.existsSync(oldFile)).toBe(false);
|
||||
@@ -124,7 +114,6 @@ describe("session-log", () => {
|
||||
const logsDir = path.join(baseDir, "session-logs");
|
||||
fs.mkdirSync(logsDir, { recursive: true });
|
||||
|
||||
// Create a file from 2 days ago (should be kept)
|
||||
const recentFile = path.join(logsDir, "session_2025-12-01_00-00-00.txt");
|
||||
fs.writeFileSync(recentFile, "recent session");
|
||||
const recentTime = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000);
|
||||
@@ -147,7 +136,6 @@ describe("session-log", () => {
|
||||
const path1 = getSessionLogPath();
|
||||
shutdownSessionLog();
|
||||
|
||||
// Small delay to ensure different timestamp
|
||||
await new Promise((resolve) => setTimeout(resolve, 1100));
|
||||
|
||||
initSessionLog(baseDir);
|
||||
|
||||
@@ -12,12 +12,6 @@ import {
|
||||
saveSessionAsync
|
||||
} from "../src/main/storage";
|
||||
|
||||
// Regression tests for queue loss across an app-update restart.
|
||||
// Both scenarios were observed empirically to drop packages before the fix:
|
||||
// - a queued stale async save clobbering a newer synchronous save
|
||||
// (persistNowSync / prepareForShutdown), and
|
||||
// - loadSession ignoring a good .bak when the primary file is momentarily absent.
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
@@ -66,7 +60,6 @@ function makeItem(id: string, packageId: string): DownloadItem {
|
||||
};
|
||||
}
|
||||
|
||||
/** Build a session whose package set is exactly `ids`. */
|
||||
function sessionWith(ids: string[]): SessionState {
|
||||
const s = emptySession();
|
||||
for (const id of ids) {
|
||||
@@ -91,8 +84,6 @@ describe("session restart loss", () => {
|
||||
|
||||
saveSession(paths, sessionWith(["A", "B"]));
|
||||
|
||||
// An async save goes in-flight, a second async save (stale snapshot) gets
|
||||
// queued, then a synchronous save persists the live state with package C.
|
||||
const inflight = saveSessionAsync(paths, sessionWith(["A", "B"]));
|
||||
const queued = saveSessionAsync(paths, sessionWith(["A", "B"]));
|
||||
saveSession(paths, sessionWith(["A", "B", "C"]));
|
||||
|
||||
@@ -13,7 +13,6 @@ afterEach(() => {
|
||||
try {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// ignore cleanup errors
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -88,7 +87,6 @@ describe("runStartupHealthCheck", () => {
|
||||
it("flags large state files", () => {
|
||||
const { outputDir, paths } = makeTempBase();
|
||||
fs.mkdirSync(paths.baseDir, { recursive: true });
|
||||
// 60 MB dummy state file, threshold is 50 MB
|
||||
fs.writeFileSync(paths.sessionFile, Buffer.alloc(60 * 1024 * 1024, 0));
|
||||
|
||||
const settings = {
|
||||
@@ -103,7 +101,6 @@ describe("runStartupHealthCheck", () => {
|
||||
|
||||
it("flags missing base dir as ERROR", () => {
|
||||
const { outputDir, paths } = makeTempBase();
|
||||
// Intentionally DON'T create baseDir.
|
||||
|
||||
const settings = {
|
||||
...defaultSettings(),
|
||||
|
||||
+478
-488
File diff suppressed because it is too large
Load Diff
@@ -10,14 +10,6 @@ import { createStoragePaths, emptySession, loadSession } from "../src/main/stora
|
||||
import { shutdownItemLogs } from "../src/main/item-log";
|
||||
import { shutdownPackageLogs } from "../src/main/package-log";
|
||||
|
||||
// Regression for the reported symptom: after an app update while downloading,
|
||||
// packages that were in flight do not continue after the restart.
|
||||
//
|
||||
// Root cause: installUpdate() called manager.stop(), whose abort continuation
|
||||
// marks the in-flight item "cancelled"/"Gestoppt". autoResumeOnStart only
|
||||
// resumes "queued"/"reconnect_wait" items, so after the silent-install relaunch
|
||||
// the download silently stays parked instead of continuing.
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
@@ -47,9 +39,6 @@ async function waitFor(predicate: () => boolean, timeoutMs = 20000): Promise<voi
|
||||
}
|
||||
}
|
||||
|
||||
/** Starts an HTTP server that trickles bytes forever so a download stays
|
||||
* actively "downloading" until it is aborted. Returns the direct URL plus a
|
||||
* stop() that tears down all open responses and the server. */
|
||||
async function startTricklingServer(): Promise<{ directUrl: string; stop: () => Promise<void> }> {
|
||||
const openTimers = new Set<NodeJS.Timeout>();
|
||||
const openResponses = new Set<http.ServerResponse>();
|
||||
@@ -68,7 +57,6 @@ async function startTricklingServer(): Promise<{ directUrl: string; stop: () =>
|
||||
try {
|
||||
res.write(Buffer.alloc(16 * 1024, 9));
|
||||
} catch {
|
||||
// socket gone
|
||||
}
|
||||
}, 100);
|
||||
openTimers.add(timer);
|
||||
@@ -94,7 +82,6 @@ async function startTricklingServer(): Promise<{ directUrl: string; stop: () =>
|
||||
try {
|
||||
res.destroy();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
openResponses.clear();
|
||||
@@ -157,7 +144,6 @@ describe("update restart resume", () => {
|
||||
const reloaded = loadSession(paths);
|
||||
const item = Object.values(reloaded.items)[0];
|
||||
expect(item).toBeTruthy();
|
||||
// Documents the loss of resumability: cancelled items are not auto-resumed.
|
||||
expect(item.status).toBe("cancelled");
|
||||
} finally {
|
||||
await serverStop();
|
||||
@@ -169,7 +155,6 @@ describe("update restart resume", () => {
|
||||
tempDirs.push(root);
|
||||
const { manager, paths, serverStop } = await driveActiveDownload(root);
|
||||
try {
|
||||
// Mirrors AppController.installUpdate(): park downloads, then sync-persist.
|
||||
manager.stop({ parkForRestart: true });
|
||||
manager.persistNowSync();
|
||||
await waitFor(() => (manager as unknown as { activeTasks: Map<string, unknown> }).activeTasks.size === 0);
|
||||
@@ -178,7 +163,6 @@ describe("update restart resume", () => {
|
||||
const reloaded = loadSession(paths);
|
||||
const item = Object.values(reloaded.items)[0];
|
||||
expect(item).toBeTruthy();
|
||||
// The package/item must survive AND be resumable so auto-resume continues it.
|
||||
expect(Object.keys(reloaded.packages).length).toBe(1);
|
||||
expect(item.status).toBe("queued");
|
||||
} finally {
|
||||
|
||||
+539
-540
File diff suppressed because it is too large
Load Diff
@@ -92,7 +92,6 @@ describe("utils", () => {
|
||||
const result = sanitizeFilename(longName);
|
||||
expect(typeof result).toBe("string");
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
// The function should return a non-empty string and not crash
|
||||
expect(result).toBe(longName);
|
||||
});
|
||||
|
||||
@@ -100,7 +99,6 @@ describe("utils", () => {
|
||||
const result = formatEta(999999);
|
||||
expect(typeof result).toBe("string");
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
// 999999 seconds = 277h 46m 39s
|
||||
expect(result).toBe("277:46:39");
|
||||
});
|
||||
|
||||
@@ -113,28 +111,22 @@ describe("utils", () => {
|
||||
|
||||
it("extracts filenames from URLs with encoded characters", () => {
|
||||
expect(filenameFromUrl("https://example.com/file%20with%20spaces.rar")).toBe("file with spaces.rar");
|
||||
// %C3%A9 decodes to e-acute (UTF-8), which is preserved
|
||||
expect(filenameFromUrl("https://example.com/t%C3%A9st%20file.zip")).toBe("t\u00e9st file.zip");
|
||||
expect(filenameFromUrl("https://example.com/dl?filename=Movie%20Name%20S01E01.mkv")).toBe("Movie Name S01E01.mkv");
|
||||
// Malformed percent-encoding should not crash
|
||||
const result = filenameFromUrl("https://example.com/%ZZ%invalid");
|
||||
expect(typeof result).toBe("string");
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("handles looksLikeOpaqueFilename edge cases", () => {
|
||||
// Empty string -> sanitizeFilename returns "Paket" which is not opaque
|
||||
expect(looksLikeOpaqueFilename("")).toBe(false);
|
||||
expect(looksLikeOpaqueFilename("a")).toBe(false);
|
||||
expect(looksLikeOpaqueFilename("ab")).toBe(false);
|
||||
expect(looksLikeOpaqueFilename("abc")).toBe(false);
|
||||
expect(looksLikeOpaqueFilename("download.bin")).toBe(true);
|
||||
// 24-char hex string is opaque (matches /^[a-f0-9]{24,}$/)
|
||||
expect(looksLikeOpaqueFilename("abcdef123456789012345678")).toBe(true);
|
||||
expect(looksLikeOpaqueFilename("abcdef1234567890abcdef12")).toBe(true);
|
||||
// Short hex strings (< 24 chars) are NOT considered opaque
|
||||
expect(looksLikeOpaqueFilename("abcdef12345")).toBe(false);
|
||||
// Real filename with extension
|
||||
expect(looksLikeOpaqueFilename("Show.S01E01.720p.mkv")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user