Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
aandrew-me
GitHub Repository: aandrew-me/ytDownloader
Path: blob/main/src/playlist.js
448 views
1
const {clipboard, ipcRenderer} = require("electron");
2
const {default: YTDlpWrap} = require("yt-dlp-wrap-plus");
3
const path = require("path");
4
const os = require("os");
5
const fs = require("fs");
6
const {execSync} = require("child_process");
7
const {constants} = require("fs/promises");
8
9
const playlistDownloader = {
10
// State and config
11
state: {
12
url: null,
13
downloadDir: null,
14
ytDlpPath: null,
15
ytDlpWrap: null,
16
ffmpegPath: null,
17
jsRuntimePath: null,
18
playlistName: "",
19
originalCount: 0,
20
currentDownloadProcess: null,
21
},
22
23
config: {
24
foldernameFormat: "%(playlist_title)s",
25
filenameFormat: "%(playlist_index)s.%(title)s.%(ext)s",
26
proxy: "",
27
cookie: {
28
browser: "",
29
arg: "",
30
},
31
configFile: {
32
arg: "",
33
path: "",
34
},
35
playlistRange: {
36
start: 1,
37
end: "",
38
},
39
},
40
41
// DOM elements
42
ui: {
43
pasteLinkBtn: document.getElementById("pasteLink"),
44
linkDisplay: document.getElementById("link"),
45
optionsContainer: document.getElementById("options"),
46
downloadList: document.getElementById("list"),
47
48
downloadVideoBtn: document.getElementById("download"),
49
downloadAudioBtn: document.getElementById("audioDownload"),
50
downloadThumbnailsBtn: document.getElementById("downloadThumbnails"),
51
saveLinksBtn: document.getElementById("saveLinks"),
52
53
selectLocationBtn: document.getElementById("selectLocation"),
54
pathDisplay: document.getElementById("path"),
55
openDownloadsBtn: document.getElementById("openDownloads"),
56
57
videoToggle: document.getElementById("videoToggle"),
58
audioToggle: document.getElementById("audioToggle"),
59
advancedToggle: document.getElementById("advancedToggle"),
60
videoBox: document.getElementById("videoBox"),
61
audioBox: document.getElementById("audioBox"),
62
videoQualitySelect: document.getElementById("select"),
63
videoTypeSelect: document.getElementById("videoTypeSelect"),
64
typeSelectBox: document.getElementById("typeSelectBox"),
65
audioTypeSelect: document.getElementById("audioSelect"),
66
audioQualitySelect: document.getElementById("audioQualitySelect"),
67
68
advancedMenu: document.getElementById("advancedMenu"),
69
playlistIndexInput: document.getElementById("playlistIndex"),
70
playlistEndInput: document.getElementById("playlistEnd"),
71
subtitlesCheckbox: document.getElementById("subChecked"),
72
closeHiddenBtn: document.getElementById("closeHidden"),
73
74
playlistNameDisplay: document.getElementById("playlistName"),
75
errorMsgDisplay: document.getElementById("incorrectMsgPlaylist"),
76
errorBtn: document.getElementById("errorBtn"),
77
errorDetails: document.getElementById("errorDetails"),
78
79
menuIcon: document.getElementById("menuIcon"),
80
menu: document.getElementById("menu"),
81
preferenceWinBtn: document.getElementById("preferenceWin"),
82
aboutWinBtn: document.getElementById("aboutWin"),
83
historyWinBtn: document.getElementById("historyWin"),
84
homeWinBtn: document.getElementById("homeWin"),
85
compressorWinBtn: document.getElementById("compressorWin"),
86
},
87
88
init() {
89
this.loadInitialConfig();
90
this.initEventListeners();
91
92
// Set initial UI state
93
this.ui.pathDisplay.textContent = this.state.downloadDir;
94
this.ui.videoToggle.style.backgroundColor = "var(--box-toggleOn)";
95
this.updateVideoTypeVisibility();
96
97
// Load translations when ready
98
document.addEventListener("translations-loaded", () => {
99
window.i18n.translatePage();
100
});
101
102
console.log(`yt-dlp path: ${this.state.ytDlpPath}`);
103
console.log(`ffmpeg path: ${this.state.ffmpegPath}`);
104
},
105
106
loadInitialConfig() {
107
// yt-dlp path
108
this.state.ytDlpPath = localStorage.getItem("ytdlp");
109
this.state.ytDlpWrap = new YTDlpWrap(`"${this.state.ytDlpPath}"`);
110
111
const defaultDownloadsDir = path.join(os.homedir(), "Downloads");
112
let preferredDir =
113
localStorage.getItem("downloadPath") || defaultDownloadsDir;
114
try {
115
fs.accessSync(preferredDir, constants.W_OK);
116
this.state.downloadDir = preferredDir;
117
} catch (err) {
118
console.error(
119
"Unable to write to preferred download directory. Reverting to default.",
120
err
121
);
122
this.state.downloadDir = defaultDownloadsDir;
123
localStorage.setItem("downloadPath", defaultDownloadsDir);
124
}
125
126
// ffmpeg and js runtime path setup
127
this.state.ffmpegPath = this.getFfmpegPath();
128
this.state.jsRuntimePath = this.getJsRuntimePath();
129
130
if (localStorage.getItem("preferredVideoQuality")) {
131
this.ui.videoQualitySelect.value = localStorage.getItem(
132
"preferredVideoQuality"
133
);
134
}
135
if (localStorage.getItem("preferredAudioQuality")) {
136
this.ui.audioQualitySelect.value = localStorage.getItem(
137
"preferredAudioQuality"
138
);
139
}
140
},
141
142
initEventListeners() {
143
this.ui.pasteLinkBtn.addEventListener("click", () => this.pasteLink());
144
document.addEventListener("keydown", (event) => {
145
if (
146
(event.ctrlKey && event.key === "v") ||
147
(event.metaKey &&
148
event.key === "v" &&
149
os.platform() === "darwin" &&
150
document.activeElement.tagName !== "INPUT" &&
151
document.activeElement.tagName !== "TEXTAREA")
152
) {
153
this.pasteLink();
154
}
155
});
156
157
this.ui.downloadVideoBtn.addEventListener("click", () =>
158
this.startDownload("video")
159
);
160
this.ui.downloadAudioBtn.addEventListener("click", () =>
161
this.startDownload("audio")
162
);
163
this.ui.downloadThumbnailsBtn.addEventListener("click", () =>
164
this.startDownload("thumbnails")
165
);
166
this.ui.saveLinksBtn.addEventListener("click", () =>
167
this.startDownload("links")
168
);
169
170
this.ui.videoToggle.addEventListener("click", () =>
171
this.toggleDownloadType("video")
172
);
173
this.ui.audioToggle.addEventListener("click", () =>
174
this.toggleDownloadType("audio")
175
);
176
this.ui.advancedToggle.addEventListener("click", () =>
177
this.toggleAdvancedMenu()
178
);
179
this.ui.videoQualitySelect.addEventListener("change", () =>
180
this.updateVideoTypeVisibility()
181
);
182
this.ui.selectLocationBtn.addEventListener("click", () =>
183
ipcRenderer.send("select-location-main", "")
184
);
185
this.ui.openDownloadsBtn.addEventListener("click", () =>
186
this.openDownloadsFolder()
187
);
188
this.ui.closeHiddenBtn.addEventListener("click", () =>
189
this.hideOptions(true)
190
);
191
192
this.ui.preferenceWinBtn.addEventListener("click", () =>
193
this.navigate("page", "/preferences.html")
194
);
195
this.ui.aboutWinBtn.addEventListener("click", () =>
196
this.navigate("page", "/about.html")
197
);
198
this.ui.historyWinBtn.addEventListener("click", () =>
199
this.navigate("page", "/history.html")
200
);
201
this.ui.homeWinBtn.addEventListener("click", () =>
202
this.navigate("win", "/index.html")
203
);
204
this.ui.compressorWinBtn.addEventListener("click", () =>
205
this.navigate("win", "/compressor.html")
206
);
207
208
ipcRenderer.on("downloadPath", (_event, downloadPath) => {
209
if (downloadPath && downloadPath[0]) {
210
this.ui.pathDisplay.textContent = downloadPath[0];
211
this.state.downloadDir = downloadPath[0];
212
}
213
});
214
},
215
216
startDownload(type) {
217
if (!this.state.url) {
218
this.showError("URL is missing. Please paste a link first.");
219
return;
220
}
221
this.updateDynamicConfig();
222
this.hideOptions();
223
224
const controller = new AbortController();
225
const baseArgs = this.buildBaseArgs();
226
let specificArgs = [];
227
228
switch (type) {
229
case "video":
230
specificArgs = this.getVideoArgs();
231
break;
232
case "audio":
233
specificArgs = this.getAudioArgs();
234
break;
235
case "thumbnails":
236
specificArgs = this.getThumbnailArgs();
237
break;
238
case "links":
239
specificArgs = this.getLinkArgs();
240
break;
241
}
242
243
const allArgs = [
244
...baseArgs,
245
...specificArgs,
246
`"${this.state.url}"`,
247
].filter(Boolean);
248
249
console.log(`Command: ${this.state.ytDlpPath}`, allArgs.join(" "));
250
this.state.currentDownloadProcess = this.state.ytDlpWrap.exec(
251
allArgs,
252
{shell: true, detached: false},
253
controller.signal
254
);
255
256
this.handleDownloadEvents(this.state.currentDownloadProcess, type);
257
},
258
259
buildBaseArgs() {
260
const {start, end} = this.config.playlistRange;
261
const outputPath = `"${path.join(
262
this.state.downloadDir,
263
this.config.foldernameFormat,
264
this.config.filenameFormat
265
)}"`;
266
267
return [
268
"--yes-playlist",
269
"-o",
270
outputPath,
271
"-I",
272
`"${start}:${end}"`,
273
"--ffmpeg-location",
274
`"${this.state.ffmpegPath}"`,
275
...(this.state.jsRuntimePath
276
? ["--no-js-runtimes", "--js-runtime", this.state.jsRuntimePath]
277
: []),
278
this.config.cookie.arg,
279
this.config.cookie.browser,
280
this.config.configFile.arg,
281
this.config.configFile.path,
282
...(this.config.proxy
283
? ["--no-check-certificate", "--proxy", this.config.proxy]
284
: []),
285
"--compat-options",
286
"no-youtube-unavailable-videos",
287
].filter(Boolean);
288
},
289
290
getVideoArgs() {
291
const quality = this.ui.videoQualitySelect.value;
292
const videoType = this.ui.videoTypeSelect.value;
293
let formatArgs = [];
294
295
if (quality === "best") {
296
formatArgs = ["-f", "bv*+ba/best"];
297
} else if (quality === "worst") {
298
formatArgs = ["-f", "wv+wa/worst"];
299
} else if (quality === "useConfig") {
300
formatArgs = [];
301
} else {
302
if (videoType === "mp4") {
303
formatArgs = [
304
"-f",
305
`"bestvideo[height<=${quality}]+bestaudio[ext=m4a]/best[height<=${quality}]/best"`,
306
"--merge-output-format",
307
"mp4",
308
"--recode-video",
309
"mp4",
310
];
311
} else if (videoType === "webm") {
312
formatArgs = [
313
"-f",
314
`"bestvideo[height<=${quality}]+bestaudio[ext=webm]/best[height<=${quality}]/best"`,
315
"--merge-output-format",
316
"webm",
317
"--recode-video",
318
"webm",
319
];
320
} else {
321
formatArgs = [
322
"-f",
323
`"bv*[height=${quality}]+ba/best[height=${quality}]/best[height<=${quality}]"`,
324
];
325
}
326
}
327
328
const isYouTube =
329
this.state.url.includes("youtube.com/") ||
330
this.state.url.includes("youtu.be/");
331
const canEmbedThumb = os.platform() !== "darwin";
332
333
return [
334
...formatArgs,
335
"--embed-metadata",
336
this.ui.subtitlesCheckbox.checked ? "--write-subs" : "",
337
this.ui.subtitlesCheckbox.checked ? "--sub-langs" : "",
338
this.ui.subtitlesCheckbox.checked ? "all" : "",
339
videoType === "mp4" && isYouTube && canEmbedThumb
340
? "--embed-thumbnail"
341
: "",
342
].filter(Boolean);
343
},
344
345
getAudioArgs() {
346
const format = this.ui.audioTypeSelect.value;
347
const quality = this.ui.audioQualitySelect.value;
348
const isYouTube =
349
this.state.url.includes("youtube.com/") ||
350
this.state.url.includes("youtu.be/");
351
const canEmbedThumb = os.platform() !== "darwin";
352
353
if (isYouTube && format === "m4a" && quality === "auto") {
354
return [
355
"-f",
356
`ba[ext=${format}]/ba`,
357
"--embed-metadata",
358
canEmbedThumb ? "--embed-thumbnail" : "",
359
];
360
}
361
362
return [
363
"-x",
364
"--audio-format",
365
format,
366
"--audio-quality",
367
quality,
368
"--embed-metadata",
369
(format === "mp3" || (format === "m4a" && isYouTube)) &&
370
canEmbedThumb
371
? "--embed-thumbnail"
372
: "",
373
];
374
},
375
376
getThumbnailArgs() {
377
return [
378
"--write-thumbnail",
379
"--convert-thumbnails",
380
"png",
381
"--skip-download",
382
];
383
},
384
385
getLinkArgs() {
386
const linksFilePath = `"${path.join(
387
this.state.downloadDir,
388
this.config.foldernameFormat,
389
"links.txt"
390
)}"`;
391
return [
392
"--skip-download",
393
"--print-to-file",
394
"webpage_url",
395
linksFilePath,
396
];
397
},
398
399
// yt-dlp event handling
400
handleDownloadEvents(process, type) {
401
let count = 0;
402
403
process.on("ytDlpEvent", (_eventType, eventData) => {
404
const playlistTxt = "Downloading playlist: ";
405
if (eventData.includes(playlistTxt)) {
406
this.state.playlistName = eventData
407
.split(playlistTxt)[1]
408
.trim();
409
410
this.state.playlistName = this.state.playlistName
411
.replaceAll("|", "|")
412
.replaceAll(`"`, `"`)
413
.replaceAll("*", "*")
414
.replaceAll("/", "⧸")
415
.replaceAll("\\", "⧹")
416
.replaceAll(":", ":")
417
.replaceAll("?", "?");
418
419
if (
420
os.platform() === "win32" &&
421
this.state.playlistName.endsWith(".")
422
) {
423
this.state.playlistName =
424
this.state.playlistName.slice(0, -1) + "#";
425
}
426
427
this.ui.playlistNameDisplay.textContent = `${window.i18n.__(
428
"downloadingPlaylist"
429
)} ${this.state.playlistName}`;
430
}
431
432
const videoIndexTxt = "Downloading item ";
433
const oldVideoIndexTxt = "Downloading video ";
434
if (
435
(eventData.includes(videoIndexTxt) ||
436
eventData.includes(oldVideoIndexTxt)) &&
437
!eventData.includes("thumbnail")
438
) {
439
count++;
440
this.state.originalCount++;
441
this.updatePlaylistUI(count, type);
442
}
443
});
444
445
process.on("progress", (progress) => {
446
const progressElement = document.getElementById(`p${count}`);
447
if (!progressElement) return;
448
449
if (progress.percent === 100) {
450
progressElement.textContent = `${window.i18n.__(
451
"processing"
452
)}...`;
453
} else {
454
progressElement.textContent = `${window.i18n.__("progress")} ${
455
progress.percent
456
}% | ${window.i18n.__("speed")} ${
457
progress.currentSpeed || "N/A"
458
}`;
459
}
460
});
461
462
process.on("error", (error) => this.showError(error));
463
process.on("close", () => this.finishDownload(count));
464
},
465
466
pasteLink() {
467
this.state.url = clipboard.readText();
468
this.ui.linkDisplay.textContent = ` ${this.state.url}`;
469
this.ui.optionsContainer.style.display = "block";
470
this.ui.errorMsgDisplay.textContent = "";
471
this.ui.errorBtn.style.display = "none";
472
},
473
474
updatePlaylistUI(count, type) {
475
let itemTitle = "";
476
switch (type) {
477
case "thumbnails":
478
itemTitle = `${window.i18n.__("thumbnail")} ${
479
this.state.originalCount
480
}`;
481
break;
482
case "links":
483
itemTitle = `${window.i18n.__("link")} ${
484
this.state.originalCount
485
}`;
486
break;
487
default:
488
itemTitle = `${window.i18n.__(type)} ${
489
this.state.originalCount
490
}`;
491
}
492
493
if (count > 1) {
494
const prevProgress = document.getElementById(`p${count - 1}`);
495
if (prevProgress)
496
prevProgress.textContent = window.i18n.__("fileSaved");
497
}
498
499
const itemHTML = `
500
<div class="playlistItem">
501
<p class="itemTitle">${itemTitle}</p>
502
<p class="itemProgress" id="p${count}">${window.i18n.__(
503
"downloading"
504
)}</p>
505
</div>`;
506
this.ui.downloadList.innerHTML += itemHTML;
507
window.scrollTo(0, document.body.scrollHeight);
508
},
509
510
updateDynamicConfig() {
511
// Naming formats from localStorage
512
this.config.foldernameFormat =
513
localStorage.getItem("foldernameFormat") || "%(playlist_title)s";
514
this.config.filenameFormat =
515
localStorage.getItem("filenameFormat") ||
516
"%(playlist_index)s.%(title)s.%(ext)s";
517
518
// Proxy, cookies, config file
519
this.config.proxy = localStorage.getItem("proxy") || "";
520
this.config.cookie.browser = localStorage.getItem("browser") || "";
521
this.config.cookie.arg = this.config.cookie.browser
522
? "--cookies-from-browser"
523
: "";
524
const configPath = localStorage.getItem("configPath");
525
this.config.configFile.path = configPath ? `"${configPath}"` : "";
526
this.config.configFile.arg = configPath ? "--config-location" : "";
527
528
// Playlist range from UI inputs
529
this.config.playlistRange.start =
530
Number(this.ui.playlistIndexInput.value) || 1;
531
this.config.playlistRange.end = this.ui.playlistEndInput.value || "";
532
this.state.originalCount =
533
this.config.playlistRange.start > 1
534
? this.config.playlistRange.start - 1
535
: 0;
536
537
// Reset playlist name for new download
538
this.state.playlistName = "";
539
},
540
541
hideOptions(justHide = false) {
542
this.ui.optionsContainer.style.display = "none";
543
this.ui.downloadList.innerHTML = "";
544
this.ui.errorBtn.style.display = "none";
545
this.ui.errorDetails.style.display = "none";
546
this.ui.errorDetails.textContent = "";
547
this.ui.errorMsgDisplay.style.display = "none";
548
549
if (!justHide) {
550
this.ui.playlistNameDisplay.textContent = `${window.i18n.__(
551
"processing"
552
)}...`;
553
this.ui.pasteLinkBtn.style.display = "none";
554
this.ui.openDownloadsBtn.style.display = "inline-block";
555
}
556
},
557
558
finishDownload(count) {
559
const lastProgress = document.getElementById(`p${count}`);
560
if (lastProgress)
561
lastProgress.textContent = window.i18n.__("fileSaved");
562
this.ui.pasteLinkBtn.style.display = "inline-block";
563
this.ui.openDownloadsBtn.style.display = "inline-block";
564
565
const notify = new Notification("ytDownloader", {
566
body: window.i18n.__("playlistDownloaded"),
567
icon: "../assets/images/icon.png",
568
});
569
570
notify.onclick = () => this.openDownloadsFolder();
571
},
572
573
showError(error) {
574
console.error("Download Error:", error.toString());
575
this.ui.pasteLinkBtn.style.display = "inline-block";
576
this.ui.openDownloadsBtn.style.display = "none";
577
this.ui.optionsContainer.style.display = "block";
578
this.ui.playlistNameDisplay.textContent = "";
579
this.ui.errorMsgDisplay.textContent =
580
window.i18n.__("errorNetworkOrUrl");
581
this.ui.errorMsgDisplay.style.display = "block";
582
this.ui.errorMsgDisplay.title = error.toString();
583
this.ui.errorBtn.style.display = "inline-block";
584
this.ui.errorDetails.innerHTML = `<strong>URL: ${
585
this.state.url
586
}</strong><br><br>${error.toString()}`;
587
// this.ui.errorDetails.title = window.i18n.__("clickToCopy");
588
},
589
590
openDownloadsFolder() {
591
const openPath =
592
this.state.playlistName &&
593
fs.existsSync(
594
path.join(this.state.downloadDir, this.state.playlistName)
595
)
596
? path.join(this.state.downloadDir, this.state.playlistName)
597
: this.state.downloadDir;
598
599
ipcRenderer.invoke("open-folder", openPath).then((result) => {
600
if (!result.success) {
601
ipcRenderer.invoke("open-folder", this.state.downloadDir);
602
}
603
});
604
},
605
606
toggleDownloadType(type) {
607
const isVideo = type === "video";
608
this.ui.videoToggle.style.backgroundColor = isVideo
609
? "var(--box-toggleOn)"
610
: "var(--box-toggle)";
611
this.ui.audioToggle.style.backgroundColor = isVideo
612
? "var(--box-toggle)"
613
: "var(--box-toggleOn)";
614
this.ui.videoBox.style.display = isVideo ? "block" : "none";
615
this.ui.audioBox.style.display = isVideo ? "none" : "block";
616
},
617
618
updateVideoTypeVisibility() {
619
const value = this.ui.videoQualitySelect.value;
620
const show = !["best", "worst", "useConfig"].includes(value);
621
this.ui.typeSelectBox.style.display = show ? "block" : "none";
622
},
623
624
toggleAdvancedMenu() {
625
const isHidden =
626
this.ui.advancedMenu.style.display === "none" ||
627
this.ui.advancedMenu.style.display === "";
628
this.ui.advancedMenu.style.display = isHidden ? "block" : "none";
629
},
630
631
closeMenu() {
632
this.ui.menuIcon.style.transform = "rotate(0deg)";
633
this.ui.menu.style.opacity = "0";
634
setTimeout(() => {
635
this.ui.menu.style.display = "none";
636
}, 300);
637
},
638
639
navigate(type, page) {
640
this.closeMenu();
641
const event = type === "page" ? "load-page" : "load-win";
642
ipcRenderer.send(event, path.join(__dirname, page));
643
},
644
645
getFfmpegPath() {
646
if (
647
process.env.YTDOWNLOADER_FFMPEG_PATH &&
648
fs.existsSync(process.env.YTDOWNLOADER_FFMPEG_PATH)
649
) {
650
console.log("Using FFMPEG from YTDOWNLOADER_FFMPEG_PATH");
651
return process.env.YTDOWNLOADER_FFMPEG_PATH;
652
}
653
654
switch (os.platform()) {
655
case "win32":
656
return path.join(__dirname, "..", "ffmpeg", "bin");
657
case "freebsd":
658
try {
659
return execSync("which ffmpeg").toString("utf8").trim();
660
} catch (error) {
661
console.error("ffmpeg not found on FreeBSD:", error);
662
return "";
663
}
664
default:
665
return path.join(__dirname, "..", "ffmpeg", "bin");
666
}
667
},
668
669
getJsRuntimePath() {
670
{
671
const exeName = "node";
672
673
if (process.env.YTDOWNLOADER_NODE_PATH) {
674
if (fs.existsSync(process.env.YTDOWNLOADER_NODE_PATH)) {
675
return `$node:"${process.env.YTDOWNLOADER_NODE_PATH}"`;
676
}
677
678
return "";
679
}
680
681
if (process.env.YTDOWNLOADER_DENO_PATH) {
682
if (fs.existsSync(process.env.YTDOWNLOADER_DENO_PATH)) {
683
return `$deno:"${process.env.YTDOWNLOADER_DENO_PATH}"`;
684
}
685
686
return "";
687
}
688
689
if (os.platform() === "darwin") {
690
return "";
691
}
692
693
let jsRuntimePath = path.join(__dirname, "..", exeName);
694
695
if (os.platform() === "win32") {
696
jsRuntimePath = path.join(__dirname, "..", `${exeName}.exe`);
697
}
698
699
if (fs.existsSync(jsRuntimePath)) {
700
return `${exeName}:"${jsRuntimePath}"`;
701
} else {
702
return "";
703
}
704
}
705
},
706
};
707
708
playlistDownloader.init();
709
710