Compare commits
8 Commits
8196263ac3
...
68f50eaa5e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
68f50eaa5e | ||
|
|
c8ea2f6765 | ||
|
|
45789918b0 | ||
|
|
2b639b7267 | ||
|
|
1a33fc2573 | ||
|
|
56bae4a384 | ||
|
|
2646cba1c7 | ||
|
|
21fb09b208 |
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "real-debrid-downloader",
|
"name": "real-debrid-downloader",
|
||||||
"version": "1.7.215",
|
"version": "1.7.216",
|
||||||
"description": "Desktop downloader",
|
"description": "Desktop downloader",
|
||||||
"main": "build/main/main/main.js",
|
"main": "build/main/main/main.js",
|
||||||
"author": "Sucukdeluxe",
|
"author": "Sucukdeluxe",
|
||||||
|
|||||||
@ -789,6 +789,7 @@ public async checkDebridAccounts(): Promise<DebridAccountStatus[]> {
|
|||||||
}
|
}
|
||||||
stopDebugServer();
|
stopDebugServer();
|
||||||
abortActiveUpdateDownload();
|
abortActiveUpdateDownload();
|
||||||
|
cancelPendingAsyncSaves();
|
||||||
this.manager.prepareForShutdown();
|
this.manager.prepareForShutdown();
|
||||||
this.megaWebFallback.dispose();
|
this.megaWebFallback.dispose();
|
||||||
this.realDebridWebFallback.dispose();
|
this.realDebridWebFallback.dispose();
|
||||||
|
|||||||
@ -3741,6 +3741,9 @@ export class DebridService {
|
|||||||
if (signal?.aborted || (/aborted/i.test(errorText) && !/timeout/i.test(errorText))) {
|
if (signal?.aborted || (/aborted/i.test(errorText) && !/timeout/i.test(errorText))) {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
if (!settings.autoProviderFallback) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -3757,6 +3760,9 @@ export class DebridService {
|
|||||||
if (signal?.aborted || (/aborted/i.test(errorText) && !/timeout/i.test(errorText))) {
|
if (signal?.aborted || (/aborted/i.test(errorText) && !/timeout/i.test(errorText))) {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
if (!settings.autoProviderFallback) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -72,6 +72,22 @@ export function planDownloadCompletion(args: {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function reconcileFinalizedSize(
|
||||||
|
streamedBytes: number,
|
||||||
|
statSize: number,
|
||||||
|
preAllocated: boolean
|
||||||
|
): number {
|
||||||
|
const streamed = Math.max(0, Math.floor(Number(streamedBytes) || 0));
|
||||||
|
if (!Number.isFinite(statSize) || statSize < 0) {
|
||||||
|
return streamed;
|
||||||
|
}
|
||||||
|
const onDisk = Math.floor(statSize);
|
||||||
|
if (preAllocated && onDisk > streamed) {
|
||||||
|
return streamed;
|
||||||
|
}
|
||||||
|
return onDisk;
|
||||||
|
}
|
||||||
|
|
||||||
export function validateDownloadedFileCompletion(args: {
|
export function validateDownloadedFileCompletion(args: {
|
||||||
actualBytes: number;
|
actualBytes: number;
|
||||||
plan: DownloadCompletionPlan;
|
plan: DownloadCompletionPlan;
|
||||||
|
|||||||
@ -51,7 +51,7 @@ function releaseTlsSkip(): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
import { cleanupCancelledPackageArtifactsAsync, removeDownloadLinkArtifacts, removeSampleArtifacts } from "./cleanup";
|
import { cleanupCancelledPackageArtifactsAsync, removeDownloadLinkArtifacts, removeSampleArtifacts } from "./cleanup";
|
||||||
import { planDownloadCompletion, validateDownloadedFileCompletion } from "./download-completion";
|
import { planDownloadCompletion, reconcileFinalizedSize, validateDownloadedFileCompletion } from "./download-completion";
|
||||||
import { AllDebridWebUnrestrictor, BestDebridWebUnrestrictor, DebridService, MegaWebUnrestrictor, RealDebridWebUnrestrictor, checkRapidgatorOnline, fetchAllDebridHostInfo, getAvailableDebridLinkApiKeys, getAvailableMegaDebridAccounts, getMegaDebridAccountCooldownState, pruneExpiredDebridLinkRuntimeState, pruneExpiredMegaDebridRuntimeState } from "./debrid";
|
import { AllDebridWebUnrestrictor, BestDebridWebUnrestrictor, DebridService, MegaWebUnrestrictor, RealDebridWebUnrestrictor, checkRapidgatorOnline, fetchAllDebridHostInfo, getAvailableDebridLinkApiKeys, getAvailableMegaDebridAccounts, getMegaDebridAccountCooldownState, pruneExpiredDebridLinkRuntimeState, pruneExpiredMegaDebridRuntimeState } from "./debrid";
|
||||||
import { cleanupArchives, clearExtractResumeState, collectArchiveCleanupTargets, detectArchiveSignature, extractPackageArchives, findArchiveCandidates, hasAnyFilesRecursive, removeEmptyDirectoryTree, resetExtractorCachesForPasswordChange, type ExtractArchiveFailureInfo } from "./extractor";
|
import { cleanupArchives, clearExtractResumeState, collectArchiveCleanupTargets, detectArchiveSignature, extractPackageArchives, findArchiveCandidates, hasAnyFilesRecursive, removeEmptyDirectoryTree, resetExtractorCachesForPasswordChange, type ExtractArchiveFailureInfo } from "./extractor";
|
||||||
import { validateFileAgainstManifest } from "./integrity";
|
import { validateFileAgainstManifest } from "./integrity";
|
||||||
@ -6392,19 +6392,38 @@ export class DownloadManager extends EventEmitter {
|
|||||||
} catch {
|
} catch {
|
||||||
}
|
}
|
||||||
} else if (duplicateExists && canonicalExists && !primaryWins && primaryItem.status !== "completed") {
|
} else if (duplicateExists && canonicalExists && !primaryWins && primaryItem.status !== "completed") {
|
||||||
try {
|
let canonicalSize = -1;
|
||||||
fs.rmSync(canonicalPath, { force: true });
|
let duplicateSize = -1;
|
||||||
fs.renameSync(duplicateTargetPath, canonicalPath);
|
try { canonicalSize = fs.statSync(canonicalPath).size; } catch { }
|
||||||
canonicalExists = true;
|
try { duplicateSize = fs.statSync(duplicateTargetPath).size; } catch { }
|
||||||
this.logVerifiedRenameSync("startup-dedup (Austausch)", duplicateTargetPath, canonicalPath);
|
if (canonicalSize >= 0 && duplicateSize >= 0 && canonicalSize >= duplicateSize) {
|
||||||
logger.info(`startupDuplicateMerge: ersetze verwaisten Originalpfad ${canonicalBaseName} durch ${path.basename(duplicateTargetPath)}`);
|
try {
|
||||||
} catch (err) {
|
fs.rmSync(duplicateTargetPath, { force: true });
|
||||||
logDesktopRename("ERROR", "startup-dedup (Austausch): Rename fehlgeschlagen", {
|
} catch {
|
||||||
source: path.basename(duplicateTargetPath),
|
}
|
||||||
target: canonicalBaseName,
|
logger.info(`startupDuplicateMerge: kanonische Datei behalten (${canonicalSize}B >= Duplikat ${duplicateSize}B), Duplikat verworfen: ${canonicalBaseName}`);
|
||||||
error: compactErrorText(err)
|
} else {
|
||||||
});
|
const dedupBackupPath = `${canonicalPath}.dedupbak`;
|
||||||
logger.warn(`startupDuplicateMerge: Austausch fehlgeschlagen ${canonicalPath}: ${compactErrorText(err)}`);
|
try {
|
||||||
|
fs.renameSync(canonicalPath, dedupBackupPath);
|
||||||
|
try {
|
||||||
|
fs.renameSync(duplicateTargetPath, canonicalPath);
|
||||||
|
try { fs.rmSync(dedupBackupPath, { force: true }); } catch { }
|
||||||
|
canonicalExists = true;
|
||||||
|
this.logVerifiedRenameSync("startup-dedup (Austausch)", duplicateTargetPath, canonicalPath);
|
||||||
|
logger.info(`startupDuplicateMerge: ersetze verwaisten Originalpfad ${canonicalBaseName} durch ${path.basename(duplicateTargetPath)}`);
|
||||||
|
} catch (swapErr) {
|
||||||
|
try { fs.renameSync(dedupBackupPath, canonicalPath); } catch { }
|
||||||
|
throw swapErr;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logDesktopRename("ERROR", "startup-dedup (Austausch): Rename fehlgeschlagen", {
|
||||||
|
source: path.basename(duplicateTargetPath),
|
||||||
|
target: canonicalBaseName,
|
||||||
|
error: compactErrorText(err)
|
||||||
|
});
|
||||||
|
logger.warn(`startupDuplicateMerge: Austausch fehlgeschlagen ${canonicalPath}: ${compactErrorText(err)}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -9032,6 +9051,15 @@ export class DownloadManager extends EventEmitter {
|
|||||||
return;
|
return;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (this.session.items[item.id] !== item) {
|
if (this.session.items[item.id] !== item) {
|
||||||
|
if (active.abortReason === "cancel") {
|
||||||
|
const orphanClaimedPath = this.claimedTargetPathByItem.get(item.id) || item.targetPath || "";
|
||||||
|
if (orphanClaimedPath) {
|
||||||
|
try {
|
||||||
|
fs.rmSync(orphanClaimedPath, { force: true });
|
||||||
|
} catch {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const reason = active.abortReason;
|
const reason = active.abortReason;
|
||||||
@ -10357,13 +10385,15 @@ export class DownloadManager extends EventEmitter {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const finalizedStat = await fs.promises.stat(effectiveTargetPath);
|
const finalizedStat = await fs.promises.stat(effectiveTargetPath);
|
||||||
if (Number.isFinite(finalizedStat.size) && finalizedStat.size >= 0 && finalizedStat.size !== written) {
|
const reconciledSize = reconcileFinalizedSize(written, finalizedStat.size, preAllocated);
|
||||||
|
if (reconciledSize !== written) {
|
||||||
logAttemptEvent("WARN", "Dateigroesse nach Stream-Abschluss korrigiert", {
|
logAttemptEvent("WARN", "Dateigroesse nach Stream-Abschluss korrigiert", {
|
||||||
attempt,
|
attempt,
|
||||||
previousWritten: written,
|
previousWritten: written,
|
||||||
statSize: finalizedStat.size
|
statSize: finalizedStat.size,
|
||||||
|
reconciledSize
|
||||||
});
|
});
|
||||||
written = finalizedStat.size;
|
written = reconciledSize;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2907,9 +2907,16 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
|
|||||||
let learnedPassword = cachedPackagePassword;
|
let learnedPassword = cachedPackagePassword;
|
||||||
let packageNeedsFlatMode = false;
|
let packageNeedsFlatMode = false;
|
||||||
const extractedArchives = new Set<string>();
|
const extractedArchives = new Set<string>();
|
||||||
|
const skippedNonArchives = new Set<string>();
|
||||||
const failedArchiveCategories = new Map<string, ExtractErrorCategory>();
|
const failedArchiveCategories = new Map<string, ExtractErrorCategory>();
|
||||||
for (const archivePath of candidates) {
|
for (const archivePath of candidates) {
|
||||||
if (resumeCompleted.has(archiveNameKey(path.basename(archivePath)))) {
|
if (resumeCompleted.has(archiveNameKey(path.basename(archivePath)))) {
|
||||||
|
const resumedName = path.basename(archivePath);
|
||||||
|
const resumedIsGenericSplit = /\.\d{3}$/i.test(resumedName) && !/\.(zip|7z)\.\d{3}$/i.test(resumedName);
|
||||||
|
if (resumedIsGenericSplit && !(await detectArchiveSignature(archivePath))) {
|
||||||
|
skippedNonArchives.add(pathSetKey(archivePath));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
extractedArchives.add(archivePath);
|
extractedArchives.add(archivePath);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -3026,7 +3033,7 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
|
|||||||
logger.info(`Generische Split-Datei übersprungen (keine Archiv-Signatur): ${archiveName}`);
|
logger.info(`Generische Split-Datei übersprungen (keine Archiv-Signatur): ${archiveName}`);
|
||||||
extracted += 1;
|
extracted += 1;
|
||||||
resumeCompleted.add(archiveResumeKey);
|
resumeCompleted.add(archiveResumeKey);
|
||||||
extractedArchives.add(archivePath);
|
skippedNonArchives.add(pathSetKey(archivePath));
|
||||||
await writeExtractResumeState(options.packageDir, resumeCompleted, options.packageId);
|
await writeExtractResumeState(options.packageDir, resumeCompleted, options.packageId);
|
||||||
clearInterval(pulseTimer);
|
clearInterval(pulseTimer);
|
||||||
archiveOutcome = "skipped";
|
archiveOutcome = "skipped";
|
||||||
@ -3370,7 +3377,8 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
|
|||||||
logger.error(`Entpacken ohne neue Ausgabe erkannt: ${options.targetDir}. Cleanup wird NICHT ausgeführt.`);
|
logger.error(`Entpacken ohne neue Ausgabe erkannt: ${options.targetDir}. Cleanup wird NICHT ausgeführt.`);
|
||||||
} else {
|
} else {
|
||||||
if (!options.skipPostCleanup) {
|
if (!options.skipPostCleanup) {
|
||||||
const cleanupSources = failed === 0 ? candidates : Array.from(extractedArchives.values());
|
const cleanupSources = (failed === 0 ? candidates : Array.from(extractedArchives.values()))
|
||||||
|
.filter((archivePath) => !skippedNonArchives.has(pathSetKey(archivePath)));
|
||||||
const sourceAndTargetEqual = pathSetKey(path.resolve(options.packageDir)) === pathSetKey(path.resolve(options.targetDir));
|
const sourceAndTargetEqual = pathSetKey(path.resolve(options.packageDir)) === pathSetKey(path.resolve(options.targetDir));
|
||||||
const removedArchives = sourceAndTargetEqual
|
const removedArchives = sourceAndTargetEqual
|
||||||
? 0
|
? 0
|
||||||
|
|||||||
@ -93,15 +93,11 @@ export function readHashManifest(packageDir: string): Map<string, ParsedHashEntr
|
|||||||
if (!parsed) {
|
if (!parsed) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const normalized: ParsedHashEntry = {
|
|
||||||
...parsed,
|
|
||||||
algorithm: hit[1]
|
|
||||||
};
|
|
||||||
const key = normalizeManifestKey(parsed.fileName);
|
const key = normalizeManifestKey(parsed.fileName);
|
||||||
if (map.has(key)) {
|
if (map.has(key)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
map.set(key, normalized);
|
map.set(key, parsed);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
manifestCache.set(cacheKey, { at: Date.now(), entries: new Map(map) });
|
manifestCache.set(cacheKey, { at: Date.now(), entries: new Map(map) });
|
||||||
|
|||||||
@ -897,6 +897,7 @@ function readSessionFile(filePath: string): SessionState | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function saveSettings(paths: StoragePaths, settings: AppSettings): void {
|
export function saveSettings(paths: StoragePaths, settings: AppSettings): void {
|
||||||
|
syncSettingsSaveGeneration += 1;
|
||||||
ensureBaseDir(paths.baseDir);
|
ensureBaseDir(paths.baseDir);
|
||||||
if (fs.existsSync(paths.configFile)) {
|
if (fs.existsSync(paths.configFile)) {
|
||||||
try {
|
try {
|
||||||
@ -917,17 +918,26 @@ export function saveSettings(paths: StoragePaths, settings: AppSettings): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let asyncSettingsSaveRunning = false;
|
let asyncSettingsSaveRunning = false;
|
||||||
let asyncSettingsSaveQueued: { paths: StoragePaths; settings: AppSettings } | null = null;
|
let asyncSettingsSaveQueued: { paths: StoragePaths; payload: string; generation: number } | null = null;
|
||||||
|
let syncSettingsSaveGeneration = 0;
|
||||||
|
|
||||||
async function writeSettingsPayload(paths: StoragePaths, payload: string): Promise<void> {
|
async function writeSettingsPayload(paths: StoragePaths, payload: string, generation: number): Promise<void> {
|
||||||
await fs.promises.mkdir(paths.baseDir, { recursive: true });
|
await fs.promises.mkdir(paths.baseDir, { recursive: true });
|
||||||
await fsp.copyFile(paths.configFile, `${paths.configFile}.bak`).catch(() => {});
|
await fsp.copyFile(paths.configFile, `${paths.configFile}.bak`).catch(() => {});
|
||||||
const tempPath = `${paths.configFile}.settings.tmp`;
|
const tempPath = `${paths.configFile}.settings.tmp`;
|
||||||
await fsp.writeFile(tempPath, payload, "utf8");
|
await fsp.writeFile(tempPath, payload, "utf8");
|
||||||
|
if (generation < syncSettingsSaveGeneration) {
|
||||||
|
await fsp.rm(tempPath, { force: true }).catch(() => {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await fsp.rename(tempPath, paths.configFile);
|
await fsp.rename(tempPath, paths.configFile);
|
||||||
} catch (renameError: unknown) {
|
} catch (renameError: unknown) {
|
||||||
if (renameError && typeof renameError === "object" && "code" in renameError && (renameError as NodeJS.ErrnoException).code === "EXDEV") {
|
if (renameError && typeof renameError === "object" && "code" in renameError && (renameError as NodeJS.ErrnoException).code === "EXDEV") {
|
||||||
|
if (generation < syncSettingsSaveGeneration) {
|
||||||
|
await fsp.rm(tempPath, { force: true }).catch(() => {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
await fsp.copyFile(tempPath, paths.configFile);
|
await fsp.copyFile(tempPath, paths.configFile);
|
||||||
await fsp.rm(tempPath, { force: true }).catch(() => {});
|
await fsp.rm(tempPath, { force: true }).catch(() => {});
|
||||||
} else {
|
} else {
|
||||||
@ -937,16 +947,14 @@ async function writeSettingsPayload(paths: StoragePaths, payload: string): Promi
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function saveSettingsAsync(paths: StoragePaths, settings: AppSettings): Promise<void> {
|
async function saveSettingsPayloadAsync(paths: StoragePaths, payload: string, generation: number): Promise<void> {
|
||||||
const persisted = sanitizeCredentialPersistence(normalizeSettings(settings));
|
|
||||||
const payload = JSON.stringify(persisted, safeJsonReplacer, 2);
|
|
||||||
if (asyncSettingsSaveRunning) {
|
if (asyncSettingsSaveRunning) {
|
||||||
asyncSettingsSaveQueued = { paths, settings };
|
asyncSettingsSaveQueued = { paths, payload, generation };
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
asyncSettingsSaveRunning = true;
|
asyncSettingsSaveRunning = true;
|
||||||
try {
|
try {
|
||||||
await writeSettingsPayload(paths, payload);
|
await writeSettingsPayload(paths, payload, generation);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Async Settings-Save fehlgeschlagen: ${String(error)}`);
|
logger.error(`Async Settings-Save fehlgeschlagen: ${String(error)}`);
|
||||||
} finally {
|
} finally {
|
||||||
@ -954,11 +962,18 @@ export async function saveSettingsAsync(paths: StoragePaths, settings: AppSettin
|
|||||||
if (asyncSettingsSaveQueued) {
|
if (asyncSettingsSaveQueued) {
|
||||||
const queued = asyncSettingsSaveQueued;
|
const queued = asyncSettingsSaveQueued;
|
||||||
asyncSettingsSaveQueued = null;
|
asyncSettingsSaveQueued = null;
|
||||||
void saveSettingsAsync(queued.paths, queued.settings);
|
void saveSettingsPayloadAsync(queued.paths, queued.payload, queued.generation);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function saveSettingsAsync(paths: StoragePaths, settings: AppSettings): Promise<void> {
|
||||||
|
const generation = syncSettingsSaveGeneration;
|
||||||
|
const persisted = sanitizeCredentialPersistence(normalizeSettings(settings));
|
||||||
|
const payload = JSON.stringify(persisted, safeJsonReplacer, 2);
|
||||||
|
await saveSettingsPayloadAsync(paths, payload, generation);
|
||||||
|
}
|
||||||
|
|
||||||
export function emptySession(): SessionState {
|
export function emptySession(): SessionState {
|
||||||
return {
|
return {
|
||||||
version: 2,
|
version: 2,
|
||||||
@ -1122,6 +1137,7 @@ export function cancelPendingAsyncSaves(): void {
|
|||||||
asyncSaveQueued = null;
|
asyncSaveQueued = null;
|
||||||
asyncSettingsSaveQueued = null;
|
asyncSettingsSaveQueued = null;
|
||||||
syncSaveGeneration += 1;
|
syncSaveGeneration += 1;
|
||||||
|
syncSettingsSaveGeneration += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function saveSessionAsync(paths: StoragePaths, session: SessionState): Promise<void> {
|
export async function saveSessionAsync(paths: StoragePaths, session: SessionState): Promise<void> {
|
||||||
|
|||||||
@ -65,8 +65,49 @@ daily-limit aggregate early-exit.
|
|||||||
- [deferred] #4 empty-response→until-restart-park: 3-consecutive-streak ist reale Mitigation gegen transiente
|
- [deferred] #4 empty-response→until-restart-park: 3-consecutive-streak ist reale Mitigation gegen transiente
|
||||||
Blips; Mega-empty-Semantik nicht sicher verifizierbar → kein Blind-Change.
|
Blips; Mega-empty-Semantik nicht sicher verifizierbar → kein Blind-Change.
|
||||||
|
|
||||||
### Batch 2 (geplant, naechste Runden)
|
### Strategie-Update (LIVE-Server, Advisor-bestaetigt)
|
||||||
- Web-Timeout-Selbstcooldown-Familie: #13 elapsedMs inkl. queue-wait → ranLongEnough-Gate auf WORK-Zeit
|
- LOW-Fix-Schwelle HOCH: nur fixen bei NULL plausibler Regression UND einem Test der OHNE Fix rot ist.
|
||||||
(mega-web traced workMs schon); + 60s-Caller-Timeout vs Web-Queue(90s) Mismatch. Braucht workMs-Threading.
|
Sonst dokumentieren ("gefunden & charakterisiert" ist valides Audit-Ergebnis). Server laeuft live,
|
||||||
- #5 Web echte Bad-Creds erreichen invalid nie. #6 onefichier/ddownload routing ignoriert fallback=off.
|
auto-update, ~1 TB/h → jede unnoetige Verhaltensaenderung = Risiko.
|
||||||
- #9 overwrite targetPath-wipe. #10 HTTP416 shared counter. #11 fresh-retry preempt. #12 shelve+shared counter.
|
- Releases BUENDELN (alle 2-3 Runden / Roll-up), nicht pro Fix. Weniger Update-Churn auf dem Live-Server.
|
||||||
|
- #5 NICHT raten: conversion.log faengt den echten Web-Login-Fehler-String schon (web-queue-Phase-Detail).
|
||||||
|
Aus naechstem Bundle ernten, dann erst invalid-Phrasen ergaenzen. Kein Phrasen-Halluzinieren.
|
||||||
|
- #13 defer: Web ist seit v1.7.214 nur noch Fallback (API-first), Selbstcooldown trifft kaum mehr;
|
||||||
|
braucht workMs-Threading → groesserer Eingriff, nicht LOW-billig.
|
||||||
|
- Vor Runde 4-5: SYNTHESE-Pass — ist Retry/Cooldown/Rotation END-TO-END kohaerent selbstheilend?
|
||||||
|
|
||||||
|
## Runde 2 (Download-Ausfuehrung: stream/resume/disk/integrity/extract/persist) — Workflow whspc8ddv
|
||||||
|
14 confirmed / 7 refuted (>=2/3 adversarisch). Alle HIGH/MED unten unabhaengig am echten Code
|
||||||
|
verifiziert (Zeilen zitiert) bevor gefixt. Jeder Fix mit rot-bewiesenem Test, tsc bleibt 6.
|
||||||
|
|
||||||
|
### Batch 2b → noch nicht released (buendeln, dann v1.7.216)
|
||||||
|
- [x] #R2-1/4 HIGH Pre-alloc-stat-Reconciliation blaeht `written` auf Padding-Groesse auf → stille
|
||||||
|
Null-Byte-Korruption auf win32. reconcileFinalizedSize() (download-completion.ts, rein+getestet),
|
||||||
|
nur noch ABWAERTS-Korrektur bei preAllocated. Commit 2646cba.
|
||||||
|
- [x] #R2-3 HIGH Nicht-Archiv-".001" ohne Signatur wurde als extrahiert gezaehlt → ganze .00x-Familie
|
||||||
|
beim Cleanup geloescht (Datenverlust). skippedNonArchives (pathSetKey) aus cleanupSources gefiltert,
|
||||||
|
frisch + resume. End-to-end-Test. Commit 56bae4a.
|
||||||
|
- [x] #R2-9 MED + #R2-14 LOW Settings-async-Writer ohne Generations-Schutz → Lost Update; shutdown rief
|
||||||
|
cancelPendingAsyncSaves nicht. Eigener syncSettingsSaveGeneration (Spiegel des Session-Pfads) +
|
||||||
|
cancel in shutdown. Commit 1a33fc2.
|
||||||
|
- [x] #R2-6 MED Teildatei verwaist beim Entfernen eines laufenden Downloads (catch-early-return vor
|
||||||
|
Cancel-Cleanup). rmSync im catch vor dem return (nach Stream-Close, kein Race). Commit 2b639b7.
|
||||||
|
- [x] #R2-8 MED Hash-Manifest: Pro-Zeile-Algorithmus von Dateiendung ueberschrieben → gute Datei
|
||||||
|
geloescht bei fehl-etikettiertem Manifest. parseHashLine-Algorithmus uebernehmen. Commit 4578991.
|
||||||
|
- [x] #R2-7 MED Startup-Dedup ersetzt gute kanonische Datei durch kleineres Duplikat (+ EXDEV-Loss-
|
||||||
|
Fenster). Size-Guard (kanonisch >= Duplikat → behalten) + rename-zu-.dedupbak-Reihenfolge mit
|
||||||
|
Restore. Commit folgt nach voller DM-Suite.
|
||||||
|
- [deferred/dokumentiert] #R2-5 stream-end akzeptiert truncated download (ohne Laengensignal nicht
|
||||||
|
entscheidbar; Web ist post-214 nur Fallback) — nur WARN-Log sinnvoll, kein sicherer Fix.
|
||||||
|
- [deferred/dokumentiert] #R2-10 CRC-verifizierte Kleindatei vom suspicious-small-Heuristik geloescht;
|
||||||
|
#R2-11 Resume-empty-output-Bypass; #R2-12 7z-Exit-1-Warnung als Erfolg; #R2-13 Companion-.srt/.nfo-
|
||||||
|
Overwrite. Je narrow/heuristisch → charakterisiert, nicht blind gefixt (LIVE-Server-Schwelle).
|
||||||
|
|
||||||
|
### Batch 2 (commit, noch nicht released — buendeln mit Runde-2-Findings)
|
||||||
|
- [x] #6 MED onefichier/ddownload-catch respektiert autoProviderFallback=off (Guard nach abort-rethrow).
|
||||||
|
Test: 1fichier KO + Fallback aus → reject, mega getLink NICHT aufgerufen (rot-ohne-Fix beweisbar).
|
||||||
|
ACHTUNG-Notiz: replace_all matchte faelschlich auch den getLinkInfos-Filename-catch (~3614) → revertet
|
||||||
|
(Filename-Aufloesung muss nicht-fatal bleiben). Nur die zwei Hoster-catch-Bloecke geaendert. Commit 21fb09b.
|
||||||
|
- [deferred LOW, dokumentiert statt blind-fix] #9 overwrite targetPath-wipe, #10 HTTP416 shared counter,
|
||||||
|
#11 fresh-retry preempt typed handlers, #12 shelve+shared counter, #13 queue-wait→elapsedMs, #5 Web-bad-creds.
|
||||||
|
→ je nur fixen wenn rot-ohne-Fix billig beweisbar + null Regression; sonst bleibt's charakterisiert.
|
||||||
|
|||||||
@ -1453,6 +1453,48 @@ describe("debrid service", () => {
|
|||||||
expect(cooldown!.category).toBe("rate_limit");
|
expect(cooldown!.category).toBe("rate_limit");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not fall through a failed 1fichier link to the provider chain when autoProviderFallback is off", async () => {
|
||||||
|
let megaGetLinkCalled = false;
|
||||||
|
const settings = {
|
||||||
|
...defaultSettings(),
|
||||||
|
token: "",
|
||||||
|
bestToken: "",
|
||||||
|
allDebridToken: "",
|
||||||
|
oneFichierApiKey: "1f-key",
|
||||||
|
megaLogin: "user",
|
||||||
|
megaPassword: "pass",
|
||||||
|
megaCredentials: "user:pass",
|
||||||
|
megaDebridApiEnabled: true,
|
||||||
|
megaDebridWebEnabled: false,
|
||||||
|
megaDebridPreferApi: true,
|
||||||
|
providerPrimary: "megadebrid" as const,
|
||||||
|
providerSecondary: "none" as const,
|
||||||
|
providerTertiary: "none" as const,
|
||||||
|
autoProviderFallback: false
|
||||||
|
};
|
||||||
|
|
||||||
|
globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => {
|
||||||
|
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
||||||
|
if (url.includes("api.1fichier.com")) {
|
||||||
|
return new Response(JSON.stringify({ status: "KO", message: "not available" }), { status: 500, headers: { "Content-Type": "application/json" } });
|
||||||
|
}
|
||||||
|
if (url.includes("action=connectUser")) {
|
||||||
|
return new Response(JSON.stringify({ response_code: "ok", token: "tok", vip_end: Math.floor(Date.now() / 1000) + 999999 }), { status: 200, headers: { "Content-Type": "application/json" } });
|
||||||
|
}
|
||||||
|
if (url.includes("action=getLink")) {
|
||||||
|
megaGetLinkCalled = true;
|
||||||
|
return new Response(JSON.stringify({ response_code: "ok", debridLink: "https://mega-cdn.example/file.rar", filename: "file.rar" }), { status: 200, headers: { "Content-Type": "application/json" } });
|
||||||
|
}
|
||||||
|
return new Response("not-found", { status: 404 });
|
||||||
|
}) as typeof fetch;
|
||||||
|
|
||||||
|
const service = new DebridService(settings);
|
||||||
|
const result = await service.unrestrictLink("https://1fichier.com/?abc12345xyz").then((r) => ({ ok: true, r }), (e: unknown) => ({ ok: false, e }));
|
||||||
|
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
expect(megaGetLinkCalled).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it("uses Mega Web only when it is configured as a separate fallback provider", async () => {
|
it("uses Mega Web only when it is configured as a separate fallback provider", async () => {
|
||||||
const settings = {
|
const settings = {
|
||||||
...defaultSettings(),
|
...defaultSettings(),
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { planDownloadCompletion, validateDownloadedFileCompletion } from "../src/main/download-completion";
|
import { planDownloadCompletion, reconcileFinalizedSize, validateDownloadedFileCompletion } from "../src/main/download-completion";
|
||||||
|
|
||||||
describe("download-completion", () => {
|
describe("download-completion", () => {
|
||||||
describe("planDownloadCompletion", () => {
|
describe("planDownloadCompletion", () => {
|
||||||
@ -58,4 +58,33 @@ describe("download-completion", () => {
|
|||||||
expect(result.ok).toBe(false);
|
expect(result.ok).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("reconcileFinalizedSize", () => {
|
||||||
|
it("keeps the streamed count for a pre-allocated file whose on-disk size is the zero-padding (corruption guard)", () => {
|
||||||
|
expect(reconcileFinalizedSize(300_000_000, 1_000_000_000, true)).toBe(300_000_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shrinks to the on-disk size when a pre-allocated file is genuinely short (real partial write)", () => {
|
||||||
|
expect(reconcileFinalizedSize(500, 300, true)).toBe(300);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reconciles in both directions for a non-pre-allocated file (stat is authoritative)", () => {
|
||||||
|
expect(reconcileFinalizedSize(300, 1000, false)).toBe(1000);
|
||||||
|
expect(reconcileFinalizedSize(1000, 300, false)).toBe(300);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the streamed count unchanged when the stat is invalid", () => {
|
||||||
|
expect(reconcileFinalizedSize(1234, Number.NaN, true)).toBe(1234);
|
||||||
|
expect(reconcileFinalizedSize(1234, -1, false)).toBe(1234);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is a no-op when on-disk size already equals the streamed count", () => {
|
||||||
|
expect(reconcileFinalizedSize(777, 777, true)).toBe(777);
|
||||||
|
expect(reconcileFinalizedSize(777, 777, false)).toBe(777);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not block legitimate overshoot on a pre-allocated file (server sent more than pre-alloc)", () => {
|
||||||
|
expect(reconcileFinalizedSize(900, 900, true)).toBe(900);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -536,6 +536,96 @@ describe("download manager", () => {
|
|||||||
expect(fs.existsSync(duplicatePath)).toBe(false);
|
expect(fs.existsSync(duplicatePath)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not replace a larger good canonical file with a smaller duplicate on startup dedup", () => {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-startup-dup-size-"));
|
||||||
|
tempDirs.push(root);
|
||||||
|
|
||||||
|
const session = emptySession();
|
||||||
|
const packageId = "dup-size-pkg";
|
||||||
|
const originalItemId = "dup-size-original";
|
||||||
|
const duplicateItemId = "dup-size-copy";
|
||||||
|
const createdAt = Date.now() - 20_000;
|
||||||
|
const outputDir = path.join(root, "downloads", "Dup Size");
|
||||||
|
const extractDir = path.join(root, "extract", "Dup Size");
|
||||||
|
fs.mkdirSync(outputDir, { recursive: true });
|
||||||
|
fs.mkdirSync(extractDir, { recursive: true });
|
||||||
|
|
||||||
|
const canonicalPath = path.join(outputDir, "movie.mkv");
|
||||||
|
const duplicatePath = path.join(outputDir, "movie (1).mkv");
|
||||||
|
fs.writeFileSync(canonicalPath, Buffer.alloc(1024, 5));
|
||||||
|
fs.writeFileSync(duplicatePath, Buffer.alloc(256, 9));
|
||||||
|
|
||||||
|
session.packageOrder = [packageId];
|
||||||
|
session.packages[packageId] = {
|
||||||
|
id: packageId,
|
||||||
|
name: "Dup Size",
|
||||||
|
outputDir,
|
||||||
|
extractDir,
|
||||||
|
status: "completed",
|
||||||
|
itemIds: [originalItemId, duplicateItemId],
|
||||||
|
cancelled: false,
|
||||||
|
enabled: true,
|
||||||
|
priority: "normal",
|
||||||
|
createdAt,
|
||||||
|
updatedAt: createdAt
|
||||||
|
};
|
||||||
|
session.items[originalItemId] = {
|
||||||
|
id: originalItemId,
|
||||||
|
packageId,
|
||||||
|
url: "https://example.com/movie.mkv",
|
||||||
|
provider: "realdebrid",
|
||||||
|
status: "failed",
|
||||||
|
retries: 0,
|
||||||
|
speedBps: 0,
|
||||||
|
downloadedBytes: 0,
|
||||||
|
totalBytes: 1024,
|
||||||
|
progressPercent: 0,
|
||||||
|
fileName: "movie.mkv",
|
||||||
|
targetPath: canonicalPath,
|
||||||
|
resumable: true,
|
||||||
|
attempts: 1,
|
||||||
|
lastError: "Fehlgeschlagen",
|
||||||
|
fullStatus: "",
|
||||||
|
createdAt,
|
||||||
|
updatedAt: createdAt + 10_000
|
||||||
|
};
|
||||||
|
session.items[duplicateItemId] = {
|
||||||
|
id: duplicateItemId,
|
||||||
|
packageId,
|
||||||
|
url: "https://example.com/movie.mkv",
|
||||||
|
provider: "realdebrid",
|
||||||
|
status: "completed",
|
||||||
|
retries: 0,
|
||||||
|
speedBps: 0,
|
||||||
|
downloadedBytes: 256,
|
||||||
|
totalBytes: 256,
|
||||||
|
progressPercent: 100,
|
||||||
|
fileName: "movie.mkv",
|
||||||
|
targetPath: duplicatePath,
|
||||||
|
resumable: true,
|
||||||
|
attempts: 1,
|
||||||
|
lastError: "",
|
||||||
|
fullStatus: "Fertig",
|
||||||
|
createdAt,
|
||||||
|
updatedAt: createdAt + 5_000
|
||||||
|
};
|
||||||
|
|
||||||
|
new DownloadManager(
|
||||||
|
{
|
||||||
|
...defaultSettings(),
|
||||||
|
token: "rd-token",
|
||||||
|
outputDir: path.join(root, "downloads"),
|
||||||
|
extractDir: path.join(root, "extract"),
|
||||||
|
autoExtract: false
|
||||||
|
},
|
||||||
|
session,
|
||||||
|
createStoragePaths(path.join(root, "state"))
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(fs.existsSync(canonicalPath)).toBe(true);
|
||||||
|
expect(fs.statSync(canonicalPath).size).toBe(1024);
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps a stronger extracted canonical startup state when removing stale duplicate copies", () => {
|
it("keeps a stronger extracted canonical startup state when removing stale duplicate copies", () => {
|
||||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-startup-dup-keep-"));
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-startup-dup-keep-"));
|
||||||
tempDirs.push(root);
|
tempDirs.push(root);
|
||||||
@ -865,6 +955,89 @@ describe("download manager", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("deletes the orphaned partial file when a downloading item is removed mid-stream", async () => {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
||||||
|
tempDirs.push(root);
|
||||||
|
const binary = Buffer.alloc(512 * 1024, 7);
|
||||||
|
|
||||||
|
let destroyHeld: () => void = () => {};
|
||||||
|
const server = http.createServer((req, res) => {
|
||||||
|
if ((req.url || "") !== "/direct") {
|
||||||
|
res.statusCode = 404;
|
||||||
|
res.end("not-found");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.statusCode = 200;
|
||||||
|
res.setHeader("Accept-Ranges", "bytes");
|
||||||
|
res.setHeader("Content-Length", String(binary.length));
|
||||||
|
res.write(binary.subarray(0, 64 * 1024));
|
||||||
|
destroyHeld = () => {
|
||||||
|
try { res.socket?.destroy(); } catch { }
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
server.listen(0, "127.0.0.1");
|
||||||
|
await once(server, "listening");
|
||||||
|
const address = server.address();
|
||||||
|
if (!address || typeof address === "string") {
|
||||||
|
throw new Error("server address unavailable");
|
||||||
|
}
|
||||||
|
const directUrl = `http://127.0.0.1:${address.port}/direct`;
|
||||||
|
|
||||||
|
globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
||||||
|
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
||||||
|
if (url.includes("/unrestrict/link")) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ download: directUrl, filename: "held.mkv", filesize: binary.length }),
|
||||||
|
{ status: 200, headers: { "Content-Type": "application/json" } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return originalFetch(input, init);
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const manager = new DownloadManager(
|
||||||
|
{
|
||||||
|
...defaultSettings(),
|
||||||
|
token: "rd-token",
|
||||||
|
outputDir: path.join(root, "downloads"),
|
||||||
|
extractDir: path.join(root, "extract"),
|
||||||
|
autoExtract: false,
|
||||||
|
autoReconnect: false,
|
||||||
|
retryLimit: 0
|
||||||
|
},
|
||||||
|
emptySession(),
|
||||||
|
createStoragePaths(path.join(root, "state"))
|
||||||
|
);
|
||||||
|
|
||||||
|
manager.addPackages([{ name: "held", links: ["https://dummy/held"] }]);
|
||||||
|
await manager.start();
|
||||||
|
|
||||||
|
let targetPath = "";
|
||||||
|
await waitFor(() => {
|
||||||
|
const it = Object.values(manager.getSnapshot().session.items)[0];
|
||||||
|
if (it && it.status === "downloading" && it.targetPath && fs.existsSync(it.targetPath)) {
|
||||||
|
targetPath = it.targetPath;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}, 20000);
|
||||||
|
|
||||||
|
const itemId = Object.values(manager.getSnapshot().session.items)[0].id;
|
||||||
|
expect(fs.existsSync(targetPath)).toBe(true);
|
||||||
|
|
||||||
|
manager.removeItem(itemId);
|
||||||
|
destroyHeld();
|
||||||
|
|
||||||
|
await waitFor(() => !fs.existsSync(targetPath), 20000);
|
||||||
|
expect(fs.existsSync(targetPath)).toBe(false);
|
||||||
|
} finally {
|
||||||
|
destroyHeld();
|
||||||
|
server.close();
|
||||||
|
await once(server, "close");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("rewinds resumed range after terminated streams so corrupted tail bytes are replaced", async () => {
|
it("rewinds resumed range after terminated streams so corrupted tail bytes are replaced", 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);
|
||||||
|
|||||||
@ -858,6 +858,40 @@ describe("extractor", () => {
|
|||||||
expect(targets.has(p003)).toBe(true);
|
expect(targets.has(p003)).toBe(true);
|
||||||
expect(targets.has(other)).toBe(false);
|
expect(targets.has(other)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does NOT delete a non-archive .00x family that sits beside a real archive (no-signature data-loss guard)", async () => {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-split-noarch-"));
|
||||||
|
tempDirs.push(root);
|
||||||
|
const packageDir = path.join(root, "pkg");
|
||||||
|
const targetDir = path.join(root, "out");
|
||||||
|
fs.mkdirSync(packageDir, { recursive: true });
|
||||||
|
|
||||||
|
const realZip = new AdmZip();
|
||||||
|
realZip.addFile("release.txt", Buffer.from("ok"));
|
||||||
|
realZip.writeZip(path.join(packageDir, "movie.zip"));
|
||||||
|
|
||||||
|
const d001 = path.join(packageDir, "mydata.001");
|
||||||
|
const d002 = path.join(packageDir, "mydata.002");
|
||||||
|
const d003 = path.join(packageDir, "mydata.003");
|
||||||
|
fs.writeFileSync(d001, "raw user split data, not an archive at all 0123456789", "utf8");
|
||||||
|
fs.writeFileSync(d002, "second raw chunk, also no archive magic bytes here", "utf8");
|
||||||
|
fs.writeFileSync(d003, "third raw chunk likewise plain content payload", "utf8");
|
||||||
|
|
||||||
|
const result = await extractPackageArchives({
|
||||||
|
packageDir,
|
||||||
|
targetDir,
|
||||||
|
cleanupMode: "delete",
|
||||||
|
conflictMode: "overwrite",
|
||||||
|
removeLinks: false,
|
||||||
|
removeSamples: false
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.failed).toBe(0);
|
||||||
|
expect(fs.existsSync(path.join(targetDir, "release.txt"))).toBe(true);
|
||||||
|
expect(fs.existsSync(d001)).toBe(true);
|
||||||
|
expect(fs.existsSync(d002)).toBe(true);
|
||||||
|
expect(fs.existsSync(d003)).toBe(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("detectArchiveSignature", () => {
|
describe("detectArchiveSignature", () => {
|
||||||
|
|||||||
@ -66,6 +66,21 @@ describe("integrity", () => {
|
|||||||
expect(parseHashLine(" ")).toBeNull();
|
expect(parseHashLine(" ")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("trusts the per-line algorithm over the file extension for a mislabeled manifest (.sfv holding md5 lines)", async () => {
|
||||||
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-int-"));
|
||||||
|
tempDirs.push(dir);
|
||||||
|
const filePath = path.join(dir, "movie.bin");
|
||||||
|
fs.writeFileSync(filePath, Buffer.from("hello"));
|
||||||
|
fs.writeFileSync(path.join(dir, "checksums.sfv"), "5d41402abc4b2a76b9719d911017c592 movie.bin\n", "utf8");
|
||||||
|
|
||||||
|
const manifest = readHashManifest(dir);
|
||||||
|
expect(manifest.get("movie.bin")?.algorithm).toBe("md5");
|
||||||
|
|
||||||
|
const result = await validateFileAgainstManifest(filePath, dir);
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
expect(result.message).toContain("MD5");
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps first hash entry when duplicate filename appears across manifests", () => {
|
it("keeps first hash entry when duplicate filename appears across manifests", () => {
|
||||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-int-"));
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-int-"));
|
||||||
tempDirs.push(dir);
|
tempDirs.push(dir);
|
||||||
|
|||||||
@ -8,9 +8,13 @@ import {
|
|||||||
createStoragePaths,
|
createStoragePaths,
|
||||||
emptySession,
|
emptySession,
|
||||||
loadSession,
|
loadSession,
|
||||||
|
loadSettings,
|
||||||
saveSession,
|
saveSession,
|
||||||
saveSessionAsync
|
saveSessionAsync,
|
||||||
|
saveSettings,
|
||||||
|
saveSettingsAsync
|
||||||
} from "../src/main/storage";
|
} from "../src/main/storage";
|
||||||
|
import { defaultSettings } from "../src/main/constants";
|
||||||
|
|
||||||
const tempDirs: string[] = [];
|
const tempDirs: string[] = [];
|
||||||
|
|
||||||
@ -129,4 +133,26 @@ describe("session restart loss", () => {
|
|||||||
const loaded = loadSession(paths);
|
const loaded = loadSession(paths);
|
||||||
expect(Object.keys(loaded.packages).sort()).toEqual(["A", "B"]);
|
expect(Object.keys(loaded.packages).sort()).toEqual(["A", "B"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not let an in-flight/queued async settings save clobber a newer synchronous saveSettings", async () => {
|
||||||
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-settings-race-"));
|
||||||
|
tempDirs.push(dir);
|
||||||
|
const paths = createStoragePaths(dir);
|
||||||
|
|
||||||
|
cancelPendingAsyncSaves();
|
||||||
|
await settle(50);
|
||||||
|
|
||||||
|
const withName = (name: string) => ({ ...defaultSettings(), packageName: name });
|
||||||
|
|
||||||
|
saveSettings(paths, withName("OLD"));
|
||||||
|
const inflight = saveSettingsAsync(paths, withName("OLD"));
|
||||||
|
const queued = saveSettingsAsync(paths, withName("OLD"));
|
||||||
|
saveSettings(paths, withName("NEW"));
|
||||||
|
|
||||||
|
await inflight;
|
||||||
|
await queued;
|
||||||
|
await settle();
|
||||||
|
|
||||||
|
expect(loadSettings(paths).packageName).toBe("NEW");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user