Rotation: jeden Account-Versuch ins ITEM-Log schreiben (Sichtbarkeit)

User sah im Item-Log nur "Link-Umwandlung gestartet" -> "Unrestrict Timeout
60s" -> "erneut Versuch 1/inf", aber nie welcher Account/Key wann probiert
wurde. Die Rotation lief nur in account-rotation.log + Panel.

Jetzt: AsyncLocalStorage-Item-Sink (parallel-sicher bei 8 gleichzeitigen
Unrestricts) leitet JEDEN Rotations-Event in das Log des betroffenen Items:
"Account-Rotation: Mega-Debrid Web - Account 1 (xy) wird versucht / fehl-
geschlagen (Timeout) -> Account 2". Damit ist im Item-Log direkt sichtbar,
ob acc2/acc3 ueberhaupt erreicht werden -> dient auch als Diagnose fuer den
vermuteten Timeout-Bug (kommt separat, falls das Log Stillstand zeigt).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Sucukdeluxe 2026-05-30 22:50:49 +02:00
parent b414ab1773
commit dd31bee8b1
3 changed files with 96 additions and 4 deletions

View File

@ -1,7 +1,22 @@
import fs from "node:fs";
import path from "node:path";
import { AsyncLocalStorage } from "node:async_hooks";
import type { RotationEvent } from "../shared/types";
/** Item-scoped sink: while a single item's link-unrestrict runs, the
* download-manager wraps it in runWithRotationItemSink() so EVERY rotation
* event for that item (Account 1 wird versucht, fehlgeschlagen, Account 2)
* lands in that item's own log exactly where the user looks. AsyncLocalStorage
* keeps this correct even with 8 items unrestricting in parallel: each runs in
* its own async context, so events never cross-attribute. */
export type RotationItemSink = (event: RotationEvent) => void;
const rotationItemContext = new AsyncLocalStorage<RotationItemSink>();
/** Run `fn` with an item-scoped rotation sink active for its whole async chain. */
export function runWithRotationItemSink<T>(sink: RotationItemSink, fn: () => Promise<T>): Promise<T> {
return rotationItemContext.run(sink, fn);
}
/** Dedicated log file for multi-account/key rotation events:
* Mega-Debrid account selection, Debrid-Link key selection, per-attempt
* test result, cooldown set, fallback to next account/key, etc.
@ -45,9 +60,6 @@ function pushRotationEvent(
fields?: Record<string, unknown>,
at = Date.now()
): void {
if (!isUiRelevantRotationEvent(event)) {
return;
}
rotationEventSeq += 1;
const entry: RotationEvent = {
id: `rot_${at}_${rotationEventSeq}`,
@ -61,6 +73,24 @@ function pushRotationEvent(
cooldownSec: fields && fields.cooldownSec != null ? Number(fields.cooldownSec) || 0 : undefined,
next: fields && fields.next != null ? String(fields.next) : undefined
};
// Always route to the item-scoped sink (if any) — the per-item log wants the
// FULL trail including "TEST" (Account X wird versucht), so the user sees the
// rotation right where they look.
const itemSink = rotationItemContext.getStore();
if (itemSink) {
try {
itemSink(entry);
} catch {
// never let item logging break the rotation flow
}
}
// The global UI panel ring + live push skip noisy per-attempt TEST markers;
// it focuses on outcomes (OK / FAILED / FATAL / skips).
if (!isUiRelevantRotationEvent(event)) {
return;
}
rotationEventRing.push(entry);
if (rotationEventRing.length > ROTATION_EVENT_RING_MAX) {
rotationEventRing.splice(0, rotationEventRing.length - ROTATION_EVENT_RING_MAX);

View File

@ -56,7 +56,8 @@ import { AllDebridWebUnrestrictor, BestDebridWebUnrestrictor, DebridService, Meg
import { cleanupArchives, clearExtractResumeState, collectArchiveCleanupTargets, detectArchiveSignature, extractPackageArchives, findArchiveCandidates, hasAnyFilesRecursive, removeEmptyDirectoryTree, resetExtractorCachesForPasswordChange, type ExtractArchiveFailureInfo } from "./extractor";
import { validateFileAgainstManifest } from "./integrity";
import { logger } from "./logger";
import { getRecentRotationEvents, setRotationEventListener } from "./account-rotation-log";
import { getRecentRotationEvents, runWithRotationItemSink, setRotationEventListener } from "./account-rotation-log";
import type { RotationEvent } from "../shared/types";
import { ensureItemLog, getItemLogPath as getPersistedItemLogPath, logItemEvent as writeItemLogEvent } from "./item-log";
import { ensurePackageLog, getPackageLogPath as getPersistedPackageLogPath, logPackageEvent as writePackageLogEvent } from "./package-log";
import { logRenameEvent as writeRenameLogEvent } from "./rename-log";

View File

@ -0,0 +1,61 @@
import { describe, it, expect } from "vitest";
import { logAccountRotation, runWithRotationItemSink, getRecentRotationEvents } from "../src/main/account-rotation-log";
import type { RotationEvent } from "../src/shared/types";
describe("rotation item-sink (AsyncLocalStorage)", () => {
it("routes the FULL rotation trail (incl. TEST) to the active item sink", async () => {
const captured: RotationEvent[] = [];
await runWithRotationItemSink((ev) => captured.push(ev), async () => {
logAccountRotation("INFO", "Mega-Debrid Web", "Account 1/3 (ab**xy)", "TEST", { link: "x" });
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();
});
const events = captured.map((e) => e.event);
expect(events).toEqual(["TEST", "FAILED", "TEST", "OK"]);
const failed = captured.find((e) => e.event === "FAILED");
expect(failed?.reason).toBe("Timeout");
expect(failed?.next).toBe("Account 2/3 (cd**zw)");
});
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);
});
it("isolates two parallel item sinks (no cross-attribution)", async () => {
const a: RotationEvent[] = [];
const b: RotationEvent[] = [];
await Promise.all([
runWithRotationItemSink((ev) => a.push(ev), async () => {
logAccountRotation("INFO", "Mega-Debrid Web", "Account 1 (a)", "TEST");
await new Promise((r) => setTimeout(r, 10));
logAccountRotation("INFO", "Mega-Debrid Web", "Account 1 (a)", "OK");
}),
runWithRotationItemSink((ev) => b.push(ev), async () => {
logAccountRotation("INFO", "Debrid-Link", "Key 1 (b)", "TEST");
await new Promise((r) => setTimeout(r, 5));
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"]);
expect(b.map((e) => e.event)).toEqual(["TEST", "FAILED"]);
});
it("still feeds the global UI ring (outcomes only, TEST filtered)", () => {
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);
});
});