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:
parent
b414ab1773
commit
dd31bee8b1
@ -1,7 +1,22 @@
|
|||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
import { AsyncLocalStorage } from "node:async_hooks";
|
||||||
import type { RotationEvent } from "../shared/types";
|
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:
|
/** Dedicated log file for multi-account/key rotation events:
|
||||||
* Mega-Debrid account selection, Debrid-Link key selection, per-attempt
|
* Mega-Debrid account selection, Debrid-Link key selection, per-attempt
|
||||||
* test result, cooldown set, fallback to next account/key, etc.
|
* test result, cooldown set, fallback to next account/key, etc.
|
||||||
@ -45,9 +60,6 @@ function pushRotationEvent(
|
|||||||
fields?: Record<string, unknown>,
|
fields?: Record<string, unknown>,
|
||||||
at = Date.now()
|
at = Date.now()
|
||||||
): void {
|
): void {
|
||||||
if (!isUiRelevantRotationEvent(event)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
rotationEventSeq += 1;
|
rotationEventSeq += 1;
|
||||||
const entry: RotationEvent = {
|
const entry: RotationEvent = {
|
||||||
id: `rot_${at}_${rotationEventSeq}`,
|
id: `rot_${at}_${rotationEventSeq}`,
|
||||||
@ -61,6 +73,24 @@ function pushRotationEvent(
|
|||||||
cooldownSec: fields && fields.cooldownSec != null ? Number(fields.cooldownSec) || 0 : undefined,
|
cooldownSec: fields && fields.cooldownSec != null ? Number(fields.cooldownSec) || 0 : undefined,
|
||||||
next: fields && fields.next != null ? String(fields.next) : 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);
|
rotationEventRing.push(entry);
|
||||||
if (rotationEventRing.length > ROTATION_EVENT_RING_MAX) {
|
if (rotationEventRing.length > ROTATION_EVENT_RING_MAX) {
|
||||||
rotationEventRing.splice(0, rotationEventRing.length - ROTATION_EVENT_RING_MAX);
|
rotationEventRing.splice(0, rotationEventRing.length - ROTATION_EVENT_RING_MAX);
|
||||||
|
|||||||
@ -56,7 +56,8 @@ import { AllDebridWebUnrestrictor, BestDebridWebUnrestrictor, DebridService, Meg
|
|||||||
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";
|
||||||
import { logger } from "./logger";
|
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 { ensureItemLog, getItemLogPath as getPersistedItemLogPath, logItemEvent as writeItemLogEvent } from "./item-log";
|
||||||
import { ensurePackageLog, getPackageLogPath as getPersistedPackageLogPath, logPackageEvent as writePackageLogEvent } from "./package-log";
|
import { ensurePackageLog, getPackageLogPath as getPersistedPackageLogPath, logPackageEvent as writePackageLogEvent } from "./package-log";
|
||||||
import { logRenameEvent as writeRenameLogEvent } from "./rename-log";
|
import { logRenameEvent as writeRenameLogEvent } from "./rename-log";
|
||||||
|
|||||||
61
tests/account-rotation-log.test.ts
Normal file
61
tests/account-rotation-log.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user