├── .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 | 179 |
180 |
181 | 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 |
63 |
64 | 66 |
67 |
68 | 70 | 71 | p 72 |
73 |
74 | 76 | 77 |
78 |
79 |
80 |
81 | 87 |
88 |
89 | 93 |
94 | 98 |
99 |
100 | 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 | 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 | 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 |
122 | {{#is_done}} 123 | Open 124 | Reveal in Finder 125 | {{/is_done}} 126 |
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 | --------------------------------------------------------------------------------