├── .editorconfig
├── .gitignore
├── .prettierrc
├── Info.json
├── README.md
├── downloads.html
├── package-lock.json
├── package.json
├── pref.html
├── src
├── add-video.ts
├── binary.ts
├── download.ts
├── downloads-window.js
├── global.ts
├── index.ts
├── options.ts
├── utils.ts
├── ytdl-hook.ts
└── ytdl.d.ts
└── tsconfig.json
/.editorconfig:
--------------------------------------------------------------------------------
1 | root = true
2 |
3 | [*]
4 | charset = utf-8
5 | end_of_line = lf
6 | indent_style = space
7 | insert_final_newline = true
8 | trim_trailing_whitespace = true
9 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 |
2 | # Created by https://www.toptal.com/developers/gitignore/api/node,macos
3 | # Edit at https://www.toptal.com/developers/gitignore?templates=node,macos
4 |
5 | ### macOS ###
6 | # General
7 | .DS_Store
8 | .AppleDouble
9 | .LSOverride
10 |
11 | # Icon must end with two \r
12 | Icon
13 |
14 |
15 | # Thumbnails
16 | ._*
17 |
18 | # Files that might appear in the root of a volume
19 | .DocumentRevisions-V100
20 | .fseventsd
21 | .Spotlight-V100
22 | .TemporaryItems
23 | .Trashes
24 | .VolumeIcon.icns
25 | .com.apple.timemachine.donotpresent
26 |
27 | # Directories potentially created on remote AFP share
28 | .AppleDB
29 | .AppleDesktop
30 | Network Trash Folder
31 | Temporary Items
32 | .apdisk
33 |
34 | ### Node ###
35 | # Logs
36 | logs
37 | *.log
38 | npm-debug.log*
39 | yarn-debug.log*
40 | yarn-error.log*
41 | lerna-debug.log*
42 | .pnpm-debug.log*
43 |
44 | # Diagnostic reports (https://nodejs.org/api/report.html)
45 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
46 |
47 | # Runtime data
48 | pids
49 | *.pid
50 | *.seed
51 | *.pid.lock
52 |
53 | # Directory for instrumented libs generated by jscoverage/JSCover
54 | lib-cov
55 |
56 | # Coverage directory used by tools like istanbul
57 | coverage
58 | *.lcov
59 |
60 | # nyc test coverage
61 | .nyc_output
62 |
63 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
64 | .grunt
65 |
66 | # Bower dependency directory (https://bower.io/)
67 | bower_components
68 |
69 | # node-waf configuration
70 | .lock-wscript
71 |
72 | # Compiled binary addons (https://nodejs.org/api/addons.html)
73 | build/Release
74 |
75 | # Dependency directories
76 | node_modules/
77 | jspm_packages/
78 |
79 | # Snowpack dependency directory (https://snowpack.dev/)
80 | web_modules/
81 |
82 | # TypeScript cache
83 | *.tsbuildinfo
84 |
85 | # Optional npm cache directory
86 | .npm
87 |
88 | # Optional eslint cache
89 | .eslintcache
90 |
91 | # Microbundle cache
92 | .rpt2_cache/
93 | .rts2_cache_cjs/
94 | .rts2_cache_es/
95 | .rts2_cache_umd/
96 |
97 | # Optional REPL history
98 | .node_repl_history
99 |
100 | # Output of 'npm pack'
101 | *.tgz
102 |
103 | # Yarn Integrity file
104 | .yarn-integrity
105 |
106 | # dotenv environment variables file
107 | .env
108 | .env.test
109 | .env.production
110 |
111 | # parcel-bundler cache (https://parceljs.org/)
112 | .cache
113 | .parcel-cache
114 |
115 | # Next.js build output
116 | .next
117 | out
118 |
119 | # Nuxt.js build / generate output
120 | .nuxt
121 | dist/**/*.map
122 |
123 | # Gatsby files
124 | .cache/
125 | # Comment in the public line in if your project uses Gatsby and not Next.js
126 | # https://nextjs.org/blog/next-9-1#public-directory-support
127 | # public
128 |
129 | # vuepress build output
130 | .vuepress/dist
131 |
132 | # Serverless directories
133 | .serverless/
134 |
135 | # FuseBox cache
136 | .fusebox/
137 |
138 | # DynamoDB Local files
139 | .dynamodb/
140 |
141 | # TernJS port file
142 | .tern-port
143 |
144 | # Stores VSCode versions used for testing VSCode extensions
145 | .vscode-test
146 |
147 | # yarn v2
148 | .yarn/cache
149 | .yarn/unplugged
150 | .yarn/build-state.yml
151 | .yarn/install-state.gz
152 | .pnp.*
153 |
154 | ### Node Patch ###
155 | # Serverless Webpack directories
156 | .webpack/
157 |
158 | # Optional stylelint cache
159 | .stylelintcache
160 |
161 | # SvelteKit build / generate output
162 | .svelte-kit
163 |
164 | # End of https://www.toptal.com/developers/gitignore/api/node,macos
165 |
166 | .vscode/
167 |
168 | dist/
169 |
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "trailingComma": "all",
3 | "tabWidth": 2,
4 | "printWidth": 100,
5 | "semi": true,
6 | "singleQuote": false
7 | }
8 |
--------------------------------------------------------------------------------
/Info.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "Online Media",
3 | "identifier": "io.iina.ytdl",
4 | "version": "0.9.7",
5 | "ghRepo": "iina/plugin-online-media",
6 | "ghVersion": 9,
7 | "description": "Official plugin for playing online media via yt-dlp / youtube-dl. The built-in youtube-dl support will be disabled when this plugin is enabled.",
8 | "author": {
9 | "name": "IINA Developers",
10 | "email": "developers@iina.io",
11 | "url": "https://iina.io"
12 | },
13 | "entry": "dist/index.js",
14 | "globalEntry": "dist/global.js",
15 | "permissions": ["show-osd", "file-system", "network-request"],
16 | "allowedDomains": ["github.com"],
17 | "preferencesPage": "pref.html",
18 | "preferenceDefaults": {
19 | "ytdl_path": "",
20 | "excluded_urls": "",
21 | "try_ytdl_first": false,
22 | "use_manifests": false,
23 | "raw_options": "",
24 | "include_subs": true,
25 | "include_auto_subs": false,
26 | "video_quality": "best",
27 | "max_video_height": 1080,
28 | "custom_ytdl_format": "",
29 | "ytdl_format": ""
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # iina-plugin-ytdl
2 |
3 | This package is largely a re-implementation of the mpv `ytdl_hook.lua` under IINA's plugin system, but with additional features.
4 |
5 | ## Features
6 |
7 | - Maintain an up-to-date local copy of the `yt-dlp` binary
8 | - Switch video resolution on the fly
9 | - Download video files
10 |
11 | ## Setup & Build
12 |
13 | ```sh
14 | npm i
15 | npm run build
16 | ```
17 |
18 | You may use the bundled `iina-plugin` binary to load or package the plugin.
19 |
20 | This will create a symbolic link to the plugin in IINA's plugin directory and IINA will load the plugin in development mode:
21 |
22 | ```sh
23 | iina-plugin link .
24 | ```
25 |
26 | An `iinaplgz` file will be created in the current directory after running the `pack` command:
27 |
28 | ```sh
29 | iina-plugin pack .
30 | ```
31 |
32 | Please check out the [guide in the documentation](https://docs.iina.io/pages/creating-plugins.html) for more information.
33 |
--------------------------------------------------------------------------------
/downloads.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 | Status and Downloads
9 |
103 |
171 |
172 |
173 |
174 |
175 |
176 | Status
177 |
178 |
Verify yt-dlp binary
179 |
180 |
181 |
Download latest yt-dlp
182 |
183 |
184 |
185 | Downloading yt-dlp. This may take minutes depending on your connection speed.
186 |
187 |
188 |
189 |
190 |
191 |
192 |
193 |
194 |
195 |
196 |
197 |
198 |
199 |
200 | Downloads
201 |
202 |
203 |
204 |
205 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "iina-plugin-ytdl",
3 | "version": "0.9.5",
4 | "description": "",
5 | "main": "src/index.js",
6 | "scripts": {
7 | "build": "rm -rf dist && parcel build .",
8 | "test": "echo \"Error: no test specified\" && exit 1"
9 | },
10 | "author": "",
11 | "license": "ISC",
12 | "devDependencies": {
13 | "iina-plugin-definition": "^0.99.0",
14 | "parcel": "^2.6.0",
15 | "prettier": "^2.6.2"
16 | },
17 | "entry": "src/index.js",
18 | "targets": {
19 | "main": false,
20 | "entry": {
21 | "distDir": "./dist/",
22 | "source": "src/index.ts",
23 | "isLibrary": false
24 | },
25 | "globalEntry": {
26 | "distDir": "./dist/",
27 | "source": "src/global.ts",
28 | "isLibrary": false
29 | },
30 | "downloadsWindowEntry": {
31 | "distDir": "./dist/",
32 | "source": "src/downloads-window.js",
33 | "isLibrary": false
34 | }
35 | },
36 | "dependencies": {
37 | "mustache": "^4.2.0"
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/pref.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Document
7 |
46 |
47 |
48 |
49 |
50 | Use custom youtube-dl/yt-dlp:
51 |
52 | Enter the path to the youtube-dl or yt-dlp binary.
53 | If you are using Homebrew, the path is usually /opt/homebrew/bin/yt-dlp
for Apple Sillion Macs and
54 | /usr/local/bin/yt-dlp
for Intel Macs.
55 |
56 |
57 |
58 |
59 |
60 |
61 | Default video and audio quality:
62 |
79 |
80 |
81 |
82 | Excluded URLs:
83 |
84 |
85 |
86 |
87 |
88 |
99 |
100 |
101 |
102 | Try youtube-dl first
103 |
104 |
105 | Try parsing the URL with youtube-dl first, instead of the default where
106 | it's only after mpv failed to open it.
107 |
108 |
109 |
110 |
111 |
112 | Use manifest URL
113 |
114 |
115 | Use the master manifest URL for formats like HLS and DASH, if available,
116 | allowing for video/audio selection at runtime.
117 |
118 |
119 |
120 |
121 | Raw options:
122 |
124 |
125 |
126 | Additional raw options to pass to youtube-dl/yt-dlp, for example:
127 | --write-auto-sub --sub-langs en
128 |
129 |
130 |
131 |
132 |
--------------------------------------------------------------------------------
/src/add-video.ts:
--------------------------------------------------------------------------------
1 | import {
2 | setHTTPHeaders,
3 | isSafeURL,
4 | optionWasSet,
5 | edlTrackJoined,
6 | optionWasSetLocally,
7 | edlEscape,
8 | ytdlCodecToMpvCodec,
9 | } from "./utils";
10 | import { opt } from "./options";
11 | import { currentURL } from "./ytdl-hook";
12 |
13 | const { core, console, global, mpv, menu } = iina;
14 |
15 | interface Chapter {
16 | time: number;
17 | title: string;
18 | }
19 |
20 | export let chapterList: Chapter[] = [];
21 | export let isSwitchingFormat = false;
22 | let currentVideoFormat: string;
23 | let currentAudioFormat: string;
24 |
25 | function matchTime(line: string): number | null {
26 | const match = line.match(/((\d+):)?(\d\d?):(\d\d)/);
27 | if (!match) return null;
28 | const [, , a, b, c] = match;
29 | return (a ? parseInt(a) : 0) * 3600 + parseInt(b) * 60 + parseInt(c);
30 | }
31 |
32 | function extractChapters(data: string, videoLength: number) {
33 | const lines = data.split(/\r|\n/);
34 | const result: Chapter[] = [];
35 | for (const line of lines) {
36 | if (!line) continue;
37 | const time = matchTime(line);
38 | if (time && time < videoLength) {
39 | result.push({ time: time, title: line });
40 | }
41 | }
42 | result.sort((a, b) => a.time - b.time);
43 | return result;
44 | }
45 |
46 | function isValidManifest(json: YTDL.Entity) {
47 | const reqfmt = json.requested_formats ? json.requested_formats[1] : ({} as YTDL.Entity);
48 | if (!reqfmt.manifest_url && !json.manifest_url) return false;
49 | const proto = reqfmt.protocol || json.protocol || "";
50 | return proto === "http_dash_segments" ? hasNativeDashDemuxer() : proto.startsWith("m3u8");
51 | }
52 |
53 | function hasNativeDashDemuxer() {
54 | const demuxers = mpv.getNative("demuxer-lavf-list") || [];
55 | return demuxers.indexOf("dash") >= 0;
56 | }
57 |
58 | function processVideo(reqfmts: YTDL.Video[], json?: YTDL.Entity) {
59 | let streamURL = "";
60 | let maxBitrate = 0;
61 |
62 | if (opt.use_manifests && isValidManifest(json)) {
63 | // prefer manifect_url if present
64 | const mpdURL = reqfmts ? reqfmts[0].manifest_url : json.manifest_url;
65 | if (!mpdURL) {
66 | console.error("No manifest URL found in JSON data.");
67 | return;
68 | } else if (!isSafeURL(mpdURL)) {
69 | return;
70 | }
71 | streamURL = mpdURL;
72 | if (reqfmts) {
73 | maxBitrate = Math.max.apply(
74 | null,
75 | reqfmts.map((fmt) => fmt.tbr),
76 | );
77 | } else if (json.tbr) {
78 | maxBitrate = Math.max(maxBitrate, json.tbr);
79 | }
80 | } else if (reqfmts) {
81 | // DASH/split tracks
82 | for (const track of reqfmts) {
83 | const edlTrack = edlTrackJoined(
84 | track.fragments,
85 | track.protocol,
86 | json.is_live,
87 | track.fragment_base_url,
88 | );
89 | if (!edlTrack && !isSafeURL(track.url)) return;
90 | if (track.vcodec && track.vcodec !== "none") {
91 | // vide track
92 | streamURL = edlTrack || track.url;
93 | } else if (track.vcodec == "none") {
94 | // according to ytdl, if vcodec is None, it's audio
95 | mpv.command("audio-add", [edlTrack || track.url, "auto", track.format_note || ""]);
96 | }
97 | }
98 | } else if (json.url) {
99 | const edlTrack = edlTrackJoined(
100 | json.fragments,
101 | json.protocol,
102 | json.is_live,
103 | json.fragment_base_url,
104 | );
105 | if (!edlTrack && !isSafeURL(json.url)) return;
106 |
107 | // normal video or single track
108 | streamURL = edlTrack || json.url;
109 | setHTTPHeaders(json.http_headers);
110 | } else {
111 | console.error("No URL found in JSON data.");
112 | return;
113 | }
114 |
115 | console.log(`streamurl: ${streamURL}`);
116 |
117 | mpv.set("stream-open-filename", streamURL.replace(/^data/, "data://"));
118 | mpv.set("file-local-options/force-media-title", json.title);
119 |
120 | // set hls-bitrate for dash track selection
121 | if (maxBitrate > 0 && !optionWasSet("hls-bitrate") && !optionWasSetLocally("hls-bitrate")) {
122 | mpv.set("file-local-options/hls-bitrate", maxBitrate * 1000);
123 | }
124 |
125 | // add subtitles
126 | if (json.requested_subtitles) {
127 | Object.keys(json.requested_subtitles).forEach((lang) => {
128 | const subInfo = json.requested_subtitles[lang];
129 | console.log(subInfo);
130 |
131 | console.log(`adding subtitle [${lang}]`);
132 | const sub = subInfo.data
133 | ? `memory://${subInfo.data}`
134 | : subInfo.url && isSafeURL(subInfo.url)
135 | ? subInfo.url
136 | : null;
137 |
138 | if (sub) {
139 | const codec = ytdlCodecToMpvCodec(subInfo.ext);
140 | const codecStr = codec ? `,codec=${codec};` : ";";
141 | const edl = `edl://!no_clip;!delay_open,media_type=sub${codecStr}${edlEscape(sub)}`;
142 | const title = subInfo.name || subInfo.ext;
143 | mpv.command("sub-add", [edl, "auto", title, lang]);
144 | } else {
145 | console.log(`No subtitle data/url for ${lang}`);
146 | }
147 | });
148 | }
149 |
150 | // add chapters
151 | if (json.chapters) {
152 | console.log("Adding pre-parsed chapters");
153 | for (let i = 0; i < json.chapters.length; i++) {
154 | const chapter = json.chapters[i];
155 | const title = chapter.title || `Chapter ${i}`;
156 | chapterList.push({ time: chapter.start_time, title: title });
157 | }
158 | } else if (json.description && json.duration) {
159 | chapterList = extractChapters(json.description, json.duration);
160 | }
161 |
162 | // set start time
163 | if (json.start_time && !optionWasSet("start") && !optionWasSetLocally("start")) {
164 | console.log(`Setting start to: ${json.start_time} secs`);
165 | mpv.set("file-local-options/start", json.start_time);
166 | }
167 |
168 | // set aspect ratio for anamorphic video
169 | if (json.stretched_ratio && !optionWasSet("video-aspect")) {
170 | mpv.set("file-local-options/video-aspect", json.stretched_ratio);
171 | }
172 |
173 | let streamOpts = mpv.getNative>("file-local-options/stream-lavf-o") || {};
174 |
175 | // for rtmp
176 | if (json.protocol == "rtmp") {
177 | streamOpts = {
178 | rtmp_tcurl: streamURL,
179 | rtmp_pageurl: json.page_url,
180 | rtmp_playpath: json.play_path,
181 | rtmp_swfverify: json.player_url,
182 | rtmp_swfurl: json.player_url,
183 | rtmp_app: json.app,
184 | ...streamOpts,
185 | };
186 | }
187 | if (json.proxy) {
188 | Object.assign(streamOpts, { http_proxy: json.proxy });
189 | }
190 |
191 | mpv.set("file-local-options/stream-lavf-o", streamOpts);
192 | }
193 |
194 | function formatDescription(f: YTDL._BaseEntity): string {
195 | return f.dynamic_range ? `${f.format} ${f.dynamic_range}` : f.format;
196 | }
197 |
198 | export function addVideo(json: YTDL.Entity) {
199 | let reqfmts = json.requested_formats;
200 |
201 | if (json.formats) {
202 | if (isSwitchingFormat) {
203 | const af = json.formats.find((f) => f.format_id === currentAudioFormat);
204 | const vf = json.formats.find((f) => f.format_id === currentVideoFormat);
205 | if (af && vf) {
206 | reqfmts = [af, vf];
207 | isSwitchingFormat = false;
208 | }
209 | } else {
210 | currentVideoFormat = json.requested_formats.find((f) => f.vcodec !== "none").format_id;
211 | currentAudioFormat = json.requested_formats.find((f) => f.vcodec === "none").format_id;
212 | }
213 |
214 | // add to menu
215 | console.log("reconstruct menu");
216 |
217 | menu.removeAllItems();
218 | menu.addItem(menu.separator());
219 |
220 | menu.addItem(
221 | menu.item(
222 | "Download this video",
223 | () => {
224 | core.osd("Preparing for download");
225 | global.postMessage("downloadVideo", currentURL);
226 | },
227 | { keyBinding: "Meta+d" },
228 | ),
229 | );
230 |
231 | const videoItem = menu.item("Video Quality");
232 | const audioItem = menu.item("Audio Quality");
233 |
234 | for (const f of json.formats) {
235 | if (f.vcodec === "none") {
236 | audioItem.addSubMenuItem(
237 | menu.item(
238 | formatDescription(f),
239 | () => {
240 | currentAudioFormat = f.format_id;
241 | isSwitchingFormat = true;
242 | mpv.command("loadfile", [mpv.getString("path")]);
243 | },
244 | { selected: f.format_id === currentAudioFormat },
245 | ),
246 | );
247 | } else {
248 | videoItem.addSubMenuItem(
249 | menu.item(
250 | formatDescription(f),
251 | () => {
252 | currentVideoFormat = f.format_id;
253 | isSwitchingFormat = true;
254 | mpv.command("loadfile", [mpv.getString("path")]);
255 | },
256 | { selected: f.format_id === currentVideoFormat },
257 | ),
258 | );
259 | }
260 | }
261 | menu.addItem(videoItem);
262 | menu.addItem(audioItem);
263 | menu.forceUpdate();
264 | }
265 |
266 | processVideo(reqfmts, json);
267 | }
268 |
--------------------------------------------------------------------------------
/src/binary.ts:
--------------------------------------------------------------------------------
1 | import { opt } from "./options";
2 |
3 | const { core, console, http, file, utils } = iina;
4 |
5 | const YTDLP_URL = "https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_macos.zip";
6 |
7 | export async function downloadYTDLP() {
8 | const tempID = new Date().getTime();
9 | const downloadedZip = utils.resolvePath(`@data/yt-dlp_${tempID}.zip`);
10 | const unzipFolder = utils.resolvePath(`@data/yt-dlp_${tempID}`);
11 | const destFolder = utils.resolvePath(`@data/yt-dlp`);
12 | let errorMessage = null;
13 | try {
14 | console.log(`Downloading yt-dlp to ${downloadedZip}`);
15 | await http.download(YTDLP_URL, downloadedZip);
16 | const res = await utils.exec("/bin/bash", [
17 | "-c",
18 | `
19 | TARGET="${destFolder}";
20 | rm -rf "${destFolder}_*";
21 | if [ -e "$TARGET" ] && [ ! -d "$TARGET" ] && [ -f "$TARGET" ]; then
22 | rm -rf "$TARGET";
23 | fi;
24 | if [ ! -e "$TARGET" ]; then
25 | mkdir -p "$TARGET";
26 | fi;
27 | unzip "${downloadedZip}" -d "${unzipFolder}" &&
28 | rm -rf "$TARGET"/* &&
29 | mv "${unzipFolder}"/* "$TARGET"/ &&
30 | rm "${downloadedZip}" &&
31 | xattr -cr "$TARGET"
32 | `,
33 | ]);
34 | if (res.status !== 0) {
35 | throw new Error(`Failed to unzip yt-dlp: ${res.stderr}`);
36 | }
37 | } catch (e) {
38 | console.error(e.toString());
39 | errorMessage = e.toString();
40 | } finally {
41 | try {
42 | if (file.exists(downloadedZip)) file.delete(downloadedZip);
43 | if (file.exists(unzipFolder)) file.delete(unzipFolder);
44 | } catch (e1) {
45 | console.error("Failed to delete temp files: " + e1.toString());
46 | }
47 | }
48 | return errorMessage;
49 | }
50 |
51 | export function findBinary(): string {
52 | let path = "youtube-dl";
53 | const searchList = [opt.ytdl_path, "@data/yt-dlp/yt-dlp_macos", "yt-dlp", "youtube-dl"];
54 | for (const item of searchList) {
55 | if (utils.fileInPath(item)) {
56 | console.log(`Found youtube-dl; using ${item}`);
57 | path = item;
58 | break;
59 | }
60 | }
61 | return path;
62 | }
63 |
--------------------------------------------------------------------------------
/src/download.ts:
--------------------------------------------------------------------------------
1 | import { findBinary } from "./binary";
2 | import { updateDownloadsWindow } from "./global";
3 | import { formatFileSize, formatSeconds } from "./utils";
4 |
5 | const { console, global, utils } = iina;
6 |
7 | export const tasks: DownloadTask[] = [];
8 | export let statusNeedUpdate = false;
9 |
10 | export function resetStatusNeedUpdate() {
11 | statusNeedUpdate = false;
12 | }
13 |
14 | class DownloadTask {
15 | startTime: Date;
16 | status: "pending" | "downloading" | "done" | "error" = "pending";
17 | res: ReturnType;
18 | errorMessage: string | null = null;
19 | downloadedBytes: number = 0;
20 | totalBytes: number = 0;
21 | eta: number | null = null;
22 |
23 | constructor(
24 | public player: string,
25 | public url: string,
26 | public filename: string,
27 | public destFolder: string,
28 | public ytdl: string,
29 | public format: string,
30 | ) {}
31 |
32 | get dest() {
33 | return `${this.destFolder}/${this.filename}`;
34 | }
35 |
36 | private get args() {
37 | const args: string[] = [];
38 | args.push("-P", this.destFolder);
39 | // args.push("--format", this.format);
40 | args.push(
41 | "--progress-template",
42 | "!!%(progress.downloaded_bytes)s-%(progress.total_bytes)s-%(progress.eta)s",
43 | );
44 | args.push("--", this.url);
45 | return args;
46 | }
47 |
48 | start() {
49 | this.startTime = new Date();
50 | utils
51 | .exec(this.ytdl, this.args, null, (data) => this.onStdout(data), null)
52 | .then(
53 | (res) => {
54 | console.log("Download finished");
55 | if (res.status === 0) {
56 | this.status = "done";
57 | global.postMessage(this.player, "downloaded", true);
58 | } else {
59 | this.status = "error";
60 | this.errorMessage = res.stderr;
61 | }
62 | updateDownloadsWindow();
63 | },
64 | (error) => {
65 | this.status = "error";
66 | this.errorMessage = error.toString();
67 | updateDownloadsWindow();
68 | },
69 | );
70 | this.status = "downloading";
71 | updateDownloadsWindow();
72 | }
73 |
74 | onStdout(data: string) {
75 | data = data.trim();
76 | if (data.length === 0 || !data.startsWith("!!")) return;
77 | const [downloaded, total, eta] = data.slice(2).split("-");
78 | if (downloaded !== "NA") this.downloadedBytes = parseInt(downloaded);
79 | if (total !== "NA") this.totalBytes = parseInt(total);
80 | if (eta !== "NA") this.eta = parseInt(eta);
81 | statusNeedUpdate = true;
82 | }
83 |
84 | serialize() {
85 | return {
86 | url: this.url,
87 | filename: this.filename,
88 | destFolder: this.destFolder,
89 | dest: this.dest,
90 | status: this.status,
91 | start: this.startTime.toString(),
92 | error: this.errorMessage,
93 | dl: formatFileSize(this.downloadedBytes),
94 | total: formatFileSize(this.totalBytes),
95 | eta: formatSeconds(this.eta),
96 | };
97 | }
98 | }
99 |
100 | export async function downloadVideo(url: string, player: string) {
101 | // const hasFFmpeg =
102 | // (await utils.exec("/bin/bash", ["-c", "'which ffmpeg'"])).status === 0;
103 | // const format = hasFFmpeg ? "bestvideo+bestaudio/best" : "best";
104 | // console.log(`FFmpeg found: ${hasFFmpeg}; using format: ${format}`);
105 | const format = null;
106 |
107 | const ytdl = findBinary();
108 | const filename = (await utils.exec(ytdl, ["--get-filename", url])).stdout.replaceAll("\n", "");
109 | console.log(filename);
110 |
111 | let destFolder = `~/Downloads`;
112 | const args: string[] = [];
113 |
114 | const task = new DownloadTask(player, url, filename, destFolder, ytdl, format);
115 | tasks.push(task);
116 | task.start();
117 | }
118 |
--------------------------------------------------------------------------------
/src/downloads-window.js:
--------------------------------------------------------------------------------
1 | import mustache from "mustache";
2 |
3 | function init() {
4 | iina.postMessage("requestUpdate", { force: true });
5 |
6 | let interval = null;
7 |
8 | const startUpdate = () =>
9 | (interval = setInterval(() => {
10 | iina.postMessage("requestUpdate", { force: false });
11 | }, 1000));
12 | const stopUpdate = () => clearInterval(interval);
13 |
14 | iina.onMessage("update", function (message) {
15 | if (message.active) {
16 | startUpdate();
17 | } else {
18 | stopUpdate();
19 | }
20 | message.data.forEach((item) => {
21 | item[`is_${item.status}`] = true;
22 | item.dest_base64 = utf8_to_b64(item.dest);
23 | });
24 | document.getElementById("content").innerHTML = mustache.render(TEMPLATE, message);
25 | });
26 |
27 | iina.onMessage("updatingBinary", () => {
28 | document.getElementById("download-error").textContent = "";
29 | document.getElementById("download-info").textContent = "";
30 | document.getElementById("downloading").style.display = "block";
31 | });
32 |
33 | iina.onMessage("binaryUpdated", ({ updated, error }) => {
34 | document.getElementById("downloading").style.display = "none";
35 | if (updated) {
36 | document.getElementById("download-info").textContent =
37 | "Binary updated successfully. Please allow a few seconds preparing and verifying the new binary.";
38 | updateBinaryInfo();
39 | } else {
40 | document.getElementById("download-error").textContent = `Failed to update binary: ${error}`;
41 | }
42 | });
43 |
44 | window.openFile = function (file) {
45 | iina.postMessage("openFile", { file: b64_to_utf8(file) });
46 | };
47 |
48 | window.revealFile = function (file) {
49 | iina.postMessage("revealFile", { fileName: b64_to_utf8(file) });
50 | };
51 |
52 | document.getElementById("check-binary").addEventListener("click", () => {
53 | updateBinaryInfo();
54 | });
55 |
56 | document.getElementById("download-binary").addEventListener("click", () => {
57 | iina.postMessage("updateBinary");
58 | });
59 | }
60 |
61 | document.addEventListener("DOMContentLoaded", init);
62 |
63 | function utf8_to_b64(str) {
64 | return window.btoa(unescape(encodeURIComponent(str)));
65 | }
66 |
67 | function b64_to_utf8(str) {
68 | return decodeURIComponent(escape(window.atob(str)));
69 | }
70 |
71 | function updateBinaryInfo() {
72 | document.getElementById("binary-desc").textContent = "Checking for yt-dlp binary...";
73 | iina.postMessage("getBinaryInfo");
74 | iina.onMessage("binaryInfo", ({ path, version, errorMessage }) => {
75 | let description,
76 | binaryLocation = "";
77 | if (path === "youtube-dl") {
78 | description = `You are using the yt-dlp binary bundled with IINA.
79 | Since IINA's update frequency is lower than yt-dlp, it can be outdated and you may encounter issues.
80 | It is recommended to download the latest version using the button below.`;
81 | } else if (path === "@data/yt-dlp/yt-dlp_macos") {
82 | description = `You are using the yt-dlp binary managed by this plugin. You can update it using the button below.`;
83 | } else {
84 | binaryLocation = path;
85 | description = `It seems that you are using a custom yt-dlp binary. You may need to update it manually.`;
86 | document.getElementById("download-binary").style.display = "none";
87 | }
88 | if (errorMessage) {
89 | document.getElementById("binary-version").textContent = errorMessage;
90 | } else {
91 | let message = "Version: " + version;
92 | if (binaryLocation) {
93 | message += ` binary location: ${binaryLocation}`;
94 | }
95 | document.getElementById("binary-version").innerHTML = message;
96 | }
97 | document.getElementById("binary-desc").textContent = description;
98 | });
99 | }
100 |
101 | const TEMPLATE = `
102 |
103 | {{#data}}
104 |
105 |
{{filename}}
106 |
107 |
108 | {{#is_pending}}
109 | Pending
110 | {{/is_pending}}
111 | {{#is_downloading}}
112 | {{dl}}/{{total}} (ETA {{eta}})
113 | {{/is_downloading}}
114 | {{#is_done}}
115 | Done
116 | {{/is_done}}
117 | {{#is_error}}
118 | Error: {{error}}
119 | {{/is_error}}
120 |
121 |
127 |
128 |
129 | {{/data}}
130 | {{^data}}
131 |
No downloads. If you just started a download, it may take a few seconds to show up here.
132 | {{/data}}
133 |
134 | `;
135 |
--------------------------------------------------------------------------------
/src/global.ts:
--------------------------------------------------------------------------------
1 | import { downloadYTDLP, findBinary } from "./binary";
2 | import { downloadVideo, resetStatusNeedUpdate, statusNeedUpdate, tasks } from "./download";
3 |
4 | let { console, global, menu, standaloneWindow, file, utils } = iina;
5 |
6 | // Menu
7 |
8 | menu.addItem(
9 | menu.item("Manage yt-dlp and Downloads...", async () => {
10 | showDownloadsWindow();
11 | }),
12 | );
13 |
14 | // Downloads window
15 |
16 | global.onMessage("downloadVideo", async (url, player) => {
17 | if (url) {
18 | await downloadVideo(url.toString(), player);
19 | global.postMessage(player, "downloading", true);
20 | showDownloadsWindow();
21 | }
22 | });
23 |
24 | export function updateDownloadsWindow() {
25 | const active = !tasks.every((t) => t.status === "done" || t.status === "error");
26 | standaloneWindow.postMessage("update", {
27 | active,
28 | data: tasks.map((t) => t.serialize()),
29 | });
30 | }
31 |
32 | function showDownloadsWindow() {
33 | standaloneWindow.loadFile("downloads.html");
34 | standaloneWindow.setProperty({
35 | title: "Downloads",
36 | resizable: true,
37 | fullSizeContentView: false,
38 | hideTitleBar: false,
39 | });
40 | standaloneWindow.setFrame(320, 400);
41 |
42 | standaloneWindow.onMessage("requestUpdate", ({ force }) => {
43 | if (!force && !statusNeedUpdate) return;
44 | updateDownloadsWindow();
45 | resetStatusNeedUpdate();
46 | });
47 |
48 | standaloneWindow.onMessage("openFile", ({ file }) => {
49 | global.createPlayerInstance({ url: file });
50 | });
51 |
52 | standaloneWindow.onMessage("revealFile", ({ fileName }) => {
53 | file.showInFinder(fileName);
54 | });
55 |
56 | standaloneWindow.onMessage("getBinaryInfo", async () => {
57 | const path = findBinary();
58 | console.log("Binary path: " + path);
59 | const res = await utils.exec(path, ["--version"]);
60 | if (res.status === 0) {
61 | const version = res.stdout;
62 | console.log("Version: " + version);
63 | standaloneWindow.postMessage("binaryInfo", {
64 | path,
65 | version,
66 | errorMessage: "",
67 | });
68 | } else {
69 | const errorMessage =
70 | "Error when executing the binary: " + (res.stderr ? res.stderr : "No error message");
71 | console.log(errorMessage);
72 | standaloneWindow.postMessage("binaryInfo", {
73 | path,
74 | version: "",
75 | errorMessage,
76 | });
77 | }
78 | });
79 |
80 | standaloneWindow.onMessage("updateBinary", () => {
81 | updateYTDLP();
82 | });
83 |
84 | standaloneWindow.open();
85 | }
86 |
87 | // Update yt-dlp window
88 |
89 | async function updateYTDLP() {
90 | standaloneWindow.postMessage("updatingBinary", null);
91 | let error = await downloadYTDLP();
92 | standaloneWindow.postMessage("binaryUpdated", { updated: !error, error });
93 | }
94 |
95 | async function showDownloadYTDLPWindow() {
96 | showDownloadsWindow();
97 | await new Promise((r) => setTimeout(r, 1000));
98 | updateYTDLP();
99 | }
100 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | import { runYTDLHook } from "./ytdl-hook";
2 | import { opt, isBlacklisted } from "./options";
3 | import { chapterList } from "./add-video";
4 |
5 | const { core, console, global, mpv } = iina;
6 |
7 | if (!opt.try_ytdl_first) {
8 | mpv.addHook("on_load", 10, async (next) => {
9 | console.log("ytdl:// hook");
10 | const url = mpv.getString("stream-open-filename");
11 | if (url.startsWith("ytdl://")) {
12 | await runYTDLHook(url);
13 | }
14 | next();
15 | });
16 | }
17 |
18 | mpv.addHook(opt.try_ytdl_first ? "on_load" : "on_load_fail", 10, async (next) => {
19 | console.log("ytdl full hook");
20 | const url = mpv.getString("stream-open-filename");
21 | if (url.startsWith("ytdl://") || url.startsWith("http://") || url.startsWith("https://")) {
22 | if (!isBlacklisted(url)) {
23 | await runYTDLHook(url);
24 | }
25 | }
26 | next();
27 | });
28 |
29 | mpv.addHook("on_preloaded", 10, () => {
30 | console.log("ytdl preload hook");
31 | if (chapterList.length > 0) {
32 | console.log("Setting chapter list");
33 | console.log(chapterList);
34 | mpv.set("chapter-list", chapterList);
35 | chapterList.length = 0;
36 | }
37 | });
38 |
39 | global.onMessage("downloading", () => {
40 | core.osd("Video downloading");
41 | });
42 |
43 | global.onMessage("downloaded", () => {
44 | core.osd("Video downloaded");
45 | });
46 |
--------------------------------------------------------------------------------
/src/options.ts:
--------------------------------------------------------------------------------
1 | const { console, preferences } = iina;
2 |
3 | export const opt = {
4 | get exclude(): string {
5 | return preferences.get("excluded_urls");
6 | },
7 | get ytdl_path(): string {
8 | return preferences.get("ytdl_path");
9 | },
10 | get try_ytdl_first(): boolean {
11 | return preferences.get("try_ytdl_first");
12 | },
13 | get use_manifests(): boolean {
14 | return preferences.get("use_manifests");
15 | },
16 | get rawOptions(): string {
17 | return preferences.get("raw_options");
18 | },
19 | get format(): string {
20 | switch (preferences.get("video_quality")) {
21 | case "use_max":
22 | const maxHeight = preferences.get("max_video_height");
23 | return `bestvideo[height<=${maxHeight}]+bestaudio/best[height<=${maxHeight}]`;
24 | case "custom":
25 | return preferences.get("custom_ytdl_format");
26 | default:
27 | // best
28 | return "bestvideo+bestaudio/best";
29 | }
30 | },
31 | get includeSubs(): boolean {
32 | return preferences.get("include_subs");
33 | },
34 | get includeAutoSubs(): boolean {
35 | return preferences.get("include_auto_subs");
36 | },
37 | };
38 |
39 | let urlBlackList: RegExp[];
40 |
41 | export function isBlacklisted(url: string) {
42 | if (opt.exclude === "") return false;
43 | if (!urlBlackList) {
44 | urlBlackList = opt.exclude.split("|").map((s) => new RegExp(s));
45 | }
46 | const match = url.match(/^https?:\/\/(.+?)$/);
47 | if (!match) return false;
48 | const body = match[1] || "";
49 | if (urlBlackList.some((b) => body.match(b))) {
50 | console.log("URL matches excluded substring. Skipping.");
51 | return true;
52 | }
53 | return false;
54 | }
55 |
--------------------------------------------------------------------------------
/src/utils.ts:
--------------------------------------------------------------------------------
1 | const { console, mpv } = iina;
2 |
3 | const safeProtos = new Set([
4 | "http",
5 | "https",
6 | "ftp",
7 | "ftps",
8 | "rtmp",
9 | "rtmps",
10 | "rtmpe",
11 | "rtmpt",
12 | "rtmpts",
13 | "rtmpte",
14 | "data",
15 | ]);
16 |
17 | export function ytdlCodecToMpvCodec(codec: string) {
18 | if (codec === "vtt") return "webvtt";
19 | if (codec === "opus" || codec === "vp9") return codec;
20 | if (codec.startsWith("avc")) return "h264";
21 | if (codec.startsWith("av0")) return "av1";
22 | if (codec.startsWith("mp4")) return "aac";
23 | return null;
24 | }
25 |
26 | export function optionWasSet(name: string) {
27 | return mpv.getFlag(`"option-info/${name}/set-from-commandline"`);
28 | }
29 |
30 | export function optionWasSetLocally(name: string) {
31 | return mpv.getFlag(`"option-info/${name}/set-locally"`);
32 | }
33 |
34 | export function setHTTPHeaders(headers: Record) {
35 | if (!headers) return;
36 |
37 | const ua = headers["User-Agent"];
38 | if (ua && !optionWasSet("user-agent")) {
39 | mpv.set("file-local-options/user-agent", ua);
40 | }
41 |
42 | const mpvHeaders: string[] = [];
43 | for (const extraField of ["Cookie", "Referer", "X-Forwarded-For"]) {
44 | const value = headers[extraField];
45 | if (value) {
46 | mpvHeaders.push(`${extraField}: ${value}`);
47 | }
48 | }
49 |
50 | if (mpvHeaders.length > 0 && !optionWasSet("http-header-fields")) {
51 | mpv.set("file-local-options/http-header-fields", mpvHeaders);
52 | }
53 | }
54 |
55 | export function edlEscape(url: string) {
56 | return `%${url.length}%${url}`;
57 | }
58 |
59 | export function isSafeURL(url: string) {
60 | if (typeof url !== "string") return;
61 | const match = url.match(/^(.+?):\/\//);
62 | if (match[1] && safeProtos.has(match[1])) {
63 | return true;
64 | }
65 | console.log(`Ignoring potentially unsafe url ${url}`);
66 | return false;
67 | }
68 |
69 | export function getIndexFromYouTubePlaylist(url: string, json: YTDL.Playlist) {
70 | if (!json.extractor || json.extractor !== "youtube:playlist") return null;
71 |
72 | const index = url.indexOf("?");
73 | if (index < 0 || index === url.length - 1) return null;
74 | const query = url
75 | .substr(index + 1)
76 | .split("&")
77 | .map((x) => x.split("="));
78 |
79 | const args: Record = {};
80 | query.forEach(([name, value]) => (args[name] = value));
81 |
82 | const maybeIdx = parseInt(args.index);
83 |
84 | if (maybeIdx && json.entries.length >= maybeIdx && json.entries[maybeIdx].id === args.v) {
85 | console.log("index matches requested video");
86 | return maybeIdx;
87 | }
88 |
89 | const idx = json.entries.findIndex((e) => e.id === args.v);
90 | if (idx >= 0) return idx;
91 |
92 | console.log("requested video not found in playlist");
93 | return null;
94 | }
95 |
96 | function joinURL(baseURL: string, fragment: YTDL.URLLike) {
97 | if (baseURL && fragment.path) {
98 | // make absolute url
99 | const url = fragment.path;
100 | if (url.startsWith("http://") || url.startsWith("https://")) return url;
101 | const [, proto, domain, rest] = baseURL.match(/(https?:\/\/)([^\/]+\/)(.*)\/?/);
102 | const segs = rest.split("/").concat(url.split("/"));
103 | const resolved: string[] = [];
104 | for (const seg of segs) {
105 | if (seg === "..") {
106 | resolved.pop();
107 | } else if (seg !== ".") {
108 | resolved.push(seg);
109 | }
110 | }
111 | return `${proto}${domain}${resolved.join("/")}`;
112 | } else {
113 | return fragment.url || "";
114 | }
115 | }
116 |
117 | export function edlTrackJoined(
118 | fragments: YTDL.URLLike[],
119 | protocol?: YTDL.Protocol,
120 | isLive?: boolean,
121 | base?: string,
122 | ) {
123 | if (!fragments || fragments.length === 0) {
124 | console.log("No fragments to join into EDL");
125 | return null;
126 | }
127 |
128 | const parts: string[] = [];
129 |
130 | if (protocol === "http_dash_segments" && !fragments[0].duration && !isLive) {
131 | // assume MP4 DASH initialization segment
132 | parts.push(`!mp4_dash,init=${edlEscape(joinURL(base, fragments[0]))}`);
133 |
134 | for (let i = 1; i < fragments.length; i++) {
135 | if (!fragments[i].duration) {
136 | console.error("EDL doesn't support fragments without duration with MP4 DASH");
137 | return null;
138 | }
139 | }
140 | }
141 |
142 | for (const frag of fragments) {
143 | if (!isSafeURL(joinURL(base, frag))) return null;
144 | parts.push(edlEscape(joinURL(base, frag)) + frag.duration ? `,length=${frag.duration}` : "");
145 | }
146 |
147 | return `edl://${parts.join(";")};`;
148 | }
149 |
150 | export function formatFileSize(bytes: number, si = false, dp = 1) {
151 | const thresh = si ? 1000 : 1024;
152 |
153 | if (Math.abs(bytes) < thresh) {
154 | return bytes + " B";
155 | }
156 |
157 | const units = si
158 | ? ["kB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"]
159 | : ["KiB", "MiB", "GiB", "TiB", "PiB", "EiB", "ZiB", "YiB"];
160 | let u = -1;
161 | const r = 10 ** dp;
162 |
163 | do {
164 | bytes /= thresh;
165 | ++u;
166 | } while (Math.round(Math.abs(bytes) * r) / r >= thresh && u < units.length - 1);
167 |
168 | return bytes.toFixed(dp) + " " + units[u];
169 | }
170 |
171 | export function formatSeconds(sec: number) {
172 | return new Date(sec * 1000).toISOString().substring(sec < 3600 ? 14 : 11, 19);
173 | }
174 |
--------------------------------------------------------------------------------
/src/ytdl-hook.ts:
--------------------------------------------------------------------------------
1 | import {
2 | setHTTPHeaders,
3 | isSafeURL,
4 | edlEscape,
5 | optionWasSet,
6 | getIndexFromYouTubePlaylist,
7 | edlTrackJoined,
8 | } from "./utils";
9 | import { addVideo, isSwitchingFormat } from "./add-video";
10 | import { opt } from "./options";
11 | import { findBinary } from "./binary";
12 |
13 | const { core, console, mpv, utils } = iina;
14 |
15 | export let currentURL: string;
16 |
17 | interface TempOption {
18 | proxy: string | null;
19 | usePlaylist: boolean;
20 | }
21 |
22 | export async function runYTDLHook(url: string) {
23 | if (isSwitchingFormat) {
24 | core.osd("Switching quality…");
25 | } else {
26 | core.osd("Fetching online media information…");
27 | }
28 |
29 | let format = opt.format;
30 | let allsubs = true;
31 | const option: TempOption = {
32 | proxy: null,
33 | usePlaylist: false,
34 | };
35 |
36 | if (url.startsWith("ytdl://")) {
37 | url = url.substring(7);
38 | }
39 |
40 | currentURL = url;
41 |
42 | const args = [
43 | "--no-warnings",
44 | "--dump-single-json",
45 | "--flat-playlist",
46 | "--sub-format",
47 | "ass/srt/best",
48 | ];
49 |
50 | if (mpv.getString("options/vid") === "no") {
51 | format = "bestaudio/best";
52 | console.log("Video is disabled. Only use audio");
53 | }
54 |
55 | args.push("--format", format);
56 |
57 | const rawOptions = opt.rawOptions;
58 | const includeSubs = opt.includeSubs;
59 | const includeAutoSubs = includeSubs && opt.includeAutoSubs;
60 |
61 | rawOptions.split(" ").forEach((rawArg, index) => {
62 | let arg = rawArg;
63 | if (rawArg.includes("—")) {
64 | arg = rawArg.replace("—", "--");
65 | console.warn(`Argument ${rawArg} contains "—", trying to autocorrect`);
66 | }
67 | if (arg.startsWith("--")) {
68 | let argName: string;
69 | let argValue: string;
70 | // handle both --arg=value and --arg value cases
71 | if (arg.includes("=")) {
72 | const splitted = arg.split("=");
73 | argName = splitted[0];
74 | argValue = splitted[1];
75 | } else {
76 | argName = arg.substring(2);
77 | argValue = rawOptions[index + 1];
78 | }
79 | if (["sub-lang", "sub-langs", "srt-lang"].includes(argName) && argValue) {
80 | allsubs = false;
81 | } else if (argName === "proxy" && argValue) {
82 | option.proxy = argValue;
83 | } else if (argName === "yes-playlist") {
84 | option.usePlaylist = true;
85 | }
86 | }
87 | if (arg) args.push(arg);
88 | });
89 |
90 | if (allsubs && includeSubs) {
91 | args.push("--sub-langs", "all");
92 | }
93 | if (includeAutoSubs) {
94 | args.push("--write-auto-subs");
95 | }
96 | if (!option.usePlaylist) {
97 | args.push("--no-playlist");
98 | }
99 |
100 | args.push("--", url);
101 |
102 | try {
103 | console.log("Running youtube-dl...");
104 |
105 | // find the binary
106 | const ytdl = findBinary();
107 |
108 | // execute
109 | const out = await utils.exec(ytdl, args);
110 | if (out.status !== 0) {
111 | core.osd("Failed to run youtube-dl");
112 | console.error(`Error running youtube-dl: ${out.stderr}`);
113 | return;
114 | }
115 | console.log("Finished running youtube-dl");
116 |
117 | // parse the result
118 | try {
119 | let json = JSON.parse(out.stdout);
120 | console.log("Youtube-dl succeeded.");
121 | ytdlSuccess(url, json, option);
122 | } catch {
123 | core.osd("Failed to fetch online media information");
124 | console.error(`Failed to parse youtube-dl's output`);
125 | }
126 | } catch (err) {
127 | core.osd("Unknown error.");
128 | console.error(`Unexpected error: ${err}`);
129 | }
130 | }
131 |
132 | function ytdlSuccess(url: string, json: YTDL.Entity, option: TempOption) {
133 | core.osd("Opening media…");
134 | json.proxy = json.proxy || option.proxy;
135 |
136 | if (json.direct) {
137 | console.log("Got direct URL");
138 | return;
139 | } else if (json._type === "playlist" || json._type === "multi_video") {
140 | // a playlist
141 | if (json.entries.length === 0) {
142 | console.warn("Got empty playlist, nothing to play");
143 | return;
144 | }
145 |
146 | const isSelfRedirectingURL =
147 | json.entries[0]._type !== "url_transparent" &&
148 | json.entries[0].webpage_url &&
149 | json.entries[0].webpage_url === json.webpage_url;
150 |
151 | if (isSelfRedirectingURL) {
152 | if (
153 | json.entries.length > 1 &&
154 | json.entries[0].protocol === "m3u8_native" &&
155 | json.entries[0].url
156 | ) {
157 | console.log("Multi-arc video detected, building EDL");
158 |
159 | const playlist = edlTrackJoined(json.entries);
160 | console.log(`EDL: ${playlist}`);
161 | if (!playlist) return;
162 |
163 | setHTTPHeaders(json.entries[0].http_headers);
164 | mpv.set("stream-open-filename", playlist);
165 | if (json.title) {
166 | mpv.set("file-local-options/force-media-title", json.title);
167 | }
168 |
169 | // there might not be subs for the first segment
170 | const entryWithSubs = json.entries.find((entry) => entry.requested_subtitles);
171 | if (entryWithSubs && entryWithSubs.duration) {
172 | const subs = entryWithSubs.requested_subtitles;
173 | Object.keys(subs).forEach((lang) => {
174 | let subFile = "edl://";
175 | for (const entry of json.entries) {
176 | if (
177 | entry.requested_subtitles &&
178 | entry.requested_subtitles[lang] &&
179 | isSafeURL(entry.requested_subtitles[lang].url)
180 | ) {
181 | subFile += edlEscape(entry.requested_subtitles[lang].url);
182 | } else {
183 | subFile += edlEscape("memory://WEBVTT");
184 | }
185 | subFile = `${subFile},length=${entry.duration};`;
186 | }
187 | console.log(`${lang} sub EDL: ${subFile}`);
188 | mpv.command("sub-add", [subFile, "auto", subs[lang].ext, lang]);
189 | });
190 | }
191 | } else if (json.entries.length === 1) {
192 | console.log("Playlist with single entry detected");
193 | addVideo(json.entries[0]);
194 | }
195 | } else {
196 | const playlistIndex = getIndexFromYouTubePlaylist(url, json);
197 | const playlist = ["#EXTM3U"];
198 |
199 | for (const entry of json.entries) {
200 | let site = entry.url;
201 | const title = entry.title;
202 | if (title) playlist.push(`#EXTINF:0,${title.replace(/\s+/, " ")}`);
203 |
204 | if (entry.webpage_url && !isSelfRedirectingURL) {
205 | site = entry.webpage_url;
206 | }
207 |
208 | if (site.indexOf("://") < 0) {
209 | const prefix = site.indexOf(":") >= 0 ? "ytdl://" : "https://youtu.be/";
210 | playlist.push(`${prefix}${site}`);
211 | } else if (isSafeURL(site)) {
212 | playlist.push(site);
213 | }
214 | }
215 |
216 | if (option.usePlaylist && optionWasSet("playlist-start") && playlistIndex) {
217 | mpv.set("playlist-start", playlistIndex);
218 | }
219 |
220 | mpv.set("stream-open-filename", `memory://${playlist.join("\n")}`);
221 | }
222 | } else {
223 | // single video
224 | addVideo(json);
225 | }
226 | }
227 |
--------------------------------------------------------------------------------
/src/ytdl.d.ts:
--------------------------------------------------------------------------------
1 | declare namespace YTDL {
2 | export type Protocol =
3 | | "http"
4 | | "https"
5 | | "rtsp"
6 | | "rtmp"
7 | | "rtmpe"
8 | | "mms"
9 | | "f4m"
10 | | "ism"
11 | | "http_dash_segments"
12 | | "m3u8"
13 | | "m3u8_native";
14 | export interface Chapter {
15 | start_time: number;
16 | end_time: number;
17 | title: string;
18 | }
19 |
20 | interface _BaseEntity {
21 | _type?: string;
22 |
23 | id: string;
24 | title: string;
25 | description: string;
26 | format: string;
27 | format_id: string;
28 | format_note: string;
29 | url: string;
30 | manifest_url?: string;
31 | app: string;
32 | play_path: string;
33 | player_url: string;
34 | ext: string;
35 | width: number;
36 | height: number;
37 | vcodec: string;
38 | acodec: string;
39 | fps: number;
40 | filesize: number;
41 | tbr: number;
42 | quality: number;
43 | duration?: number;
44 | start_time: number;
45 | stretched_ratio: number;
46 | is_live?: boolean;
47 | fragments?: URLLike[];
48 | fragment_base_url?: string;
49 | protocol?: Protocol;
50 | http_headers?: Record;
51 | dynamic_range?: string;
52 | chapters: Chapter[];
53 |
54 | requested_subtitles?: Record<
55 | string,
56 | { url: string; ext: string; name?: string; data?: string; _auto: boolean }
57 | >;
58 | formats?: Video[];
59 | requested_formats?: Video[];
60 |
61 | proxy?: string;
62 |
63 | extractor: string;
64 | extractor_key: string;
65 | page_url: string;
66 | webpage_url: string;
67 |
68 | direct?: boolean;
69 | }
70 |
71 | export interface Playlist extends _BaseEntity {
72 | _type: "playlist" | "multi_video";
73 | entries: URLLike[];
74 | }
75 |
76 | export interface Video extends _BaseEntity {
77 | _type?: "video";
78 | playlist: string;
79 | playlist_index: number;
80 | }
81 |
82 | export interface URLLike extends _BaseEntity {
83 | _type: "url" | "url_transparent";
84 | url: string;
85 | extract_flat: boolean;
86 | path?: string;
87 | }
88 |
89 | export interface URL extends URLLike {
90 | _type: "url";
91 | }
92 |
93 | export interface URLTransparent extends URLLike {
94 | _type: "url_transparent";
95 | }
96 |
97 | export type Entity = Playlist | Video | URL | URLTransparent;
98 | }
99 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "lib": ["es6", "es7", "esnext"],
4 | "sourceMap": false,
5 | "target": "esnext",
6 | "module": "es6",
7 | "types": [],
8 | "typeRoots": [
9 | "./node_modules/@types",
10 | "./node_modules/iina-plugin-definition"
11 | ]
12 | },
13 | "include": [
14 | "src/**/*",
15 | "./node_modules/iina-plugin-definition/iina/**/*.d.ts"
16 | ],
17 | "compileOnSave": false
18 | }
19 |
--------------------------------------------------------------------------------