diff --git a/main.js b/main.js index e58946f1..8b27dc08 100644 --- a/main.js +++ b/main.js @@ -61,6 +61,9 @@ function startCriticalHandlers(env) { taskList.load(); } + //Send the saved download type to the renderer + win.webContents.send("videoAction", {action: "setDownloadType", type: env.settings.downloadType}); + env.errorHandler = new ErrorHandler(win, queryManager, env); if(appStarting) { @@ -71,6 +74,16 @@ function startCriticalHandlers(env) { taskList.restore() }); + //Send the log for a specific download to renderer + ipcMain.handle("getLog", (event, identifier) => { + return env.logger.get(identifier); + }); + + //Save the log when renderer asks main + ipcMain.handle("saveLog", (event, identifier) => { + return env.logger.save(identifier); + }) + //Catch all console.log calls, print them to stdout and send them to the renderer devtools. console.log = (arg) => { process.stdout.write(arg + "\n"); @@ -205,6 +218,7 @@ function createWindow(env) { } app.on('ready', async () => { + app.setAppUserModelId("com.jelleglebbeek.youtube-dl-gui"); env = new Environment(app, analytics); await env.initialize(); createWindow(env); diff --git a/modules/Environment.js b/modules/Environment.js index a51dfd80..1d36fc96 100644 --- a/modules/Environment.js +++ b/modules/Environment.js @@ -2,6 +2,7 @@ const Bottleneck = require("bottleneck"); const Filepaths = require("./Filepaths"); const Settings = require("./persistence/Settings"); const DetectPython = require("./DetectPython"); +const Logger = require("./persistence/Logger"); const fs = require("fs").promises; class Environment { @@ -15,6 +16,7 @@ class Environment { this.mainAudioQuality = "best"; this.mainDownloadSubs = false; this.doneAction = "Do nothing"; + this.logger = new Logger(this); this.paths = new Filepaths(app, this); this.downloadLimiter = new Bottleneck({ trackDoneStatus: true, diff --git a/modules/Filepaths.js b/modules/Filepaths.js index 190a2128..68c01590 100644 --- a/modules/Filepaths.js +++ b/modules/Filepaths.js @@ -38,10 +38,10 @@ class Filepaths { break; } case "win32portable": - this.persistentPath = path.join(this.app.getPath('appData'), "youtube-dl-gui-portable"); + this.persistentPath = path.join(process.env.PORTABLE_EXECUTABLE_DIR , "open-video-downloader"); this.unpackedPrefix = path.join(path.dirname(this.appPath), "app.asar.unpacked"); this.packedPrefix = this.appPath; - await this.createAppDataFolder(); + await this.createPortableFolder(); this.ffmpeg = path.join(this.persistentPath, "ffmpeg.exe"); this.ytdl = path.join(this.persistentPath, "youtube-dl.exe"); this.icon = path.join(this.packedPrefix, "renderer/img/icon.png"); @@ -97,7 +97,7 @@ class Filepaths { } detectPlatform() { - if(this.appPath.includes("\\AppData\\Local\\Temp\\")) return "win32portable"; + if(process.env.PORTABLE_EXECUTABLE_DIR != null) return "win32portable"; else if(this.appPath.includes("WindowsApps")) return "win32app" else return process.platform; } @@ -112,30 +112,67 @@ class Filepaths { } async createAppDataFolder() { - await new Promise((resolve) => { - mkdirp(this.persistentPath).then(made => { - if (made != null) { - fs.copyFileSync(path.join(this.unpackedPrefix, "binaries/youtube-dl.exe"), path.join(this.persistentPath, "youtube-dl.exe")); - fs.copyFileSync(path.join(this.unpackedPrefix, "binaries/ffmpeg.exe"), path.join(this.persistentPath, "ffmpeg.exe")); - fs.copyFileSync(path.join(this.unpackedPrefix, "binaries/AtomicParsley.exe"), path.join(this.persistentPath, "AtomicParsley.exe")); - fs.copyFileSync(path.join(this.unpackedPrefix, "binaries/ytdlVersion"), path.join(this.persistentPath, "ytdlVersion")); - } - resolve(); - }) - }) + const from = path.join(this.unpackedPrefix + "binaries"); + const toCopy = ["youtube-dl.exe", "ffmpeg.exe", "ytdlVersion", "AtomicParsley.exe"]; + await this.copyFiles(from, this.persistentPath, toCopy); + } + + async createPortableFolder() { + try { + await fs.promises.access(process.env.PORTABLE_EXECUTABLE_DIR, fs.constants.W_OK); + if(await this.migrateExistingAppDataFolder()) return; + const from = path.join(this.unpackedPrefix + "binaries"); + const toCopy = ["youtube-dl.exe", "ffmpeg.exe", "ytdlVersion", "AtomicParsley.exe"]; + await this.copyFiles(from, this.persistentPath, toCopy); + } catch (e) { + setTimeout(() => console.error(e), 5000); + this.persistentPath = path.join(this.app.getPath("appData"), "open-video-downloader"); + await this.createAppDataFolder(); + } + } + + async migrateExistingAppDataFolder() { + const from = path.join(this.app.getPath("appData"), "youtube-dl-gui-portable"); + try { + await fs.promises.access(from, fs.constants.W_OK); + const toCopy = ["youtube-dl.exe", "ffmpeg.exe", "ytdlVersion", "userSettings", "taskList"]; + await this.copyFiles(from, this.persistentPath, toCopy); + try { + await fs.promises.rmdir(from, {recursive: true}); + } catch (e) { + console.error(e); + } + return true; + } catch (e) { + return false; + } } async createHomeFolder() { + const from = path.join(this.unpackedPrefix + "binaries"); + const toCopy = ["youtube-dl-unix", "ffmpeg-linux", "ytdlVersion"]; + await this.copyFiles(from, this.persistentPath, toCopy); + } + + async copyFiles(from, to, files) { await new Promise((resolve) => { - mkdirp(this.persistentPath).then(made => { + mkdirp(to).then(made => { if (made != null) { - fs.copyFileSync(path.join(this.unpackedPrefix, "binaries/youtube-dl-unix"), path.join(this.persistentPath, "youtube-dl-unix")); - fs.copyFileSync(path.join(this.unpackedPrefix, "binaries/ffmpeg-linux"), path.join(this.persistentPath, "ffmpeg")); - fs.copyFileSync(path.join(this.unpackedPrefix, "binaries/ytdlVersion"), path.join(this.persistentPath, "ytdlVersion")); + for (const file of files) { + this.copyFile(from, to, file); + } } resolve(); - }) - }) + }); + }); + } + + copyFile(from, to, filename) { + try { + fs.copyFileSync(path.join(from, filename), path.join(to, filename)); + } catch (e) { + console.error("Could not copy " + filename + " to " + to + "."); + } } } diff --git a/modules/QueryManager.js b/modules/QueryManager.js index 46ce662e..9130b434 100644 --- a/modules/QueryManager.js +++ b/modules/QueryManager.js @@ -321,6 +321,7 @@ class QueryManager { this.managedVideos = this.managedVideos.filter(item => item.identifier !== video.identifier); this.playlistMetadata = this.playlistMetadata.filter(item => item.video_url !== video.url && item.playlist_url !== video.url); this.window.webContents.send("videoAction", { action: "remove", identifier: video.identifier }) + this.environment.logger.clear(video.identifier); } onError(identifier) { diff --git a/modules/download/DownloadQuery.js b/modules/download/DownloadQuery.js index 98d3a8e7..5ebc6fee 100644 --- a/modules/download/DownloadQuery.js +++ b/modules/download/DownloadQuery.js @@ -21,10 +21,15 @@ class DownloadQuery extends Query { let args = []; let output = path.join(this.environment.settings.downloadPath, Utils.resolvePlaylistPlaceholders(this.environment.settings.nameFormat, this.playlistMeta)); if(this.video.audioOnly) { - let numeralAudioQuality = (this.video.audioQuality === "best") ? "0" : "9"; + let audioQuality = this.video.audioQuality; + if(audioQuality === "best") { + audioQuality = "0"; + } else if(audioQuality === "worst") { + audioQuality = "9"; + } const audioOutputFormat = this.environment.settings.audioOutputFormat; args = [ - '--extract-audio', '--audio-quality', numeralAudioQuality, + '--extract-audio', '--audio-quality', audioQuality, '--ffmpeg-location', this.environment.paths.ffmpeg, '--no-mtime', '-o', output, @@ -101,6 +106,16 @@ class DownloadQuery extends Query { const perLine = liveData.split("\n"); for(const line of perLine) { this.video.setFilename(line); + if(line.lastIndexOf("[download]") !== line.indexOf("[download]")) { + const splitLines = line.split("["); + for(const splitLine of splitLines) { + if(splitLine.trim() !== "") { + this.environment.logger.log(this.video.identifier, "[" + splitLine.trim()); + } + } + } else { + this.environment.logger.log(this.video.identifier, line); + } } if (!liveData.includes("[download]")) return; if (!initialReset) { diff --git a/modules/exceptions/ErrorHandler.js b/modules/exceptions/ErrorHandler.js index 826eadd3..7ffef608 100644 --- a/modules/exceptions/ErrorHandler.js +++ b/modules/exceptions/ErrorHandler.js @@ -19,7 +19,7 @@ class ErrorHandler { console.error("An error has occurred but no error message was given.") return false; } - if(stderr.includes("WARNING")) { + if(stderr.trim().startsWith("WARNING:")) { console.warn(stderr); return; } diff --git a/modules/exceptions/errorDefinitions.json b/modules/exceptions/errorDefinitions.json index 79973b22..c81584a0 100644 --- a/modules/exceptions/errorDefinitions.json +++ b/modules/exceptions/errorDefinitions.json @@ -45,6 +45,11 @@ "Unsupported URL" ] }, + { + "code": "URL not found", + "description": "This is an incorrect or non-existing URL.", + "trigger": "[Errno 11001]" + }, { "code": "Private or non-existent playlist", "description": "This playlist does not exist or is private.", @@ -195,7 +200,7 @@ }, { "code": "Binaries missing/corrupted", - "description": "Please restart the app, or disable antivirus.", + "description": "Please restart the app and disable antivirus.", "trigger": [ "Command failed with ENOENT: resources\\app.asar.unpacked\\binaries\\youtube-dl.exe", "Command failed with ENOENT: binaries/youtube-dl.exe", diff --git a/modules/info/InfoQueryList.js b/modules/info/InfoQueryList.js index e8ed63cb..c3a48f4a 100644 --- a/modules/info/InfoQueryList.js +++ b/modules/info/InfoQueryList.js @@ -13,7 +13,7 @@ class InfoQueryList { } async start() { - let result = await new Promise(((resolve) => { + return await new Promise(((resolve) => { let totalMetadata = []; let playlistUrls = Utils.extractPlaylistUrls(this.query); for (const videoData of playlistUrls[1]) { @@ -43,7 +43,6 @@ class InfoQueryList { }); } })); - return result; } createVideo(data, url) { diff --git a/modules/persistence/Logger.js b/modules/persistence/Logger.js new file mode 100644 index 00000000..425fb3d6 --- /dev/null +++ b/modules/persistence/Logger.js @@ -0,0 +1,62 @@ +const {dialog} = require("electron"); +const path = require("path"); +const fs = require("fs"); + +class Logger { + constructor(environment) { + this.environment = environment; + this.logs = {}; + } + + log(identifier, line) { + if(line == null || line === "") return; + let trimmedLine; + if(line === "done") { + trimmedLine = "Download finished"; + } else if(line === "killed") { + trimmedLine = "Download stopped"; + } else { + trimmedLine = line.replace(/[\n\r]/g, ""); + } + if(identifier in this.logs) { + this.logs[identifier].push(trimmedLine); + } else { + this.logs[identifier] = [trimmedLine]; + } + } + + get(identifier) { + return this.logs[identifier]; + } + + clear(identifier) { + delete this.logs[identifier]; + } + + async save(identifier) { + const logLines = this.logs[identifier]; + let log = ""; + for(const line of logLines) { + log += line + "\n"; + } + const date = new Date().toLocaleString() + .replace(", ", "-") + .replace(/\//g, "-") + .replace(/:/g, "-") + let result = await dialog.showSaveDialog(this.environment.win, { + defaultPath: path.join(this.environment.settings.downloadPath, "ytdl-log-" + date.slice(0, date.length - 6)), + buttonLabel: "Save metadata", + filters: [ + { name: "txt", extensions: ["txt"] }, + { name: "All Files", extensions: ["*"] }, + ], + properties: ["createDirectory"] + }); + if(!result.canceled) { + fs.promises.writeFile(result.filePath, log).then(() => console.log("Download log saved.")); + } + } + +} + +module.exports = Logger; diff --git a/modules/persistence/Settings.js b/modules/persistence/Settings.js index e218e3cf..867cc377 100644 --- a/modules/persistence/Settings.js +++ b/modules/persistence/Settings.js @@ -3,7 +3,13 @@ const { globalShortcut, clipboard } = require('electron'); const fs = require("fs").promises; class Settings { - constructor(paths, env, outputFormat, audioOutputFormat, downloadPath, proxy, rateLimit, autoFillClipboard, globalShortcut, spoofUserAgent, validateCertificate, enableEncoding, taskList, nameFormat, nameFormatMode, sizeMode, splitMode, maxConcurrent, updateBinary, updateApplication, cookiePath, statSend, downloadMetadata, downloadThumbnail, keepUnmerged, calculateTotalSize, theme) { + constructor( + paths, env, outputFormat, audioOutputFormat, downloadPath, + proxy, rateLimit, autoFillClipboard, noPlaylist, globalShortcut, spoofUserAgent, + validateCertificate, enableEncoding, taskList, nameFormat, nameFormatMode, + sizeMode, splitMode, maxConcurrent, updateBinary, downloadType, updateApplication, cookiePath, + statSend, downloadMetadata, downloadThumbnail, keepUnmerged, calculateTotalSize, theme + ) { this.paths = paths; this.env = env this.outputFormat = outputFormat == null ? "none" : outputFormat; @@ -12,6 +18,7 @@ class Settings { this.proxy = proxy == null ? "" : proxy; this.rateLimit = rateLimit == null ? "" : rateLimit; this.autoFillClipboard = autoFillClipboard == null ? true : autoFillClipboard; + this.noPlaylist = noPlaylist == null ? false : noPlaylist; this.globalShortcut = globalShortcut == null ? true : globalShortcut; this.spoofUserAgent = spoofUserAgent == null ? true : spoofUserAgent; this.validateCertificate = validateCertificate == null ? false : validateCertificate; @@ -27,6 +34,7 @@ class Settings { this.splitMode = splitMode == null? "49" : splitMode; this.maxConcurrent = (maxConcurrent == null || maxConcurrent <= 0) ? Math.round(os.cpus().length / 2) : maxConcurrent; //Max concurrent is standard half of the system's available cores this.updateBinary = updateBinary == null ? true : updateBinary; + this.downloadType = downloadType == null ? "video" : downloadType; this.updateApplication = updateApplication == null ? true : updateApplication; this.cookiePath = cookiePath; this.statSend = statSend == null ? false : statSend; @@ -47,6 +55,7 @@ class Settings { data.proxy, data.rateLimit, data.autoFillClipboard, + data.noPlaylist, data.globalShortcut, data.spoofUserAgent, data.validateCertificate, @@ -58,6 +67,7 @@ class Settings { data.splitMode, data.maxConcurrent, data.updateBinary, + data.downloadType, data.updateApplication, data.cookiePath, data.statSend, @@ -82,6 +92,7 @@ class Settings { this.proxy = settings.proxy; this.rateLimit = settings.rateLimit; this.autoFillClipboard = settings.autoFillClipboard; + this.noPlaylist = settings.noPlaylist; this.globalShortcut = settings.globalShortcut; this.spoofUserAgent = settings.spoofUserAgent; this.validateCertificate = settings.validateCertificate; @@ -100,6 +111,7 @@ class Settings { this.env.changeMaxConcurrent(settings.maxConcurrent); } this.updateBinary = settings.updateBinary; + this.downloadType = settings.downloadType; this.updateApplication = settings.updateApplication; this.theme = settings.theme; this.save(); @@ -117,6 +129,7 @@ class Settings { proxy: this.proxy, rateLimit: this.rateLimit, autoFillClipboard: this.autoFillClipboard, + noPlaylist: this.noPlaylist, globalShortcut: this.globalShortcut, spoofUserAgent: this.spoofUserAgent, validateCertificate: this.validateCertificate, @@ -129,6 +142,7 @@ class Settings { maxConcurrent: this.maxConcurrent, defaultConcurrent: Math.round(os.cpus().length / 2), updateBinary: this.updateBinary, + downloadType: this.downloadType, updateApplication: this.updateApplication, cookiePath: this.cookiePath, statSend: this.statSend, @@ -142,7 +156,10 @@ class Settings { } save() { - fs.writeFile(this.paths.settings, JSON.stringify(this.serialize()), "utf8").then(() => console.log("Saved settings file.")); + console.log(this.serialize()); + fs.writeFile(this.paths.settings, JSON.stringify(this.serialize()), "utf8").then(() => { + console.log("Saved settings file.") + }); } setGlobalShortcuts() { diff --git a/modules/types/Query.js b/modules/types/Query.js index 150822f0..8f7bf1d4 100644 --- a/modules/types/Query.js +++ b/modules/types/Query.js @@ -45,6 +45,12 @@ class Query { args.push(this.environment.settings.rateLimit + "K"); } + if(this.environment.settings.noPlaylist) { + args.push("--no-playlist"); + } else { + args.push("--yes-playlist") + } + args.push(url) //Url must always be added as the final argument let command = this.environment.paths.ytdl; //Set the command to be executed @@ -98,6 +104,7 @@ class Query { resolve("done"); }); this.process.stderr.on("data", (data) => { + cb(data.toString()); if(this.environment.errorHandler.checkError(data.toString(), this.identifier)) { cb("killed"); resolve("killed"); diff --git a/package-lock.json b/package-lock.json index efdea96c..1420c6b8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,11 +1,11 @@ { "name": "youtube-dl-gui", - "version": "2.2.1", + "version": "2.2.2", "lockfileVersion": 2, "requires": true, "packages": { "": { - "version": "2.2.1", + "version": "2.2.2", "license": "AGPL-3.0-only", "dependencies": { "@sentry/electron": "^2.5.0", @@ -22,6 +22,7 @@ "jquery": "^3.5.1", "mkdirp": "^1.0.4", "popper.js": "^1.16.1", + "sortablejs": "^1.14.0", "user-agents": "^1.0.586", "windowbar": "^1.7.4" }, @@ -8386,6 +8387,11 @@ "node": ">=0.10.0" } }, + "node_modules/sortablejs": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.14.0.tgz", + "integrity": "sha512-pBXvQCs5/33fdN1/39pPL0NZF20LeRbLQ5jtnheIPN9JQAaufGjKdWduZn4U7wCtVuzKhmRkI0DFYHYRbB2H1w==" + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -16298,6 +16304,11 @@ } } }, + "sortablejs": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.14.0.tgz", + "integrity": "sha512-pBXvQCs5/33fdN1/39pPL0NZF20LeRbLQ5jtnheIPN9JQAaufGjKdWduZn4U7wCtVuzKhmRkI0DFYHYRbB2H1w==" + }, "source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", diff --git a/package.json b/package.json index d2478051..f0964752 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ "jquery": "^3.5.1", "mkdirp": "^1.0.4", "popper.js": "^1.16.1", + "sortablejs": "^1.14.0", "user-agents": "^1.0.586", "windowbar": "^1.7.4" }, diff --git a/preload.js b/preload.js index 817974d4..13ce1ba9 100644 --- a/preload.js +++ b/preload.js @@ -40,7 +40,9 @@ contextBridge.exposeInMainWorld( "getDoneActions", "setDoneAction", "getSubtitles", - "getSelectedSubtitles" + "getSelectedSubtitles", + "getLog", + "saveLog" ]; if (validChannels.includes(channel)) { return await ipcRenderer.invoke(channel, data); diff --git a/renderer/.eslintrc.js b/renderer/.eslintrc.js index b934e894..a1944402 100644 --- a/renderer/.eslintrc.js +++ b/renderer/.eslintrc.js @@ -1,4 +1,7 @@ module.exports = { + "globals": { + "Sortable": "readonly" + }, "env": { "browser": true, "es2021": true, @@ -148,7 +151,7 @@ module.exports = { "no-multiple-empty-lines": "error", "no-negated-condition": "off", "no-nested-ternary": "error", - "no-new": "error", + "no-new": "off", "no-new-func": "error", "no-new-object": "error", "no-new-wrappers": "error", diff --git a/renderer/renderer.css b/renderer/renderer.css index 13e253a7..e9cdc5cb 100644 --- a/renderer/renderer.css +++ b/renderer/renderer.css @@ -204,6 +204,7 @@ select { } .video-card { + position: relative; max-width: 700px; min-height: 140px; max-height: 155px; @@ -214,6 +215,18 @@ select { box-shadow: 0 0.1em 0.15em var(--tertiary-bg-color); } +.video-card .handle { + position: absolute; + left: 0.25rem; + top: calc(50% + 2rem); + margin: -2rem 0; + padding: 2rem 0; + transform: translateY(-50%) scale(1.5); + filter: brightness(55%); + cursor: move; + z-index: 100; +} + .video-card p { white-space: nowrap; overflow: hidden; @@ -266,7 +279,7 @@ select { color: var(--font-color); } -.video-card i:hover { +.video-card i:hover :not(.bi-grip-vertical) { transition: 150ms color; filter: brightness(55%); } @@ -500,6 +513,27 @@ option.audio { } } +/* LOG MODAL */ +#logModal { + padding: 0 5rem; +} + +#logModal .modal-dialog { + max-width: 1200px; + margin: 1.75rem auto !important; +} + +#logModal .log { + overflow-y: auto; + max-height: 550px; + border-color: var(--tertiary-bg-color); + background-color: var(--secondary-bg-color); +} + +#logModal .log p { + margin-bottom: 0.5rem; +} + /* INFO MODAL */ #info-description { resize: none; diff --git a/renderer/renderer.html b/renderer/renderer.html index 11969533..258b969a 100644 --- a/renderer/renderer.html +++ b/renderer/renderer.html @@ -11,6 +11,7 @@ +
@@ -79,6 +80,7 @@