@mkterswingman/5mghost-yonder 0.0.13 → 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.
@@ -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
  }
@@ -39,6 +40,7 @@ interface DownloadOneItemDeps {
39
40
  onProgress: (update: DownloadOneItemProgressUpdate) => void;
40
41
  }) => Promise<string>;
41
42
  refreshCookies: () => Promise<boolean>;
43
+ getYtDlpSchedulerState: () => YtDlpSchedulerState;
42
44
  downloadSubtitles: (input: {
43
45
  videoId: string;
44
46
  sourceUrl: string;
@@ -8,6 +8,7 @@ import { formatBytes } from "../utils/formatters.js";
8
8
  import { hasYtDlp, runYtDlp, runYtDlpJson } from "../utils/ytdlp.js";
9
9
  import { appendDiagnosticLog, classifyYtDlpFailure } from "../utils/ytdlpFailures.js";
10
10
  import { parseYtDlpProgressLine } from "../utils/ytdlpProgress.js";
11
+ import { getGlobalYtDlpSchedulerState } from "../utils/ytdlpScheduler.js";
11
12
  export async function downloadOneItem(input) {
12
13
  const deps = resolveDeps(input.deps);
13
14
  const manager = input.jobManager;
@@ -52,6 +53,7 @@ export async function downloadOneItem(input) {
52
53
  if (needsVideo(input.mode)) {
53
54
  currentStep = "download_video";
54
55
  manager?.updateItemProgress(jobId, input.item.video_id, currentStep, 0);
56
+ manager?.setItemQueuedByGlobalRateLimit(jobId, input.item.video_id, deps.getYtDlpSchedulerState().wouldThrottle);
55
57
  try {
56
58
  result.video_file = await deps.downloadVideo({
57
59
  sourceUrl: input.item.source_url,
@@ -74,20 +76,29 @@ export async function downloadOneItem(input) {
74
76
  throw error;
75
77
  }
76
78
  }
79
+ finally {
80
+ manager?.setItemQueuedByGlobalRateLimit(jobId, input.item.video_id, false);
81
+ }
77
82
  result.final_file_size = formatBytes((await deps.stat(result.video_file)).size);
78
83
  }
79
84
  if (needsSubtitles(input.mode)) {
80
85
  currentStep = "download_subtitles";
81
86
  manager?.updateItemProgress(jobId, input.item.video_id, currentStep, 0);
82
- result.subtitle_files = await deps.downloadSubtitles({
83
- videoId: input.item.video_id,
84
- sourceUrl: input.item.source_url,
85
- subtitlesDir,
86
- formats: subtitleFormats,
87
- languages: subtitleLanguages,
88
- skipMissingLanguages: usingDefaultSubtitleLanguages,
89
- onProgress,
90
- });
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
+ }
91
102
  }
92
103
  currentStep = "write_metadata";
93
104
  manager?.updateItemProgress(jobId, input.item.video_id, currentStep, 100);
@@ -122,6 +133,7 @@ function resolveDeps(overrides) {
122
133
  fetchMetadata: fetchMetadataWithYtDlp,
123
134
  downloadVideo: downloadVideoWithYtDlp,
124
135
  refreshCookies: tryHeadlessRefresh,
136
+ getYtDlpSchedulerState: getGlobalYtDlpSchedulerState,
125
137
  downloadSubtitles: downloadSubtitlesWithYtDlp,
126
138
  mkdir: async (path, options) => {
127
139
  await mkdir(path, options);
@@ -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
  }
@@ -19,6 +19,7 @@ export interface DownloadJobItemSnapshot {
19
19
  title: string;
20
20
  output_dir: string;
21
21
  status: DownloadItemStatus;
22
+ queued_by_global_rate_limit: boolean;
22
23
  step: DownloadStep | null;
23
24
  progress_percent: number | null;
24
25
  error: string | null;
@@ -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,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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mkterswingman/5mghost-yonder",
3
- "version": "0.0.13",
3
+ "version": "0.0.14",
4
4
  "description": "Internal MCP client with local data tools and remote API proxy",
5
5
  "type": "module",
6
6
  "bin": {