Fix auto-recovery re-download loop: check archive magic bytes before forcing re-download
Files with valid RAR/7z/ZIP signature are not corrupt (wrong password), only files with invalid signature get force-redownloaded. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
3ab7b6c9a3
commit
291c80c7fc
@ -4232,12 +4232,39 @@ export class DownloadManager extends EventEmitter {
|
|||||||
.filter(({ state }) => state.reason !== "ok");
|
.filter(({ state }) => state.reason !== "ok");
|
||||||
|
|
||||||
if (corruptArchiveItems.length === 0) {
|
if (corruptArchiveItems.length === 0) {
|
||||||
// The extractor confirmed corruption (CRC error) but all files look
|
// All files have the expected size on disk. This can mean either:
|
||||||
// correct by size. This happens when content is corrupt despite having
|
// (a) content is corrupt despite correct size (network corruption), or
|
||||||
// the right byte count (e.g. network corruption during download).
|
// (b) archive is valid but password is wrong (e.g. header-encrypted RAR).
|
||||||
// Trust the extractor verdict and force re-download of ALL archive parts.
|
// Check the RAR magic bytes of the first part to distinguish:
|
||||||
|
// valid signature → password issue → don't waste traffic re-downloading.
|
||||||
|
// invalid signature → genuine corruption → force re-download.
|
||||||
|
const firstPart = inspectedArchiveItems.find(({ state }) => state.diskPath);
|
||||||
|
let hasValidSignature = false;
|
||||||
|
if (firstPart?.state.diskPath) {
|
||||||
|
try {
|
||||||
|
const fd = fs.openSync(firstPart.state.diskPath, "r");
|
||||||
|
try {
|
||||||
|
const header = Buffer.alloc(8);
|
||||||
|
fs.readSync(fd, header, 0, 8, 0);
|
||||||
|
// RAR4: 52 61 72 21 1a 07 00, RAR5: 52 61 72 21 1a 07 01 00
|
||||||
|
// 7z: 37 7a bc af 27 1c, ZIP: 50 4b 03 04
|
||||||
|
hasValidSignature =
|
||||||
|
(header[0] === 0x52 && header[1] === 0x61 && header[2] === 0x72 && header[3] === 0x21 && header[4] === 0x1a && header[5] === 0x07) ||
|
||||||
|
(header[0] === 0x37 && header[1] === 0x7a && header[2] === 0xbc && header[3] === 0xaf) ||
|
||||||
|
(header[0] === 0x50 && header[1] === 0x4b && header[2] === 0x03 && header[3] === 0x04);
|
||||||
|
} finally {
|
||||||
|
fs.closeSync(fd);
|
||||||
|
}
|
||||||
|
} catch { /* can't read → treat as corrupt */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasValidSignature) {
|
||||||
|
logger.warn(`Auto-Recovery (${scope}): ${failure.archiveName} uebersprungen - Dateien korrekte Groesse, Archiv-Signatur gueltig (vermutlich falsches Passwort)`);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
logger.warn(
|
logger.warn(
|
||||||
`Auto-Recovery (${scope}): ${failure.archiveName} - Dateien korrekte Groesse aber Extractor meldet CRC-Fehler, ` +
|
`Auto-Recovery (${scope}): ${failure.archiveName} - Dateien korrekte Groesse aber ungueltige Archiv-Signatur, ` +
|
||||||
`erzwinge Re-Download aller ${archiveItems.length} Parts`
|
`erzwinge Re-Download aller ${archiveItems.length} Parts`
|
||||||
);
|
);
|
||||||
corruptArchiveItems.push(...inspectedArchiveItems);
|
corruptArchiveItems.push(...inspectedArchiveItems);
|
||||||
|
|||||||
@ -2042,25 +2042,25 @@ describe("download manager", () => {
|
|||||||
expect(session.packages[packageId]?.status).toBe("queued");
|
expect(session.packages[packageId]?.status).toBe("queued");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("requeues completed archive parts on CRC error even when file size matches", () => {
|
it("requeues archive parts on CRC error when file has invalid archive signature (corrupt content)", () => {
|
||||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
||||||
tempDirs.push(root);
|
tempDirs.push(root);
|
||||||
|
|
||||||
const session = emptySession();
|
const session = emptySession();
|
||||||
const packageId = "crc-clean-pkg";
|
const packageId = "crc-corrupt-sig-pkg";
|
||||||
const createdAt = Date.now() - 10_000;
|
const createdAt = Date.now() - 10_000;
|
||||||
const outputDir = path.join(root, "downloads", "crc-clean");
|
const outputDir = path.join(root, "downloads", "crc-corrupt-sig");
|
||||||
const extractDir = path.join(root, "extract", "crc-clean");
|
const extractDir = path.join(root, "extract", "crc-corrupt-sig");
|
||||||
fs.mkdirSync(outputDir, { recursive: true });
|
fs.mkdirSync(outputDir, { recursive: true });
|
||||||
|
|
||||||
const archiveNames = ["show.s01e01.part1.rar", "show.s01e01.part2.rar"];
|
const archiveNames = ["show.s01e01.part1.rar", "show.s01e01.part2.rar"];
|
||||||
const itemIds = archiveNames.map((_, index) => `crc-clean-item-${index}`);
|
const itemIds = archiveNames.map((_, index) => `crc-corrupt-sig-item-${index}`);
|
||||||
const archiveSize = 64 * 1024;
|
const archiveSize = 64 * 1024;
|
||||||
|
|
||||||
session.packageOrder = [packageId];
|
session.packageOrder = [packageId];
|
||||||
session.packages[packageId] = {
|
session.packages[packageId] = {
|
||||||
id: packageId,
|
id: packageId,
|
||||||
name: "crc-clean",
|
name: "crc-corrupt-sig",
|
||||||
outputDir,
|
outputDir,
|
||||||
extractDir,
|
extractDir,
|
||||||
status: "extracting",
|
status: "extracting",
|
||||||
@ -2073,7 +2073,8 @@ describe("download manager", () => {
|
|||||||
|
|
||||||
for (const [index, archiveName] of archiveNames.entries()) {
|
for (const [index, archiveName] of archiveNames.entries()) {
|
||||||
const targetPath = path.join(outputDir, archiveName);
|
const targetPath = path.join(outputDir, archiveName);
|
||||||
fs.writeFileSync(targetPath, Buffer.alloc(archiveSize, index + 1));
|
// Write garbage content (no valid archive signature) — simulates corrupt download
|
||||||
|
fs.writeFileSync(targetPath, Buffer.alloc(archiveSize, 0xAA));
|
||||||
session.items[itemIds[index]!] = {
|
session.items[itemIds[index]!] = {
|
||||||
id: itemIds[index]!,
|
id: itemIds[index]!,
|
||||||
packageId,
|
packageId,
|
||||||
@ -2121,8 +2122,7 @@ describe("download manager", () => {
|
|||||||
"hybrid"
|
"hybrid"
|
||||||
);
|
);
|
||||||
|
|
||||||
// CRC error from extractor IS evidence of corruption — even when files
|
// Invalid archive signature = genuine corruption → force re-download
|
||||||
// have the right size, content may be corrupt. Must force re-download.
|
|
||||||
expect(changed).toBe(2);
|
expect(changed).toBe(2);
|
||||||
for (const itemId of itemIds) {
|
for (const itemId of itemIds) {
|
||||||
const item = session.items[itemId]!;
|
const item = session.items[itemId]!;
|
||||||
@ -2135,6 +2135,98 @@ describe("download manager", () => {
|
|||||||
expect(fs.existsSync(path.join(outputDir, archiveNames[1]!))).toBe(false);
|
expect(fs.existsSync(path.join(outputDir, archiveNames[1]!))).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not requeue archive parts on CRC error when file has valid RAR signature (wrong password)", () => {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
||||||
|
tempDirs.push(root);
|
||||||
|
|
||||||
|
const session = emptySession();
|
||||||
|
const packageId = "crc-valid-sig-pkg";
|
||||||
|
const createdAt = Date.now() - 10_000;
|
||||||
|
const outputDir = path.join(root, "downloads", "crc-valid-sig");
|
||||||
|
const extractDir = path.join(root, "extract", "crc-valid-sig");
|
||||||
|
fs.mkdirSync(outputDir, { recursive: true });
|
||||||
|
|
||||||
|
const archiveNames = ["show.s01e01.part1.rar", "show.s01e01.part2.rar"];
|
||||||
|
const itemIds = archiveNames.map((_, index) => `crc-valid-sig-item-${index}`);
|
||||||
|
const archiveSize = 64 * 1024;
|
||||||
|
|
||||||
|
session.packageOrder = [packageId];
|
||||||
|
session.packages[packageId] = {
|
||||||
|
id: packageId,
|
||||||
|
name: "crc-valid-sig",
|
||||||
|
outputDir,
|
||||||
|
extractDir,
|
||||||
|
status: "extracting",
|
||||||
|
itemIds,
|
||||||
|
cancelled: false,
|
||||||
|
enabled: true,
|
||||||
|
createdAt,
|
||||||
|
updatedAt: createdAt
|
||||||
|
};
|
||||||
|
|
||||||
|
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);
|
||||||
|
session.items[itemIds[index]!] = {
|
||||||
|
id: itemIds[index]!,
|
||||||
|
packageId,
|
||||||
|
url: `https://dummy/${archiveName}`,
|
||||||
|
provider: "realdebrid",
|
||||||
|
status: "completed",
|
||||||
|
retries: 0,
|
||||||
|
speedBps: 0,
|
||||||
|
downloadedBytes: archiveSize,
|
||||||
|
totalBytes: archiveSize,
|
||||||
|
progressPercent: 100,
|
||||||
|
fileName: archiveName,
|
||||||
|
targetPath,
|
||||||
|
resumable: true,
|
||||||
|
attempts: 1,
|
||||||
|
lastError: "",
|
||||||
|
fullStatus: "Entpacken - Ausstehend",
|
||||||
|
createdAt,
|
||||||
|
updatedAt: createdAt
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const manager = new DownloadManager(
|
||||||
|
{
|
||||||
|
...defaultSettings(),
|
||||||
|
token: "rd-token",
|
||||||
|
outputDir: path.join(root, "downloads"),
|
||||||
|
extractDir: path.join(root, "extract"),
|
||||||
|
autoExtract: true
|
||||||
|
},
|
||||||
|
session,
|
||||||
|
createStoragePaths(path.join(root, "state"))
|
||||||
|
);
|
||||||
|
|
||||||
|
const changed = (manager as any).autoRecoverArchiveCrcFailure(
|
||||||
|
session.packages[packageId],
|
||||||
|
itemIds.map((itemId) => session.items[itemId]!),
|
||||||
|
{
|
||||||
|
archiveName: "show.s01e01.part1.rar",
|
||||||
|
errorText: "Checksum error in the encrypted file",
|
||||||
|
category: "crc_error",
|
||||||
|
suggestRedownload: true,
|
||||||
|
jvmFailureReason: "Can not open the file as archive"
|
||||||
|
},
|
||||||
|
"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]!;
|
||||||
|
expect(item.status).toBe("completed");
|
||||||
|
expect(item.targetPath).toContain(".rar");
|
||||||
|
expect(item.downloadedBytes).toBe(archiveSize);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("does not treat rev files as ready archive parts during disk fallback", async () => {
|
it("does not treat rev files as ready archive parts during disk fallback", async () => {
|
||||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
||||||
tempDirs.push(root);
|
tempDirs.push(root);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user