Skip to content

Commit 9776b80

Browse files
committed
chore: update live-check workflow for production API monitoring
- Renamed workflow from "Live YouTube canary" to "Production API canary" to reflect its purpose. - Adjusted comments to clarify the workflow's function in checking the deployed production API instead of YouTube directly. - Modified job name from "live" to "prod" and updated environment variables for the production API. - Changed the failure notification title and message to align with the new focus on the production API check.
1 parent 4628be3 commit 9776b80

2 files changed

Lines changed: 140 additions & 28 deletions

File tree

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
const baseUrl = (process.env.CAPTION_API_BASE_URL || '').replace(/\/+$/, '');
2+
const videoIds = (process.env.CAPTION_API_VIDEO_IDS || '')
3+
.split(',')
4+
.map((id) => id.trim())
5+
.filter(Boolean);
6+
const lang = process.env.CAPTION_API_LANG || 'en';
7+
const timeoutMs = Number(process.env.CAPTION_API_TIMEOUT_MS || 30_000);
8+
const attempts = Math.max(
9+
1,
10+
Math.floor(Number(process.env.CAPTION_API_ATTEMPTS || 3))
11+
);
12+
13+
if (!baseUrl) {
14+
throw new Error('CAPTION_API_BASE_URL is required');
15+
}
16+
17+
if (videoIds.length === 0) {
18+
throw new Error('CAPTION_API_VIDEO_IDS must include at least one video ID');
19+
}
20+
21+
function assert(condition, message) {
22+
if (!condition) throw new Error(message);
23+
}
24+
25+
function validateSubtitle(subtitle, videoId) {
26+
assert(
27+
subtitle && typeof subtitle === 'object',
28+
`${videoId}: first subtitle must be an object`
29+
);
30+
assert(
31+
typeof subtitle.start === 'string' && !Number.isNaN(Number(subtitle.start)),
32+
`${videoId}: subtitle.start must be a numeric string`
33+
);
34+
assert(
35+
typeof subtitle.dur === 'string' && !Number.isNaN(Number(subtitle.dur)),
36+
`${videoId}: subtitle.dur must be a numeric string`
37+
);
38+
assert(
39+
typeof subtitle.text === 'string' && subtitle.text.trim().length > 0,
40+
`${videoId}: subtitle.text must be a non-empty string`
41+
);
42+
}
43+
44+
async function fetchJson(url, videoId) {
45+
const controller = new AbortController();
46+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
47+
48+
try {
49+
const response = await fetch(url, {
50+
cache: 'no-store',
51+
signal: controller.signal,
52+
});
53+
const body = await response.text();
54+
55+
assert(
56+
response.ok,
57+
`${videoId}: expected 2xx from production API, got ${response.status} ${response.statusText}: ${body.slice(0, 500)}`
58+
);
59+
60+
try {
61+
return JSON.parse(body);
62+
} catch {
63+
throw new Error(`${videoId}: production API did not return valid JSON`);
64+
}
65+
} finally {
66+
clearTimeout(timeout);
67+
}
68+
}
69+
70+
async function checkVideo(videoId) {
71+
const params = new URLSearchParams({ videoID: videoId, lang });
72+
const url = `${baseUrl}/api/videoDetails?${params.toString()}`;
73+
let payload;
74+
let lastError;
75+
76+
for (let attempt = 1; attempt <= attempts; attempt += 1) {
77+
try {
78+
payload = await fetchJson(url, videoId);
79+
break;
80+
} catch (error) {
81+
lastError = error;
82+
if (attempt === attempts) break;
83+
console.warn(
84+
`${videoId}: attempt ${attempt} failed, retrying - ${
85+
error instanceof Error ? error.message : String(error)
86+
}`
87+
);
88+
await new Promise((resolve) => setTimeout(resolve, attempt * 1000));
89+
}
90+
}
91+
92+
if (!payload) throw lastError;
93+
94+
const details = payload.videoDetails;
95+
96+
assert(
97+
details && typeof details === 'object',
98+
`${videoId}: response must include videoDetails`
99+
);
100+
assert(
101+
typeof details.title === 'string' && details.title.trim().length > 0,
102+
`${videoId}: videoDetails.title must be non-empty`
103+
);
104+
assert(
105+
Array.isArray(details.subtitles) && details.subtitles.length > 0,
106+
`${videoId}: videoDetails.subtitles must be a non-empty array`
107+
);
108+
109+
validateSubtitle(details.subtitles[0], videoId);
110+
111+
console.log(
112+
`${videoId}: OK (${details.subtitles.length} captions) - ${details.title}`
113+
);
114+
}
115+
116+
for (const videoId of videoIds) {
117+
await checkVideo(videoId);
118+
}

.github/workflows/live-check.yml

Lines changed: 22 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,8 @@
1-
name: Live YouTube canary
1+
name: Production API canary
22

3-
# Best-effort nightly probe against real YouTube. NOTE: YouTube blocks most
4-
# datacenter IPs (including GitHub Actions) with bot challenges, so this
5-
# job is *expected* to fail intermittently from CI. It exists to surface
6-
# the rare days when YouTube changes something AND the GHA IP range is
7-
# briefly unblocked — in which case we want to know.
3+
# Best-effort nightly probe against the deployed production API. This checks the
4+
# real hosted path users hit instead of asking YouTube directly from GitHub
5+
# Actions, which is commonly blocked by YouTube's bot detection.
86
#
97
# This workflow does NOT fail the build. It opens / comments on a tracking
108
# issue if a run fails, so maintainers can investigate.
@@ -19,46 +17,42 @@ permissions:
1917
issues: write
2018

2119
jobs:
22-
live:
20+
prod:
2321
runs-on: ubuntu-latest
2422
continue-on-error: true
23+
env:
24+
CAPTION_API_BASE_URL: https://youtube-caption-extractor.vercel.app
25+
CAPTION_API_VIDEO_IDS: 0Gb1z-2SjHY,D37Ijn2o5U0,g9JIUM0MHgQ,6BB6exR8Zd8,55pTFVoclvE
26+
CAPTION_API_LANG: en
27+
CAPTION_API_ATTEMPTS: 3
2528
steps:
2629
- uses: actions/checkout@v4
2730
- uses: actions/setup-node@v4
2831
with:
2932
node-version: 22
30-
cache: 'npm'
31-
- run: npm ci
32-
- run: npm run build
3333

34-
- name: Run live YouTube tests
35-
id: live
36-
env:
37-
YOUTUBE_LIVE: '1'
38-
run: npx vitest run --retry=2
34+
- name: Check production API
35+
id: prod
36+
run: node .github/scripts/check-production-api.mjs
3937

4038
- name: Open / update tracking issue on failure
41-
if: failure() && steps.live.outcome == 'failure'
39+
if: failure() && steps.prod.outcome == 'failure'
4240
env:
4341
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
4442
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
4543
run: |
46-
TITLE="Nightly YouTube canary failed"
44+
TITLE="Nightly production API canary failed"
4745
EXISTING=$(gh issue list --state open --search "$TITLE in:title" --json number --jq '.[0].number // empty')
4846
BODY=$(cat <<EOF
49-
The nightly live-network check against YouTube failed.
47+
The nightly production API check failed.
5048
51-
**Most common cause:** YouTube's bot-detection blocked the GitHub
52-
Actions runner IP (this happens routinely — datacenter IP ranges
53-
are aggressively filtered). The library itself is likely fine for
54-
end users on residential IPs.
49+
This workflow calls the deployed Vercel app at
50+
\`$CAPTION_API_BASE_URL/api/videoDetails\` and validates that it
51+
returns non-empty metadata and captions for dashboard sample videos.
5552
56-
**When to act:** if this issue keeps reopening for many days *and*
57-
you can reproduce the failure locally on a residential connection,
58-
then YouTube has likely changed something. The fix is to bump
59-
\`clientVersion\` values in \`CLIENT_PROFILES\` in
60-
[\`src/index.ts\`](../blob/main/src/index.ts) — track recent commits
61-
to yt-dlp's youtube extractor for known-good versions.
53+
**When to act:** if this issue keeps reopening, investigate the
54+
deployed API path first: Vercel environment variables, proxy routing,
55+
runtime errors, rate limits, or upstream YouTube/proxy behavior.
6256
6357
**Failed run:** $RUN_URL
6458
EOF

0 commit comments

Comments
 (0)