@@ -2,11 +2,72 @@ import { memoize } from "@fxts/core"
22import { ofetch } from "ofetch"
33import { 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+
522export 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
0 commit comments