@mkterswingman/5mghost-yonder 0.0.12 → 0.0.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/download/downloader.d.ts +4 -1
- package/dist/download/downloader.js +61 -16
- package/dist/download/jobManager.d.ts +1 -0
- package/dist/download/jobManager.js +13 -0
- package/dist/download/types.d.ts +1 -0
- package/dist/tools/subtitles.js +32 -25
- package/dist/utils/ytdlp.js +7 -2
- package/dist/utils/ytdlpFailures.d.ts +3 -0
- package/dist/utils/ytdlpFailures.js +27 -0
- package/dist/utils/ytdlpScheduler.d.ts +22 -0
- package/dist/utils/ytdlpScheduler.js +66 -0
- package/package.json +1 -1
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { DownloadJobManager } from "./jobManager.js";
|
|
2
2
|
import type { DownloadJobItemSnapshot, DownloadMode } from "./types.js";
|
|
3
3
|
import type { PreflightVideoMetadata } from "../utils/videoMetadata.js";
|
|
4
|
+
import { type YtDlpSchedulerState } from "../utils/ytdlpScheduler.js";
|
|
4
5
|
type DownloadingStep = "download_video" | "download_subtitles" | "merge";
|
|
5
6
|
export interface DownloadOneItemProgressUpdate {
|
|
6
7
|
step: DownloadingStep;
|
|
@@ -20,7 +21,7 @@ export interface DownloadOneItemInput {
|
|
|
20
21
|
subtitleFormats: string[];
|
|
21
22
|
subtitleLanguages?: string[];
|
|
22
23
|
defaultSubtitleLanguages?: string[];
|
|
23
|
-
jobManager?: Pick<DownloadJobManager, "startItem" | "updateItemProgress" | "completeItem" | "failItem">;
|
|
24
|
+
jobManager?: Pick<DownloadJobManager, "startItem" | "updateItemProgress" | "completeItem" | "failItem" | "setItemQueuedByGlobalRateLimit">;
|
|
24
25
|
jobId?: string;
|
|
25
26
|
deps?: Partial<DownloadOneItemDeps>;
|
|
26
27
|
}
|
|
@@ -38,6 +39,8 @@ interface DownloadOneItemDeps {
|
|
|
38
39
|
videoQuality: string;
|
|
39
40
|
onProgress: (update: DownloadOneItemProgressUpdate) => void;
|
|
40
41
|
}) => Promise<string>;
|
|
42
|
+
refreshCookies: () => Promise<boolean>;
|
|
43
|
+
getYtDlpSchedulerState: () => YtDlpSchedulerState;
|
|
41
44
|
downloadSubtitles: (input: {
|
|
42
45
|
videoId: string;
|
|
43
46
|
sourceUrl: string;
|
|
@@ -2,10 +2,13 @@ import { mkdir, rm, stat, writeFile } from "node:fs/promises";
|
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { downloadSubtitlesForLanguages, toReadableSubtitleJobError } from "../tools/subtitles.js";
|
|
4
4
|
import { loadConfig } from "../utils/config.js";
|
|
5
|
+
import { tryHeadlessRefresh } from "../utils/cookieRefresh.js";
|
|
5
6
|
import { hasFfmpeg, isRuntimeMissingMessage } from "../utils/ffmpeg.js";
|
|
6
7
|
import { formatBytes } from "../utils/formatters.js";
|
|
7
8
|
import { hasYtDlp, runYtDlp, runYtDlpJson } from "../utils/ytdlp.js";
|
|
9
|
+
import { appendDiagnosticLog, classifyYtDlpFailure } from "../utils/ytdlpFailures.js";
|
|
8
10
|
import { parseYtDlpProgressLine } from "../utils/ytdlpProgress.js";
|
|
11
|
+
import { getGlobalYtDlpSchedulerState } from "../utils/ytdlpScheduler.js";
|
|
9
12
|
export async function downloadOneItem(input) {
|
|
10
13
|
const deps = resolveDeps(input.deps);
|
|
11
14
|
const manager = input.jobManager;
|
|
@@ -50,26 +53,52 @@ export async function downloadOneItem(input) {
|
|
|
50
53
|
if (needsVideo(input.mode)) {
|
|
51
54
|
currentStep = "download_video";
|
|
52
55
|
manager?.updateItemProgress(jobId, input.item.video_id, currentStep, 0);
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
56
|
+
manager?.setItemQueuedByGlobalRateLimit(jobId, input.item.video_id, deps.getYtDlpSchedulerState().wouldThrottle);
|
|
57
|
+
try {
|
|
58
|
+
result.video_file = await deps.downloadVideo({
|
|
59
|
+
sourceUrl: input.item.source_url,
|
|
60
|
+
outputFile: videoFile,
|
|
61
|
+
videoQuality,
|
|
62
|
+
onProgress,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
catch (error) {
|
|
66
|
+
const failureKind = classifyVideoDownloadError(error);
|
|
67
|
+
if (failureKind != null && failureKind !== "RATE_LIMITED" && await deps.refreshCookies()) {
|
|
68
|
+
result.video_file = await deps.downloadVideo({
|
|
69
|
+
sourceUrl: input.item.source_url,
|
|
70
|
+
outputFile: videoFile,
|
|
71
|
+
videoQuality,
|
|
72
|
+
onProgress,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
else {
|
|
76
|
+
throw error;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
finally {
|
|
80
|
+
manager?.setItemQueuedByGlobalRateLimit(jobId, input.item.video_id, false);
|
|
81
|
+
}
|
|
59
82
|
result.final_file_size = formatBytes((await deps.stat(result.video_file)).size);
|
|
60
83
|
}
|
|
61
84
|
if (needsSubtitles(input.mode)) {
|
|
62
85
|
currentStep = "download_subtitles";
|
|
63
86
|
manager?.updateItemProgress(jobId, input.item.video_id, currentStep, 0);
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
87
|
+
manager?.setItemQueuedByGlobalRateLimit(jobId, input.item.video_id, deps.getYtDlpSchedulerState().wouldThrottle);
|
|
88
|
+
try {
|
|
89
|
+
result.subtitle_files = await deps.downloadSubtitles({
|
|
90
|
+
videoId: input.item.video_id,
|
|
91
|
+
sourceUrl: input.item.source_url,
|
|
92
|
+
subtitlesDir,
|
|
93
|
+
formats: subtitleFormats,
|
|
94
|
+
languages: subtitleLanguages,
|
|
95
|
+
skipMissingLanguages: usingDefaultSubtitleLanguages,
|
|
96
|
+
onProgress,
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
finally {
|
|
100
|
+
manager?.setItemQueuedByGlobalRateLimit(jobId, input.item.video_id, false);
|
|
101
|
+
}
|
|
73
102
|
}
|
|
74
103
|
currentStep = "write_metadata";
|
|
75
104
|
manager?.updateItemProgress(jobId, input.item.video_id, currentStep, 100);
|
|
@@ -103,6 +132,8 @@ function resolveDeps(overrides) {
|
|
|
103
132
|
hasFfmpeg,
|
|
104
133
|
fetchMetadata: fetchMetadataWithYtDlp,
|
|
105
134
|
downloadVideo: downloadVideoWithYtDlp,
|
|
135
|
+
refreshCookies: tryHeadlessRefresh,
|
|
136
|
+
getYtDlpSchedulerState: getGlobalYtDlpSchedulerState,
|
|
106
137
|
downloadSubtitles: downloadSubtitlesWithYtDlp,
|
|
107
138
|
mkdir: async (path, options) => {
|
|
108
139
|
await mkdir(path, options);
|
|
@@ -166,7 +197,17 @@ async function downloadVideoWithYtDlp(input) {
|
|
|
166
197
|
}
|
|
167
198
|
});
|
|
168
199
|
if (result.exitCode !== 0) {
|
|
169
|
-
|
|
200
|
+
const failureKind = classifyYtDlpFailure(result.stderr);
|
|
201
|
+
if (failureKind === "RATE_LIMITED") {
|
|
202
|
+
throw new Error(appendDiagnosticLog("The current YouTube session has been rate-limited. Wait and retry later.", result.failureLogPath));
|
|
203
|
+
}
|
|
204
|
+
if (failureKind === "SIGN_IN_REQUIRED") {
|
|
205
|
+
throw new Error(appendDiagnosticLog("YouTube requested an additional sign-in confirmation for this video.", result.failureLogPath));
|
|
206
|
+
}
|
|
207
|
+
if (failureKind === "COOKIES_INVALID" || failureKind === "COOKIES_EXPIRED") {
|
|
208
|
+
throw new Error(appendDiagnosticLog("YouTube rejected the current cookies for video download.", result.failureLogPath));
|
|
209
|
+
}
|
|
210
|
+
throw new Error(appendDiagnosticLog(result.stderr.slice(0, 500) || `yt-dlp exited with ${result.exitCode}`, result.failureLogPath));
|
|
170
211
|
}
|
|
171
212
|
return resolveYtDlpOutputPath(input.outputFile, result.stdout);
|
|
172
213
|
}
|
|
@@ -259,6 +300,10 @@ function sortSubtitleFiles(files) {
|
|
|
259
300
|
.sort(([left], [right]) => left.localeCompare(right))
|
|
260
301
|
.map(([format, paths]) => [format, [...paths].sort()]));
|
|
261
302
|
}
|
|
303
|
+
function classifyVideoDownloadError(error) {
|
|
304
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
305
|
+
return classifyYtDlpFailure(message);
|
|
306
|
+
}
|
|
262
307
|
function toErrorMessage(error) {
|
|
263
308
|
return toReadableSubtitleJobError(error);
|
|
264
309
|
}
|
|
@@ -10,6 +10,7 @@ export declare class DownloadJobManager {
|
|
|
10
10
|
pollJob(jobId: string): DownloadJobSnapshot;
|
|
11
11
|
startItem(jobId: string, videoId: string, step?: DownloadStep): DownloadJobSnapshot;
|
|
12
12
|
updateItemProgress(jobId: string, videoId: string, step: DownloadStep, progressPercent: number | null): DownloadJobSnapshot;
|
|
13
|
+
setItemQueuedByGlobalRateLimit(jobId: string, videoId: string, queued: boolean): DownloadJobSnapshot;
|
|
13
14
|
completeItem(jobId: string, videoId: string, outputs?: Pick<DownloadJobItemSnapshot, "video_file" | "metadata_file" | "final_file_size" | "subtitle_files">): DownloadJobSnapshot;
|
|
14
15
|
failItem(jobId: string, videoId: string, error: string, step?: DownloadStep): DownloadJobSnapshot;
|
|
15
16
|
private applyItemMutation;
|
|
@@ -26,6 +26,7 @@ export class DownloadJobManager {
|
|
|
26
26
|
title: video.title,
|
|
27
27
|
output_dir: video.output_dir,
|
|
28
28
|
status: "queued",
|
|
29
|
+
queued_by_global_rate_limit: false,
|
|
29
30
|
step: null,
|
|
30
31
|
progress_percent: null,
|
|
31
32
|
error: null,
|
|
@@ -65,6 +66,12 @@ export class DownloadJobManager {
|
|
|
65
66
|
});
|
|
66
67
|
return this.toSnapshot(job);
|
|
67
68
|
}
|
|
69
|
+
setItemQueuedByGlobalRateLimit(jobId, videoId, queued) {
|
|
70
|
+
const job = this.getJob(jobId);
|
|
71
|
+
const item = this.getItem(job, videoId);
|
|
72
|
+
item.queued_by_global_rate_limit = queued;
|
|
73
|
+
return this.toSnapshot(job);
|
|
74
|
+
}
|
|
68
75
|
completeItem(jobId, videoId, outputs) {
|
|
69
76
|
const job = this.getJob(jobId);
|
|
70
77
|
const item = this.getItem(job, videoId);
|
|
@@ -96,6 +103,10 @@ export class DownloadJobManager {
|
|
|
96
103
|
const nextStep = mutation.step === undefined ? item.step : mutation.step;
|
|
97
104
|
const previousStatus = item.status;
|
|
98
105
|
item.status = nextStatus;
|
|
106
|
+
item.queued_by_global_rate_limit =
|
|
107
|
+
mutation.queued_by_global_rate_limit === undefined
|
|
108
|
+
? item.queued_by_global_rate_limit
|
|
109
|
+
: mutation.queued_by_global_rate_limit;
|
|
99
110
|
item.step = nextStep;
|
|
100
111
|
item.video_file = mutation.video_file === undefined ? item.video_file : mutation.video_file;
|
|
101
112
|
item.metadata_file = mutation.metadata_file === undefined ? item.metadata_file : mutation.metadata_file;
|
|
@@ -107,6 +118,7 @@ export class DownloadJobManager {
|
|
|
107
118
|
job.started_at = job.started_at ?? now;
|
|
108
119
|
item.completed_at = null;
|
|
109
120
|
item.error = null;
|
|
121
|
+
item.queued_by_global_rate_limit = false;
|
|
110
122
|
item.progress_percent = resolveRunningProgress(item.progress_percent, mutation.progress_percent, previousStatus);
|
|
111
123
|
}
|
|
112
124
|
if (nextStatus === "completed" || nextStatus === "failed") {
|
|
@@ -114,6 +126,7 @@ export class DownloadJobManager {
|
|
|
114
126
|
job.started_at = job.started_at ?? now;
|
|
115
127
|
item.completed_at = now;
|
|
116
128
|
item.error = mutation.error === undefined ? item.error : mutation.error;
|
|
129
|
+
item.queued_by_global_rate_limit = false;
|
|
117
130
|
item.progress_percent =
|
|
118
131
|
mutation.progress_percent === undefined ? item.progress_percent : clampProgressOrNull(mutation.progress_percent);
|
|
119
132
|
}
|
package/dist/download/types.d.ts
CHANGED
package/dist/tools/subtitles.js
CHANGED
|
@@ -5,12 +5,15 @@ import { PATHS } from "../utils/config.js";
|
|
|
5
5
|
import { runYtDlp } from "../utils/ytdlp.js";
|
|
6
6
|
import { hasSIDCookies, areCookiesExpired } from "../utils/cookies.js";
|
|
7
7
|
import { tryHeadlessRefresh } from "../utils/cookieRefresh.js";
|
|
8
|
+
import { appendDiagnosticLog, classifyYtDlpFailure } from "../utils/ytdlpFailures.js";
|
|
8
9
|
import { resolveVideoInput, normalizeVideoInputs } from "../utils/videoInput.js";
|
|
9
10
|
const AUTH_REQUIRED_MSG = "❌ 未认证。请先登录:\n• OAuth: npx @mkterswingman/5mghost-yonder setup\n• PAT: 设置环境变量 YT_MCP_TOKEN 或在 https://mkterswingman.com/pat/login 生成 token";
|
|
10
11
|
const COOKIE_SETUP_COMMAND = "yt-mcp setup-cookies";
|
|
11
12
|
const COOKIE_MISSING_MESSAGE = `No valid YouTube cookies found.\nPlease run in your terminal: ${COOKIE_SETUP_COMMAND}`;
|
|
12
13
|
const COOKIE_EXPIRED_MESSAGE = `YouTube cookies have expired and auto-refresh failed.\nPlease run in your terminal: ${COOKIE_SETUP_COMMAND}`;
|
|
13
14
|
const COOKIE_INVALID_MESSAGE = `YouTube rejected the current cookies and auto-refresh failed.\nPlease run in your terminal: ${COOKIE_SETUP_COMMAND}`;
|
|
15
|
+
const SIGN_IN_REQUIRED_MESSAGE = `YouTube requested an additional sign-in confirmation and auto-refresh failed.\nPlease run in your terminal: ${COOKIE_SETUP_COMMAND}`;
|
|
16
|
+
const RATE_LIMITED_MESSAGE = "The current YouTube session has been rate-limited. Wait and retry later.";
|
|
14
17
|
const COOKIE_JOB_MESSAGE = `YouTube cookies are missing, expired, or invalid.\nPlease run in your terminal: ${COOKIE_SETUP_COMMAND}`;
|
|
15
18
|
function toolOk(payload) {
|
|
16
19
|
return {
|
|
@@ -56,30 +59,20 @@ function isCookieFailureText(error) {
|
|
|
56
59
|
normalized.includes("login") ||
|
|
57
60
|
normalized.includes("not a bot"));
|
|
58
61
|
}
|
|
59
|
-
function appendDiagnosticLog(message, logPath) {
|
|
60
|
-
return logPath ? `${message}\nDiagnostic log: ${logPath}` : message;
|
|
61
|
-
}
|
|
62
62
|
function classifyYtDlpCookieFailure(stderr) {
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
message:
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
normalized.includes("cookies") ||
|
|
76
|
-
normalized.includes("not a bot")) {
|
|
77
|
-
return {
|
|
78
|
-
code: "COOKIES_INVALID",
|
|
79
|
-
message: COOKIE_INVALID_MESSAGE,
|
|
80
|
-
};
|
|
63
|
+
const kind = classifyYtDlpFailure(stderr);
|
|
64
|
+
switch (kind) {
|
|
65
|
+
case "COOKIES_EXPIRED":
|
|
66
|
+
return { code: kind, message: COOKIE_EXPIRED_MESSAGE };
|
|
67
|
+
case "COOKIES_INVALID":
|
|
68
|
+
return { code: kind, message: COOKIE_INVALID_MESSAGE };
|
|
69
|
+
case "SIGN_IN_REQUIRED":
|
|
70
|
+
return { code: kind, message: SIGN_IN_REQUIRED_MESSAGE };
|
|
71
|
+
case "RATE_LIMITED":
|
|
72
|
+
return { code: kind, message: RATE_LIMITED_MESSAGE };
|
|
73
|
+
default:
|
|
74
|
+
return null;
|
|
81
75
|
}
|
|
82
|
-
return null;
|
|
83
76
|
}
|
|
84
77
|
export function toReadableSubtitleJobError(error) {
|
|
85
78
|
const message = error instanceof Error ? error.message : String(error);
|
|
@@ -367,7 +360,7 @@ export async function downloadSubtitlesForLanguages(input) {
|
|
|
367
360
|
outputStem: `${lang}`,
|
|
368
361
|
targetFile,
|
|
369
362
|
});
|
|
370
|
-
if (result.cookieFailureCode) {
|
|
363
|
+
if (result.cookieFailureCode && result.cookieFailureCode !== "RATE_LIMITED") {
|
|
371
364
|
const refreshed = await tryRefreshSubtitleCookies();
|
|
372
365
|
if (refreshed) {
|
|
373
366
|
result = await downloadSubtitle(input.videoId, lang, format, {
|
|
@@ -467,7 +460,14 @@ export function registerSubtitleTools(server, config, tokenManager) {
|
|
|
467
460
|
}
|
|
468
461
|
// Retry also failed with expired cookies — give up
|
|
469
462
|
}
|
|
470
|
-
return toolErr(dl.cookieFailureCode, dl.error ??
|
|
463
|
+
return toolErr(dl.cookieFailureCode, dl.error ??
|
|
464
|
+
(dl.cookieFailureCode === "COOKIES_EXPIRED"
|
|
465
|
+
? COOKIE_EXPIRED_MESSAGE
|
|
466
|
+
: dl.cookieFailureCode === "SIGN_IN_REQUIRED"
|
|
467
|
+
? SIGN_IN_REQUIRED_MESSAGE
|
|
468
|
+
: dl.cookieFailureCode === "RATE_LIMITED"
|
|
469
|
+
? RATE_LIMITED_MESSAGE
|
|
470
|
+
: COOKIE_INVALID_MESSAGE));
|
|
471
471
|
}
|
|
472
472
|
if (!dl.ok) {
|
|
473
473
|
// When using default languages, silently skip unavailable ones
|
|
@@ -554,6 +554,9 @@ export function registerSubtitleTools(server, config, tokenManager) {
|
|
|
554
554
|
lastError = dl.error;
|
|
555
555
|
}
|
|
556
556
|
}
|
|
557
|
+
if (cookieFailureCode === "RATE_LIMITED") {
|
|
558
|
+
return toolErr("RATE_LIMITED", RATE_LIMITED_MESSAGE);
|
|
559
|
+
}
|
|
557
560
|
if (cookieFailureCode) {
|
|
558
561
|
const refreshed = await tryRefreshSubtitleCookies();
|
|
559
562
|
if (refreshed) {
|
|
@@ -571,7 +574,11 @@ export function registerSubtitleTools(server, config, tokenManager) {
|
|
|
571
574
|
}
|
|
572
575
|
}
|
|
573
576
|
if (cookieFailureCode) {
|
|
574
|
-
return toolErr(cookieFailureCode, cookieFailureCode === "COOKIES_EXPIRED"
|
|
577
|
+
return toolErr(cookieFailureCode, cookieFailureCode === "COOKIES_EXPIRED"
|
|
578
|
+
? COOKIE_EXPIRED_MESSAGE
|
|
579
|
+
: cookieFailureCode === "SIGN_IN_REQUIRED"
|
|
580
|
+
? SIGN_IN_REQUIRED_MESSAGE
|
|
581
|
+
: COOKIE_INVALID_MESSAGE);
|
|
575
582
|
}
|
|
576
583
|
}
|
|
577
584
|
if (langFound.length > 0) {
|
package/dist/utils/ytdlp.js
CHANGED
|
@@ -4,6 +4,8 @@ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { PATHS } from "./config.js";
|
|
6
6
|
import { getYtDlpPath } from "./ytdlpPath.js";
|
|
7
|
+
import { markGlobalYtDlpRateLimited, scheduleYtDlp } from "./ytdlpScheduler.js";
|
|
8
|
+
import { classifyYtDlpFailure } from "./ytdlpFailures.js";
|
|
7
9
|
function writeYtDlpFailureLog(payload) {
|
|
8
10
|
try {
|
|
9
11
|
mkdirSync(PATHS.logsDir, { recursive: true });
|
|
@@ -84,7 +86,7 @@ export function buildYtDlpArgs(args, options = {}) {
|
|
|
84
86
|
return finalArgs;
|
|
85
87
|
}
|
|
86
88
|
export function runYtDlp(args, timeoutMs = 45_000, onStderrLine) {
|
|
87
|
-
return new Promise((resolve, reject) => {
|
|
89
|
+
return scheduleYtDlp(() => new Promise((resolve, reject) => {
|
|
88
90
|
const start = Date.now();
|
|
89
91
|
const finalArgs = buildYtDlpArgs(args);
|
|
90
92
|
const proc = spawn(getYtDlpPath(), finalArgs, {
|
|
@@ -153,6 +155,9 @@ export function runYtDlp(args, timeoutMs = 45_000, onStderrLine) {
|
|
|
153
155
|
trigger: "exit",
|
|
154
156
|
}),
|
|
155
157
|
});
|
|
158
|
+
if (exitCode !== 0 && classifyYtDlpFailure(stderr) === "RATE_LIMITED") {
|
|
159
|
+
markGlobalYtDlpRateLimited();
|
|
160
|
+
}
|
|
156
161
|
}
|
|
157
162
|
});
|
|
158
163
|
proc.on("error", (err) => {
|
|
@@ -175,7 +180,7 @@ export function runYtDlp(args, timeoutMs = 45_000, onStderrLine) {
|
|
|
175
180
|
}));
|
|
176
181
|
}
|
|
177
182
|
});
|
|
178
|
-
});
|
|
183
|
+
}));
|
|
179
184
|
}
|
|
180
185
|
export async function runYtDlpJson(args, timeoutMs = 45_000) {
|
|
181
186
|
const result = await runYtDlp(args, timeoutMs);
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export type YtDlpFailureKind = "COOKIES_EXPIRED" | "COOKIES_INVALID" | "SIGN_IN_REQUIRED" | "RATE_LIMITED";
|
|
2
|
+
export declare function classifyYtDlpFailure(stderr: string): YtDlpFailureKind | null;
|
|
3
|
+
export declare function appendDiagnosticLog(message: string, logPath?: string): string;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export function classifyYtDlpFailure(stderr) {
|
|
2
|
+
const normalized = stderr.toLowerCase();
|
|
3
|
+
if (normalized.includes("cookies are no longer valid") ||
|
|
4
|
+
normalized.includes("cookie has expired") ||
|
|
5
|
+
normalized.includes("cookies have expired") ||
|
|
6
|
+
normalized.includes("session expired")) {
|
|
7
|
+
return "COOKIES_EXPIRED";
|
|
8
|
+
}
|
|
9
|
+
if (normalized.includes("rate-limited by youtube") ||
|
|
10
|
+
normalized.includes("current session has been rate-limited")) {
|
|
11
|
+
return "RATE_LIMITED";
|
|
12
|
+
}
|
|
13
|
+
if (normalized.includes("sign in to confirm you're not a bot") ||
|
|
14
|
+
normalized.includes("sign in to confirm you’re not a bot")) {
|
|
15
|
+
return "SIGN_IN_REQUIRED";
|
|
16
|
+
}
|
|
17
|
+
if (normalized.includes("sign in") ||
|
|
18
|
+
normalized.includes("login") ||
|
|
19
|
+
normalized.includes("cookies") ||
|
|
20
|
+
normalized.includes("not a bot")) {
|
|
21
|
+
return "COOKIES_INVALID";
|
|
22
|
+
}
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
export function appendDiagnosticLog(message, logPath) {
|
|
26
|
+
return logPath ? `${message}\nDiagnostic log: ${logPath}` : message;
|
|
27
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export interface YtDlpSchedulerState {
|
|
2
|
+
running: boolean;
|
|
3
|
+
pendingCount: number;
|
|
4
|
+
nextAvailableAt: number | null;
|
|
5
|
+
wouldThrottle: boolean;
|
|
6
|
+
}
|
|
7
|
+
interface YtDlpSchedulerOptions {
|
|
8
|
+
minIntervalMs?: number;
|
|
9
|
+
rateLimitBackoffMs?: number;
|
|
10
|
+
now?: () => number;
|
|
11
|
+
sleep?: (ms: number) => Promise<void>;
|
|
12
|
+
}
|
|
13
|
+
export interface YtDlpScheduler {
|
|
14
|
+
schedule<T>(task: () => Promise<T>): Promise<T>;
|
|
15
|
+
markRateLimited(): void;
|
|
16
|
+
getState(): YtDlpSchedulerState;
|
|
17
|
+
}
|
|
18
|
+
export declare function createYtDlpScheduler(options?: YtDlpSchedulerOptions): YtDlpScheduler;
|
|
19
|
+
export declare function scheduleYtDlp<T>(task: () => Promise<T>): Promise<T>;
|
|
20
|
+
export declare function markGlobalYtDlpRateLimited(): void;
|
|
21
|
+
export declare function getGlobalYtDlpSchedulerState(): YtDlpSchedulerState;
|
|
22
|
+
export {};
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
const DEFAULT_MIN_INTERVAL_MS = 5_000;
|
|
2
|
+
const DEFAULT_RATE_LIMIT_BACKOFF_MS = 10 * 60_000;
|
|
3
|
+
export function createYtDlpScheduler(options = {}) {
|
|
4
|
+
const now = options.now ?? Date.now;
|
|
5
|
+
const sleep = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
6
|
+
const minIntervalMs = options.minIntervalMs ?? DEFAULT_MIN_INTERVAL_MS;
|
|
7
|
+
const rateLimitBackoffMs = options.rateLimitBackoffMs ?? DEFAULT_RATE_LIMIT_BACKOFF_MS;
|
|
8
|
+
let running = false;
|
|
9
|
+
let pendingCount = 0;
|
|
10
|
+
let nextAvailableAt = null;
|
|
11
|
+
let tail = Promise.resolve();
|
|
12
|
+
async function waitForAvailability() {
|
|
13
|
+
const currentNext = nextAvailableAt;
|
|
14
|
+
if (currentNext == null) {
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
const delayMs = currentNext - now();
|
|
18
|
+
if (delayMs > 0) {
|
|
19
|
+
await sleep(delayMs);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return {
|
|
23
|
+
async schedule(task) {
|
|
24
|
+
pendingCount += 1;
|
|
25
|
+
const previousTail = tail;
|
|
26
|
+
let release;
|
|
27
|
+
tail = new Promise((resolve) => {
|
|
28
|
+
release = resolve;
|
|
29
|
+
});
|
|
30
|
+
await previousTail;
|
|
31
|
+
pendingCount -= 1;
|
|
32
|
+
await waitForAvailability();
|
|
33
|
+
running = true;
|
|
34
|
+
try {
|
|
35
|
+
return await task();
|
|
36
|
+
}
|
|
37
|
+
finally {
|
|
38
|
+
running = false;
|
|
39
|
+
nextAvailableAt = Math.max(nextAvailableAt ?? 0, now() + minIntervalMs);
|
|
40
|
+
release();
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
markRateLimited() {
|
|
44
|
+
nextAvailableAt = Math.max(nextAvailableAt ?? 0, now() + rateLimitBackoffMs);
|
|
45
|
+
},
|
|
46
|
+
getState() {
|
|
47
|
+
const next = nextAvailableAt;
|
|
48
|
+
return {
|
|
49
|
+
running,
|
|
50
|
+
pendingCount,
|
|
51
|
+
nextAvailableAt: next,
|
|
52
|
+
wouldThrottle: running || pendingCount > 0 || (next != null && next > now()),
|
|
53
|
+
};
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
const globalYtDlpScheduler = createYtDlpScheduler();
|
|
58
|
+
export function scheduleYtDlp(task) {
|
|
59
|
+
return globalYtDlpScheduler.schedule(task);
|
|
60
|
+
}
|
|
61
|
+
export function markGlobalYtDlpRateLimited() {
|
|
62
|
+
globalYtDlpScheduler.markRateLimited();
|
|
63
|
+
}
|
|
64
|
+
export function getGlobalYtDlpSchedulerState() {
|
|
65
|
+
return globalYtDlpScheduler.getState();
|
|
66
|
+
}
|