User-Report: Logs zeigten z.B. "17:29:43" obwohl es lokal 19:29:43 war (CEST/UTC+2), weil alle Logger `new Date().toISOString()` (UTC "...Z") nutzten. Neuer Helper logTimestamp() formatiert lokale Zeit mit explizitem Offset (ISO 8601, z.B. "2026-05-31T19:29:43.605+02:00") — menschlich lokal UND weiterhin eindeutig/Date.parse-bar. Angewandt auf alle Log-Zeilen- Writer: item-log, logger (rd_downloader.log), audit-log, rename-log, session-log, package-log, account-rotation-log, trace-log. Interne/API-/Dateinamen-Zeitstempel (debug-server, support-bundle, trace autoDisableAt-Config) bleiben absichtlich UTC. Test: tests/log-timestamp.test.ts (Format + Round-Trip zum selben Instant + lokale Stunde, TZ-unabhaengig). 650 Tests gruen, tsc 9, Build sauber. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
246 lines
8.3 KiB
TypeScript
246 lines
8.3 KiB
TypeScript
import fs from "node:fs";
|
|
import { logTimestamp } from "./log-timestamp";
|
|
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.
|
|
* Separate from rd_downloader.log so the user can see the rotation flow
|
|
* without the noise of normal download activity. */
|
|
|
|
type RotationLevel = "INFO" | "WARN" | "ERROR";
|
|
|
|
/** In-memory ring buffer of the most recent rotation events so the UI can show
|
|
* a live "which account was tried and why it failed" panel — the same events
|
|
* written to account-rotation.log, but surfaced to the renderer via snapshot. */
|
|
const ROTATION_EVENT_RING_MAX = 60;
|
|
const rotationEventRing: RotationEvent[] = [];
|
|
let rotationEventSeq = 0;
|
|
let rotationEventListener: ((event: RotationEvent) => void) | null = null;
|
|
|
|
/** Register a callback fired whenever a new rotation event is recorded (used by
|
|
* the download-manager to push a fresh snapshot to the UI immediately). */
|
|
export function setRotationEventListener(listener: ((event: RotationEvent) => void) | null): void {
|
|
rotationEventListener = listener;
|
|
}
|
|
|
|
/** Returns the recent rotation events, newest first. */
|
|
export function getRecentRotationEvents(limit = ROTATION_EVENT_RING_MAX): RotationEvent[] {
|
|
const slice = rotationEventRing.slice(-limit);
|
|
slice.reverse();
|
|
return slice;
|
|
}
|
|
|
|
/** Events that are noise for the UI panel (per-attempt TEST markers). The panel
|
|
* focuses on outcomes: OK / FAILED / FATAL / skips. */
|
|
function isUiRelevantRotationEvent(event: string): boolean {
|
|
return event !== "TEST";
|
|
}
|
|
|
|
function pushRotationEvent(
|
|
level: RotationLevel,
|
|
provider: string,
|
|
accountLabel: string,
|
|
event: string,
|
|
fields?: Record<string, unknown>,
|
|
at = Date.now()
|
|
): void {
|
|
rotationEventSeq += 1;
|
|
const entry: RotationEvent = {
|
|
id: `rot_${at}_${rotationEventSeq}`,
|
|
at,
|
|
level,
|
|
provider,
|
|
accountLabel,
|
|
event,
|
|
reason: fields && fields.reason != null ? String(fields.reason) : undefined,
|
|
category: fields && fields.category != null ? String(fields.category) : undefined,
|
|
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);
|
|
}
|
|
if (rotationEventListener) {
|
|
try {
|
|
rotationEventListener(entry);
|
|
} catch {
|
|
// never let a UI push break the rotation flow
|
|
}
|
|
}
|
|
}
|
|
|
|
const ROTATION_LOG_MAX_FILE_BYTES = Number(process.env.RD_ACCOUNT_ROTATION_LOG_MAX_BYTES || 5 * 1024 * 1024);
|
|
const ROTATION_LOG_RETENTION_DAYS = Number(process.env.RD_ACCOUNT_ROTATION_LOG_RETENTION_DAYS || 14);
|
|
|
|
let rotationLogPath: string | null = null;
|
|
|
|
function sanitizeFieldValue(value: unknown): string {
|
|
if (value === undefined || value === null) {
|
|
return "";
|
|
}
|
|
if (typeof value === "string") {
|
|
return value.replace(/\r?\n/g, "\\n");
|
|
}
|
|
if (typeof value === "number" || typeof value === "boolean") {
|
|
return String(value);
|
|
}
|
|
try {
|
|
return JSON.stringify(value).replace(/\r?\n/g, "\\n");
|
|
} catch {
|
|
return String(value);
|
|
}
|
|
}
|
|
|
|
function formatFields(fields?: Record<string, unknown>): string {
|
|
if (!fields) {
|
|
return "";
|
|
}
|
|
const parts = Object.entries(fields)
|
|
.filter(([, value]) => value !== undefined && value !== null && sanitizeFieldValue(value) !== "")
|
|
.map(([key, value]) => `${key}=${sanitizeFieldValue(value)}`);
|
|
return parts.length > 0 ? ` | ${parts.join(" | ")}` : "";
|
|
}
|
|
|
|
function rotateIfNeeded(filePath: string): void {
|
|
try {
|
|
const stat = fs.statSync(filePath);
|
|
if (stat.size < ROTATION_LOG_MAX_FILE_BYTES) {
|
|
return;
|
|
}
|
|
const backup = `${filePath}.old`;
|
|
try {
|
|
fs.rmSync(backup, { force: true });
|
|
} catch {
|
|
// ignore
|
|
}
|
|
fs.renameSync(filePath, backup);
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
|
|
function cleanupOldBackup(filePath: string): void {
|
|
const backup = `${filePath}.old`;
|
|
try {
|
|
const stat = fs.statSync(backup);
|
|
const cutoff = Date.now() - ROTATION_LOG_RETENTION_DAYS * 24 * 60 * 60 * 1000;
|
|
if (stat.mtimeMs < cutoff) {
|
|
fs.rmSync(backup, { force: true });
|
|
}
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
|
|
export function initAccountRotationLog(baseDir: string): void {
|
|
rotationLogPath = path.join(baseDir, "account-rotation.log");
|
|
try {
|
|
fs.mkdirSync(path.dirname(rotationLogPath), { recursive: true });
|
|
cleanupOldBackup(rotationLogPath);
|
|
if (!fs.existsSync(rotationLogPath)) {
|
|
fs.writeFileSync(rotationLogPath, "", "utf8");
|
|
}
|
|
rotateIfNeeded(rotationLogPath);
|
|
if (!fs.existsSync(rotationLogPath)) {
|
|
fs.writeFileSync(rotationLogPath, "", "utf8");
|
|
}
|
|
fs.appendFileSync(
|
|
rotationLogPath,
|
|
`=== Account-Rotation Log Start: ${logTimestamp()} ===\n`,
|
|
"utf8"
|
|
);
|
|
} catch {
|
|
rotationLogPath = null;
|
|
}
|
|
}
|
|
|
|
/** Record an account/key rotation event. The format is intentionally compact
|
|
* and grep-friendly: timestamp + level + provider + accountLabel + event + fields.
|
|
* Example output:
|
|
* 2026-04-19T20:48:50.000Z [INFO] Mega-Debrid Web | Account 2 (fa**david@...) | TEST | link=https://...
|
|
* 2026-04-19T20:48:52.000Z [WARN] Mega-Debrid Web | Account 2 (fa**david@...) | FAILED reason="Antwort leer" cooldownSec=30 | link=https://...
|
|
* 2026-04-19T20:48:53.000Z [INFO] Mega-Debrid Web | Account 3 (am**@example.com) | TEST | link=https://...
|
|
* 2026-04-19T20:48:55.000Z [INFO] Mega-Debrid Web | Account 3 (am**@example.com) | OK directLink=https://... | link=https://... */
|
|
export function logAccountRotation(
|
|
level: RotationLevel,
|
|
provider: string,
|
|
accountLabel: string,
|
|
event: string,
|
|
fields?: Record<string, unknown>
|
|
): void {
|
|
// Surface to the UI ring buffer regardless of whether the file log is ready.
|
|
pushRotationEvent(level, provider, accountLabel, event, fields);
|
|
if (!rotationLogPath) {
|
|
return;
|
|
}
|
|
try {
|
|
rotateIfNeeded(rotationLogPath);
|
|
if (!fs.existsSync(rotationLogPath)) {
|
|
fs.writeFileSync(rotationLogPath, "", "utf8");
|
|
}
|
|
const head = `${logTimestamp()} [${level}] ${provider} | ${accountLabel} | ${event}`;
|
|
fs.appendFileSync(rotationLogPath, `${head}${formatFields(fields)}\n`, "utf8");
|
|
} catch {
|
|
// ignore write errors
|
|
}
|
|
}
|
|
|
|
export function getAccountRotationLogPath(): string | null {
|
|
if (!rotationLogPath) {
|
|
return null;
|
|
}
|
|
return fs.existsSync(rotationLogPath) ? rotationLogPath : null;
|
|
}
|
|
|
|
export function shutdownAccountRotationLog(): void {
|
|
if (!rotationLogPath) {
|
|
return;
|
|
}
|
|
try {
|
|
fs.appendFileSync(
|
|
rotationLogPath,
|
|
`=== Account-Rotation Log Ende: ${logTimestamp()} ===\n`,
|
|
"utf8"
|
|
);
|
|
} catch {
|
|
// ignore
|
|
}
|
|
rotationLogPath = null;
|
|
}
|