@@ -2,10 +2,14 @@ import { NextRequest } from 'next/server';
22
33import { err , ok } from '@/server/lib/api-utils' ;
44
5+ import { PERSPECTIVE_CHANNELS } from '@/data/perspective-channels' ;
6+
57const CACHE_TTL = 600 ;
68const IS_LIVE_RE = / " i s L i v e " \s * : \s * t r u e / ;
7- const VIDEO_ID_RE = / " v i d e o I d " \s * : \s * " ( [ ^ " ] + ) " / ;
9+ const PAGE_VIDEO_ID_RE = / " v i d e o I d " \s * : \s * " ( [ ^ " ] + ) " / ;
810const CANONICAL_VIDEO_RE = / < l i n k r e l = " c a n o n i c a l " h r e f = " h t t p s : \/ \/ w w w \. y o u t u b e \. c o m \/ w a t c h \? v = ( [ ^ " & ] + ) " / ;
11+ const YOUTUBE_API_KEY = process . env . YOUTUBE_API_KEY ?? '' ;
12+ const VIDEO_ID_RE = / < y t : v i d e o I d > ( [ ^ < ] + ) < \/ y t : v i d e o I d > / g;
913
1014type CacheEntry = {
1115 isLive : boolean ;
@@ -15,36 +19,111 @@ type CacheEntry = {
1519
1620const cache = new Map < string , CacheEntry > ( ) ;
1721
22+ function resolveChannelId ( handle : string ) : string | null {
23+ const ch = PERSPECTIVE_CHANNELS . find ( c => c . handle . toLowerCase ( ) === handle . toLowerCase ( ) ) ;
24+ return ch ?. channelId ?? null ;
25+ }
26+
1827function extractVideoId ( html : string ) : string | null {
1928 return html . match ( CANONICAL_VIDEO_RE ) ?. [ 1 ]
20- ?? html . match ( VIDEO_ID_RE ) ?. [ 1 ]
29+ ?? html . match ( PAGE_VIDEO_ID_RE ) ?. [ 1 ]
2130 ?? null ;
2231}
2332
33+ async function fetchRecentVideoIds ( channelId : string , limit = 5 ) : Promise < string [ ] > {
34+ const res = await fetch (
35+ `https://www.youtube.com/feeds/videos.xml?channel_id=${ channelId } ` ,
36+ { signal : AbortSignal . timeout ( 8000 ) } ,
37+ ) ;
38+ const xml = await res . text ( ) ;
39+ const ids : string [ ] = [ ] ;
40+ let match : RegExpExecArray | null ;
41+ while ( ( match = VIDEO_ID_RE . exec ( xml ) ) !== null && ids . length < limit ) {
42+ ids . push ( match [ 1 ] ) ;
43+ }
44+ VIDEO_ID_RE . lastIndex = 0 ;
45+ return ids ;
46+ }
47+
48+ async function findLiveVideo ( videoIds : string [ ] ) : Promise < { videoId : string } | null > {
49+ if ( ! YOUTUBE_API_KEY || videoIds . length === 0 ) return null ;
50+
51+ const ids = videoIds . join ( ',' ) ;
52+ const url = `https://www.googleapis.com/youtube/v3/videos?id=${ ids } &part=snippet,liveStreamingDetails&fields=items(id,snippet/liveBroadcastContent)&key=${ YOUTUBE_API_KEY } ` ;
53+
54+ const res = await fetch ( url , { signal : AbortSignal . timeout ( 8000 ) } ) ;
55+ if ( ! res . ok ) return null ;
56+
57+ const data = ( await res . json ( ) ) as {
58+ items ?: { id : string ; snippet ?: { liveBroadcastContent ?: string } } [ ] ;
59+ } ;
60+
61+ for ( const item of data . items ?? [ ] ) {
62+ if ( item . snippet ?. liveBroadcastContent === 'live' ) {
63+ return { videoId : item . id } ;
64+ }
65+ }
66+
67+ return null ;
68+ }
69+
70+ async function checkLiveStatusViaPage ( handle : string ) : Promise < { isLive : boolean ; videoId : string | null } > {
71+ const res = await fetch ( `https://www.youtube.com/${ handle } /live` , {
72+ headers : {
73+ 'User-Agent' : 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36' ,
74+ 'Accept-Language' : 'en-US,en;q=0.9' ,
75+ Cookie : 'CONSENT=YES+1' ,
76+ } ,
77+ signal : AbortSignal . timeout ( 8000 ) ,
78+ } ) ;
79+ const html = await res . text ( ) ;
80+ const isLive = IS_LIVE_RE . test ( html ) ;
81+
82+ return {
83+ isLive,
84+ videoId : isLive ? extractVideoId ( html ) : null ,
85+ } ;
86+ }
87+
2488async function checkLiveStatus ( handle : string ) : Promise < { isLive : boolean ; videoId : string | null } > {
2589 const cached = cache . get ( handle ) ;
2690 if ( cached && Date . now ( ) - cached . checkedAt < CACHE_TTL * 1000 ) {
2791 return { isLive : cached . isLive , videoId : cached . videoId } ;
2892 }
2993
94+ const offline = { isLive : false , videoId : null } ;
95+
3096 try {
31- const res = await fetch ( `https://www.youtube.com/${ handle } /live` , {
32- headers : {
33- 'User-Agent' : 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36' ,
34- 'Accept-Language' : 'en-US,en;q=0.9' ,
35- 'Cookie' : 'CONSENT=YES+1' ,
36- } ,
37- signal : AbortSignal . timeout ( 8000 ) ,
38- } ) ;
39- const html = await res . text ( ) ;
40- const isLive = IS_LIVE_RE . test ( html ) ;
41- const videoId = isLive ? extractVideoId ( html ) : null ;
42-
43- cache . set ( handle , { isLive, videoId, checkedAt : Date . now ( ) } ) ;
44- return { isLive, videoId } ;
97+ if ( ! YOUTUBE_API_KEY ) {
98+ const fallback = await checkLiveStatusViaPage ( handle ) ;
99+ cache . set ( handle , { ...fallback , checkedAt : Date . now ( ) } ) ;
100+ return fallback ;
101+ }
102+
103+ const channelId = resolveChannelId ( handle ) ;
104+ if ( ! channelId ) {
105+ const fallback = await checkLiveStatusViaPage ( handle ) ;
106+ cache . set ( handle , { ...fallback , checkedAt : Date . now ( ) } ) ;
107+ return fallback ;
108+ }
109+
110+ const videoIds = await fetchRecentVideoIds ( channelId ) ;
111+ if ( videoIds . length === 0 ) {
112+ const fallback = await checkLiveStatusViaPage ( handle ) ;
113+ cache . set ( handle , { ...fallback , checkedAt : Date . now ( ) } ) ;
114+ return fallback ;
115+ }
116+
117+ const live = await findLiveVideo ( videoIds ) ;
118+ const result = live
119+ ? { isLive : true , videoId : live . videoId }
120+ : await checkLiveStatusViaPage ( handle ) ;
121+
122+ cache . set ( handle , { ...result , checkedAt : Date . now ( ) } ) ;
123+ return result ;
45124 } catch {
46- cache . set ( handle , { isLive : false , videoId : null , checkedAt : Date . now ( ) } ) ;
47- return { isLive : false , videoId : null } ;
125+ cache . set ( handle , { ... offline , checkedAt : Date . now ( ) } ) ;
126+ return offline ;
48127 }
49128}
50129
@@ -56,8 +135,6 @@ export async function GET(req: NextRequest) {
56135
57136 const { isLive, videoId } = await checkLiveStatus ( handle ) ;
58137
59- // Always allow embedding if the channel is live — worst case YouTube shows
60- // its own error inside the iframe for the ~1 in 30 non-embeddable channels
61138 return ok (
62139 { handle, isLive, playableInEmbed : isLive , videoId, ttl : CACHE_TTL } ,
63140 {
0 commit comments