@unified-live/youtube 0.0.1 → 0.1.1

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.
package/README.md CHANGED
@@ -1,6 +1,13 @@
1
+ <p align="center">
2
+ <img src="../../apps/docs/public/logo.svg" alt="unified-live logo" width="48" height="48" />
3
+ </p>
4
+
1
5
  # @unified-live/youtube
2
6
 
3
- YouTube platform plugin for the unified-live SDK.
7
+ YouTube Data API v3 plugin for the unified-live SDK. Provides quota-based rate limiting, API key auth, and search support.
8
+
9
+ [![npm](https://img.shields.io/npm/v/@unified-live/youtube.svg)](https://www.npmjs.com/package/@unified-live/youtube)
10
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](../../LICENSE)
4
11
 
5
12
  ## Install
6
13
 
@@ -8,6 +15,19 @@ YouTube platform plugin for the unified-live SDK.
8
15
  pnpm add @unified-live/core @unified-live/youtube
9
16
  ```
10
17
 
18
+ ## Usage
19
+
20
+ ```ts
21
+ import { UnifiedClient } from "@unified-live/core";
22
+ import { createYouTubePlugin } from "@unified-live/youtube";
23
+
24
+ const client = UnifiedClient.create({
25
+ plugins: [createYouTubePlugin({ apiKey: process.env.YOUTUBE_API_KEY! })],
26
+ });
27
+
28
+ const content = await client.resolve("https://www.youtube.com/watch?v=dQw4w9WgXcQ");
29
+ ```
30
+
11
31
  ## Development
12
32
 
13
33
  ```bash
@@ -18,6 +38,4 @@ pnpm test:run # Run tests
18
38
 
19
39
  ## Docs
20
40
 
21
- - [Overview](../../docs/plan/unified-live-sdk/00_OVERVIEW.md)
22
- - [Plugins](../../docs/plan/unified-live-sdk/02_PLUGINS.md)
23
- - [Client API](../../docs/plan/unified-live-sdk/03_CLIENT_API.md)
41
+ See the [full documentation](https://sugar-cat7.github.io/unified-live).
package/dist/index.cjs CHANGED
@@ -1,2 +1,2 @@
1
- Object.defineProperty(exports,Symbol.toStringTag,{value:`Module`});let e=require(`@unified-live/core`);const t=e=>{let t=e?.high??e?.medium??e?.default;if(!(!t?.url||t.width==null||t.height==null))return{url:t.url,width:t.width,height:t.height}},n=n=>{let{id:r,snippet:i,contentDetails:o,statistics:s,liveStreamingDetails:c}=n;if(!r||!i||!o||!s)throw new e.ParseError(`youtube`,`PARSE_RESPONSE`,{message:`YouTube video resource missing required parts (id, snippet, contentDetails, statistics)${r?` for video ${r}`:``}`,path:`/videos`});let l=i.channelId,u=i.publishedAt;if(!l)throw new e.ParseError(`youtube`,`PARSE_RESPONSE`,{message:`YouTube video resource missing channelId for video ${r}`,path:`/videos`});if(!u)throw new e.ParseError(`youtube`,`PARSE_RESPONSE`,{message:`YouTube video resource missing publishedAt for video ${r}`,path:`/videos`});let d=t(i.thumbnails);if(!d)throw new e.ParseError(`youtube`,`PARSE_RESPONSE`,{message:`YouTube resource has no thumbnail for video ${r}`,path:`/videos`});let f={id:l,name:i.channelTitle??``,url:`https://www.youtube.com/channel/${l}`},p=i.liveBroadcastContent===`live`,m=i.liveBroadcastContent===`upcoming`;return p&&c?.actualStartTime?{id:r,platform:`youtube`,title:i.title??``,description:i.description??``,tags:i.tags??[],url:`https://www.youtube.com/watch?v=${r}`,thumbnail:d,channel:f,sessionId:r,type:`broadcast`,viewerCount:Number.parseInt(c.concurrentViewers??`0`,10),startedAt:new Date(c.actualStartTime),endedAt:c.actualEndTime?new Date(c.actualEndTime):void 0,languageCode:i.defaultAudioLanguage??void 0,raw:n}:m?{id:r,platform:`youtube`,title:i.title??``,description:i.description??``,tags:i.tags??[],url:`https://www.youtube.com/watch?v=${r}`,thumbnail:d,channel:f,sessionId:r,type:`scheduled`,scheduledStartAt:c?.scheduledStartTime?new Date(c.scheduledStartTime):new Date(u),languageCode:i.defaultAudioLanguage??void 0,raw:n}:{id:r,platform:`youtube`,title:i.title??``,description:i.description??``,tags:i.tags??[],url:`https://www.youtube.com/watch?v=${r}`,thumbnail:d,channel:f,sessionId:r,type:`archive`,duration:a(o.duration??``),viewCount:Number.parseInt(s.viewCount??`0`,10),publishedAt:new Date(u),startedAt:c?.actualStartTime?new Date(c.actualStartTime):void 0,endedAt:c?.actualEndTime?new Date(c.actualEndTime):void 0,languageCode:i.defaultAudioLanguage??void 0,raw:n}},r=n=>{let{id:r,snippet:i,statistics:a}=n;if(!r||!i)throw new e.ParseError(`youtube`,`PARSE_RESPONSE`,{message:`YouTube channel resource missing required parts (id, snippet)${r?` for channel ${r}`:``}`,path:`/channels`});return{id:r,platform:`youtube`,name:i.title??``,url:`https://www.youtube.com/channel/${r}`,thumbnail:t(i.thumbnails),description:i.description??void 0,subscriberCount:a?.subscriberCount?Number.parseInt(a.subscriberCount,10):void 0,publishedAt:i.publishedAt?new Date(i.publishedAt):void 0}},i=/P(?:(\d+)D)?T?(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?/,a=e=>{let t=e.match(i);if(!t)return 0;let n=Number.parseInt(t[1]??`0`,10),r=Number.parseInt(t[2]??`0`,10),a=Number.parseInt(t[3]??`0`,10),o=Number.parseInt(t[4]??`0`,10);return n*86400+r*3600+a*60+o},o=`snippet,contentDetails,statistics,liveStreamingDetails`,s=async(t,r)=>{let i=(await t.request({method:`GET`,path:`/videos`,query:{part:o,id:r},bucketId:`videos:list`})).data.items?.[0];if(!i)throw new e.NotFoundError(`youtube`,r);return n(i)},c=async(t,n)=>{let i={part:`snippet,contentDetails,statistics`};n.startsWith(`@`)?i.forHandle=n:n.startsWith(`UC`)?i.id=n:i.forUsername=n;let a=(await t.request({method:`GET`,path:`/channels`,query:i,bucketId:`channels:list`})).data.items?.[0];if(!a)throw new e.NotFoundError(`youtube`,n);return r(a)},l=async(e,t)=>{let r=await e.request({method:`GET`,path:`/search`,query:{part:`id`,channelId:t,type:`video`,eventType:`live`},bucketId:`search:list`});if(!r.data.items||r.data.items.length===0)return[];let i=r.data.items.map(e=>e.id?.videoId).filter(Boolean).join(`,`),a=(await e.request({method:`GET`,path:`/videos`,query:{part:o,id:i},bucketId:`videos:list`})).data.items??[],s=[];for(let e of a){let t=n(e);t.type===`broadcast`&&s.push(t)}return s},u=async(t,r,i,a=50)=>{let s=(await t.request({method:`GET`,path:`/channels`,query:{part:`contentDetails`,id:r},bucketId:`channels:list`})).data.items?.[0];if(!s)throw new e.NotFoundError(`youtube`,r);let c=s.contentDetails?.relatedPlaylists?.uploads;if(!c)throw new e.NotFoundError(`youtube`,r);let l={part:`snippet`,playlistId:c,maxResults:String(a)};i&&(l.pageToken=i);let u=await t.request({method:`GET`,path:`/playlistItems`,query:l,bucketId:`playlistItems:list`});if(!u.data.items||u.data.items.length===0)return{items:[],hasMore:!1};let d=u.data.items.map(e=>e.snippet?.resourceId?.videoId).filter(Boolean).join(`,`),f=(await t.request({method:`GET`,path:`/videos`,query:{part:o,id:d},bucketId:`videos:list`})).data.items??[],p=[];for(let e of f){let t=n(e);t.type===`archive`&&p.push(t)}return{items:p,cursor:u.data.nextPageToken,total:u.data.pageInfo?.totalResults??0,hasMore:u.data.nextPageToken!==void 0}},d=async(e,t)=>{let n=await s(e,t.id);return n.type===`archive`?n:null},f=async(t,r)=>{let i=new Map,a=new Map;for(let s=0;s<r.length;s+=50){let c=r.slice(s,s+50),l=await t.request({method:`GET`,path:`/videos`,query:{part:o,id:c.join(`,`)},bucketId:`videos:list`}),u=new Set;for(let e of l.data.items??[])e.id&&(i.set(e.id,n(e)),u.add(e.id));for(let t of c)u.has(t)||a.set(t,new e.NotFoundError(`youtube`,t))}return{values:i,errors:a}},p=async(t,r)=>{let i={part:`id`,type:`video`};r.query&&(i.q=r.query),r.channelId&&(i.channelId=r.channelId),r.order&&(i.order=r.order),r.limit&&(i.maxResults=String(Math.min(r.limit,50))),r.cursor&&(i.pageToken=r.cursor),r.safeSearch&&(i.safeSearch=r.safeSearch),r.languageCode&&(i.relevanceLanguage=r.languageCode),r.status&&(i.eventType={live:`live`,upcoming:`upcoming`,ended:`completed`}[r.status]);let a=await t.request({method:`GET`,path:`/search`,query:i,bucketId:`search:list`});if(!a.data.items||a.data.items.length===0)return e.Page.empty();let s=a.data.items.map(e=>e.id?.videoId).filter(Boolean).join(`,`);return s?{items:((await t.request({method:`GET`,path:`/videos`,query:{part:o,id:s},bucketId:`videos:list`})).data.items??[]).map(n),cursor:a.data.nextPageToken,total:a.data.pageInfo?.totalResults,hasMore:a.data.nextPageToken!==void 0}:e.Page.empty()},m={"videos:list":1,"channels:list":1,"playlists:list":1,"playlistItems:list":1,"liveBroadcasts:list":1,"liveStreams:list":1,"search:list":100},h=t=>(0,e.createQuotaBudgetStrategy)({dailyLimit:t??1e4,costMap:m,defaultCost:1,platform:`youtube`}),g=[/^https?:\/\/(?:www\.)?youtube\.com\/watch\?.*v=([a-zA-Z0-9_-]{11})/,/^https?:\/\/youtu\.be\/([a-zA-Z0-9_-]{11})/,/^https?:\/\/(?:www\.)?youtube\.com\/live\/([a-zA-Z0-9_-]{11})/],_=[{pattern:/^https?:\/\/(?:www\.)?youtube\.com\/channel\/(UC[a-zA-Z0-9_-]{22})/,type:`id`},{pattern:/^https?:\/\/(?:www\.)?youtube\.com\/@([a-zA-Z0-9_.-]+)/,type:`handle`},{pattern:/^https?:\/\/(?:www\.)?youtube\.com\/c\/([a-zA-Z0-9_.-]+)/,type:`custom`}],v=e=>{for(let t of g){let n=e.match(t);if(n?.[1])return{platform:`youtube`,type:`content`,id:n[1]}}for(let{pattern:t}of _){let n=e.match(t);if(n?.[1])return{platform:`youtube`,type:`channel`,id:n[1]}}return null},y=t=>{if(!t.apiKey?.trim())throw new e.ValidationError(`VALIDATION_INVALID_INPUT`,`YouTube API key is required`,{platform:`youtube`});let n=h(t.quota?.dailyLimit);return e.PlatformPlugin.create({name:`youtube`,baseUrl:`https://www.googleapis.com/youtube/v3`,rateLimitStrategy:n,matchUrl:v,fetch:t.fetch,capabilities:{supportsBroadcasts:!0,supportsArchiveResolution:!0,authModel:`apiKey`,rateLimitModel:`quota`,supportsBatchContent:!0,supportsBatchBroadcasts:!1,supportsSearch:!0,supportsClips:!1},transformRequest:e=>({...e,query:{...e.query,key:t.apiKey}}),handleRateLimit:async(t,r,i)=>{if(t.status===403){let r=(await t.clone().json().catch(()=>null))?.error?.errors?.[0]?.reason;if(r===`quotaExceeded`||r===`dailyLimitExceeded`){let t=n.getStatus();throw new e.QuotaExhaustedError(`youtube`,{consumed:t.limit-t.remaining,limit:t.limit,resetsAt:t.resetsAt,requestedCost:0})}if(r===`rateLimitExceeded`){let n=(0,e.parseRetryAfter)(t.headers.get(`Retry-After`),5);return await new Promise(e=>setTimeout(e,n*1e3)),!0}}if(t.status===429){let n=(0,e.parseRetryAfter)(t.headers.get(`Retry-After`),1);return await new Promise(e=>setTimeout(e,n*1e3)),!0}return!1}},{getContent:s,getChannel:c,listBroadcasts:l,listArchives:u,resolveArchive:d,batchGetContents:f,search:p})};exports.YOUTUBE_COST_MAP=m,exports.createYouTubePlugin=y,exports.createYouTubeQuotaStrategy=h,exports.matchYouTubeUrl=v,exports.parseDuration=a,exports.toChannel=r,exports.toContent=n;
1
+ Object.defineProperty(exports,Symbol.toStringTag,{value:`Module`});let e=require(`@unified-live/core`);const t=e=>{let t=e?.high??e?.medium??e?.default;if(!(!t?.url||t.width==null||t.height==null))return{url:t.url,width:t.width,height:t.height}},n=n=>{let{id:r,snippet:i,contentDetails:o,statistics:s,liveStreamingDetails:c}=n;if(!r||!i||!o||!s)throw new e.ParseError(`youtube`,`PARSE_RESPONSE`,{message:`YouTube video resource missing required parts (id, snippet, contentDetails, statistics)${r?` for video ${r}`:``}`,path:`/videos`});let l=i.channelId,u=i.publishedAt;if(!l)throw new e.ParseError(`youtube`,`PARSE_RESPONSE`,{message:`YouTube video resource missing channelId for video ${r}`,path:`/videos`});if(!u)throw new e.ParseError(`youtube`,`PARSE_RESPONSE`,{message:`YouTube video resource missing publishedAt for video ${r}`,path:`/videos`});let d=t(i.thumbnails);if(!d)throw new e.ParseError(`youtube`,`PARSE_RESPONSE`,{message:`YouTube resource has no thumbnail for video ${r}`,path:`/videos`});let f={id:l,name:i.channelTitle??``,url:`https://www.youtube.com/channel/${l}`},p=i.liveBroadcastContent===`live`,m=i.liveBroadcastContent===`upcoming`;return p&&c?.actualStartTime?{id:r,platform:`youtube`,title:i.title??``,description:i.description??``,tags:i.tags??[],url:`https://www.youtube.com/watch?v=${r}`,thumbnail:d,channel:f,sessionId:r,type:`broadcast`,viewerCount:Number.parseInt(c.concurrentViewers??`0`,10),startedAt:new Date(c.actualStartTime),endedAt:c.actualEndTime?new Date(c.actualEndTime):void 0,languageCode:i.defaultAudioLanguage??void 0,raw:n}:m?{id:r,platform:`youtube`,title:i.title??``,description:i.description??``,tags:i.tags??[],url:`https://www.youtube.com/watch?v=${r}`,thumbnail:d,channel:f,sessionId:r,type:`scheduled`,scheduledStartAt:c?.scheduledStartTime?new Date(c.scheduledStartTime):new Date(u),languageCode:i.defaultAudioLanguage??void 0,raw:n}:{id:r,platform:`youtube`,title:i.title??``,description:i.description??``,tags:i.tags??[],url:`https://www.youtube.com/watch?v=${r}`,thumbnail:d,channel:f,sessionId:r,type:`archive`,duration:a(o.duration??``),viewCount:Number.parseInt(s.viewCount??`0`,10),publishedAt:new Date(u),startedAt:c?.actualStartTime?new Date(c.actualStartTime):void 0,endedAt:c?.actualEndTime?new Date(c.actualEndTime):void 0,languageCode:i.defaultAudioLanguage??void 0,raw:n}},r=n=>{let{id:r,snippet:i,statistics:a}=n;if(!r||!i)throw new e.ParseError(`youtube`,`PARSE_RESPONSE`,{message:`YouTube channel resource missing required parts (id, snippet)${r?` for channel ${r}`:``}`,path:`/channels`});return{id:r,platform:`youtube`,name:i.title??``,url:`https://www.youtube.com/channel/${r}`,thumbnail:t(i.thumbnails),description:i.description??void 0,subscriberCount:a?.subscriberCount?Number.parseInt(a.subscriberCount,10):void 0,publishedAt:i.publishedAt?new Date(i.publishedAt):void 0}},i=/P(?:(\d+)D)?T?(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?/,a=e=>{let t=e.match(i);if(!t)return 0;let n=Number.parseInt(t[1]??`0`,10),r=Number.parseInt(t[2]??`0`,10),a=Number.parseInt(t[3]??`0`,10),o=Number.parseInt(t[4]??`0`,10);return n*86400+r*3600+a*60+o},o=`snippet,contentDetails,statistics,liveStreamingDetails`,s=async(t,r)=>{let i=(await t.request({method:`GET`,path:`/videos`,query:{part:o,id:r},bucketId:`videos:list`})).data.items?.[0];if(!i)throw new e.NotFoundError(`youtube`,r);return n(i)},c=async(t,n)=>{let i={part:`snippet,contentDetails,statistics`};n.startsWith(`@`)?i.forHandle=n:n.startsWith(`UC`)?i.id=n:i.forUsername=n;let a=(await t.request({method:`GET`,path:`/channels`,query:i,bucketId:`channels:list`})).data.items?.[0];if(!a)throw new e.NotFoundError(`youtube`,n);return r(a)},l=async(e,t)=>{let r=await e.request({method:`GET`,path:`/search`,query:{part:`id`,channelId:t,type:`video`,eventType:`live`},bucketId:`search:list`});if(!r.data.items||r.data.items.length===0)return[];let i=r.data.items.map(e=>e.id?.videoId).filter(Boolean).join(`,`),a=(await e.request({method:`GET`,path:`/videos`,query:{part:o,id:i},bucketId:`videos:list`})).data.items??[],s=[];for(let e of a){let t=n(e);t.type===`broadcast`&&s.push(t)}return s},u=async(t,r,i,a=50)=>{let s=(await t.request({method:`GET`,path:`/channels`,query:{part:`contentDetails`,id:r},bucketId:`channels:list`})).data.items?.[0];if(!s)throw new e.NotFoundError(`youtube`,r);let c=s.contentDetails?.relatedPlaylists?.uploads;if(!c)throw new e.NotFoundError(`youtube`,r);let l={part:`snippet`,playlistId:c,maxResults:String(a)};i&&(l.pageToken=i);let u=await t.request({method:`GET`,path:`/playlistItems`,query:l,bucketId:`playlistItems:list`});if(!u.data.items||u.data.items.length===0)return{items:[],hasMore:!1};let d=u.data.items.map(e=>e.snippet?.resourceId?.videoId).filter(Boolean).join(`,`),f=(await t.request({method:`GET`,path:`/videos`,query:{part:o,id:d},bucketId:`videos:list`})).data.items??[],p=[];for(let e of f){let t=n(e);t.type===`archive`&&p.push(t)}return{items:p,cursor:u.data.nextPageToken,total:u.data.pageInfo?.totalResults??0,hasMore:u.data.nextPageToken!==void 0}},d=async(e,t)=>{let n=await s(e,t.id);return n.type===`archive`?n:null},f=async(t,r)=>{let i=new Map,a=new Map;for(let s=0;s<r.length;s+=50){let c=r.slice(s,s+50),l=await t.request({method:`GET`,path:`/videos`,query:{part:o,id:c.join(`,`)},bucketId:`videos:list`}),u=new Set;for(let e of l.data.items??[])e.id&&(i.set(e.id,n(e)),u.add(e.id));for(let t of c)u.has(t)||a.set(t,new e.NotFoundError(`youtube`,t))}return{values:i,errors:a}},p=async(t,n)=>{let i=new Map,a=new Map;for(let o=0;o<n.length;o+=50){let s=n.slice(o,o+50),c=await t.request({method:`GET`,path:`/channels`,query:{part:`snippet,contentDetails,statistics`,id:s.join(`,`)},bucketId:`channels:list`}),l=new Set;for(let e of c.data.items??[])e.id&&(i.set(e.id,r(e)),l.add(e.id));for(let t of s)l.has(t)||a.set(t,new e.NotFoundError(`youtube`,t))}return{values:i,errors:a}},m=async(t,r)=>{let i={part:`id`,type:`video`};r.query&&(i.q=r.query),r.channelId&&(i.channelId=r.channelId),r.order&&(i.order=r.order),r.limit&&(i.maxResults=String(Math.min(r.limit,50))),r.cursor&&(i.pageToken=r.cursor),r.safeSearch&&(i.safeSearch=r.safeSearch),r.languageCode&&(i.relevanceLanguage=r.languageCode),r.status&&(i.eventType={live:`live`,upcoming:`upcoming`,ended:`completed`}[r.status]);let a=await t.request({method:`GET`,path:`/search`,query:i,bucketId:`search:list`});if(!a.data.items||a.data.items.length===0)return e.Page.empty();let s=a.data.items.map(e=>e.id?.videoId).filter(Boolean).join(`,`);return s?{items:((await t.request({method:`GET`,path:`/videos`,query:{part:o,id:s},bucketId:`videos:list`})).data.items??[]).map(n),cursor:a.data.nextPageToken,total:a.data.pageInfo?.totalResults,hasMore:a.data.nextPageToken!==void 0}:e.Page.empty()},h={"videos:list":1,"channels:list":1,"playlists:list":1,"playlistItems:list":1,"liveBroadcasts:list":1,"liveStreams:list":1,"search:list":100},g=t=>(0,e.createQuotaBudgetStrategy)({dailyLimit:t??1e4,costMap:h,defaultCost:1,platform:`youtube`}),_=[/^https?:\/\/(?:www\.)?youtube\.com\/watch\?.*v=([a-zA-Z0-9_-]{11})/,/^https?:\/\/youtu\.be\/([a-zA-Z0-9_-]{11})/,/^https?:\/\/(?:www\.)?youtube\.com\/live\/([a-zA-Z0-9_-]{11})/],v=[{pattern:/^https?:\/\/(?:www\.)?youtube\.com\/channel\/(UC[a-zA-Z0-9_-]{22})/,type:`id`},{pattern:/^https?:\/\/(?:www\.)?youtube\.com\/@([a-zA-Z0-9_.-]+)/,type:`handle`},{pattern:/^https?:\/\/(?:www\.)?youtube\.com\/c\/([a-zA-Z0-9_.-]+)/,type:`custom`}],y=e=>{for(let t of _){let n=e.match(t);if(n?.[1])return{platform:`youtube`,type:`content`,id:n[1]}}for(let{pattern:t}of v){let n=e.match(t);if(n?.[1])return{platform:`youtube`,type:`channel`,id:n[1]}}return null},b=t=>{if(!t.apiKey?.trim())throw new e.ValidationError(`VALIDATION_INVALID_INPUT`,`YouTube API key is required`,{platform:`youtube`});let n=g(t.quota?.dailyLimit);return e.PlatformPlugin.create({name:`youtube`,baseUrl:`https://www.googleapis.com/youtube/v3`,rateLimitStrategy:n,matchUrl:y,fetch:t.fetch,capabilities:{supportsBroadcasts:!0,supportsArchiveResolution:!0,authModel:`apiKey`,rateLimitModel:`quota`,supportsBatchContent:!0,supportsBatchBroadcasts:!1,supportsSearch:!0,supportsClips:!1},transformRequest:e=>({...e,query:{...e.query,key:t.apiKey}}),handleRateLimit:async(t,r,i)=>{if(t.status===403){let r=(await t.clone().json().catch(()=>null))?.error?.errors?.[0]?.reason;if(r===`quotaExceeded`||r===`dailyLimitExceeded`){let t=n.getStatus();throw new e.QuotaExhaustedError(`youtube`,{consumed:t.limit-t.remaining,limit:t.limit,resetsAt:t.resetsAt,requestedCost:0})}if(r===`rateLimitExceeded`){let n=(0,e.parseRetryAfter)(t.headers.get(`Retry-After`),5);return await new Promise(e=>setTimeout(e,n*1e3)),!0}}if(t.status===429){let n=(0,e.parseRetryAfter)(t.headers.get(`Retry-After`),1);return await new Promise(e=>setTimeout(e,n*1e3)),!0}return!1}},{getContent:s,getChannel:c,listBroadcasts:l,listArchives:u,resolveArchive:d,batchGetContents:f,batchGetChannels:p,search:m})};exports.YOUTUBE_COST_MAP=h,exports.createYouTubePlugin=b,exports.createYouTubeQuotaStrategy=g,exports.matchYouTubeUrl=y,exports.parseDuration=a,exports.toChannel=r,exports.toContent=n;
2
2
  //# sourceMappingURL=index.cjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.cjs","names":["ParseError","NotFoundError","Page","ValidationError","PlatformPlugin","QuotaExhaustedError"],"sources":["../src/mapper.ts","../src/methods.ts","../src/quota.ts","../src/urls.ts","../src/plugin.ts"],"sourcesContent":["import type { Archive, Broadcast, Channel, Content, ScheduledBroadcast } from \"@unified-live/core\";\nimport { ParseError } from \"@unified-live/core\";\nimport type { components } from \"./generated/youtube-api\";\n\nexport type Schemas = components[\"schemas\"];\n\n/** YouTube Data API v3 Video resource (generated from Discovery Document). */\nexport type YTVideoResource = Schemas[\"Video\"];\n\n/** YouTube Data API v3 Channel resource (generated from Discovery Document). */\nexport type YTChannelResource = Schemas[\"Channel\"];\n\n/**\n * Select the best available thumbnail (high > medium > default) from a YouTube resource.\n *\n * @param thumbnails - thumbnail details from the API\n * @returns the selected thumbnail with dimensions, or undefined if none available\n */\nconst pickThumbnail = (\n thumbnails: Schemas[\"ThumbnailDetails\"] | undefined,\n): { url: string; width: number; height: number } | undefined => {\n const thumb = thumbnails?.high ?? thumbnails?.medium ?? thumbnails?.default;\n if (!thumb?.url || thumb.width == null || thumb.height == null) return undefined;\n return { url: thumb.url, width: thumb.width, height: thumb.height };\n};\n\n/**\n * Convert a YouTube Video resource to a unified Content type.\n *\n * @param item - YouTube video resource from the API\n * @returns unified Content (Broadcast if live, ScheduledBroadcast if upcoming, Archive otherwise)\n * @throws ParseError if required fields (id, snippet, contentDetails, statistics) are missing\n * @precondition item was fetched with part=snippet,contentDetails,statistics,liveStreamingDetails\n * @postcondition returns Broadcast if currently live, ScheduledBroadcast if upcoming, Archive otherwise\n */\nexport const toContent = (item: YTVideoResource): Content => {\n const { id, snippet, contentDetails, statistics, liveStreamingDetails } = item;\n if (!id || !snippet || !contentDetails || !statistics) {\n throw new ParseError(\"youtube\", \"PARSE_RESPONSE\", {\n message: `YouTube video resource missing required parts (id, snippet, contentDetails, statistics)${id ? ` for video ${id}` : \"\"}`,\n path: \"/videos\",\n });\n }\n\n const channelId = snippet.channelId;\n const publishedAt = snippet.publishedAt;\n if (!channelId) {\n throw new ParseError(\"youtube\", \"PARSE_RESPONSE\", {\n message: `YouTube video resource missing channelId for video ${id}`,\n path: \"/videos\",\n });\n }\n if (!publishedAt) {\n throw new ParseError(\"youtube\", \"PARSE_RESPONSE\", {\n message: `YouTube video resource missing publishedAt for video ${id}`,\n path: \"/videos\",\n });\n }\n\n const thumbnail = pickThumbnail(snippet.thumbnails);\n if (!thumbnail) {\n throw new ParseError(\"youtube\", \"PARSE_RESPONSE\", {\n message: `YouTube resource has no thumbnail for video ${id}`,\n path: \"/videos\",\n });\n }\n\n const channel = {\n id: channelId,\n name: snippet.channelTitle ?? \"\",\n url: `https://www.youtube.com/channel/${channelId}`,\n };\n\n const isLive = snippet.liveBroadcastContent === \"live\";\n const isUpcoming = snippet.liveBroadcastContent === \"upcoming\";\n\n if (isLive && liveStreamingDetails?.actualStartTime) {\n return {\n id,\n platform: \"youtube\",\n title: snippet.title ?? \"\",\n description: snippet.description ?? \"\",\n tags: snippet.tags ?? [],\n url: `https://www.youtube.com/watch?v=${id}`,\n thumbnail,\n channel,\n sessionId: id,\n type: \"broadcast\",\n viewerCount: Number.parseInt(liveStreamingDetails.concurrentViewers ?? \"0\", 10),\n startedAt: new Date(liveStreamingDetails.actualStartTime),\n endedAt: liveStreamingDetails.actualEndTime\n ? new Date(liveStreamingDetails.actualEndTime)\n : undefined,\n languageCode: snippet.defaultAudioLanguage ?? undefined,\n raw: item,\n } satisfies Broadcast;\n }\n\n if (isUpcoming) {\n return {\n id,\n platform: \"youtube\",\n title: snippet.title ?? \"\",\n description: snippet.description ?? \"\",\n tags: snippet.tags ?? [],\n url: `https://www.youtube.com/watch?v=${id}`,\n thumbnail,\n channel,\n sessionId: id,\n type: \"scheduled\",\n scheduledStartAt: liveStreamingDetails?.scheduledStartTime\n ? new Date(liveStreamingDetails.scheduledStartTime)\n : new Date(publishedAt),\n languageCode: snippet.defaultAudioLanguage ?? undefined,\n raw: item,\n } satisfies ScheduledBroadcast;\n }\n\n return {\n id,\n platform: \"youtube\",\n title: snippet.title ?? \"\",\n description: snippet.description ?? \"\",\n tags: snippet.tags ?? [],\n url: `https://www.youtube.com/watch?v=${id}`,\n thumbnail,\n channel,\n sessionId: id,\n type: \"archive\",\n duration: parseDuration(contentDetails.duration ?? \"\"),\n viewCount: Number.parseInt(statistics.viewCount ?? \"0\", 10),\n publishedAt: new Date(publishedAt),\n startedAt: liveStreamingDetails?.actualStartTime\n ? new Date(liveStreamingDetails.actualStartTime)\n : undefined,\n endedAt: liveStreamingDetails?.actualEndTime\n ? new Date(liveStreamingDetails.actualEndTime)\n : undefined,\n languageCode: snippet.defaultAudioLanguage ?? undefined,\n raw: item,\n } satisfies Archive;\n};\n\n/**\n * Convert a YouTube Channel resource to a unified Channel type.\n *\n * @param item - YouTube channel resource from the API\n * @returns unified Channel\n * @throws ParseError if required fields (id, snippet) are missing\n * @precondition item was fetched with part=snippet,contentDetails,statistics\n * @postcondition returns a Channel with thumbnail undefined if none available\n * @idempotency Safe — pure function\n */\nexport const toChannel = (item: YTChannelResource): Channel => {\n const { id, snippet, statistics } = item;\n if (!id || !snippet) {\n throw new ParseError(\"youtube\", \"PARSE_RESPONSE\", {\n message: `YouTube channel resource missing required parts (id, snippet)${id ? ` for channel ${id}` : \"\"}`,\n path: \"/channels\",\n });\n }\n\n return {\n id,\n platform: \"youtube\",\n name: snippet.title ?? \"\",\n url: `https://www.youtube.com/channel/${id}`,\n thumbnail: pickThumbnail(snippet.thumbnails),\n description: snippet.description ?? undefined,\n subscriberCount: statistics?.subscriberCount\n ? Number.parseInt(statistics.subscriberCount, 10)\n : undefined,\n publishedAt: snippet.publishedAt ? new Date(snippet.publishedAt) : undefined,\n } satisfies Channel;\n};\n\n/**\n * Parse an ISO 8601 duration string (e.g., \"PT1H2M3S\") into seconds.\n *\n * @param duration - ISO 8601 duration string\n * @returns total seconds\n * @precondition duration is a valid ISO 8601 duration\n * @postcondition returns total seconds as a number >= 0\n * @idempotency Safe — pure function\n */\nconst ISO_8601_DURATION = /P(?:(\\d+)D)?T?(?:(\\d+)H)?(?:(\\d+)M)?(?:(\\d+)S)?/;\n\nexport const parseDuration = (duration: string): number => {\n const match = duration.match(ISO_8601_DURATION);\n if (!match) return 0;\n\n const days = Number.parseInt(match[1] ?? \"0\", 10);\n const hours = Number.parseInt(match[2] ?? \"0\", 10);\n const minutes = Number.parseInt(match[3] ?? \"0\", 10);\n const seconds = Number.parseInt(match[4] ?? \"0\", 10);\n\n return days * 86400 + hours * 3600 + minutes * 60 + seconds;\n};\n","import {\n type Archive,\n type BatchResult,\n type Broadcast,\n type Channel,\n type Content,\n NotFoundError,\n Page,\n type RestManager,\n type SearchOptions,\n UnifiedLiveError,\n} from \"@unified-live/core\";\nimport {\n toChannel,\n toContent,\n type Schemas,\n type YTChannelResource,\n type YTVideoResource,\n} from \"./mapper\";\n\n/** List response shape shared by all YouTube Data API list endpoints. */\ntype YTListResponse<T> = {\n items?: T[];\n pageInfo?: Schemas[\"PageInfo\"];\n nextPageToken?: string;\n};\n\nconst YOUTUBE_VIDEO_PARTS = \"snippet,contentDetails,statistics,liveStreamingDetails\";\n\n/**\n * Fetch a YouTube video by ID and map to unified Content.\n *\n * @param rest - REST manager for API requests\n * @param id - YouTube video ID\n * @returns unified Content (live or video)\n * @throws NotFoundError if video does not exist\n * @precondition id is a valid YouTube video ID\n * @postcondition returns Content mapped from the YouTube video resource\n */\nexport const youtubeGetContent = async (rest: RestManager, id: string): Promise<Content> => {\n const res = await rest.request<YTListResponse<YTVideoResource>>({\n method: \"GET\",\n path: \"/videos\",\n query: {\n part: YOUTUBE_VIDEO_PARTS,\n id,\n },\n bucketId: \"videos:list\",\n });\n\n const item = res.data.items?.[0];\n if (!item) {\n throw new NotFoundError(\"youtube\", id);\n }\n\n return toContent(item);\n};\n\n/**\n * Fetch a YouTube channel by ID, handle, or username and map to unified Channel.\n *\n * @param rest - REST manager for API requests\n * @param id - YouTube channel ID (UC...), handle (@...), or legacy username\n * @returns unified Channel\n * @throws NotFoundError if channel does not exist\n * @precondition id is a valid YouTube channel ID, handle, or username\n * @postcondition returns Channel mapped from the YouTube channel resource\n */\nexport const youtubeGetChannel = async (rest: RestManager, id: string): Promise<Channel> => {\n const query: Record<string, string> = {\n part: \"snippet,contentDetails,statistics\",\n };\n\n if (id.startsWith(\"@\")) {\n query.forHandle = id;\n } else if (id.startsWith(\"UC\")) {\n query.id = id;\n } else {\n query.forUsername = id;\n }\n\n const res = await rest.request<YTListResponse<YTChannelResource>>({\n method: \"GET\",\n path: \"/channels\",\n query,\n bucketId: \"channels:list\",\n });\n\n const item = res.data.items?.[0];\n if (!item) {\n throw new NotFoundError(\"youtube\", id);\n }\n\n return toChannel(item);\n};\n\n/**\n * Fetch active broadcasts for a YouTube channel.\n *\n * @param rest - REST manager for API requests\n * @param channelId - YouTube channel ID\n * @returns array of active Broadcast objects (empty if none are live)\n * @precondition channelId is a valid YouTube channel ID\n * @postcondition returns only streams with type \"broadcast\"\n */\nexport const youtubeListBroadcasts = async (\n rest: RestManager,\n channelId: string,\n): Promise<Broadcast[]> => {\n const res = await rest.request<YTListResponse<Schemas[\"SearchResult\"]>>({\n method: \"GET\",\n path: \"/search\",\n query: {\n part: \"id\",\n channelId,\n type: \"video\",\n eventType: \"live\",\n },\n bucketId: \"search:list\",\n });\n\n if (!res.data.items || res.data.items.length === 0) {\n return [];\n }\n\n const videoIds = res.data.items\n .map((item) => item.id?.videoId)\n .filter(Boolean)\n .join(\",\");\n\n const videosRes = await rest.request<YTListResponse<YTVideoResource>>({\n method: \"GET\",\n path: \"/videos\",\n query: {\n part: YOUTUBE_VIDEO_PARTS,\n id: videoIds,\n },\n bucketId: \"videos:list\",\n });\n\n const items = videosRes.data.items ?? [];\n const broadcasts: Broadcast[] = [];\n for (const item of items) {\n const content = toContent(item);\n if (content.type === \"broadcast\") {\n broadcasts.push(content);\n }\n }\n return broadcasts;\n};\n\n/**\n * Fetch paginated uploaded archives for a YouTube channel.\n *\n * @param rest - REST manager for API requests\n * @param channelId - YouTube channel ID\n * @param cursor - optional page token for pagination\n * @param pageSize - number of items per page (default 50)\n * @returns paginated list of Archive objects\n * @throws NotFoundError if channel does not exist or has no uploads playlist\n * @precondition channelId is a valid YouTube channel ID\n * @postcondition returns archives from the channel's uploads playlist\n */\nexport const youtubeListArchives = async (\n rest: RestManager,\n channelId: string,\n cursor?: string,\n pageSize = 50,\n): Promise<Page<Archive>> => {\n const channelRes = await rest.request<YTListResponse<YTChannelResource>>({\n method: \"GET\",\n path: \"/channels\",\n query: {\n part: \"contentDetails\",\n id: channelId,\n },\n bucketId: \"channels:list\",\n });\n\n const channel = channelRes.data.items?.[0];\n if (!channel) {\n throw new NotFoundError(\"youtube\", channelId);\n }\n\n const uploadsPlaylistId = channel.contentDetails?.relatedPlaylists?.uploads;\n if (!uploadsPlaylistId) {\n throw new NotFoundError(\"youtube\", channelId);\n }\n\n const query: Record<string, string> = {\n part: \"snippet\",\n playlistId: uploadsPlaylistId,\n maxResults: String(pageSize),\n };\n if (cursor) {\n query.pageToken = cursor;\n }\n\n const playlistRes = await rest.request<YTListResponse<Schemas[\"PlaylistItem\"]>>({\n method: \"GET\",\n path: \"/playlistItems\",\n query,\n bucketId: \"playlistItems:list\",\n });\n\n if (!playlistRes.data.items || playlistRes.data.items.length === 0) {\n return { items: [], hasMore: false };\n }\n\n const videoIds = playlistRes.data.items\n .map((item) => item.snippet?.resourceId?.videoId)\n .filter(Boolean)\n .join(\",\");\n\n const videosRes = await rest.request<YTListResponse<YTVideoResource>>({\n method: \"GET\",\n path: \"/videos\",\n query: {\n part: YOUTUBE_VIDEO_PARTS,\n id: videoIds,\n },\n bucketId: \"videos:list\",\n });\n\n const items = videosRes.data.items ?? [];\n const archives: Archive[] = [];\n for (const item of items) {\n const content = toContent(item);\n if (content.type === \"archive\") {\n archives.push(content);\n }\n }\n\n return {\n items: archives,\n cursor: playlistRes.data.nextPageToken,\n total: playlistRes.data.pageInfo?.totalResults ?? 0,\n hasMore: playlistRes.data.nextPageToken !== undefined,\n };\n};\n\n/**\n * Resolve a broadcast to its archived content.\n *\n * YouTube uses the same video ID for live and archive, so this re-fetches\n * the content and returns it only if it has transitioned to an archive.\n *\n * @param rest - REST manager for API requests\n * @param live - broadcast to check for archive\n * @returns Archive, or null if still live\n * @postcondition returns Archive if the stream ended, null otherwise\n */\nexport const youtubeResolveArchive = async (\n rest: RestManager,\n live: Broadcast,\n): Promise<Archive | null> => {\n const content = await youtubeGetContent(rest, live.id);\n return content.type === \"archive\" ? content : null;\n};\n\nconst YOUTUBE_MAX_IDS_PER_REQUEST = 50;\n\n/**\n * Batch-fetch YouTube videos by IDs and map to unified Content.\n *\n * @param rest - REST manager for API requests\n * @param ids - array of YouTube video IDs\n * @returns BatchResult with values for found videos and NotFoundError for missing IDs\n * @precondition each id is a valid YouTube video ID\n * @postcondition values contains Content for each found video; errors contains NotFoundError for each missing ID\n * @idempotency Safe — read-only API calls\n */\nexport const youtubeBatchGetContents = async (\n rest: RestManager,\n ids: string[],\n): Promise<BatchResult<Content>> => {\n const values = new Map<string, Content>();\n const errors = new Map<string, UnifiedLiveError>();\n\n for (let i = 0; i < ids.length; i += YOUTUBE_MAX_IDS_PER_REQUEST) {\n const chunk = ids.slice(i, i + YOUTUBE_MAX_IDS_PER_REQUEST);\n const res = await rest.request<YTListResponse<YTVideoResource>>({\n method: \"GET\",\n path: \"/videos\",\n query: {\n part: YOUTUBE_VIDEO_PARTS,\n id: chunk.join(\",\"),\n },\n bucketId: \"videos:list\",\n });\n\n const returnedIds = new Set<string>();\n for (const item of res.data.items ?? []) {\n if (item.id) {\n values.set(item.id, toContent(item));\n returnedIds.add(item.id);\n }\n }\n\n for (const id of chunk) {\n if (!returnedIds.has(id)) {\n errors.set(id, new NotFoundError(\"youtube\", id));\n }\n }\n }\n\n return { values, errors };\n};\n\n/**\n * Search YouTube for videos matching the given options.\n *\n * @param rest - REST manager for API requests\n * @param options - search options (query, status, limit, cursor)\n * @returns paginated list of Content items\n * @precondition options.query or options.status should be provided for meaningful results\n * @postcondition returns Page with items mapped from YouTube video resources\n * @idempotency Safe — read-only API calls\n */\nexport const youtubeSearch = async (\n rest: RestManager,\n options: SearchOptions,\n): Promise<Page<Content>> => {\n const query: Record<string, string> = {\n part: \"id\",\n type: \"video\",\n };\n\n if (options.query) query.q = options.query;\n if (options.channelId) query.channelId = options.channelId;\n if (options.order) query.order = options.order;\n if (options.limit) query.maxResults = String(Math.min(options.limit, 50));\n if (options.cursor) query.pageToken = options.cursor;\n if (options.safeSearch) query.safeSearch = options.safeSearch;\n if (options.languageCode) query.relevanceLanguage = options.languageCode;\n\n if (options.status) {\n const eventTypeMap = {\n live: \"live\",\n upcoming: \"upcoming\",\n ended: \"completed\",\n } as const;\n query.eventType = eventTypeMap[options.status];\n }\n\n const searchRes = await rest.request<YTListResponse<Schemas[\"SearchResult\"]>>({\n method: \"GET\",\n path: \"/search\",\n query,\n bucketId: \"search:list\",\n });\n\n if (!searchRes.data.items || searchRes.data.items.length === 0) {\n return Page.empty<Content>();\n }\n\n const videoIds = searchRes.data.items\n .map((item) => item.id?.videoId)\n .filter(Boolean)\n .join(\",\");\n\n if (!videoIds) return Page.empty<Content>();\n\n const videosRes = await rest.request<YTListResponse<YTVideoResource>>({\n method: \"GET\",\n path: \"/videos\",\n query: {\n part: YOUTUBE_VIDEO_PARTS,\n id: videoIds,\n },\n bucketId: \"videos:list\",\n });\n\n const items = (videosRes.data.items ?? []).map(toContent);\n\n return {\n items,\n cursor: searchRes.data.nextPageToken,\n total: searchRes.data.pageInfo?.totalResults,\n hasMore: searchRes.data.nextPageToken !== undefined,\n };\n};\n","import type { RateLimitStrategy } from \"@unified-live/core\";\nimport { createQuotaBudgetStrategy } from \"@unified-live/core\";\n\n/** YouTube Data API v3 quota cost map. */\nexport const YOUTUBE_COST_MAP: Record<string, number> = {\n \"videos:list\": 1,\n \"channels:list\": 1,\n \"playlists:list\": 1,\n \"playlistItems:list\": 1,\n \"liveBroadcasts:list\": 1,\n \"liveStreams:list\": 1,\n \"search:list\": 100,\n};\n\n/**\n * Creates a QuotaBudgetStrategy configured for YouTube.\n *\n * @param dailyLimit - optional daily quota cap (defaults to 10,000)\n * @returns rate limit strategy tracking YouTube quota\n * @precondition dailyLimit > 0\n * @postcondition returns a strategy that tracks YouTube quota consumption\n */\nexport const createYouTubeQuotaStrategy = (dailyLimit?: number): RateLimitStrategy => {\n return createQuotaBudgetStrategy({\n dailyLimit: dailyLimit ?? 10_000,\n costMap: YOUTUBE_COST_MAP,\n defaultCost: 1,\n platform: \"youtube\",\n });\n};\n","import type { ResolvedUrl } from \"@unified-live/core\";\n\nconst CONTENT_PATTERNS = [\n // youtube.com/watch?v=<id>\n /^https?:\\/\\/(?:www\\.)?youtube\\.com\\/watch\\?.*v=([a-zA-Z0-9_-]{11})/,\n // youtu.be/<id>\n /^https?:\\/\\/youtu\\.be\\/([a-zA-Z0-9_-]{11})/,\n // youtube.com/live/<id>\n /^https?:\\/\\/(?:www\\.)?youtube\\.com\\/live\\/([a-zA-Z0-9_-]{11})/,\n];\n\nconst CHANNEL_PATTERNS = [\n // youtube.com/channel/<id>\n {\n pattern: /^https?:\\/\\/(?:www\\.)?youtube\\.com\\/channel\\/(UC[a-zA-Z0-9_-]{22})/,\n type: \"id\" as const,\n },\n // youtube.com/@<handle>\n {\n pattern: /^https?:\\/\\/(?:www\\.)?youtube\\.com\\/@([a-zA-Z0-9_.-]+)/,\n type: \"handle\" as const,\n },\n // youtube.com/c/<name>\n {\n pattern: /^https?:\\/\\/(?:www\\.)?youtube\\.com\\/c\\/([a-zA-Z0-9_.-]+)/,\n type: \"custom\" as const,\n },\n];\n\n/**\n * Match a URL to a YouTube content or channel resource.\n *\n * @param url - URL string to match\n * @returns resolved YouTube URL or null if not a YouTube URL\n * @precondition url is a valid URL string\n * @postcondition returns ResolvedUrl for YouTube URLs, null otherwise\n * @idempotency Safe — no side effects\n */\nexport const matchYouTubeUrl = (url: string): ResolvedUrl | null => {\n for (const pattern of CONTENT_PATTERNS) {\n const match = url.match(pattern);\n if (match?.[1]) {\n return { platform: \"youtube\", type: \"content\", id: match[1] };\n }\n }\n\n for (const { pattern } of CHANNEL_PATTERNS) {\n const match = url.match(pattern);\n if (match?.[1]) {\n return { platform: \"youtube\", type: \"channel\", id: match[1] };\n }\n }\n\n return null;\n};\n","import {\n parseRetryAfter,\n PlatformPlugin,\n QuotaExhaustedError,\n ValidationError,\n} from \"@unified-live/core\";\nimport {\n youtubeBatchGetContents,\n youtubeGetChannel,\n youtubeGetContent,\n youtubeListArchives,\n youtubeListBroadcasts,\n youtubeResolveArchive,\n youtubeSearch,\n} from \"./methods\";\nimport { createYouTubeQuotaStrategy } from \"./quota\";\nimport { matchYouTubeUrl } from \"./urls\";\n\nconst YOUTUBE_BASE_URL = \"https://www.googleapis.com/youtube/v3\";\n\nexport type YouTubePluginConfig = {\n apiKey: string;\n quota?: {\n dailyLimit?: number;\n };\n /** Override fetch for testing. */\n fetch?: typeof globalThis.fetch;\n};\n\n/**\n * Creates a YouTube platform plugin.\n *\n * @param config - YouTube plugin configuration including API key\n * @returns configured PlatformPlugin for YouTube\n * @throws {ValidationError} if apiKey is empty/whitespace\n * @precondition config.apiKey is a valid YouTube Data API v3 key\n * @postcondition returns a PlatformPlugin that handles YouTube URLs and API calls\n * @idempotency Not idempotent — each call creates a new plugin instance\n */\nexport const createYouTubePlugin = (config: YouTubePluginConfig): PlatformPlugin => {\n if (!config.apiKey?.trim()) {\n throw new ValidationError(\"VALIDATION_INVALID_INPUT\", \"YouTube API key is required\", {\n platform: \"youtube\",\n });\n }\n\n const quotaStrategy = createYouTubeQuotaStrategy(config.quota?.dailyLimit);\n\n return PlatformPlugin.create(\n {\n name: \"youtube\",\n baseUrl: YOUTUBE_BASE_URL,\n rateLimitStrategy: quotaStrategy,\n matchUrl: matchYouTubeUrl,\n fetch: config.fetch,\n capabilities: {\n supportsBroadcasts: true,\n supportsArchiveResolution: true,\n authModel: \"apiKey\",\n rateLimitModel: \"quota\",\n supportsBatchContent: true,\n supportsBatchBroadcasts: false,\n supportsSearch: true,\n supportsClips: false,\n },\n transformRequest: (req) => ({\n ...req,\n query: { ...req.query, key: config.apiKey },\n }),\n handleRateLimit: async (response, _req, _attempt) => {\n if (response.status === 403) {\n const body = (await response\n .clone()\n .json()\n .catch(() => null)) as {\n error?: { errors?: Array<{ reason?: string }> };\n } | null;\n const reason = body?.error?.errors?.[0]?.reason;\n\n if (reason === \"quotaExceeded\" || reason === \"dailyLimitExceeded\") {\n const status = quotaStrategy.getStatus();\n throw new QuotaExhaustedError(\"youtube\", {\n consumed: status.limit - status.remaining,\n limit: status.limit,\n resetsAt: status.resetsAt,\n requestedCost: 0,\n });\n }\n\n if (reason === \"rateLimitExceeded\") {\n const retryAfter = parseRetryAfter(response.headers.get(\"Retry-After\"), 5);\n await new Promise((r) => setTimeout(r, retryAfter * 1000));\n return true;\n }\n }\n\n if (response.status === 429) {\n const retryAfter = parseRetryAfter(response.headers.get(\"Retry-After\"), 1);\n await new Promise((r) => setTimeout(r, retryAfter * 1000));\n return true;\n }\n\n return false;\n },\n },\n {\n getContent: youtubeGetContent,\n getChannel: youtubeGetChannel,\n listBroadcasts: youtubeListBroadcasts,\n listArchives: youtubeListArchives,\n resolveArchive: youtubeResolveArchive,\n batchGetContents: youtubeBatchGetContents,\n search: youtubeSearch,\n },\n );\n};\n"],"mappings":"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"}
1
+ {"version":3,"file":"index.cjs","names":["ParseError","NotFoundError","Page","ValidationError","PlatformPlugin","QuotaExhaustedError"],"sources":["../src/mapper.ts","../src/methods.ts","../src/quota.ts","../src/urls.ts","../src/plugin.ts"],"sourcesContent":["import type { Archive, Broadcast, Channel, Content, ScheduledBroadcast } from \"@unified-live/core\";\nimport { ParseError } from \"@unified-live/core\";\nimport type { components } from \"./generated/youtube-api\";\n\nexport type Schemas = components[\"schemas\"];\n\n/** YouTube Data API v3 Video resource (generated from Discovery Document). */\nexport type YTVideoResource = Schemas[\"Video\"];\n\n/** YouTube Data API v3 Channel resource (generated from Discovery Document). */\nexport type YTChannelResource = Schemas[\"Channel\"];\n\n/**\n * Select the best available thumbnail (high > medium > default) from a YouTube resource.\n *\n * @param thumbnails - thumbnail details from the API\n * @returns the selected thumbnail with dimensions, or undefined if none available\n */\nconst pickThumbnail = (\n thumbnails: Schemas[\"ThumbnailDetails\"] | undefined,\n): { url: string; width: number; height: number } | undefined => {\n const thumb = thumbnails?.high ?? thumbnails?.medium ?? thumbnails?.default;\n if (!thumb?.url || thumb.width == null || thumb.height == null) return undefined;\n return { url: thumb.url, width: thumb.width, height: thumb.height };\n};\n\n/**\n * Convert a YouTube Video resource to a unified Content type.\n *\n * @param item - YouTube video resource from the API\n * @returns unified Content (Broadcast if live, ScheduledBroadcast if upcoming, Archive otherwise)\n * @throws ParseError if required fields (id, snippet, contentDetails, statistics) are missing\n * @precondition item was fetched with part=snippet,contentDetails,statistics,liveStreamingDetails\n * @postcondition returns Broadcast if currently live, ScheduledBroadcast if upcoming, Archive otherwise\n */\nexport const toContent = (item: YTVideoResource): Content => {\n const { id, snippet, contentDetails, statistics, liveStreamingDetails } = item;\n if (!id || !snippet || !contentDetails || !statistics) {\n throw new ParseError(\"youtube\", \"PARSE_RESPONSE\", {\n message: `YouTube video resource missing required parts (id, snippet, contentDetails, statistics)${id ? ` for video ${id}` : \"\"}`,\n path: \"/videos\",\n });\n }\n\n const channelId = snippet.channelId;\n const publishedAt = snippet.publishedAt;\n if (!channelId) {\n throw new ParseError(\"youtube\", \"PARSE_RESPONSE\", {\n message: `YouTube video resource missing channelId for video ${id}`,\n path: \"/videos\",\n });\n }\n if (!publishedAt) {\n throw new ParseError(\"youtube\", \"PARSE_RESPONSE\", {\n message: `YouTube video resource missing publishedAt for video ${id}`,\n path: \"/videos\",\n });\n }\n\n const thumbnail = pickThumbnail(snippet.thumbnails);\n if (!thumbnail) {\n throw new ParseError(\"youtube\", \"PARSE_RESPONSE\", {\n message: `YouTube resource has no thumbnail for video ${id}`,\n path: \"/videos\",\n });\n }\n\n const channel = {\n id: channelId,\n name: snippet.channelTitle ?? \"\",\n url: `https://www.youtube.com/channel/${channelId}`,\n };\n\n const isLive = snippet.liveBroadcastContent === \"live\";\n const isUpcoming = snippet.liveBroadcastContent === \"upcoming\";\n\n if (isLive && liveStreamingDetails?.actualStartTime) {\n return {\n id,\n platform: \"youtube\",\n title: snippet.title ?? \"\",\n description: snippet.description ?? \"\",\n tags: snippet.tags ?? [],\n url: `https://www.youtube.com/watch?v=${id}`,\n thumbnail,\n channel,\n sessionId: id,\n type: \"broadcast\",\n viewerCount: Number.parseInt(liveStreamingDetails.concurrentViewers ?? \"0\", 10),\n startedAt: new Date(liveStreamingDetails.actualStartTime),\n endedAt: liveStreamingDetails.actualEndTime\n ? new Date(liveStreamingDetails.actualEndTime)\n : undefined,\n languageCode: snippet.defaultAudioLanguage ?? undefined,\n raw: item,\n } satisfies Broadcast;\n }\n\n if (isUpcoming) {\n return {\n id,\n platform: \"youtube\",\n title: snippet.title ?? \"\",\n description: snippet.description ?? \"\",\n tags: snippet.tags ?? [],\n url: `https://www.youtube.com/watch?v=${id}`,\n thumbnail,\n channel,\n sessionId: id,\n type: \"scheduled\",\n scheduledStartAt: liveStreamingDetails?.scheduledStartTime\n ? new Date(liveStreamingDetails.scheduledStartTime)\n : new Date(publishedAt),\n languageCode: snippet.defaultAudioLanguage ?? undefined,\n raw: item,\n } satisfies ScheduledBroadcast;\n }\n\n return {\n id,\n platform: \"youtube\",\n title: snippet.title ?? \"\",\n description: snippet.description ?? \"\",\n tags: snippet.tags ?? [],\n url: `https://www.youtube.com/watch?v=${id}`,\n thumbnail,\n channel,\n sessionId: id,\n type: \"archive\",\n duration: parseDuration(contentDetails.duration ?? \"\"),\n viewCount: Number.parseInt(statistics.viewCount ?? \"0\", 10),\n publishedAt: new Date(publishedAt),\n startedAt: liveStreamingDetails?.actualStartTime\n ? new Date(liveStreamingDetails.actualStartTime)\n : undefined,\n endedAt: liveStreamingDetails?.actualEndTime\n ? new Date(liveStreamingDetails.actualEndTime)\n : undefined,\n languageCode: snippet.defaultAudioLanguage ?? undefined,\n raw: item,\n } satisfies Archive;\n};\n\n/**\n * Convert a YouTube Channel resource to a unified Channel type.\n *\n * @param item - YouTube channel resource from the API\n * @returns unified Channel\n * @throws ParseError if required fields (id, snippet) are missing\n * @precondition item was fetched with part=snippet,contentDetails,statistics\n * @postcondition returns a Channel with thumbnail undefined if none available\n * @idempotency Safe — pure function\n */\nexport const toChannel = (item: YTChannelResource): Channel => {\n const { id, snippet, statistics } = item;\n if (!id || !snippet) {\n throw new ParseError(\"youtube\", \"PARSE_RESPONSE\", {\n message: `YouTube channel resource missing required parts (id, snippet)${id ? ` for channel ${id}` : \"\"}`,\n path: \"/channels\",\n });\n }\n\n return {\n id,\n platform: \"youtube\",\n name: snippet.title ?? \"\",\n url: `https://www.youtube.com/channel/${id}`,\n thumbnail: pickThumbnail(snippet.thumbnails),\n description: snippet.description ?? undefined,\n subscriberCount: statistics?.subscriberCount\n ? Number.parseInt(statistics.subscriberCount, 10)\n : undefined,\n publishedAt: snippet.publishedAt ? new Date(snippet.publishedAt) : undefined,\n } satisfies Channel;\n};\n\n/**\n * Parse an ISO 8601 duration string (e.g., \"PT1H2M3S\") into seconds.\n *\n * @param duration - ISO 8601 duration string\n * @returns total seconds\n * @precondition duration is a valid ISO 8601 duration\n * @postcondition returns total seconds as a number >= 0\n * @idempotency Safe — pure function\n */\nconst ISO_8601_DURATION = /P(?:(\\d+)D)?T?(?:(\\d+)H)?(?:(\\d+)M)?(?:(\\d+)S)?/;\n\nexport const parseDuration = (duration: string): number => {\n const match = duration.match(ISO_8601_DURATION);\n if (!match) return 0;\n\n const days = Number.parseInt(match[1] ?? \"0\", 10);\n const hours = Number.parseInt(match[2] ?? \"0\", 10);\n const minutes = Number.parseInt(match[3] ?? \"0\", 10);\n const seconds = Number.parseInt(match[4] ?? \"0\", 10);\n\n return days * 86400 + hours * 3600 + minutes * 60 + seconds;\n};\n","import {\n type Archive,\n type BatchResult,\n type Broadcast,\n type Channel,\n type Content,\n NotFoundError,\n Page,\n type RestManager,\n type SearchOptions,\n UnifiedLiveError,\n} from \"@unified-live/core\";\nimport {\n toChannel,\n toContent,\n type Schemas,\n type YTChannelResource,\n type YTVideoResource,\n} from \"./mapper\";\n\n/** List response shape shared by all YouTube Data API list endpoints. */\ntype YTListResponse<T> = {\n items?: T[];\n pageInfo?: Schemas[\"PageInfo\"];\n nextPageToken?: string;\n};\n\nconst YOUTUBE_VIDEO_PARTS = \"snippet,contentDetails,statistics,liveStreamingDetails\";\n\n/**\n * Fetch a YouTube video by ID and map to unified Content.\n *\n * @param rest - REST manager for API requests\n * @param id - YouTube video ID\n * @returns unified Content (live or video)\n * @throws NotFoundError if video does not exist\n * @precondition id is a valid YouTube video ID\n * @postcondition returns Content mapped from the YouTube video resource\n */\nexport const youtubeGetContent = async (rest: RestManager, id: string): Promise<Content> => {\n const res = await rest.request<YTListResponse<YTVideoResource>>({\n method: \"GET\",\n path: \"/videos\",\n query: {\n part: YOUTUBE_VIDEO_PARTS,\n id,\n },\n bucketId: \"videos:list\",\n });\n\n const item = res.data.items?.[0];\n if (!item) {\n throw new NotFoundError(\"youtube\", id);\n }\n\n return toContent(item);\n};\n\n/**\n * Fetch a YouTube channel by ID, handle, or username and map to unified Channel.\n *\n * @param rest - REST manager for API requests\n * @param id - YouTube channel ID (UC...), handle (@...), or legacy username\n * @returns unified Channel\n * @throws NotFoundError if channel does not exist\n * @precondition id is a valid YouTube channel ID, handle, or username\n * @postcondition returns Channel mapped from the YouTube channel resource\n */\nexport const youtubeGetChannel = async (rest: RestManager, id: string): Promise<Channel> => {\n const query: Record<string, string> = {\n part: \"snippet,contentDetails,statistics\",\n };\n\n if (id.startsWith(\"@\")) {\n query.forHandle = id;\n } else if (id.startsWith(\"UC\")) {\n query.id = id;\n } else {\n query.forUsername = id;\n }\n\n const res = await rest.request<YTListResponse<YTChannelResource>>({\n method: \"GET\",\n path: \"/channels\",\n query,\n bucketId: \"channels:list\",\n });\n\n const item = res.data.items?.[0];\n if (!item) {\n throw new NotFoundError(\"youtube\", id);\n }\n\n return toChannel(item);\n};\n\n/**\n * Fetch active broadcasts for a YouTube channel.\n *\n * @param rest - REST manager for API requests\n * @param channelId - YouTube channel ID\n * @returns array of active Broadcast objects (empty if none are live)\n * @precondition channelId is a valid YouTube channel ID\n * @postcondition returns only streams with type \"broadcast\"\n */\nexport const youtubeListBroadcasts = async (\n rest: RestManager,\n channelId: string,\n): Promise<Broadcast[]> => {\n const res = await rest.request<YTListResponse<Schemas[\"SearchResult\"]>>({\n method: \"GET\",\n path: \"/search\",\n query: {\n part: \"id\",\n channelId,\n type: \"video\",\n eventType: \"live\",\n },\n bucketId: \"search:list\",\n });\n\n if (!res.data.items || res.data.items.length === 0) {\n return [];\n }\n\n const videoIds = res.data.items\n .map((item) => item.id?.videoId)\n .filter(Boolean)\n .join(\",\");\n\n const videosRes = await rest.request<YTListResponse<YTVideoResource>>({\n method: \"GET\",\n path: \"/videos\",\n query: {\n part: YOUTUBE_VIDEO_PARTS,\n id: videoIds,\n },\n bucketId: \"videos:list\",\n });\n\n const items = videosRes.data.items ?? [];\n const broadcasts: Broadcast[] = [];\n for (const item of items) {\n const content = toContent(item);\n if (content.type === \"broadcast\") {\n broadcasts.push(content);\n }\n }\n return broadcasts;\n};\n\n/**\n * Fetch paginated uploaded archives for a YouTube channel.\n *\n * @param rest - REST manager for API requests\n * @param channelId - YouTube channel ID\n * @param cursor - optional page token for pagination\n * @param pageSize - number of items per page (default 50)\n * @returns paginated list of Archive objects\n * @throws NotFoundError if channel does not exist or has no uploads playlist\n * @precondition channelId is a valid YouTube channel ID\n * @postcondition returns archives from the channel's uploads playlist\n */\nexport const youtubeListArchives = async (\n rest: RestManager,\n channelId: string,\n cursor?: string,\n pageSize = 50,\n): Promise<Page<Archive>> => {\n const channelRes = await rest.request<YTListResponse<YTChannelResource>>({\n method: \"GET\",\n path: \"/channels\",\n query: {\n part: \"contentDetails\",\n id: channelId,\n },\n bucketId: \"channels:list\",\n });\n\n const channel = channelRes.data.items?.[0];\n if (!channel) {\n throw new NotFoundError(\"youtube\", channelId);\n }\n\n const uploadsPlaylistId = channel.contentDetails?.relatedPlaylists?.uploads;\n if (!uploadsPlaylistId) {\n throw new NotFoundError(\"youtube\", channelId);\n }\n\n const query: Record<string, string> = {\n part: \"snippet\",\n playlistId: uploadsPlaylistId,\n maxResults: String(pageSize),\n };\n if (cursor) {\n query.pageToken = cursor;\n }\n\n const playlistRes = await rest.request<YTListResponse<Schemas[\"PlaylistItem\"]>>({\n method: \"GET\",\n path: \"/playlistItems\",\n query,\n bucketId: \"playlistItems:list\",\n });\n\n if (!playlistRes.data.items || playlistRes.data.items.length === 0) {\n return { items: [], hasMore: false };\n }\n\n const videoIds = playlistRes.data.items\n .map((item) => item.snippet?.resourceId?.videoId)\n .filter(Boolean)\n .join(\",\");\n\n const videosRes = await rest.request<YTListResponse<YTVideoResource>>({\n method: \"GET\",\n path: \"/videos\",\n query: {\n part: YOUTUBE_VIDEO_PARTS,\n id: videoIds,\n },\n bucketId: \"videos:list\",\n });\n\n const items = videosRes.data.items ?? [];\n const archives: Archive[] = [];\n for (const item of items) {\n const content = toContent(item);\n if (content.type === \"archive\") {\n archives.push(content);\n }\n }\n\n return {\n items: archives,\n cursor: playlistRes.data.nextPageToken,\n total: playlistRes.data.pageInfo?.totalResults ?? 0,\n hasMore: playlistRes.data.nextPageToken !== undefined,\n };\n};\n\n/**\n * Resolve a broadcast to its archived content.\n *\n * YouTube uses the same video ID for live and archive, so this re-fetches\n * the content and returns it only if it has transitioned to an archive.\n *\n * @param rest - REST manager for API requests\n * @param live - broadcast to check for archive\n * @returns Archive, or null if still live\n * @postcondition returns Archive if the stream ended, null otherwise\n */\nexport const youtubeResolveArchive = async (\n rest: RestManager,\n live: Broadcast,\n): Promise<Archive | null> => {\n const content = await youtubeGetContent(rest, live.id);\n return content.type === \"archive\" ? content : null;\n};\n\nconst YOUTUBE_MAX_IDS_PER_REQUEST = 50;\n\n/**\n * Batch-fetch YouTube videos by IDs and map to unified Content.\n *\n * @param rest - REST manager for API requests\n * @param ids - array of YouTube video IDs\n * @returns BatchResult with values for found videos and NotFoundError for missing IDs\n * @precondition each id is a valid YouTube video ID\n * @postcondition values contains Content for each found video; errors contains NotFoundError for each missing ID\n * @idempotency Safe — read-only API calls\n */\nexport const youtubeBatchGetContents = async (\n rest: RestManager,\n ids: string[],\n): Promise<BatchResult<Content>> => {\n const values = new Map<string, Content>();\n const errors = new Map<string, UnifiedLiveError>();\n\n for (let i = 0; i < ids.length; i += YOUTUBE_MAX_IDS_PER_REQUEST) {\n const chunk = ids.slice(i, i + YOUTUBE_MAX_IDS_PER_REQUEST);\n const res = await rest.request<YTListResponse<YTVideoResource>>({\n method: \"GET\",\n path: \"/videos\",\n query: {\n part: YOUTUBE_VIDEO_PARTS,\n id: chunk.join(\",\"),\n },\n bucketId: \"videos:list\",\n });\n\n const returnedIds = new Set<string>();\n for (const item of res.data.items ?? []) {\n if (item.id) {\n values.set(item.id, toContent(item));\n returnedIds.add(item.id);\n }\n }\n\n for (const id of chunk) {\n if (!returnedIds.has(id)) {\n errors.set(id, new NotFoundError(\"youtube\", id));\n }\n }\n }\n\n return { values, errors };\n};\n\n/**\n * Batch-fetch YouTube channels by IDs and map to unified Channel.\n *\n * @param rest - REST manager for API requests\n * @param ids - array of YouTube channel IDs (UC...)\n * @returns BatchResult with values for found channels and NotFoundError for missing IDs\n * @precondition each id is a valid YouTube channel ID (UC prefix)\n * @postcondition values contains Channel for each found channel; errors contains NotFoundError for each missing ID\n * @idempotency Safe — read-only API calls\n */\nexport const youtubeBatchGetChannels = async (\n rest: RestManager,\n ids: string[],\n): Promise<BatchResult<Channel>> => {\n const values = new Map<string, Channel>();\n const errors = new Map<string, UnifiedLiveError>();\n\n for (let i = 0; i < ids.length; i += YOUTUBE_MAX_IDS_PER_REQUEST) {\n const chunk = ids.slice(i, i + YOUTUBE_MAX_IDS_PER_REQUEST);\n const res = await rest.request<YTListResponse<YTChannelResource>>({\n method: \"GET\",\n path: \"/channels\",\n query: {\n part: \"snippet,contentDetails,statistics\",\n id: chunk.join(\",\"),\n },\n bucketId: \"channels:list\",\n });\n\n const returnedIds = new Set<string>();\n for (const item of res.data.items ?? []) {\n if (item.id) {\n values.set(item.id, toChannel(item));\n returnedIds.add(item.id);\n }\n }\n\n for (const id of chunk) {\n if (!returnedIds.has(id)) {\n errors.set(id, new NotFoundError(\"youtube\", id));\n }\n }\n }\n\n return { values, errors };\n};\n\n/**\n * Search YouTube for videos matching the given options.\n *\n * @param rest - REST manager for API requests\n * @param options - search options (query, status, limit, cursor)\n * @returns paginated list of Content items\n * @precondition options.query or options.status should be provided for meaningful results\n * @postcondition returns Page with items mapped from YouTube video resources\n * @idempotency Safe — read-only API calls\n */\nexport const youtubeSearch = async (\n rest: RestManager,\n options: SearchOptions,\n): Promise<Page<Content>> => {\n const query: Record<string, string> = {\n part: \"id\",\n type: \"video\",\n };\n\n if (options.query) query.q = options.query;\n if (options.channelId) query.channelId = options.channelId;\n if (options.order) query.order = options.order;\n if (options.limit) query.maxResults = String(Math.min(options.limit, 50));\n if (options.cursor) query.pageToken = options.cursor;\n if (options.safeSearch) query.safeSearch = options.safeSearch;\n if (options.languageCode) query.relevanceLanguage = options.languageCode;\n\n if (options.status) {\n const eventTypeMap = {\n live: \"live\",\n upcoming: \"upcoming\",\n ended: \"completed\",\n } as const;\n query.eventType = eventTypeMap[options.status];\n }\n\n const searchRes = await rest.request<YTListResponse<Schemas[\"SearchResult\"]>>({\n method: \"GET\",\n path: \"/search\",\n query,\n bucketId: \"search:list\",\n });\n\n if (!searchRes.data.items || searchRes.data.items.length === 0) {\n return Page.empty<Content>();\n }\n\n const videoIds = searchRes.data.items\n .map((item) => item.id?.videoId)\n .filter(Boolean)\n .join(\",\");\n\n if (!videoIds) return Page.empty<Content>();\n\n const videosRes = await rest.request<YTListResponse<YTVideoResource>>({\n method: \"GET\",\n path: \"/videos\",\n query: {\n part: YOUTUBE_VIDEO_PARTS,\n id: videoIds,\n },\n bucketId: \"videos:list\",\n });\n\n const items = (videosRes.data.items ?? []).map(toContent);\n\n return {\n items,\n cursor: searchRes.data.nextPageToken,\n total: searchRes.data.pageInfo?.totalResults,\n hasMore: searchRes.data.nextPageToken !== undefined,\n };\n};\n","import type { RateLimitStrategy } from \"@unified-live/core\";\nimport { createQuotaBudgetStrategy } from \"@unified-live/core\";\n\n/** YouTube Data API v3 quota cost map. */\nexport const YOUTUBE_COST_MAP: Record<string, number> = {\n \"videos:list\": 1,\n \"channels:list\": 1,\n \"playlists:list\": 1,\n \"playlistItems:list\": 1,\n \"liveBroadcasts:list\": 1,\n \"liveStreams:list\": 1,\n \"search:list\": 100,\n};\n\n/**\n * Creates a QuotaBudgetStrategy configured for YouTube.\n *\n * @param dailyLimit - optional daily quota cap (defaults to 10,000)\n * @returns rate limit strategy tracking YouTube quota\n * @precondition dailyLimit > 0\n * @postcondition returns a strategy that tracks YouTube quota consumption\n */\nexport const createYouTubeQuotaStrategy = (dailyLimit?: number): RateLimitStrategy => {\n return createQuotaBudgetStrategy({\n dailyLimit: dailyLimit ?? 10_000,\n costMap: YOUTUBE_COST_MAP,\n defaultCost: 1,\n platform: \"youtube\",\n });\n};\n","import type { ResolvedUrl } from \"@unified-live/core\";\n\nconst CONTENT_PATTERNS = [\n // youtube.com/watch?v=<id>\n /^https?:\\/\\/(?:www\\.)?youtube\\.com\\/watch\\?.*v=([a-zA-Z0-9_-]{11})/,\n // youtu.be/<id>\n /^https?:\\/\\/youtu\\.be\\/([a-zA-Z0-9_-]{11})/,\n // youtube.com/live/<id>\n /^https?:\\/\\/(?:www\\.)?youtube\\.com\\/live\\/([a-zA-Z0-9_-]{11})/,\n];\n\nconst CHANNEL_PATTERNS = [\n // youtube.com/channel/<id>\n {\n pattern: /^https?:\\/\\/(?:www\\.)?youtube\\.com\\/channel\\/(UC[a-zA-Z0-9_-]{22})/,\n type: \"id\" as const,\n },\n // youtube.com/@<handle>\n {\n pattern: /^https?:\\/\\/(?:www\\.)?youtube\\.com\\/@([a-zA-Z0-9_.-]+)/,\n type: \"handle\" as const,\n },\n // youtube.com/c/<name>\n {\n pattern: /^https?:\\/\\/(?:www\\.)?youtube\\.com\\/c\\/([a-zA-Z0-9_.-]+)/,\n type: \"custom\" as const,\n },\n];\n\n/**\n * Match a URL to a YouTube content or channel resource.\n *\n * @param url - URL string to match\n * @returns resolved YouTube URL or null if not a YouTube URL\n * @precondition url is a valid URL string\n * @postcondition returns ResolvedUrl for YouTube URLs, null otherwise\n * @idempotency Safe — no side effects\n */\nexport const matchYouTubeUrl = (url: string): ResolvedUrl | null => {\n for (const pattern of CONTENT_PATTERNS) {\n const match = url.match(pattern);\n if (match?.[1]) {\n return { platform: \"youtube\", type: \"content\", id: match[1] };\n }\n }\n\n for (const { pattern } of CHANNEL_PATTERNS) {\n const match = url.match(pattern);\n if (match?.[1]) {\n return { platform: \"youtube\", type: \"channel\", id: match[1] };\n }\n }\n\n return null;\n};\n","import {\n parseRetryAfter,\n PlatformPlugin,\n QuotaExhaustedError,\n ValidationError,\n} from \"@unified-live/core\";\nimport {\n youtubeBatchGetChannels,\n youtubeBatchGetContents,\n youtubeGetChannel,\n youtubeGetContent,\n youtubeListArchives,\n youtubeListBroadcasts,\n youtubeResolveArchive,\n youtubeSearch,\n} from \"./methods\";\nimport { createYouTubeQuotaStrategy } from \"./quota\";\nimport { matchYouTubeUrl } from \"./urls\";\n\nconst YOUTUBE_BASE_URL = \"https://www.googleapis.com/youtube/v3\";\n\nexport type YouTubePluginConfig = {\n apiKey: string;\n quota?: {\n dailyLimit?: number;\n };\n /** Override fetch for testing. */\n fetch?: typeof globalThis.fetch;\n};\n\n/**\n * Creates a YouTube platform plugin.\n *\n * @param config - YouTube plugin configuration including API key\n * @returns configured PlatformPlugin for YouTube\n * @throws {ValidationError} if apiKey is empty/whitespace\n * @precondition config.apiKey is a valid YouTube Data API v3 key\n * @postcondition returns a PlatformPlugin that handles YouTube URLs and API calls\n * @idempotency Not idempotent — each call creates a new plugin instance\n */\nexport const createYouTubePlugin = (config: YouTubePluginConfig): PlatformPlugin => {\n if (!config.apiKey?.trim()) {\n throw new ValidationError(\"VALIDATION_INVALID_INPUT\", \"YouTube API key is required\", {\n platform: \"youtube\",\n });\n }\n\n const quotaStrategy = createYouTubeQuotaStrategy(config.quota?.dailyLimit);\n\n return PlatformPlugin.create(\n {\n name: \"youtube\",\n baseUrl: YOUTUBE_BASE_URL,\n rateLimitStrategy: quotaStrategy,\n matchUrl: matchYouTubeUrl,\n fetch: config.fetch,\n capabilities: {\n supportsBroadcasts: true,\n supportsArchiveResolution: true,\n authModel: \"apiKey\",\n rateLimitModel: \"quota\",\n supportsBatchContent: true,\n supportsBatchBroadcasts: false,\n supportsSearch: true,\n supportsClips: false,\n },\n transformRequest: (req) => ({\n ...req,\n query: { ...req.query, key: config.apiKey },\n }),\n handleRateLimit: async (response, _req, _attempt) => {\n if (response.status === 403) {\n const body = (await response\n .clone()\n .json()\n .catch(() => null)) as {\n error?: { errors?: Array<{ reason?: string }> };\n } | null;\n const reason = body?.error?.errors?.[0]?.reason;\n\n if (reason === \"quotaExceeded\" || reason === \"dailyLimitExceeded\") {\n const status = quotaStrategy.getStatus();\n throw new QuotaExhaustedError(\"youtube\", {\n consumed: status.limit - status.remaining,\n limit: status.limit,\n resetsAt: status.resetsAt,\n requestedCost: 0,\n });\n }\n\n if (reason === \"rateLimitExceeded\") {\n const retryAfter = parseRetryAfter(response.headers.get(\"Retry-After\"), 5);\n await new Promise((r) => setTimeout(r, retryAfter * 1000));\n return true;\n }\n }\n\n if (response.status === 429) {\n const retryAfter = parseRetryAfter(response.headers.get(\"Retry-After\"), 1);\n await new Promise((r) => setTimeout(r, retryAfter * 1000));\n return true;\n }\n\n return false;\n },\n },\n {\n getContent: youtubeGetContent,\n getChannel: youtubeGetChannel,\n listBroadcasts: youtubeListBroadcasts,\n listArchives: youtubeListArchives,\n resolveArchive: youtubeResolveArchive,\n batchGetContents: youtubeBatchGetContents,\n batchGetChannels: youtubeBatchGetChannels,\n search: youtubeSearch,\n },\n );\n};\n"],"mappings":"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"}