getraw 0.2.0 → 0.3.0

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.
@@ -41,3 +41,4 @@ jobs:
41
41
  --generate-notes
42
42
  env:
43
43
  GH_TOKEN: ${{ github.token }}
44
+
package/bun.lock CHANGED
@@ -5,8 +5,11 @@
5
5
  "": {
6
6
  "name": "dlpx",
7
7
  "dependencies": {
8
+ "googlevideo": "^4.0.4",
8
9
  "hls-parser": "^0.13.6",
10
+ "meriyah": "^6.0.7",
9
11
  "mpd-parser": "^1.3.0",
12
+ "youtubei.js": "^17.0.1",
10
13
  },
11
14
  "devDependencies": {
12
15
  "@types/bun": "latest",
@@ -19,6 +22,8 @@
19
22
  "packages": {
20
23
  "@babel/runtime": ["@babel/runtime@7.29.7", "", {}, "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw=="],
21
24
 
25
+ "@bufbuild/protobuf": ["@bufbuild/protobuf@2.12.0", "", {}, "sha512-B/XlCaFIP8LOwzo+bz5uFzATYokcwCKQcghqnlfwSmM5eX/qTkvDBnDPs+gXtX/RyjxJ4DRikECcPJbyALA8FA=="],
26
+
22
27
  "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="],
23
28
 
24
29
  "@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="],
@@ -33,8 +38,12 @@
33
38
 
34
39
  "global": ["global@4.4.0", "", { "dependencies": { "min-document": "^2.19.0", "process": "^0.11.10" } }, "sha512-wv/LAoHdRE3BeTGz53FAamhGlPLhlssK45usmGFThIi4XqnBmjKQ16u+RNbP7WvigRZDxUsM0J3gcQ5yicaL0w=="],
35
40
 
41
+ "googlevideo": ["googlevideo@4.0.4", "", { "dependencies": { "@bufbuild/protobuf": "^2.0.0" } }, "sha512-S/rfuoPBI+qXCEUPJeVhXsHoISMgVhOz8hHSpGWa0OztfHhh+g9EKaEcqAb/+ttO7meoNQNqIy9dfIpz7HPc4g=="],
42
+
36
43
  "hls-parser": ["hls-parser@0.13.6", "", {}, "sha512-I40sl22E2muqeSTpG8kMN2dAegAhubkXPXtnsUXFwdKwZK47d1Q+XwuX32VMZ++AZU5oeQIZqAnGNHxSG1sWaw=="],
37
44
 
45
+ "meriyah": ["meriyah@6.1.4", "", {}, "sha512-Sz8FzjzI0kN13GK/6MVEsVzMZEPvOhnmmI1lU5+/1cGOiK3QUahntrNNtdVeihrO7t9JpoH75iMNXg6R6uWflQ=="],
46
+
38
47
  "min-document": ["min-document@2.19.2", "", { "dependencies": { "dom-walk": "^0.1.0" } }, "sha512-8S5I8db/uZN8r9HSLFVWPdJCvYOejMcEC82VIzNUc6Zkklf/d1gg2psfE79/vyhWOj4+J8MtwmoOz3TmvaGu5A=="],
39
48
 
40
49
  "mpd-parser": ["mpd-parser@1.3.1", "", { "dependencies": { "@babel/runtime": "^7.12.5", "@videojs/vhs-utils": "^4.0.0", "@xmldom/xmldom": "^0.8.3", "global": "^4.4.0" }, "bin": { "mpd-to-m3u8-json": "bin/parse.js" } }, "sha512-1FuyEWI5k2HcmhS1HkKnUAQV7yFPfXPht2DnRRGtoiiAAW+ESTbtEXIDpRkwdU+XyrQuwrIym7UkoPKsZ0SyFw=="],
@@ -46,5 +55,7 @@
46
55
  "undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="],
47
56
 
48
57
  "url-toolkit": ["url-toolkit@2.2.5", "", {}, "sha512-mtN6xk+Nac+oyJ/PrI7tzfmomRVNFIWKUbG8jdYFt52hxbiReFAXIjYskvu64/dvuW71IcB7lV8l0HvZMac6Jg=="],
58
+
59
+ "youtubei.js": ["youtubei.js@17.0.1", "", { "dependencies": { "@bufbuild/protobuf": "^2.0.0", "meriyah": "^6.1.4" } }, "sha512-1lO4b8UqMDzE0oh2qEGzbBOd4UYRdxn/4PdpRM7BGTHxM6ddsEsKZTu90jp8V9FHVgC2h1UirQyqoqLiKwl+Zg=="],
49
60
  }
50
61
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "getraw",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Fast media downloader CLI built natively in Bun/TypeScript",
5
5
  "type": "module",
6
6
  "bin": {
@@ -13,8 +13,11 @@
13
13
  "dashboard": "bun run tools/dashboard.ts"
14
14
  },
15
15
  "dependencies": {
16
+ "googlevideo": "^4.0.4",
16
17
  "hls-parser": "^0.13.6",
17
- "mpd-parser": "^1.3.0"
18
+ "meriyah": "^6.0.7",
19
+ "mpd-parser": "^1.3.0",
20
+ "youtubei.js": "^17.0.1"
18
21
  },
19
22
  "devDependencies": {
20
23
  "@types/bun": "latest"
package/src/cli/index.ts CHANGED
@@ -3,7 +3,8 @@ import { parseArgs, printHelp } from "./options";
3
3
  import { Orchestrator } from "../core/orchestrator";
4
4
  import { logger } from "../core/logger";
5
5
 
6
- const VERSION = "0.0.0";
6
+ import pkg from "../../package.json";
7
+ const VERSION = pkg.version;
7
8
 
8
9
  async function main(): Promise<void> {
9
10
  const args = process.argv.slice(2);
@@ -20,7 +20,7 @@ export const FLAG_DEFS: FlagDef[] = [
20
20
  { long: "--list-formats", short: "-F", description: "List available formats", type: "boolean", key: "listFormats" },
21
21
  { long: "--dump-json", short: "-j", description: "Dump info JSON to stdout", type: "boolean", key: "dumpJson" },
22
22
  { long: "--quiet", short: "-q", description: "Suppress output", type: "boolean", key: "quiet" },
23
- { long: "--verbose", short: "-v", description: "Verbose output", type: "boolean", key: "verbose" },
23
+ { long: "--verbose", description: "Verbose output", type: "boolean", key: "verbose" },
24
24
  { long: "--no-progress", description: "Disable progress bar", type: "boolean", key: "noProgress" },
25
25
  { long: "--retries", short: "-R", description: "Number of retries", type: "number", key: "retries" },
26
26
  { long: "--rate-limit", short: "-r", description: "Rate limit in bytes/sec", type: "number", key: "rateLimit" },
@@ -32,7 +32,7 @@ export const FLAG_DEFS: FlagDef[] = [
32
32
  { long: "--embed-subs", description: "Embed subtitles in output", type: "boolean", key: "embedSubs" },
33
33
  { long: "--merge-output-format", description: "Output container for merging", type: "string", key: "mergeOutputFormat" },
34
34
  { long: "--ffmpeg-location", description: "Path to ffmpeg binary", type: "string", key: "ffmpegLocation" },
35
- { long: "--version", short: "-V", description: "Print version", type: "boolean", key: "version" },
35
+ { long: "--version", short: "-v", description: "Print version", type: "boolean", key: "version" },
36
36
  { long: "--help", short: "-h", description: "Show help", type: "boolean", key: "help" },
37
37
  ];
38
38
 
@@ -102,7 +102,7 @@ export class Orchestrator {
102
102
  ? `${filepath}.f${format.format_id}.${format.ext}`
103
103
  : filepath;
104
104
 
105
- await downloader.download(targetPath, format.url, {
105
+ await downloader.download(format.url, targetPath, {
106
106
  headers: { ...info.http_headers, ...format.http_headers },
107
107
  rateLimit: options.rateLimit,
108
108
  retries: options.retries,
@@ -1,9 +1,78 @@
1
1
  import { BaseExtractor } from "../core/types";
2
2
  import { GenericExtractor } from "./generic";
3
+ import { YouTubeExtractor } from "./youtube/index";
4
+ import { TwitterExtractor } from "./twitter/index";
5
+ import { TwitterSpacesExtractor } from "./twitter/spaces";
6
+ import { TikTokExtractor } from "./tiktok/index";
7
+ import { TikTokUserExtractor } from "./tiktok/user";
8
+ import { InstagramExtractor } from "./instagram/index";
9
+ import { InstagramReelsExtractor } from "./instagram/reels";
10
+ import { RedditExtractor } from "./reddit/index";
11
+ import { RedditGalleryExtractor } from "./reddit/gallery";
12
+ import { TwitchVODExtractor } from "./twitch/index";
13
+ import { TwitchClipExtractor } from "./twitch/clips";
14
+ import { TwitchLiveExtractor } from "./twitch/live";
15
+ import { VimeoExtractor } from "./vimeo/index";
16
+ import { SoundCloudExtractor } from "./soundcloud/index";
17
+ import { SoundCloudPlaylistExtractor } from "./soundcloud/playlist";
18
+ import { BilibiliExtractor } from "./bilibili/index";
19
+ import { BilibiliBangumiExtractor } from "./bilibili/bangumi";
20
+ import { KickExtractor } from "./kick/index";
21
+ import { KickClipsExtractor } from "./kick/clips";
22
+ import { KickLiveExtractor } from "./kick/live";
23
+ import { NiconicoExtractor } from "./niconico/index";
24
+ import { DailymotionExtractor } from "./dailymotion";
25
+ import { RumbleExtractor } from "./rumble";
26
+ import { BandcampExtractor } from "./bandcamp";
27
+ import { SpotifyExtractor } from "./spotify";
28
+ import { PeerTubeExtractor } from "./peertube";
29
+ import { OdyseeExtractor } from "./odysee";
30
+ import { StreamableExtractor } from "./streamable";
31
+ import { ImgurExtractor } from "./imgur";
32
+ import { CoubExtractor } from "./coub";
33
+ import { TEDExtractor } from "./ted";
34
+ import { ArchiveOrgExtractor } from "./archive-org";
35
+ import { DropboxExtractor } from "./dropbox";
36
+ import { GoogleDriveExtractor } from "./google-drive";
3
37
 
4
38
  export { BaseExtractor };
5
39
 
6
- const extractors: BaseExtractor[] = [];
40
+ const extractors: BaseExtractor[] = [
41
+ new YouTubeExtractor(),
42
+ new TwitterExtractor(),
43
+ new TwitterSpacesExtractor(),
44
+ new TikTokExtractor(),
45
+ new TikTokUserExtractor(),
46
+ new InstagramExtractor(),
47
+ new InstagramReelsExtractor(),
48
+ new RedditExtractor(),
49
+ new RedditGalleryExtractor(),
50
+ new TwitchVODExtractor(),
51
+ new TwitchClipExtractor(),
52
+ new TwitchLiveExtractor(),
53
+ new VimeoExtractor(),
54
+ new SoundCloudExtractor(),
55
+ new SoundCloudPlaylistExtractor(),
56
+ new BilibiliExtractor(),
57
+ new BilibiliBangumiExtractor(),
58
+ new KickExtractor(),
59
+ new KickClipsExtractor(),
60
+ new KickLiveExtractor(),
61
+ new NiconicoExtractor(),
62
+ new DailymotionExtractor(),
63
+ new RumbleExtractor(),
64
+ new BandcampExtractor(),
65
+ new SpotifyExtractor(),
66
+ new PeerTubeExtractor(),
67
+ new OdyseeExtractor(),
68
+ new StreamableExtractor(),
69
+ new ImgurExtractor(),
70
+ new CoubExtractor(),
71
+ new TEDExtractor(),
72
+ new ArchiveOrgExtractor(),
73
+ new DropboxExtractor(),
74
+ new GoogleDriveExtractor(),
75
+ ];
7
76
  const genericExtractor = new GenericExtractor();
8
77
 
9
78
  export function registerExtractor(extractor: BaseExtractor): void {
@@ -1,17 +1,51 @@
1
1
  import { BaseExtractor, ExtractorError } from "../../core/types";
2
2
  import type { InfoDict, Format, Thumbnail } from "../../core/types";
3
- import { InnerTubeClient } from "./innertube";
4
- import type { PlayerResponse, VideoDetails, StreamingData } from "./innertube";
5
- import { fetchPlayerJs, decipherSignatureUrl, clearCache as clearSigCache } from "./signature";
6
- import { transformNsig, clearNsigCache } from "./nsig";
7
3
  import { parseCaptionTracks } from "./captions";
8
- import { PlaylistExtractor } from "./playlist";
4
+ import { InnerTubeClient } from "./innertube";
5
+ import type { RawFormat, PlayerResponse, StreamingData } from "./innertube";
6
+ import { decipherStreamUrl, setPageHtmlForPlayerExtraction } from "./player";
9
7
 
10
8
  const VALID_URL = /^https?:\/\/(?:(?:www|m|music)\.)?(?:youtube\.com\/(?:watch\?.*v=|shorts\/|live\/|embed\/|v\/)|youtu\.be\/)([a-zA-Z0-9_-]{11})/;
11
9
  const PLAYLIST_URL = /^https?:\/\/(?:(?:www|m|music)\.)?youtube\.com\/playlist\?.*list=([a-zA-Z0-9_-]+)/;
12
10
  const CHANNEL_URL = /^https?:\/\/(?:(?:www|m|music)\.)?youtube\.com\/(?:channel\/|@)([a-zA-Z0-9_-]+)/;
13
11
 
14
- const PLAYER_URL_RE = /"jsUrl"\s*:\s*"(\/s\/player\/[^"]+\/base\.js)"/;
12
+ const USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36";
13
+
14
+ function generateCpn(): string {
15
+ const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_";
16
+ return Array.from({ length: 16 }, () => chars[Math.floor(Math.random() * 64)]).join("");
17
+ }
18
+
19
+ interface PageData {
20
+ playerResponse: PlayerResponse;
21
+ html: string;
22
+ }
23
+
24
+ async function fetchPageData(videoId: string): Promise<PageData> {
25
+ const resp = await fetch(`https://www.youtube.com/watch?v=${videoId}`, {
26
+ headers: {
27
+ "User-Agent": USER_AGENT,
28
+ "Accept-Language": "en-US,en;q=0.9",
29
+ },
30
+ });
31
+
32
+ if (!resp.ok) {
33
+ throw new ExtractorError(`Failed to fetch YouTube page: ${resp.status}`);
34
+ }
35
+
36
+ const html = await resp.text();
37
+ setPageHtmlForPlayerExtraction(html);
38
+
39
+ const prMatch = html.match(/var\s+ytInitialPlayerResponse\s*=\s*(\{.+?\});/s);
40
+ if (!prMatch) {
41
+ throw new ExtractorError("Could not extract player response from page");
42
+ }
43
+
44
+ return {
45
+ playerResponse: JSON.parse(prMatch[1]) as PlayerResponse,
46
+ html,
47
+ };
48
+ }
15
49
 
16
50
  export class YouTubeExtractor extends BaseExtractor {
17
51
  readonly _VALID_URL = new RegExp(
@@ -19,234 +53,179 @@ export class YouTubeExtractor extends BaseExtractor {
19
53
  );
20
54
  readonly _NAME = "youtube";
21
55
 
22
- private playlistExtractor = new PlaylistExtractor();
23
-
24
56
  protected async _real_extract(url: string): Promise<InfoDict> {
25
- const playlistMatch = url.match(PLAYLIST_URL);
26
- if (playlistMatch) {
27
- return this.playlistExtractor.extractPlaylist(playlistMatch[1]);
28
- }
29
-
30
- const channelMatch = url.match(CHANNEL_URL);
31
- if (channelMatch && !url.match(VALID_URL)) {
32
- return this.playlistExtractor.extractChannelVideos(channelMatch[1]);
33
- }
34
-
35
57
  const videoMatch = url.match(VALID_URL);
36
58
  if (!videoMatch) {
37
- throw new ExtractorError(`Could not extract video ID from URL: ${url}`);
59
+ throw new ExtractorError(`Unsupported YouTube URL: ${url}`);
38
60
  }
39
61
 
40
62
  return this.extractVideo(videoMatch[1]);
41
63
  }
42
64
 
43
65
  private async extractVideo(videoId: string): Promise<InfoDict> {
44
- const webClient = InnerTubeClient.withClient("WEB");
45
- let playerResponse = await webClient.getPlayerResponse(videoId);
46
-
47
- const status = playerResponse.playabilityStatus?.status;
48
- if (status === "LOGIN_REQUIRED" || status === "CONTENT_CHECK_REQUIRED") {
49
- playerResponse = await this.tryAgeGateBypass(videoId, playerResponse);
50
- }
51
-
52
- if (playerResponse.playabilityStatus?.status === "ERROR") {
53
- throw new ExtractorError(
54
- playerResponse.playabilityStatus.reason ?? "Video unavailable"
55
- );
56
- }
66
+ const pageData = await fetchPageData(videoId);
67
+ const playerResponse = pageData.playerResponse;
57
68
 
58
- const videoDetails = playerResponse.videoDetails;
59
- if (!videoDetails) {
60
- throw new ExtractorError("No video details in player response");
69
+ const status = playerResponse.playabilityStatus;
70
+ if (status?.status !== "OK") {
71
+ throw new ExtractorError(status?.reason ?? "Video unavailable");
61
72
  }
62
73
 
63
- let formats = await this.extractFormats(playerResponse, webClient, videoId);
64
-
65
- if (formats.length === 0) {
66
- const androidClient = InnerTubeClient.withClient("ANDROID");
67
- const androidResponse = await androidClient.getPlayerResponse(videoId);
68
- if (androidResponse.streamingData) {
69
- formats = androidClient.parseFormats(androidResponse.streamingData);
70
- }
71
- }
72
-
73
- const info = this.buildInfoDict(videoId, videoDetails, playerResponse, formats);
74
- return info;
75
- }
76
-
77
- private async tryAgeGateBypass(
78
- videoId: string,
79
- originalResponse: PlayerResponse,
80
- ): Promise<PlayerResponse> {
81
- const tvClient = InnerTubeClient.withClient("TVHTML5_EMBED");
82
- const embedUrl = `https://www.youtube.com/embed/${videoId}`;
83
- const tvResponse = await tvClient.getPlayerResponse(videoId, embedUrl);
84
-
85
- if (tvResponse.playabilityStatus?.status === "OK" && tvResponse.streamingData) {
86
- return {
87
- ...tvResponse,
88
- videoDetails: originalResponse.videoDetails ?? tvResponse.videoDetails,
89
- captions: originalResponse.captions ?? tvResponse.captions,
90
- microformat: originalResponse.microformat ?? tvResponse.microformat,
91
- };
74
+ const details = playerResponse.videoDetails;
75
+ if (!details?.title) {
76
+ throw new ExtractorError("Could not extract video info");
92
77
  }
93
78
 
94
- return originalResponse;
95
- }
79
+ const formats = await this.extractFormats(playerResponse.streamingData, pageData.html, videoId);
96
80
 
97
- private async extractFormats(
98
- playerResponse: PlayerResponse,
99
- client: InnerTubeClient,
100
- videoId: string,
101
- ): Promise<Format[]> {
102
- const streamingData = playerResponse.streamingData;
103
- if (!streamingData) return [];
81
+ const thumbnails: Thumbnail[] = (details.thumbnail?.thumbnails ?? []).map((t) => ({
82
+ url: t.url,
83
+ width: t.width,
84
+ height: t.height,
85
+ }));
104
86
 
105
- let formats = client.parseFormats(streamingData);
87
+ const result: InfoDict = {
88
+ id: videoId,
89
+ title: details.title,
90
+ formats,
91
+ thumbnails,
92
+ description: details.shortDescription,
93
+ channel: details.author,
94
+ channel_id: details.channelId,
95
+ duration: parseInt(details.lengthSeconds, 10) || undefined,
96
+ view_count: parseInt(details.viewCount, 10) || undefined,
97
+ webpage_url: `https://www.youtube.com/watch?v=${videoId}`,
98
+ live_status: details.isLive ? "is_live" : "not_live",
99
+ };
106
100
 
107
- const needsDecipher = this.formatsNeedDecipher(streamingData);
108
- if (needsDecipher) {
109
- formats = await this.decipherFormats(formats, streamingData, videoId);
101
+ const captionTracks = playerResponse.captions?.playerCaptionsTracklistRenderer?.captionTracks;
102
+ if (captionTracks?.length) {
103
+ const { subtitles, automatic_captions } = parseCaptionTracks(captionTracks);
104
+ result.subtitles = subtitles;
105
+ result.automatic_captions = automatic_captions;
110
106
  }
111
107
 
112
- return formats;
113
- }
114
-
115
- private formatsNeedDecipher(streamingData: StreamingData): boolean {
116
- const allFormats = [
117
- ...(streamingData.formats ?? []),
118
- ...(streamingData.adaptiveFormats ?? []),
119
- ];
120
- return allFormats.some((f) => f.signatureCipher && !f.url);
108
+ return result;
121
109
  }
122
110
 
123
- private async decipherFormats(
124
- formats: Format[],
125
- streamingData: StreamingData,
126
- videoId: string,
127
- ): Promise<Format[]> {
128
- const playerJsUrl = await this.getPlayerJsUrl(videoId);
129
- if (!playerJsUrl) return formats;
130
-
131
- const playerJs = await fetchPlayerJs(playerJsUrl);
132
-
133
- const allRaw = [
134
- ...(streamingData.formats ?? []),
135
- ...(streamingData.adaptiveFormats ?? []),
136
- ];
111
+ private async extractFormats(streamingData: StreamingData | undefined, pageHtml: string, videoId?: string): Promise<Format[]> {
112
+ const cpn = generateCpn();
113
+ const formats: Format[] = [];
137
114
 
138
- for (let i = 0; i < formats.length; i++) {
139
- const raw = allRaw[i];
140
- if (!raw) continue;
115
+ // First: get formats from page response (muxed formats with signatureCipher)
116
+ if (streamingData) {
117
+ const pageFormats: RawFormat[] = [
118
+ ...(streamingData.formats ?? []),
119
+ ...(streamingData.adaptiveFormats ?? []),
120
+ ];
141
121
 
142
- if (raw.signatureCipher && !raw.url) {
122
+ for (const raw of pageFormats) {
123
+ if (!raw.url && !raw.signatureCipher) continue;
143
124
  try {
144
- formats[i].url = decipherSignatureUrl(raw.signatureCipher, playerJs);
125
+ const url = await decipherStreamUrl(raw.url, raw.signatureCipher, pageHtml);
126
+ if (!url) continue;
127
+ const parsed = new URL(url);
128
+ parsed.searchParams.set("cpn", cpn);
129
+ formats.push(this.buildFormat(raw, parsed.toString()));
145
130
  } catch {
146
131
  continue;
147
132
  }
148
133
  }
134
+ }
149
135
 
150
- if (formats[i].url) {
151
- try {
152
- formats[i].url = transformNsig(formats[i].url, playerJs);
153
- } catch {
154
- continue;
136
+ if (videoId) {
137
+ try {
138
+ const iosFormats = await this.fetchIosFormats(videoId, pageHtml, cpn);
139
+ const existingItags = new Set(formats.map((f) => f.format_id));
140
+ for (const f of iosFormats) {
141
+ if (!existingItags.has(f.format_id)) {
142
+ formats.push(f);
143
+ }
155
144
  }
145
+ } catch {
146
+ // IOS client failed, continue with page formats
156
147
  }
157
148
  }
158
149
 
159
150
  return formats;
160
151
  }
161
152
 
162
- private async getPlayerJsUrl(videoId: string): Promise<string | null> {
163
- const watchUrl = `https://www.youtube.com/watch?v=${videoId}`;
153
+ private async fetchIosFormats(videoId: string, pageHtml: string, cpn: string): Promise<Format[]> {
154
+ const iosClient = InnerTubeClient.withClient("IOS");
155
+ let response: PlayerResponse;
164
156
  try {
165
- const response = await fetch(watchUrl, {
166
- headers: {
167
- "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36",
168
- },
169
- });
170
- const html = await response.text();
171
- const match = html.match(PLAYER_URL_RE);
172
- return match ? `https://www.youtube.com${match[1]}` : null;
157
+ response = await iosClient.getPlayerResponse(videoId);
173
158
  } catch {
174
- return null;
159
+ const androidClient = InnerTubeClient.withClient("ANDROID");
160
+ response = await androidClient.getPlayerResponse(videoId);
175
161
  }
176
- }
177
-
178
- private buildInfoDict(
179
- videoId: string,
180
- details: VideoDetails,
181
- response: PlayerResponse,
182
- formats: Format[],
183
- ): InfoDict {
184
- const microformat = response.microformat?.playerMicroformatRenderer;
185
-
186
- const thumbnails: Thumbnail[] = (details.thumbnail?.thumbnails ?? []).map((t) => ({
187
- url: t.url,
188
- width: t.width,
189
- height: t.height,
190
- }));
191
162
 
192
- const liveStatus = this.getLiveStatus(details, response);
163
+ if (response.playabilityStatus?.status !== "OK") return [];
193
164
 
194
- const info: InfoDict = {
195
- id: videoId,
196
- title: details.title,
197
- formats,
198
- thumbnails,
199
- description: details.shortDescription ?? microformat?.description?.simpleText,
200
- channel: details.author,
201
- channel_id: details.channelId,
202
- channel_url: `https://www.youtube.com/channel/${details.channelId}`,
203
- uploader: details.author,
204
- uploader_id: details.channelId,
205
- uploader_url: microformat?.ownerProfileUrl,
206
- duration: parseInt(details.lengthSeconds, 10) || undefined,
207
- view_count: parseInt(details.viewCount, 10) || undefined,
208
- upload_date: microformat?.uploadDate?.replace(/-/g, ""),
209
- live_status: liveStatus,
210
- webpage_url: `https://www.youtube.com/watch?v=${videoId}`,
211
- age_limit: 0,
212
- categories: microformat?.category ? [microformat.category] : undefined,
213
- };
165
+ const allRaw: RawFormat[] = [
166
+ ...(response.streamingData?.formats ?? []),
167
+ ...(response.streamingData?.adaptiveFormats ?? []),
168
+ ];
214
169
 
215
- if (microformat?.liveBroadcastDetails?.startTimestamp) {
216
- info.release_timestamp = Math.floor(
217
- new Date(microformat.liveBroadcastDetails.startTimestamp).getTime() / 1000
218
- );
219
- }
170
+ const formats: Format[] = [];
171
+ const IOS_UA = "com.google.ios.youtube/19.45.4 (iPhone16,2; U; CPU iOS 17_5_1 like Mac OS X;)";
172
+
173
+ for (const raw of allRaw) {
174
+ if (!raw.url) continue;
175
+ let finalUrl: string;
176
+ try {
177
+ const deciphered = await decipherStreamUrl(raw.url, undefined, pageHtml);
178
+ finalUrl = deciphered ?? raw.url;
179
+ } catch {
180
+ finalUrl = raw.url;
181
+ }
220
182
 
221
- const captionTracks = response.captions?.playerCaptionsTracklistRenderer?.captionTracks;
222
- if (captionTracks?.length) {
223
- const { subtitles, automatic_captions } = parseCaptionTracks(captionTracks);
224
- info.subtitles = subtitles;
225
- info.automatic_captions = automatic_captions;
183
+ const parsed = new URL(finalUrl);
184
+ parsed.searchParams.set("cpn", cpn);
185
+ const format = this.buildFormat(raw, parsed.toString());
186
+ format.http_headers = {
187
+ "User-Agent": IOS_UA,
188
+ "Origin": "https://www.youtube.com",
189
+ "Referer": "https://www.youtube.com/",
190
+ };
191
+ formats.push(format);
226
192
  }
227
193
 
228
- return info;
194
+ return formats;
229
195
  }
230
196
 
231
- private getLiveStatus(
232
- details: VideoDetails,
233
- response: PlayerResponse,
234
- ): InfoDict["live_status"] {
235
- if (details.isLive) return "is_live";
236
- if (details.isUpcoming) return "is_upcoming";
237
- if (details.isLiveContent) return "was_live";
238
- if (response.playabilityStatus?.liveStreamability) return "is_live";
239
- return "not_live";
240
- }
197
+ private buildFormat(raw: RawFormat, url: string): Format {
198
+ const mime = raw.mimeType;
199
+ const mimeMatch = mime.match(/^(video|audio)\/(\w+);\s*codecs="([^"]+)"/);
200
+ const ext = mimeMatch?.[2] ?? "mp4";
201
+ const codecs = mimeMatch?.[3] ?? "";
202
+ const isVideo = mime.startsWith("video");
203
+ const isAudio = mime.startsWith("audio");
204
+
205
+ const format: Format = {
206
+ format_id: String(raw.itag),
207
+ url,
208
+ ext: isAudio && ext === "mp4" ? "m4a" : ext,
209
+ vcodec: isVideo ? codecs.split(",")[0]?.trim() : "none",
210
+ acodec: isAudio ? codecs : (isVideo && codecs.includes(",") ? codecs.split(",")[1]?.trim() : undefined),
211
+ width: raw.width,
212
+ height: raw.height,
213
+ fps: raw.fps,
214
+ tbr: raw.bitrate ? Math.round(raw.bitrate / 1000) : undefined,
215
+ filesize: raw.contentLength ? parseInt(raw.contentLength, 10) : undefined,
216
+ format_note: raw.qualityLabel ?? raw.quality ?? undefined,
217
+ audio_channels: raw.audioChannels,
218
+ http_headers: {
219
+ "Origin": "https://www.youtube.com",
220
+ "Referer": "https://www.youtube.com/",
221
+ "User-Agent": USER_AGENT,
222
+ },
223
+ };
241
224
 
242
- static clearCaches(): void {
243
- clearSigCache();
244
- clearNsigCache();
225
+ if (raw.width && raw.height) {
226
+ format.resolution = `${raw.width}x${raw.height}`;
227
+ }
228
+
229
+ return format;
245
230
  }
246
231
  }
247
-
248
- export { InnerTubeClient } from "./innertube";
249
- export { PlaylistExtractor } from "./playlist";
250
- export { parseCaptionTracks, convertToSrt, convertToVtt } from "./captions";
251
- export { decipherSignatureUrl, fetchPlayerJs } from "./signature";
252
- export { transformNsig } from "./nsig";