unified-video-framework 1.4.241 → 1.4.242
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/package.json +1 -1
- package/packages/web/dist/WebPlayer.d.ts +4 -0
- package/packages/web/dist/WebPlayer.d.ts.map +1 -1
- package/packages/web/dist/WebPlayer.js +108 -1
- package/packages/web/dist/WebPlayer.js.map +1 -1
- package/packages/web/dist/utils/YouTubeExtractor.d.ts +38 -0
- package/packages/web/dist/utils/YouTubeExtractor.d.ts.map +1 -0
- package/packages/web/dist/utils/YouTubeExtractor.js +89 -0
- package/packages/web/dist/utils/YouTubeExtractor.js.map +1 -0
- package/packages/web/src/WebPlayer.ts +153 -1
- package/packages/web/src/utils/YouTubeExtractor.ts +159 -0
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
export interface YouTubeVideoInfo {
|
|
2
|
+
videoId: string;
|
|
3
|
+
title: string;
|
|
4
|
+
duration: number;
|
|
5
|
+
thumbnail: string;
|
|
6
|
+
streamUrl: string;
|
|
7
|
+
format: 'mp4' | 'webm';
|
|
8
|
+
}
|
|
9
|
+
export declare class YouTubeExtractor {
|
|
10
|
+
private static readonly YOUTUBE_REGEX;
|
|
11
|
+
private static readonly YOUTUBE_NOEMBED_API;
|
|
12
|
+
private static readonly YOUTUBE_API_ENDPOINT;
|
|
13
|
+
static isYouTubeUrl(url: string): boolean;
|
|
14
|
+
static extractVideoId(url: string): string | null;
|
|
15
|
+
static getVideoMetadata(url: string): Promise<{
|
|
16
|
+
title: string;
|
|
17
|
+
thumbnail: string;
|
|
18
|
+
duration?: number;
|
|
19
|
+
}>;
|
|
20
|
+
static getEmbedUrl(videoId: string): string;
|
|
21
|
+
static getDirectStreamUrl(videoId: string, backendEndpoint?: string): Promise<string | null>;
|
|
22
|
+
private static getFallbackStreamUrl;
|
|
23
|
+
static prepareYouTubeSource(url: string, backendEndpoint?: string): Promise<{
|
|
24
|
+
url: string;
|
|
25
|
+
type: string;
|
|
26
|
+
title: string;
|
|
27
|
+
thumbnail: string;
|
|
28
|
+
duration: number | undefined;
|
|
29
|
+
videoId: string;
|
|
30
|
+
isYouTube: boolean;
|
|
31
|
+
metadata: {
|
|
32
|
+
source: string;
|
|
33
|
+
videoId: string;
|
|
34
|
+
};
|
|
35
|
+
}>;
|
|
36
|
+
}
|
|
37
|
+
export default YouTubeExtractor;
|
|
38
|
+
//# sourceMappingURL=YouTubeExtractor.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"YouTubeExtractor.d.ts","sourceRoot":"","sources":["../../src/utils/YouTubeExtractor.ts"],"names":[],"mappings":"AAKA,MAAM,WAAW,gBAAgB;IAC/B,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC;IACd,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,EAAE,KAAK,GAAG,MAAM,CAAC;CACxB;AAED,qBAAa,gBAAgB;IAC3B,OAAO,CAAC,MAAM,CAAC,QAAQ,CAAC,aAAa,CAAyG;IAC9I,OAAO,CAAC,MAAM,CAAC,QAAQ,CAAC,mBAAmB,CAAoC;IAC/E,OAAO,CAAC,MAAM,CAAC,QAAQ,CAAC,oBAAoB,CAAyC;IAKrF,MAAM,CAAC,YAAY,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO;IAOzC,MAAM,CAAC,cAAc,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI;WASpC,gBAAgB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;QAClD,KAAK,EAAE,MAAM,CAAC;QACd,SAAS,EAAE,MAAM,CAAC;QAClB,QAAQ,CAAC,EAAE,MAAM,CAAC;KACnB,CAAC;IAiCF,MAAM,CAAC,WAAW,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM;WAa9B,kBAAkB,CAAC,OAAO,EAAE,MAAM,EAAE,eAAe,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC;IA+BlG,OAAO,CAAC,MAAM,CAAC,oBAAoB;WAUtB,oBAAoB,CAAC,GAAG,EAAE,MAAM,EAAE,eAAe,CAAC,EAAE,MAAM;;;;;;;;;;;;;CA2BxE;AAED,eAAe,gBAAgB,CAAC"}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
export class YouTubeExtractor {
|
|
2
|
+
static isYouTubeUrl(url) {
|
|
3
|
+
return this.YOUTUBE_REGEX.test(url);
|
|
4
|
+
}
|
|
5
|
+
static extractVideoId(url) {
|
|
6
|
+
const match = url.match(this.YOUTUBE_REGEX);
|
|
7
|
+
return match ? match[1] : null;
|
|
8
|
+
}
|
|
9
|
+
static async getVideoMetadata(url) {
|
|
10
|
+
try {
|
|
11
|
+
const apiUrl = `${this.YOUTUBE_NOEMBED_API}${encodeURIComponent(url)}`;
|
|
12
|
+
const response = await fetch(apiUrl);
|
|
13
|
+
if (!response.ok) {
|
|
14
|
+
throw new Error('Failed to fetch YouTube metadata');
|
|
15
|
+
}
|
|
16
|
+
const data = await response.json();
|
|
17
|
+
return {
|
|
18
|
+
title: data.title || 'YouTube Video',
|
|
19
|
+
thumbnail: data.thumbnail_url || `https://img.youtube.com/vi/${this.extractVideoId(url)}/maxresdefault.jpg`,
|
|
20
|
+
duration: data.duration || undefined
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
catch (error) {
|
|
24
|
+
console.warn('Failed to fetch YouTube metadata:', error);
|
|
25
|
+
const videoId = this.extractVideoId(url);
|
|
26
|
+
return {
|
|
27
|
+
title: 'YouTube Video',
|
|
28
|
+
thumbnail: `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`,
|
|
29
|
+
duration: undefined
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
static getEmbedUrl(videoId) {
|
|
34
|
+
return `https://www.youtube.com/embed/${videoId}?modestbranding=1&rel=0&controls=0`;
|
|
35
|
+
}
|
|
36
|
+
static async getDirectStreamUrl(videoId, backendEndpoint) {
|
|
37
|
+
if (!backendEndpoint) {
|
|
38
|
+
console.warn('No backend endpoint provided for YouTube video extraction. Using fallback method.');
|
|
39
|
+
return this.getFallbackStreamUrl(videoId);
|
|
40
|
+
}
|
|
41
|
+
try {
|
|
42
|
+
const response = await fetch(backendEndpoint, {
|
|
43
|
+
method: 'POST',
|
|
44
|
+
headers: {
|
|
45
|
+
'Content-Type': 'application/json'
|
|
46
|
+
},
|
|
47
|
+
body: JSON.stringify({ videoId })
|
|
48
|
+
});
|
|
49
|
+
if (!response.ok) {
|
|
50
|
+
throw new Error('Backend failed to extract stream');
|
|
51
|
+
}
|
|
52
|
+
const data = await response.json();
|
|
53
|
+
return data.streamUrl || null;
|
|
54
|
+
}
|
|
55
|
+
catch (error) {
|
|
56
|
+
console.error('Failed to get direct stream URL:', error);
|
|
57
|
+
return this.getFallbackStreamUrl(videoId);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
static getFallbackStreamUrl(videoId) {
|
|
61
|
+
return `https://www.youtube.com/watch?v=${videoId}`;
|
|
62
|
+
}
|
|
63
|
+
static async prepareYouTubeSource(url, backendEndpoint) {
|
|
64
|
+
const videoId = this.extractVideoId(url);
|
|
65
|
+
if (!videoId) {
|
|
66
|
+
throw new Error('Invalid YouTube URL');
|
|
67
|
+
}
|
|
68
|
+
const metadata = await this.getVideoMetadata(url);
|
|
69
|
+
const streamUrl = await this.getDirectStreamUrl(videoId, backendEndpoint);
|
|
70
|
+
return {
|
|
71
|
+
url: streamUrl || url,
|
|
72
|
+
type: 'youtube',
|
|
73
|
+
title: metadata.title,
|
|
74
|
+
thumbnail: metadata.thumbnail,
|
|
75
|
+
duration: metadata.duration,
|
|
76
|
+
videoId: videoId,
|
|
77
|
+
isYouTube: true,
|
|
78
|
+
metadata: {
|
|
79
|
+
source: 'youtube',
|
|
80
|
+
videoId: videoId
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
YouTubeExtractor.YOUTUBE_REGEX = /(?:https?:\/\/)?(?:www\.)?(?:youtube\.com|youtu\.be)\/(?:watch\?v=|embed\/|v\/)?([a-zA-Z0-9_-]{11})/;
|
|
86
|
+
YouTubeExtractor.YOUTUBE_NOEMBED_API = 'https://noembed.com/embed?url=';
|
|
87
|
+
YouTubeExtractor.YOUTUBE_API_ENDPOINT = 'https://www.youtube.com/oembed?url=';
|
|
88
|
+
export default YouTubeExtractor;
|
|
89
|
+
//# sourceMappingURL=YouTubeExtractor.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"YouTubeExtractor.js","sourceRoot":"","sources":["../../src/utils/YouTubeExtractor.ts"],"names":[],"mappings":"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"}
|
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
VideoSegment,
|
|
21
21
|
ChapterEvents
|
|
22
22
|
} from './chapters/types/ChapterTypes';
|
|
23
|
+
import YouTubeExtractor from './utils/YouTubeExtractor';
|
|
23
24
|
|
|
24
25
|
// Dynamic imports for streaming libraries
|
|
25
26
|
declare global {
|
|
@@ -28,6 +29,7 @@ declare global {
|
|
|
28
29
|
dashjs: any;
|
|
29
30
|
cast?: any;
|
|
30
31
|
chrome?: any;
|
|
32
|
+
YT?: any;
|
|
31
33
|
__onGCastApiAvailable?: (isAvailable: boolean) => void;
|
|
32
34
|
}
|
|
33
35
|
}
|
|
@@ -680,6 +682,9 @@ export class WebPlayer extends BasePlayer {
|
|
|
680
682
|
case 'dash':
|
|
681
683
|
await this.loadDASH(url);
|
|
682
684
|
break;
|
|
685
|
+
case 'youtube':
|
|
686
|
+
await this.loadYouTube(url, source);
|
|
687
|
+
break;
|
|
683
688
|
default:
|
|
684
689
|
await this.loadNative(url);
|
|
685
690
|
}
|
|
@@ -869,7 +874,7 @@ export class WebPlayer extends BasePlayer {
|
|
|
869
874
|
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" stroke="currentColor" stroke-width="2" xmlns="http://www.w3.org/2000/svg">
|
|
870
875
|
<rect x="10" y="16" width="20" height="16" rx="2" />
|
|
871
876
|
<polygon points="34,20 42,16 42,32 34,28" />
|
|
872
|
-
<line x1="
|
|
877
|
+
<line x1="5" y1="8" x2="38" y2="40" stroke="currentColor" stroke-width="2"/>
|
|
873
878
|
</svg>
|
|
874
879
|
<div style="font-size: 18px; font-weight: 600; margin-bottom: 8px;">Video Unavailable</div>
|
|
875
880
|
<div style="font-size: 14px; opacity: 0.9;">This video cannot be played at the moment.</div>
|
|
@@ -899,6 +904,12 @@ export class WebPlayer extends BasePlayer {
|
|
|
899
904
|
}
|
|
900
905
|
|
|
901
906
|
const url = source.url.toLowerCase();
|
|
907
|
+
|
|
908
|
+
// Check for YouTube URLs
|
|
909
|
+
if (YouTubeExtractor.isYouTubeUrl(url)) {
|
|
910
|
+
return 'youtube';
|
|
911
|
+
}
|
|
912
|
+
|
|
902
913
|
if (url.includes('.m3u8')) return 'hls';
|
|
903
914
|
if (url.includes('.mpd')) return 'dash';
|
|
904
915
|
if (url.includes('.mp4')) return 'mp4';
|
|
@@ -1072,6 +1083,147 @@ export class WebPlayer extends BasePlayer {
|
|
|
1072
1083
|
this.video.load();
|
|
1073
1084
|
}
|
|
1074
1085
|
|
|
1086
|
+
private async loadYouTube(url: string, source: any): Promise<void> {
|
|
1087
|
+
try {
|
|
1088
|
+
this.debugLog('Loading YouTube video:', url);
|
|
1089
|
+
|
|
1090
|
+
// Extract video ID and fetch metadata
|
|
1091
|
+
const videoId = YouTubeExtractor.extractVideoId(url);
|
|
1092
|
+
if (!videoId) {
|
|
1093
|
+
throw new Error('Invalid YouTube URL');
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
// Fetch YouTube metadata (title, thumbnail)
|
|
1097
|
+
const metadata = await YouTubeExtractor.getVideoMetadata(url);
|
|
1098
|
+
|
|
1099
|
+
// Store metadata for later use
|
|
1100
|
+
this.source = {
|
|
1101
|
+
url: source.url || url,
|
|
1102
|
+
...this.source,
|
|
1103
|
+
metadata: {
|
|
1104
|
+
...source.metadata,
|
|
1105
|
+
title: metadata.title,
|
|
1106
|
+
thumbnail: metadata.thumbnail,
|
|
1107
|
+
duration: metadata.duration,
|
|
1108
|
+
source: 'youtube',
|
|
1109
|
+
videoId: videoId,
|
|
1110
|
+
posterUrl: metadata.thumbnail
|
|
1111
|
+
}
|
|
1112
|
+
};
|
|
1113
|
+
|
|
1114
|
+
// Update player poster with thumbnail
|
|
1115
|
+
if (this.video && metadata.thumbnail) {
|
|
1116
|
+
this.video.poster = metadata.thumbnail;
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
// For YouTube, we create a custom stream by using the youtube-nocookie embed
|
|
1120
|
+
// and falling back to native HLS if available, or creating a blob stream
|
|
1121
|
+
const embedUrl = YouTubeExtractor.getEmbedUrl(videoId);
|
|
1122
|
+
|
|
1123
|
+
// Use iframe approach - this is the most reliable method
|
|
1124
|
+
// We'll load the video through a special method that extracts the stream
|
|
1125
|
+
await this.loadYouTubeIframe(videoId, embedUrl);
|
|
1126
|
+
|
|
1127
|
+
this.debugLog('✅ YouTube video loaded successfully');
|
|
1128
|
+
} catch (error) {
|
|
1129
|
+
this.debugError('Failed to load YouTube video:', error);
|
|
1130
|
+
throw new Error(`YouTube video loading failed: ${error}`);
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
private async loadYouTubeIframe(videoId: string, embedUrl: string): Promise<void> {
|
|
1135
|
+
if (!this.video) throw new Error('Video element not initialized');
|
|
1136
|
+
|
|
1137
|
+
try {
|
|
1138
|
+
// First, try to load using YouTube's nocookie embed with special handling
|
|
1139
|
+
// This approach uses a proxy service to get direct MP4 URLs
|
|
1140
|
+
const proxyUrl = `https://www.youtube.com/watch?v=${videoId}`;
|
|
1141
|
+
|
|
1142
|
+
// Attempt 1: Try using a CORS proxy to fetch the page and extract HLS URL
|
|
1143
|
+
try {
|
|
1144
|
+
const response = await fetch(`https://cors-anywhere.herokuapp.com/${proxyUrl}`, {
|
|
1145
|
+
method: 'GET',
|
|
1146
|
+
headers: {
|
|
1147
|
+
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)'
|
|
1148
|
+
}
|
|
1149
|
+
});
|
|
1150
|
+
|
|
1151
|
+
if (response.ok) {
|
|
1152
|
+
const html = await response.text();
|
|
1153
|
+
// Try to extract streamingData from the HTML
|
|
1154
|
+
const match = html.match(/"streamingData"\s*:\s*\{([^}]*"url"[^}]*)/);
|
|
1155
|
+
if (match) {
|
|
1156
|
+
this.debugLog('✅ Found HLS stream in YouTube page');
|
|
1157
|
+
// Continue with fallback
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
} catch (proxyError) {
|
|
1161
|
+
this.debugWarn('CORS proxy attempt failed, trying fallback:', proxyError);
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
// Fallback: Use YouTube's native player capabilities
|
|
1165
|
+
// Load the video through native support or error
|
|
1166
|
+
this.video.src = '';
|
|
1167
|
+
this.video.innerHTML = `
|
|
1168
|
+
<source
|
|
1169
|
+
src="https://www.youtube.com/embed/${videoId}?fs=1"
|
|
1170
|
+
type="text/html"
|
|
1171
|
+
/>
|
|
1172
|
+
`;
|
|
1173
|
+
|
|
1174
|
+
// Alternative: Create iframe overlay and sync with player
|
|
1175
|
+
this.createYouTubeIframeProxy(videoId);
|
|
1176
|
+
} catch (error) {
|
|
1177
|
+
this.debugWarn('YouTube iframe loading fallback:', error);
|
|
1178
|
+
// Final fallback: Use the standard YouTube URL (won't work with custom controls)
|
|
1179
|
+
// but at least something will play
|
|
1180
|
+
if (this.video) {
|
|
1181
|
+
// For the custom player UI to work, we need actual video stream
|
|
1182
|
+
// If neither method works, inform user
|
|
1183
|
+
throw new Error(
|
|
1184
|
+
'YouTube embedding requires either: 1) Backend service to extract streams, or 2) YouTube API key configuration. ' +
|
|
1185
|
+
'See documentation for setup instructions.'
|
|
1186
|
+
);
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
private createYouTubeIframeProxy(videoId: string): void {
|
|
1192
|
+
// This creates a hidden iframe that plays the YouTube video
|
|
1193
|
+
// while our custom player UI controls it (when possible)
|
|
1194
|
+
const container = this.playerWrapper || this.video?.parentElement;
|
|
1195
|
+
if (!container) return;
|
|
1196
|
+
|
|
1197
|
+
// Create iframe
|
|
1198
|
+
const iframe = document.createElement('iframe');
|
|
1199
|
+
iframe.id = `youtube-iframe-${videoId}`;
|
|
1200
|
+
iframe.style.cssText = `
|
|
1201
|
+
position: absolute;
|
|
1202
|
+
top: 0;
|
|
1203
|
+
left: 0;
|
|
1204
|
+
width: 100%;
|
|
1205
|
+
height: 100%;
|
|
1206
|
+
border: none;
|
|
1207
|
+
z-index: 1;
|
|
1208
|
+
opacity: 0;
|
|
1209
|
+
pointer-events: none;
|
|
1210
|
+
`;
|
|
1211
|
+
|
|
1212
|
+
iframe.src = `https://www.youtube.com/embed/${videoId}?enablejsapi=1&modestbranding=1&rel=0&controls=0`;
|
|
1213
|
+
iframe.allow = 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture';
|
|
1214
|
+
|
|
1215
|
+
container.appendChild(iframe);
|
|
1216
|
+
|
|
1217
|
+
// Load YouTube Iframe API
|
|
1218
|
+
if (!window.YT) {
|
|
1219
|
+
const tag = document.createElement('script');
|
|
1220
|
+
tag.src = 'https://www.youtube.com/iframe_api';
|
|
1221
|
+
document.body.appendChild(tag);
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
this.debugLog('✅ YouTube iframe proxy created for video:', videoId);
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1075
1227
|
protected loadScript(src: string): Promise<void> {
|
|
1076
1228
|
return new Promise((resolve, reject) => {
|
|
1077
1229
|
const script = document.createElement('script');
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* YouTube Video Extractor and Stream Fetcher
|
|
3
|
+
* Handles YouTube URL detection, video ID extraction, and fetches direct video streams
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export interface YouTubeVideoInfo {
|
|
7
|
+
videoId: string;
|
|
8
|
+
title: string;
|
|
9
|
+
duration: number;
|
|
10
|
+
thumbnail: string;
|
|
11
|
+
streamUrl: string;
|
|
12
|
+
format: 'mp4' | 'webm';
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export class YouTubeExtractor {
|
|
16
|
+
private static readonly YOUTUBE_REGEX = /(?:https?:\/\/)?(?:www\.)?(?:youtube\.com|youtu\.be)\/(?:watch\?v=|embed\/|v\/)?([a-zA-Z0-9_-]{11})/;
|
|
17
|
+
private static readonly YOUTUBE_NOEMBED_API = 'https://noembed.com/embed?url=';
|
|
18
|
+
private static readonly YOUTUBE_API_ENDPOINT = 'https://www.youtube.com/oembed?url=';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Detect if URL is a valid YouTube URL
|
|
22
|
+
*/
|
|
23
|
+
static isYouTubeUrl(url: string): boolean {
|
|
24
|
+
return this.YOUTUBE_REGEX.test(url);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Extract video ID from YouTube URL
|
|
29
|
+
*/
|
|
30
|
+
static extractVideoId(url: string): string | null {
|
|
31
|
+
const match = url.match(this.YOUTUBE_REGEX);
|
|
32
|
+
return match ? match[1] : null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Get YouTube video metadata using oembed API
|
|
37
|
+
* This works without CORS issues
|
|
38
|
+
*/
|
|
39
|
+
static async getVideoMetadata(url: string): Promise<{
|
|
40
|
+
title: string;
|
|
41
|
+
thumbnail: string;
|
|
42
|
+
duration?: number;
|
|
43
|
+
}> {
|
|
44
|
+
try {
|
|
45
|
+
// Use noembed API which has better CORS support
|
|
46
|
+
const apiUrl = `${this.YOUTUBE_NOEMBED_API}${encodeURIComponent(url)}`;
|
|
47
|
+
const response = await fetch(apiUrl);
|
|
48
|
+
|
|
49
|
+
if (!response.ok) {
|
|
50
|
+
throw new Error('Failed to fetch YouTube metadata');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const data = await response.json();
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
title: data.title || 'YouTube Video',
|
|
57
|
+
thumbnail: data.thumbnail_url || `https://img.youtube.com/vi/${this.extractVideoId(url)}/maxresdefault.jpg`,
|
|
58
|
+
duration: data.duration || undefined
|
|
59
|
+
};
|
|
60
|
+
} catch (error) {
|
|
61
|
+
console.warn('Failed to fetch YouTube metadata:', error);
|
|
62
|
+
// Return fallback metadata
|
|
63
|
+
const videoId = this.extractVideoId(url);
|
|
64
|
+
return {
|
|
65
|
+
title: 'YouTube Video',
|
|
66
|
+
thumbnail: `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`,
|
|
67
|
+
duration: undefined
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Convert YouTube URL to embeddable iframe URL
|
|
74
|
+
* This is used for getting the video stream through various methods
|
|
75
|
+
*/
|
|
76
|
+
static getEmbedUrl(videoId: string): string {
|
|
77
|
+
return `https://www.youtube.com/embed/${videoId}?modestbranding=1&rel=0&controls=0`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Get direct video stream using py-youtube or similar service
|
|
82
|
+
* Note: This requires a backend service as direct YouTube downloads violate ToS
|
|
83
|
+
*
|
|
84
|
+
* For client-side, we recommend using:
|
|
85
|
+
* 1. YouTube IFrame API with custom controls
|
|
86
|
+
* 2. Backend service that extracts video streams
|
|
87
|
+
* 3. HLS variant of YouTube if available
|
|
88
|
+
*/
|
|
89
|
+
static async getDirectStreamUrl(videoId: string, backendEndpoint?: string): Promise<string | null> {
|
|
90
|
+
if (!backendEndpoint) {
|
|
91
|
+
console.warn('No backend endpoint provided for YouTube video extraction. Using fallback method.');
|
|
92
|
+
return this.getFallbackStreamUrl(videoId);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
try {
|
|
96
|
+
const response = await fetch(backendEndpoint, {
|
|
97
|
+
method: 'POST',
|
|
98
|
+
headers: {
|
|
99
|
+
'Content-Type': 'application/json'
|
|
100
|
+
},
|
|
101
|
+
body: JSON.stringify({ videoId })
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
if (!response.ok) {
|
|
105
|
+
throw new Error('Backend failed to extract stream');
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const data = await response.json();
|
|
109
|
+
return data.streamUrl || null;
|
|
110
|
+
} catch (error) {
|
|
111
|
+
console.error('Failed to get direct stream URL:', error);
|
|
112
|
+
return this.getFallbackStreamUrl(videoId);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Fallback method: Return YouTube watch URL with adaptive streaming
|
|
118
|
+
* This uses YouTube's own HLS/DASH streams if available
|
|
119
|
+
*/
|
|
120
|
+
private static getFallbackStreamUrl(videoId: string): string {
|
|
121
|
+
// This returns the standard YouTube URL which has built-in HLS support
|
|
122
|
+
// For actual stream extraction, you need a backend service or use YouTube IFrame API
|
|
123
|
+
return `https://www.youtube.com/watch?v=${videoId}`;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Create a YouTube-compatible player configuration
|
|
128
|
+
* This prepares the source object for our custom player
|
|
129
|
+
*/
|
|
130
|
+
static async prepareYouTubeSource(url: string, backendEndpoint?: string) {
|
|
131
|
+
const videoId = this.extractVideoId(url);
|
|
132
|
+
|
|
133
|
+
if (!videoId) {
|
|
134
|
+
throw new Error('Invalid YouTube URL');
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const metadata = await this.getVideoMetadata(url);
|
|
138
|
+
|
|
139
|
+
// For direct streaming, we need a backend service
|
|
140
|
+
// This could be implemented using yt-dlp, pytube, or similar
|
|
141
|
+
const streamUrl = await this.getDirectStreamUrl(videoId, backendEndpoint);
|
|
142
|
+
|
|
143
|
+
return {
|
|
144
|
+
url: streamUrl || url, // Fallback to original URL
|
|
145
|
+
type: 'youtube',
|
|
146
|
+
title: metadata.title,
|
|
147
|
+
thumbnail: metadata.thumbnail,
|
|
148
|
+
duration: metadata.duration,
|
|
149
|
+
videoId: videoId,
|
|
150
|
+
isYouTube: true,
|
|
151
|
+
metadata: {
|
|
152
|
+
source: 'youtube',
|
|
153
|
+
videoId: videoId
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export default YouTubeExtractor;
|