Skip to content

Commit 91274b6

Browse files
committed
fix(yt): pick subtitle matching the main audio language and bump to 1.3.13
Previously the topmost track in captionTracks was always selected, which often differed from the voice language. Now resolves the caption via audioTracks[defaultAudioTrackIndex].captionTrackIndices, preferring manual over auto-generated (asr), with a vssId-based fallback for older API responses.
1 parent 9ca6a1d commit 91274b6

2 files changed

Lines changed: 64 additions & 3 deletions

File tree

lib/yt/getVideoSubtitle.ts

Lines changed: 63 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,72 @@ import { memoize } from "@fxts/core"
22
import { ofetch } from "ofetch"
33
import { getVideoInfo } from "@/lib/yt/getVideoInfo"
44

5+
type CaptionTrackLike = {
6+
baseUrl?: string
7+
vssId?: string
8+
kind?: string
9+
languageCode?: string
10+
}
11+
12+
const isManual = (t: CaptionTrackLike) => t.kind !== "asr"
13+
14+
// YouTube vssId convention:
15+
// ".{lang}" = original manually authored caption (e.g. ".en")
16+
// "a.{lang}" = original auto-generated caption (e.g. "a.en")
17+
const isOriginalManual = (t: CaptionTrackLike) =>
18+
Boolean(t.vssId?.startsWith(".")) && isManual(t)
19+
const isOriginalAsr = (t: CaptionTrackLike) =>
20+
Boolean(t.vssId?.startsWith("a."))
21+
522
export const getVideoSubtitle = memoize(async (videoId: string) => {
623
const { r, pot } = await getVideoInfo(videoId)
724

8-
const url =
9-
r.captions.playerCaptionsTracklistRenderer.captionTracks.at(0)?.baseUrl
25+
const tracklist = r.captions?.playerCaptionsTracklistRenderer
26+
const captionTracks: CaptionTrackLike[] = tracklist?.captionTracks ?? []
27+
28+
if (captionTracks.length === 0) {
29+
return null
30+
}
31+
32+
// Pick the caption track that matches the main audio language.
33+
// Priority:
34+
// 1. Manual caption in default audio's language (kind !== "asr")
35+
// 2. Auto-generated caption in default audio's lang (kind === "asr")
36+
// 3. vssId-based fallback: original manual, then original asr
37+
// 4. captionTracks[0] as a last resort
38+
//
39+
// `captionTracks[0]` alone is unreliable: it's just whatever YouTube lists
40+
// first (often a translated track), so it can mismatch the voice.
41+
let captionTrack: CaptionTrackLike | undefined
42+
43+
// Strategy 1: audioTracks -> captionTrackIndices (most reliable on modern API)
44+
const audioTracks = tracklist?.audioTracks
45+
const defaultAudioTrackIndex = tracklist?.defaultAudioTrackIndex
46+
const defaultAudioTrack =
47+
typeof defaultAudioTrackIndex === "number"
48+
? audioTracks?.[defaultAudioTrackIndex]
49+
: undefined
50+
51+
const audioCaptionIndices: number[] =
52+
defaultAudioTrack?.captionTrackIndices ?? []
53+
const audioCaptionCandidates = audioCaptionIndices
54+
.map((i: number) => captionTracks[i])
55+
.filter((t): t is CaptionTrackLike => Boolean(t))
56+
57+
if (audioCaptionCandidates.length > 0) {
58+
captionTrack =
59+
audioCaptionCandidates.find(isManual) ?? audioCaptionCandidates[0]
60+
}
61+
62+
// Strategy 2: vssId-based fallback when audioTracks data is unavailable.
63+
if (!captionTrack) {
64+
captionTrack =
65+
captionTracks.find(isOriginalManual) ??
66+
captionTracks.find(isOriginalAsr) ??
67+
captionTracks[0]
68+
}
69+
70+
const url = captionTrack?.baseUrl
1071

1172
if (!url) {
1273
return null

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "cpdown",
33
"description": "Copy any webpage as clean markdown",
4-
"version": "1.3.12",
4+
"version": "1.3.13",
55
"type": "module",
66
"scripts": {
77
"dev": "wxt",

0 commit comments

Comments
 (0)