├── TODO.md ├── .gitignore ├── native-app ├── MigrationBackup │ └── 86146f95 │ │ └── YoutubeDlButton │ │ ├── packages.config │ │ └── YoutubeDlButton.csproj ├── App.config ├── Messages │ ├── CancelJob.cs │ ├── JobEnded.cs │ ├── JobOutput.cs │ ├── CreateJob.cs │ └── Message.cs ├── YoutubeDlButton.sln ├── ProcessKiller.cs ├── Properties │ └── AssemblyInfo.cs ├── YoutubeDlButton.csproj ├── YoutubeDlProps.cs ├── Program.cs └── YoutubeDlRunner.cs ├── .eslintrc.js ├── src ├── content │ └── scrape.js ├── output │ ├── output.js │ └── output.html ├── common │ └── bootstrap.scss ├── manifest.json ├── popup │ ├── _bootstrap-popup.scss │ ├── popup.scss │ ├── popup.html │ └── popup.js ├── options │ ├── options.scss │ ├── options.js │ └── options.html ├── background │ ├── updates.js │ └── background.js └── icons │ ├── film-dark.svg │ ├── film-black.svg │ ├── film-light.svg │ └── film-blue.svg ├── package.json ├── LICENSE ├── gulpfile.js └── README.md /TODO.md: -------------------------------------------------------------------------------- 1 | # TODO 2 | 3 | * Cookie integration should use cookieStoreId of tab's current container -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | 3 | # web-ext 4 | node_modules/* 5 | src/**/*.css 6 | dist/* 7 | 8 | # native app 9 | **/.vs/ 10 | **/packages/ 11 | **/bin/ 12 | **/obj/ -------------------------------------------------------------------------------- /native-app/MigrationBackup/86146f95/YoutubeDlButton/packages.config: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /native-app/App.config: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /native-app/Messages/CancelJob.cs: -------------------------------------------------------------------------------- 1 | using Newtonsoft.Json; 2 | using System; 3 | 4 | namespace YoutubeDlButton.Messages 5 | { 6 | public class CancelJob 7 | { 8 | [JsonProperty("jobId")] 9 | public int JobId 10 | { 11 | get; set; 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /native-app/Messages/JobEnded.cs: -------------------------------------------------------------------------------- 1 | using Newtonsoft.Json; 2 | using System; 3 | 4 | namespace YoutubeDlButton.Messages 5 | { 6 | public class JobEnded 7 | { 8 | [JsonProperty("jobId")] 9 | public int JobId 10 | { 11 | get; set; 12 | } 13 | 14 | [JsonProperty("exitCode")] 15 | public int ExitCode 16 | { 17 | get; set; 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /native-app/Messages/JobOutput.cs: -------------------------------------------------------------------------------- 1 | using Newtonsoft.Json; 2 | using System; 3 | 4 | namespace YoutubeDlButton.Messages 5 | { 6 | public class JobOutput 7 | { 8 | [JsonProperty("jobId")] 9 | public int JobId 10 | { 11 | get; set; 12 | } 13 | 14 | [JsonProperty("output")] 15 | public string Output 16 | { 17 | get; set; 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /native-app/Messages/CreateJob.cs: -------------------------------------------------------------------------------- 1 | using Newtonsoft.Json; 2 | using System; 3 | 4 | namespace YoutubeDlButton.Messages 5 | { 6 | public class CreateJob 7 | { 8 | [JsonProperty("jobId")] 9 | public int JobId 10 | { 11 | get; set; 12 | } 13 | 14 | [JsonProperty("props")] 15 | public YoutubeDlProps Props 16 | { 17 | get; set; 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: 'eslint:recommended', 3 | 4 | parserOptions: { 5 | ecmaVersion: 8, 6 | sourceType: "module", 7 | ecmaFeatures: { 8 | jsx: true 9 | } 10 | }, 11 | 12 | env: { 13 | browser: true, 14 | webextensions: true, 15 | es6: true 16 | }, 17 | 18 | rules: { 19 | semi: 'error', 20 | quotes: ['error', 'single'] 21 | }, 22 | 23 | globals: {} 24 | }; -------------------------------------------------------------------------------- /src/content/scrape.js: -------------------------------------------------------------------------------- 1 | var data = { 2 | url: String(window.location.href), 3 | metadata: { 4 | title: null, 5 | author: null 6 | } 7 | }; 8 | 9 | // Collect metadata from Youtube. 10 | if (data.url.includes('youtube.com/watch')) { 11 | try { 12 | let player = window.wrappedJSObject.ytplayer; 13 | if (player) { 14 | data.metadata.title = player.config.args.title; 15 | data.metadata.author = player.config.args.author; 16 | } 17 | } catch (error) { 18 | // empty 19 | } 20 | } 21 | 22 | data; -------------------------------------------------------------------------------- /native-app/Messages/Message.cs: -------------------------------------------------------------------------------- 1 | using Newtonsoft.Json; 2 | using System; 3 | 4 | namespace YoutubeDlButton.Messages 5 | { 6 | partial class Message 7 | { 8 | [JsonProperty("topic")] 9 | public string Topic 10 | { 11 | get; set; 12 | } 13 | 14 | [JsonProperty("data")] 15 | public T Data 16 | { 17 | get; set; 18 | } 19 | 20 | public Message() {} 21 | 22 | public Message(string topic, T data) 23 | { 24 | this.Topic = topic; 25 | this.Data = data; 26 | } 27 | 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "youtube-dl-button", 3 | "version": "1.1.5", 4 | "description": "Add a browser action that sends the tab or a URL to youtube-dl.", 5 | "scripts": { 6 | "test": "echo \"Error: no test specified\" && exit 1", 7 | "build": "gulp dist", 8 | "watch": "gulp watch" 9 | }, 10 | "author": "Mark Lieberman", 11 | "license": "GPL-3.0", 12 | "devDependencies": { 13 | "eslint": "^7.31.0", 14 | "gulp": "^4.0.2", 15 | "gulp-eslint": "^6.0.0", 16 | "gulp-sass": "^5.1.0", 17 | "gulp-zip": "^5.0.1" 18 | }, 19 | "dependencies": { 20 | "bootstrap": "^4.1.1", 21 | "sass": "^1.75.0" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/output/output.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const el = { 4 | spanJobId: document.getElementById('job-id'), 5 | preProps: document.getElementById('props'), 6 | preOutput: document.getElementById('output') 7 | }; 8 | 9 | let url = new window.URL(window.location.href); 10 | 11 | let jobId = Number(url.searchParams.get('jobId')); 12 | 13 | browser.runtime.sendMessage({ 14 | topic: 'ydb-get-jobs', 15 | data: { 16 | jobId 17 | } 18 | }).then(jobs => { 19 | if (jobs.length) { 20 | el.spanJobId.innerText = jobs[0].id; 21 | el.preProps.innerText = JSON.stringify(jobs[0].props, null, 2); 22 | el.preOutput.innerText = jobs[0].output.join('\n'); 23 | } 24 | }); 25 | -------------------------------------------------------------------------------- /src/common/bootstrap.scss: -------------------------------------------------------------------------------- 1 | // Required 2 | @import "../../node_modules/bootstrap/scss/functions"; 3 | @import "../../node_modules/bootstrap/scss/variables"; 4 | @import "../../node_modules/bootstrap/scss/mixins"; 5 | 6 | // Optional 7 | @import "../../node_modules/bootstrap/scss/reboot"; 8 | @import "../../node_modules/bootstrap/scss/type"; 9 | @import "../../node_modules/bootstrap/scss/images"; 10 | @import "../../node_modules/bootstrap/scss/code"; 11 | @import "../../node_modules/bootstrap/scss/grid"; 12 | @import "../../node_modules/bootstrap/scss/utilities"; 13 | 14 | @import "../../node_modules/bootstrap/scss/buttons"; 15 | @import "../../node_modules/bootstrap/scss/button-group"; 16 | @import "../../node_modules/bootstrap/scss/forms"; 17 | @import "../../node_modules/bootstrap/scss/input-group"; 18 | @import "../../node_modules/bootstrap/scss/alert"; -------------------------------------------------------------------------------- /src/output/output.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Youtube-DL Buttton Job Output 6 | 7 | 11 | 13 | 14 | 15 |

16 | Job ID: 17 | 18 |

19 |

Props

20 |

21 |     

Output

22 |

23 | 
24 |     
25 |   
26 | 
27 | 


--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
 1 | MIT License
 2 | 
 3 | Copyright (c) 2018 marklieberman
 4 | 
 5 | Permission is hereby granted, free of charge, to any person obtaining a copy
 6 | of this software and associated documentation files (the "Software"), to deal
 7 | in the Software without restriction, including without limitation the rights
 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
 9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 | 
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 | 
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 | 


--------------------------------------------------------------------------------
/native-app/YoutubeDlButton.sln:
--------------------------------------------------------------------------------
 1 | 
 2 | Microsoft Visual Studio Solution File, Format Version 12.00
 3 | # Visual Studio 15
 4 | VisualStudioVersion = 15.0.27004.2008
 5 | MinimumVisualStudioVersion = 10.0.40219.1
 6 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "YoutubeDlButton", "YoutubeDlButton.csproj", "{63B5B43C-26E4-4577-9602-78DD272B7EA9}"
 7 | EndProject
 8 | Global
 9 | 	GlobalSection(SolutionConfigurationPlatforms) = preSolution
10 | 		Debug|Any CPU = Debug|Any CPU
11 | 		Release|Any CPU = Release|Any CPU
12 | 	EndGlobalSection
13 | 	GlobalSection(ProjectConfigurationPlatforms) = postSolution
14 | 		{63B5B43C-26E4-4577-9602-78DD272B7EA9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
15 | 		{63B5B43C-26E4-4577-9602-78DD272B7EA9}.Debug|Any CPU.Build.0 = Debug|Any CPU
16 | 		{63B5B43C-26E4-4577-9602-78DD272B7EA9}.Release|Any CPU.ActiveCfg = Release|Any CPU
17 | 		{63B5B43C-26E4-4577-9602-78DD272B7EA9}.Release|Any CPU.Build.0 = Release|Any CPU
18 | 	EndGlobalSection
19 | 	GlobalSection(SolutionProperties) = preSolution
20 | 		HideSolutionNode = FALSE
21 | 	EndGlobalSection
22 | 	GlobalSection(ExtensibilityGlobals) = postSolution
23 | 		SolutionGuid = {373B417A-5BC1-4F2D-A690-97466CB9FBF7}
24 | 	EndGlobalSection
25 | EndGlobal
26 | 


--------------------------------------------------------------------------------
/src/manifest.json:
--------------------------------------------------------------------------------
 1 | {
 2 | 
 3 |   "manifest_version": 2,
 4 |   "name": "Youtube-DL Button",
 5 |   "version": "1.1.5",
 6 | 
 7 |   "applications": {
 8 |     "gecko": {
 9 |       "id": "youtube-dl-button@liebs.ca",
10 |       "strict_min_version": "57.0"
11 |     }
12 |   },
13 | 
14 |   "description": "Add a browser action that sends the tab or a URL to youtube-dl.",
15 | 
16 |   "icons": {
17 |     "48": "icons/film-black.svg",
18 |     "96": "icons/film-black.svg"
19 |   },
20 | 
21 |   "permissions": [
22 |     "activeTab",
23 |     "nativeMessaging",
24 |     "storage"
25 |   ],
26 | 
27 |   "optional_permissions": [
28 |     "cookies",
29 |     ""
30 |   ],
31 | 
32 |   "options_ui": {
33 |     "open_in_tab": true,
34 |     "browser_style": false,
35 |     "page": "options/options.html"
36 |   },
37 | 
38 |   "browser_action": {
39 |     "browser_style": false,
40 |     "default_icon": "icons/film-dark.svg",
41 |     "default_popup": "popup/popup.html",
42 |     "theme_icons": [
43 |       {
44 |         "light": "icons/film-light.svg",
45 |         "dark": "icons/film-dark.svg",
46 |         "size": 32
47 |       }
48 |     ]
49 |   },
50 | 
51 |   "background": {
52 |     "scripts": [
53 |       "background/background.js",
54 |       "background/updates.js"
55 |     ]
56 |   }
57 | }
58 | 


--------------------------------------------------------------------------------
/native-app/ProcessKiller.cs:
--------------------------------------------------------------------------------
 1 | using System;
 2 | using System.Diagnostics;
 3 | using System.Management;
 4 | 
 5 | namespace YoutubeDlButton
 6 | {
 7 |     class ProcessKiller
 8 |     {
 9 |         /// 
10 |         /// Recursively kill a process and its child processes.
11 |         /// 
12 |         /// The process ID to terminate.
13 |         public static void KillWithChildren(int pid)
14 |         {
15 |             ManagementObjectSearcher processSearcher = new ManagementObjectSearcher("Select * From Win32_Process Where ParentProcessID=" + pid);
16 |             ManagementObjectCollection processCollection = processSearcher.Get();
17 | 
18 |             try
19 |             {
20 |                 Process process = Process.GetProcessById(pid);
21 |                 if (!process.HasExited)
22 |                 {
23 |                     process.Kill();
24 |                 }
25 |             }
26 |             catch (ArgumentException)
27 |             {
28 |                 // Process already exited.
29 |             }
30 | 
31 |             if (processCollection != null)
32 |             {
33 |                 foreach (ManagementObject mo in processCollection)
34 |                 {
35 |                     // Recursively kill child processes.
36 |                     KillWithChildren(Convert.ToInt32(mo["ProcessID"])); 
37 |                 }
38 |             }
39 |         }
40 |     }
41 | }
42 | 


--------------------------------------------------------------------------------
/native-app/Properties/AssemblyInfo.cs:
--------------------------------------------------------------------------------
 1 | using System.Reflection;
 2 | using System.Runtime.CompilerServices;
 3 | using System.Runtime.InteropServices;
 4 | 
 5 | // General Information about an assembly is controlled through the following
 6 | // set of attributes. Change these attribute values to modify the information
 7 | // associated with an assembly.
 8 | [assembly: AssemblyTitle("YoutubeDlButton")]
 9 | [assembly: AssemblyDescription("")]
10 | [assembly: AssemblyConfiguration("")]
11 | [assembly: AssemblyCompany("")]
12 | [assembly: AssemblyProduct("YoutubeDlButton")]
13 | [assembly: AssemblyCopyright("Copyright ©  2018")]
14 | [assembly: AssemblyTrademark("")]
15 | [assembly: AssemblyCulture("")]
16 | 
17 | // Setting ComVisible to false makes the types in this assembly not visible
18 | // to COM components.  If you need to access a type in this assembly from
19 | // COM, set the ComVisible attribute to true on that type.
20 | [assembly: ComVisible(false)]
21 | 
22 | // The following GUID is for the ID of the typelib if this project is exposed to COM
23 | [assembly: Guid("63b5b43c-26e4-4577-9602-78dd272b7ea9")]
24 | 
25 | // Version information for an assembly consists of the following four values:
26 | //
27 | //      Major Version
28 | //      Minor Version
29 | //      Build Number
30 | //      Revision
31 | //
32 | // You can specify all the values or you can default the Build and Revision Numbers
33 | // by using the '*' as shown below:
34 | // [assembly: AssemblyVersion("1.0.*")]
35 | [assembly: AssemblyVersion("1.0.0.0")]
36 | [assembly: AssemblyFileVersion("1.0.0.0")]
37 | 


--------------------------------------------------------------------------------
/gulpfile.js:
--------------------------------------------------------------------------------
 1 | 'use strict';
 2 | 
 3 | var gulp   = require('gulp'),
 4 |     eslint = require('gulp-eslint'),
 5 |     sass   = require('gulp-sass')(require('sass')),
 6 |     zip    = require('gulp-zip');
 7 | 
 8 | var sources = {
 9 |   js: [
10 |     'src/**/*.js'
11 |   ],
12 |   sass: [
13 |     'src/common/bootstrap.scss',
14 |     'src/options/options.scss',
15 |     'src/popup/popup.scss'
16 |   ],
17 |   watch: {
18 |     sass: [
19 |       'src/**/*.scss'
20 |     ]
21 |   },
22 |   dist: [
23 |     'src/**'
24 |   ]
25 | };
26 | 
27 | function watchFiles () {
28 |   gulp.watch(sources.js, lintTask);
29 |   
30 |   // Other sass files import these, so build them when these change.
31 |   gulp.watch(sources.watch.sass, sassTask);
32 | }
33 | 
34 | function sassTask () {
35 |   return gulp.src(sources.sass)
36 |     .pipe(sass().on('error', sass.logError))
37 |     .pipe(gulp.dest(function (file) {
38 |       return file.base;
39 |     }));
40 | }
41 | 
42 | function lintTask () {
43 |   return gulp.src(sources.js)
44 |     .pipe(eslint())
45 |     .pipe(eslint.format())
46 |     .pipe(eslint.failAfterError());
47 | }
48 | 
49 | function distTask () {
50 |   return gulp.src(sources.dist)
51 |     .pipe(zip('youtube-dl-button.xpi', {
52 |       compress: false
53 |     }))
54 |     .pipe(gulp.dest('dist'));
55 | }
56 | 
57 | exports.sass = sassTask;
58 | exports.lint = lintTask;
59 | 
60 | exports.watch = gulp.series(sassTask, watchFiles);
61 | exports.default = gulp.series(lintTask, watchFiles);
62 | exports.dist = gulp.series(lintTask, sassTask, distTask);


--------------------------------------------------------------------------------
/src/popup/_bootstrap-popup.scss:
--------------------------------------------------------------------------------
 1 | // Custom.scss
 2 | // Option B: Include parts of Bootstrap
 3 | 
 4 | $font-size-base: 0.8rem;
 5 | 
 6 | $form-group-margin-bottom: 5px;
 7 | $form-check-inline-margin-x: 0px;
 8 | $label-margin-bottom: 0px;
 9 | $table-cell-padding: 2px;
10 | 
11 | // Required
12 | @import "../../node_modules/bootstrap/scss/functions";
13 | @import "../../node_modules/bootstrap/scss/variables";
14 | @import "../../node_modules/bootstrap/scss/mixins";
15 | 
16 | $nav-tabs-border-color: $input-group-addon-border-color;
17 | $nav-tabs-link-hover-border-color: $input-group-addon-border-color;
18 | $nav-tabs-link-active-color: #000;
19 | $nav-tabs-link-active-border-color: $input-group-addon-border-color $input-group-addon-border-color $input-group-addon-bg;
20 | $nav-tabs-link-active-bg: #eee;
21 | 
22 | // Optional
23 | @import "../../node_modules/bootstrap/scss/reboot";
24 | @import "../../node_modules/bootstrap/scss/type";
25 | @import "../../node_modules/bootstrap/scss/images";
26 | @import "../../node_modules/bootstrap/scss/code";
27 | @import "../../node_modules/bootstrap/scss/grid";
28 | @import "../../node_modules/bootstrap/scss/utilities";
29 | 
30 | @import "../../node_modules/bootstrap/scss/buttons";
31 | @import "../../node_modules/bootstrap/scss/button-group";
32 | @import "../../node_modules/bootstrap/scss/forms";
33 | @import "../../node_modules/bootstrap/scss/input-group";
34 | @import "../../node_modules/bootstrap/scss/nav";
35 | 
36 | .btn {
37 |   min-width: 32px;
38 | }
39 | 
40 | label:not(.form-check-label) {
41 |   font-weight: bold;
42 | }
43 | 
44 | .nav-link {
45 |   color: $body-color;
46 |   padding: 5px 5px;
47 | }
48 | 
49 | .tab-content {
50 |   padding: 5px;
51 |   border-width: 0 1px 1px 1px;
52 |   border-style: solid;
53 |   border-color: $input-group-addon-border-color;
54 |   @include border-bottom-radius($nav-tabs-border-radius);
55 | 
56 |   .form-group:last-child {
57 |     margin-bottom: 0;
58 |   }
59 | }
60 | 


--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
 1 | # Youtube-DL Button
 2 | 
 3 | Add a browser action that sends the tab or a URL to youtube-dl.
 4 | 
 5 | ## Description
 6 | 
 7 | This addon is a simple GUI frontend to youtube-dl, available in the browser. A companion application is required to
 8 | manage communication between the browser and youtube-dl. See the [User Guide](https://github.com/marklieberman/youtube-dl-button/wiki/User-Guide) for an overview of the interface and features.
 9 | 
10 | ### Notable Features
11 | 
12 | * Queue system lets you download multiple videos in the background.
13 | * View youtube-dl's console output in your browser.
14 | * Optionally pass your browser's cookies to youtube-dl when downloading files from youtube.com.
15 | * Update youtube-dl to latest version with a single click.
16 | 
17 | 
18 | 
19 | ## Build
20 | 
21 | ### Web-Ext
22 | 
23 | The web-ext can be built using `npm run dist` which will produce a zip in dist/. You must [sign the addon on AMO](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/Distribution) before it can be installed in Firefox. Alternatively, a signed .xpi is available under Releases.
24 | 
25 | ### Native App
26 | 
27 | The native-app is a C# application for Windows. It can be built using Visual Studio and will produce YoutubeDlButton.exe, which is required to communicate between the web-ext in Firefox and youtube-dl.exe. Pre-built files are available in Releases.
28 | 
29 | ## Install
30 | 
31 | Instructions for installing the native app and youtube-dl.exe are [in the Wiki](https://github.com/marklieberman/youtube-dl-button/wiki/Installing-the-Native-App).
32 | 
33 | ## Contributing
34 | 
35 | This project could use 1) a cross-platform native-app to add support for Linux and MacOS, and 2) an installer to create manifest and registry entries automatically. Any contributions would be appreciated and I would publish the addon on AMO once these features are available.
36 | 


--------------------------------------------------------------------------------
/native-app/YoutubeDlButton.csproj:
--------------------------------------------------------------------------------
 1 | 
 2 | 
 3 |   
 4 |   
 5 |     Debug
 6 |     AnyCPU
 7 |     {63B5B43C-26E4-4577-9602-78DD272B7EA9}
 8 |     Exe
 9 |     YoutubeDlButton
10 |     YoutubeDlButton
11 |     v4.7.2
12 |     512
13 |     true
14 |     
15 |   
16 |   
17 |     AnyCPU
18 |     true
19 |     full
20 |     false
21 |     bin\Debug\
22 |     DEBUG;TRACE
23 |     prompt
24 |     4
25 |     7.1
26 |   
27 |   
28 |     AnyCPU
29 |     pdbonly
30 |     true
31 |     bin\Release\
32 |     TRACE
33 |     prompt
34 |     4
35 |   
36 |   
37 |     
38 |     
39 |     
40 |     
41 |     
42 |     
43 |     
44 |     
45 |     
46 |   
47 |   
48 |     
49 |     
50 |     
51 |     
52 |     
53 |     
54 |     
55 |     
56 |     
57 |     
58 |   
59 |   
60 |     
61 |   
62 |   
63 |     
64 |       13.0.1
65 |     
66 |   
67 |   
68 | 


--------------------------------------------------------------------------------
/native-app/MigrationBackup/86146f95/YoutubeDlButton/YoutubeDlButton.csproj:
--------------------------------------------------------------------------------
 1 | 
 2 | 
 3 |   
 4 |   
 5 |     Debug
 6 |     AnyCPU
 7 |     {63B5B43C-26E4-4577-9602-78DD272B7EA9}
 8 |     Exe
 9 |     YoutubeDlButton
10 |     YoutubeDlButton
11 |     v4.7.2
12 |     512
13 |     true
14 |     
15 |   
16 |   
17 |     AnyCPU
18 |     true
19 |     full
20 |     false
21 |     bin\Debug\
22 |     DEBUG;TRACE
23 |     prompt
24 |     4
25 |     7.1
26 |   
27 |   
28 |     AnyCPU
29 |     pdbonly
30 |     true
31 |     bin\Release\
32 |     TRACE
33 |     prompt
34 |     4
35 |   
36 |   
37 |     
38 |       packages\Newtonsoft.Json.13.0.1\lib\net45\Newtonsoft.Json.dll
39 |     
40 |     
41 |     
42 |     
43 |     
44 |     
45 |     
46 |     
47 |     
48 |   
49 |   
50 |     
51 |     
52 |     
53 |     
54 |     
55 |     
56 |     
57 |     
58 |     
59 |   
60 |   
61 |     
62 |     
63 |   
64 |   
65 | 


--------------------------------------------------------------------------------
/src/options/options.scss:
--------------------------------------------------------------------------------
  1 | @import "../../node_modules/bootstrap/scss/functions";
  2 | @import "../../node_modules/bootstrap/scss/variables";
  3 | 
  4 | .form-control.no-validate:valid {
  5 |     border-color: $input-border-color;
  6 |     padding: $input-padding-x;
  7 |     background: $input-bg;
  8 | }
  9 | 
 10 | #options-form {
 11 |     margin-top: 20px;
 12 |     margin-bottom: 20px;
 13 | 
 14 |     .restore-settings {
 15 |         margin-bottom: 0;
 16 | 
 17 |         input {
 18 |             display: none;
 19 |         }
 20 |     }
 21 | }
 22 | 
 23 | #props-list {
 24 |     & > div {
 25 |         padding: 1rem;
 26 |         border: 1px solid $list-group-border-color;
 27 |         border-radius: $border-radius;
 28 |         margin-bottom: $form-group-margin-bottom;
 29 |     }
 30 | 
 31 |     .icon-preview {
 32 |         display: inline-block;
 33 |         width: 22px;
 34 |         height: 22px;
 35 |         vertical-align: bottom;
 36 |         background-color: $secondary;
 37 |         background-position: center;
 38 |         background-size: auto 16px;
 39 |         background-repeat: no-repeat;
 40 |         background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 384 512'%3E%3C!--! Font Awesome Pro 6.1.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. --%3E%3Cpath fill='white' d='M374.6 310.6l-160 160C208.4 476.9 200.2 480 192 480s-16.38-3.125-22.62-9.375l-160-160c-12.5-12.5-12.5-32.75 0-45.25s32.75-12.5 45.25 0L160 370.8V64c0-17.69 14.33-31.1 31.1-31.1S224 46.31 224 64v306.8l105.4-105.4c12.5-12.5 32.75-12.5 45.25 0S387.1 298.1 374.6 310.6z'/%3E%3C/svg%3E");
 41 |         border-radius: 2px;
 42 |     }
 43 | 
 44 |     label[for="icon"] {
 45 |         display: flex;
 46 |         justify-content: space-between;
 47 |         align-items: flex-end;
 48 |         width: 100%;
 49 |     }
 50 | 
 51 |     .show-default-props,
 52 |     .show-audio-props,
 53 |     .show-custom-props,
 54 |     .show-inherits-default {
 55 |         display: none;
 56 |     }
 57 | 
 58 |     .default-props .show-default-props {
 59 |         display: block;
 60 |     }
 61 | 
 62 |     .audio-props .show-audio-props {
 63 |         display: block;
 64 |     }
 65 | 
 66 |     .custom-props .show-custom-props {
 67 |         display: block;
 68 |     }
 69 | 
 70 |     .inherits-default .show-inherits-default {
 71 |         display: block;
 72 |     }
 73 | 
 74 |     & > div:nth-child(1),
 75 |     & > div:nth-child(2) {    
 76 |         .move-up {
 77 |             display: none;
 78 |         }
 79 |         .move-down {
 80 |             display: none;
 81 |         }
 82 |     }
 83 | 
 84 |     & > div:nth-child(3) {
 85 |         .move-up {
 86 |             display: none;
 87 |         }
 88 |     }
 89 | 
 90 |     & > div:last-child {
 91 |         .move-down {
 92 |             display: none;
 93 |         }
 94 |     }
 95 | }
 96 | 
 97 | #cookie-domains {
 98 |     width: 100%;
 99 |     margin-bottom: $form-group-margin-bottom;
100 | 
101 |     tr:not(:first-child) td {
102 |         padding-top: 4px;
103 |     }
104 | 
105 |     td, th {
106 |         padding-left: 5px;
107 |         padding-right: 5px;
108 | 
109 |         &.video-domain {
110 |             width: 33%;
111 |         }
112 |     
113 |         &.extra-domains {
114 |             width: 66%;
115 |         }
116 |     
117 |         &.actions {
118 |             width: 1%;
119 |         }
120 |     }    
121 | }


--------------------------------------------------------------------------------
/native-app/YoutubeDlProps.cs:
--------------------------------------------------------------------------------
  1 | using Newtonsoft.Json;
  2 | using System;
  3 | using System.Collections.Generic;
  4 | using System.IO;
  5 | 
  6 | namespace YoutubeDlButton
  7 | {
  8 |     public class YoutubeDlProps
  9 |     {
 10 |         [JsonProperty("exePath")]
 11 |         public string ExePath { get; set; }
 12 | 
 13 |         [JsonProperty("updateExe")]
 14 |         public bool UpdateExe { get; set; }
 15 | 
 16 |         [JsonProperty("saveIn")]
 17 |         public string SaveIn { get; set; } = Environment.GetFolderPath(Environment.SpecialFolder.Desktop);
 18 | 
 19 |         [JsonProperty("template")]
 20 |         public string Template { get; set; }
 21 | 
 22 |         [JsonProperty("videoUrl")]
 23 |         public string VideoUrl { get; set; }
 24 | 
 25 |         [JsonProperty("format")]
 26 |         public string Format { get; set; }
 27 | 
 28 |         [JsonProperty("cookieJar")]
 29 |         public string CookieJar { get; set; }
 30 | 
 31 |         [JsonProperty("customArgs")]
 32 |         public string CustomArgs { get; set; }
 33 | 
 34 |         [JsonProperty("restrictFilenames")]
 35 |         public bool RestrictFilenames { get; set; } = false;
 36 | 
 37 |         [JsonProperty("postProcessScript")]
 38 |         public string PostProcessScript { get; set; }
 39 | 
 40 |         private string cookieJarPath;
 41 | 
 42 |         /// 
 43 |         /// Write the CookieJar to a temporary file.
 44 |         /// 
 45 |         public void CreateCookieJar()
 46 |         {
 47 |             if (!string.IsNullOrEmpty(CookieJar))
 48 |             {
 49 |                 cookieJarPath = Path.GetTempFileName();
 50 |                 File.WriteAllText(cookieJarPath, CookieJar);
 51 |             }
 52 |         }
 53 | 
 54 |         /// 
 55 |         /// Delete the temporary CookieJar file.
 56 |         /// 
 57 |         public void RemoveCookieJar()
 58 |         {
 59 |             if (!string.IsNullOrEmpty(cookieJarPath) && File.Exists(cookieJarPath))
 60 |             {
 61 |                 File.Delete(cookieJarPath);
 62 |                 cookieJarPath = null;                
 63 |             }
 64 |         }
 65 | 
 66 |         /// 
 67 |         /// Create an argument string from the properties.
 68 |         /// 
 69 |         public string ToArguments()
 70 |         {
 71 |             if (UpdateExe)
 72 |             {
 73 |                 return "--update";
 74 |             }
 75 | 
 76 |             var args = new List();
 77 | 
 78 |             if (RestrictFilenames)
 79 |             {
 80 |                 args.Add("--restrict-filenames");
 81 |             }
 82 | 
 83 |             if (!string.IsNullOrEmpty(SaveIn) || !string.IsNullOrEmpty(Template))
 84 |             {
 85 |                 args.Add(string.Format("--output \"{0}\"", Path.Combine(SaveIn ?? "", Template ?? "")));                
 86 |             }
 87 | 
 88 |             if (!string.IsNullOrEmpty(Format))
 89 |             {
 90 |                 args.Add(string.Format("--format {0}", Format));
 91 |             }
 92 | 
 93 |             if (!string.IsNullOrEmpty(cookieJarPath))
 94 |             {
 95 |                 args.Add(string.Format("--cookies {0}", cookieJarPath));
 96 |             }
 97 | 
 98 |             if (!string.IsNullOrEmpty(CustomArgs))
 99 |             {
100 |                 args.Add(CustomArgs);
101 |             }
102 | 
103 |             args.Add(VideoUrl);
104 | 
105 |             return string.Join(" ", args.ToArray());
106 |         }
107 |     }
108 | }
109 | 


--------------------------------------------------------------------------------
/src/background/updates.js:
--------------------------------------------------------------------------------
  1 | 'use strict';
  2 | 
  3 | /**
  4 |  * Perform maintenance operations when installed or updated.
  5 |  */
  6 | (function () {
  7 | 
  8 |   const initialSettings = {
  9 |     concurrentJobsLimit: 1,
 10 |     sendCookiesYoutube: false,
 11 |     props: [{
 12 |       name: 'Default',
 13 |       icon: null,
 14 |       saveIn: '',
 15 |       format: 'bv+ba/best',
 16 |       template: '%(title)s-%(id)s.%(ext)s',
 17 |       customArgs: '',
 18 |       inheritDefault: false
 19 |     },{
 20 |       name: 'Audio',
 21 |       icon: null,
 22 |       format: 'ba',
 23 |       inheritDefault: true
 24 |     }]
 25 |   };
 26 | 
 27 |   async function resetToDefaults () {
 28 |     return await browser.storage.local.set({
 29 |       exePath: '',
 30 |       concurrentJobsLimit: initialSettings.concurrentJobsLimit,
 31 |       sendCookiesYoutube: initialSettings.sendCookiesYoutube,
 32 |       props: initialSettings.props
 33 |     });
 34 |   }
 35 | 
 36 |   if (browser.runtime.onInstalled) {
 37 |     browser.runtime.onInstalled.addListener(details => {
 38 |       switch (details.reason) {
 39 |         case 'install':
 40 |           onInstalled(details);
 41 |           break;
 42 |         case 'update':
 43 |           onUpdated(details);
 44 |           break;
 45 |       }
 46 |     });
 47 |   }
 48 | 
 49 |   // Initialize the addon when first installed.
 50 |   function onInstalled (details) {
 51 |     console.log('youtube-dl button installed', details);
 52 |     resetToDefaults();    
 53 |   }
 54 | 
 55 |   // Update the settings when the addon is updated.
 56 |   function onUpdated (details) {
 57 |     let versionParts = details.previousVersion
 58 |       .split('.')
 59 |       .map(n => Number(n));
 60 |     console.log('youtube-dl button updated', details, versionParts);
 61 |     
 62 |     // Upgrade from 1.1.2 or lower.
 63 |     if ((versionParts[0] === 1) && (versionParts[1] === 1) && (versionParts[2] <= 2)) {
 64 |       return browser.storage.local.get({
 65 |         addon: null,
 66 |         props: null
 67 |       }).then(results => {
 68 |         let quickAudioFormat = initialSettings.props[1].format,
 69 |             switchSets = [];
 70 |         if (results.addon) {
 71 |           quickAudioFormat = results.addon.quickAudioFormat || quickAudioFormat;
 72 |           results.concurrentJobsLimit = results.addon.concurrentJobsLimit || 1;
 73 |           results.sendCookiesYoutube = results.addon.sendCookiesYoutube || false;
 74 |           if (results.addon.switchSets) {
 75 |             switchSets = JSON.parse(results.addon.switchSets).map(switchSet => ({
 76 |               name: switchSet.name,
 77 |               saveIn: switchSet.saveIn,
 78 |               format: switchSet.format,
 79 |               icon: switchSet.iconUrl || null
 80 |             }));
 81 |           }
 82 |         } else {
 83 |           results.exePath = '';
 84 |           results.concurrentJobsLimit = initialSettings.concurrentJobsLimitl;
 85 |           results.sendCookiesYoutube = initialSettings.sendCookiesYoutube;
 86 |         }
 87 | 
 88 |         if (results.props && !Array.isArray(results.props)) {
 89 |           results.exePath = results.props.exePath || '';
 90 |           results.props = [{
 91 |             name: 'Default',
 92 |             icon: null,
 93 |             saveIn: results.props.saveIn || '',
 94 |             format: results.props.format || initialSettings.props[0].format,
 95 |             template: results.props.template || initialSettings.props[0].template,
 96 |             customArgs: results.props.customArgs || '',
 97 |             inheritDefault: false
 98 |           },{
 99 |             name: 'Audio',
100 |             icon: null,
101 |             format: quickAudioFormat,
102 |             inheritDefault: true
103 |           }].concat(switchSets);          
104 |         } else {
105 |           results.props = initialSettings.props;
106 |         }
107 | 
108 |         // Upgrade settings.
109 |         console.log('new settings are', results);
110 | 
111 |         return browser.storage.local.set(results)
112 |           .then(() => browser.storage.local.remove('addon'));
113 |       });
114 |     }
115 | 
116 |   }
117 | 
118 | }());
119 | 


--------------------------------------------------------------------------------
/src/popup/popup.scss:
--------------------------------------------------------------------------------
  1 | /* import custom bootstrap 4 */
  2 | @import "bootstrap-popup";
  3 | 
  4 | $popup-width: 599px;
  5 | $popup-height: 399px;
  6 | 
  7 | body {
  8 |   display: flex;
  9 |   flex-flow: column nowrap;
 10 |   justify-content: flex-start;
 11 |   align-items: stretch;
 12 | 
 13 |   @media screen and (min-width: 400px) {
 14 |     min-width: $popup-width;
 15 |     max-width: $popup-width;    
 16 |   }
 17 | 
 18 |   min-height: $popup-height;
 19 |   max-height: $popup-height;
 20 | 
 21 |   overflow: hidden;
 22 |   padding: 5px;
 23 |   background: linear-gradient(180deg, #fff 10px, #eee 50px, #eee 100%);
 24 | }
 25 | 
 26 | a {
 27 |   color: #000;
 28 | }
 29 | 
 30 | .grab-video {  
 31 |   display: flex;
 32 |   flex-flow: row nowrap;
 33 |   margin-bottom: 0;
 34 | }
 35 | 
 36 | #toggle-main-dropdown {
 37 |   min-width: 16px;
 38 |   padding: 0;  
 39 | }
 40 | 
 41 | #main-dropdown {
 42 |   position: absolute;
 43 |   top: 30px;
 44 |   z-index: 1000;
 45 |   right: 0;
 46 |   min-width: 100px;    
 47 |   padding: 5px 10px;
 48 | 
 49 |   color: #fff;
 50 |   background: $secondary;
 51 |   border: 1px solid $input-group-addon-border-color;
 52 |   @include border-radius($nav-tabs-border-radius);
 53 | 
 54 |   a, a:active, a:visited {
 55 |     display: block;
 56 |     color: #fff;
 57 |     width: 100%;
 58 |   }
 59 | 
 60 |   .group-heading {
 61 |     font-weight: bold;
 62 |     margin-top: 10px;
 63 |   }
 64 | }
 65 | 
 66 | .custom-props-button {
 67 |   background-size: auto 16px;
 68 |   background-position: center;
 69 |   background-repeat: no-repeat;
 70 |   background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 384 512'%3E%3C!--! Font Awesome Pro 6.1.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. --%3E%3Cpath fill='white' d='M374.6 310.6l-160 160C208.4 476.9 200.2 480 192 480s-16.38-3.125-22.62-9.375l-160-160c-12.5-12.5-12.5-32.75 0-45.25s32.75-12.5 45.25 0L160 370.8V64c0-17.69 14.33-31.1 31.1-31.1S224 46.31 224 64v306.8l105.4-105.4c12.5-12.5 32.75-12.5 45.25 0S387.1 298.1 374.6 310.6z'/%3E%3C/svg%3E");
 71 | }
 72 | 
 73 | #quick-job-buttons {
 74 |   margin-left: 5px;
 75 | }
 76 | 
 77 | #settings-panel {
 78 |   margin-top: 5px;
 79 | }
 80 | 
 81 | #jobs-list-container {
 82 |   flex-grow: 1;
 83 |   overflow-y: auto;
 84 |   border-bottom: 1px solid #ccc;
 85 |   margin: 5px 0;
 86 | }
 87 | 
 88 | #jobs-list {
 89 |   #empty-list {
 90 |     padding-top: 15px;
 91 |     text-align: center;
 92 |     color: #ccc;
 93 |     font-weight: bold;
 94 |   }
 95 | 
 96 |   .job {
 97 |     display: flex;
 98 |     flex-flow: row nowrap;
 99 |     overflow: hidden;
100 |     padding: 5px;
101 | 
102 |     .cancel-job,
103 |     .retry-job {
104 |       display: none;
105 |     }
106 | 
107 |     &[data-state="waiting"] {
108 |       .ended { display: none }
109 |       .active { display: none }
110 |       .cancelled { display: none }
111 |       .errored { display: none }
112 |       .cancel-job {
113 |         display: block;
114 |       }
115 |     }
116 | 
117 |     &[data-state="active"] {
118 |       .waiting { display: none }
119 |       .ended { display: none }
120 |       .cancelled { display: none }
121 |       .errored { display: none }
122 |       .icon {
123 |         background-color: #718EA4;
124 |       }
125 |       .cancel-job {
126 |         display: block;
127 |       }
128 |     }
129 | 
130 |     &[data-state="ended"] {
131 |       .waiting { display: none }
132 |       .active { display: none }
133 |       .cancelled { display: none }
134 |       .errored { display: none }
135 |       .icon {
136 |         background-color: #8ABD5F;
137 |       }
138 |     }
139 | 
140 |     &[data-state="cancelled"] {
141 |       .waiting { display: none }
142 |       .active { display: none }
143 |       .ended { display: none }
144 |       .errored { display: none }
145 |       .retry-job {
146 |         display: block;
147 |       }
148 |     }
149 | 
150 |     &[data-state="errored"] {
151 |       .waiting { display: none }
152 |       .active { display: none }
153 |       .ended { display: none }
154 |       .cancelled { display: none }
155 |       .icon {
156 |         background-color: #FFAAAA;
157 |       }
158 |       .retry-job {
159 |         display: block;
160 |       }
161 |     }
162 | 
163 |     &:not(:last-child) {
164 |       border-bottom: 1px solid #ddd;
165 |     }
166 | 
167 |     .icon {
168 |       display: flex;
169 |       justify-content: center;
170 |       align-items: center;
171 |       min-width: 32px;
172 |       max-width: 32px;
173 |       min-height: 24px;
174 |       max-height: 24px;
175 |       background: #cdcdcd;
176 |       border-radius: 5px;
177 |       margin-right: 5px;
178 |     }
179 | 
180 |     .details {
181 |       display: flex;
182 |       flex-flow: column nowrap;
183 |       flex-grow: 1;
184 |       overflow: hidden;
185 |       font-size: 0.8em;
186 | 
187 |       .file-name {
188 |         font-weight: bold;
189 | 
190 |         &:empty {
191 |           display: none;
192 | 
193 |           & + .video-url {
194 |             font-weight: bold;
195 |             color: #000;
196 |           }
197 |         }
198 |       }
199 | 
200 |       .video-url {
201 |         overflow: hidden;
202 |         text-overflow: ellipsis;
203 |         white-space: nowrap;
204 |         color: #999;
205 |       }
206 | 
207 |       .output {
208 |         word-break: break-all;
209 |       }
210 |     }
211 |   }
212 | }
213 | 
214 | .list-buttons {
215 |   display: flex;
216 |   justify-content: space-between;
217 | }
218 | 


--------------------------------------------------------------------------------
/native-app/Program.cs:
--------------------------------------------------------------------------------
  1 | using Newtonsoft.Json;
  2 | using Newtonsoft.Json.Linq;
  3 | using System;
  4 | using System.Collections.Concurrent;
  5 | using System.Diagnostics;
  6 | using System.IO;
  7 | using System.Text;
  8 | using System.Threading.Tasks;
  9 | using YoutubeDlButton.Messages;
 10 | 
 11 | namespace YoutubeDlButton
 12 | {
 13 |     class Program
 14 |     {
 15 |         static readonly object Lock = new object();
 16 | 
 17 |         static readonly ConcurrentDictionary jobs = new ConcurrentDictionary();
 18 | 
 19 |         public static async Task Main()
 20 |         {
 21 | #if DEBUG
 22 |             Debugger.Launch();
 23 | #endif
 24 | 
 25 |             while (true)
 26 |             {
 27 |                 // Read messages from the addon over standard input.
 28 |                 var message = await ReadMessage();
 29 |                 if (message != null)
 30 |                 {
 31 |                     OnMessage(message);
 32 |                 }
 33 |             }
 34 |         }
 35 | 
 36 |         /// 
 37 |         /// Read a message from standard input.
 38 |         /// 
 39 |         /// 
 40 |         static async Task ReadMessage ()
 41 |         {
 42 |             var stdin = Console.OpenStandardInput();
 43 |             using (var reader = new BinaryReader(stdin, Encoding.UTF8))
 44 |             {
 45 |                 var lengthBytes = new byte[4];
 46 |                 await stdin.ReadAsync(lengthBytes, 0, 4);
 47 |                 var length = BitConverter.ToInt32(lengthBytes, 0);
 48 |                 if (length > 0)
 49 |                 {
 50 |                     var bodyBuffer = new byte[length];
 51 |                     await stdin.ReadAsync(bodyBuffer, 0, length);
 52 |                     var json = Encoding.UTF8.GetString(bodyBuffer);
 53 |                     return JObject.Parse(json);
 54 |                 }
 55 |             }
 56 |             return null;
 57 |         }
 58 |         
 59 |         /// 
 60 |         /// Write a message to standard output.
 61 |         /// 
 62 |         /// 
 63 |         static void Write(object message)
 64 |         {
 65 |             var json = JObject.FromObject(message).ToString(Formatting.None);
 66 |             var jsonBytes = Encoding.UTF8.GetBytes(json);
 67 | 
 68 |             // Use of stdout should be thread-safe.
 69 |             lock (Lock)
 70 |             {
 71 |                 var stdout = Console.OpenStandardOutput();
 72 |                   
 73 |                 // Write the length header.
 74 |                 stdout.WriteByte((byte)((jsonBytes.Length >> 0) & 0xFF));
 75 |                 stdout.WriteByte((byte)((jsonBytes.Length >> 8) & 0xFF));
 76 |                 stdout.WriteByte((byte)((jsonBytes.Length >> 16) & 0xFF));
 77 |                 stdout.WriteByte((byte)((jsonBytes.Length >> 24) & 0xFF));
 78 | 
 79 |                 // Write the message body.
 80 |                 stdout.Write(jsonBytes, 0, jsonBytes.Length);
 81 | 
 82 |                 stdout.Flush();
 83 |                 stdout.Close();
 84 |             }
 85 |         }
 86 | 
 87 |         /// 
 88 |         /// Handle messages from the browser.
 89 |         /// 
 90 |         /// 
 91 |         static void OnMessage(JObject message) {
 92 |             switch (message["topic"].Value())
 93 |             {
 94 |                 case "create-job":
 95 |                     OnCreateJob(message.ToObject>());
 96 |                     break;
 97 |                 case "cancel-job":
 98 |                     OnCancelJob(message.ToObject>());
 99 |                     break;
100 |             }
101 |         }
102 | 
103 |         /// 
104 |         /// Invoked when a new job message is received.
105 |         /// 
106 |         /// 
107 |         static void OnCreateJob(Message message)
108 |         {
109 |             YoutubeDlRunner runner = new YoutubeDlRunner
110 |             {
111 |                 JobId = message.Data.JobId,
112 |                 Props = message.Data.Props
113 |             };
114 |             
115 |             runner.Output += (r, output) => {
116 |                 Write(new Message("job-output", new JobOutput
117 |                 {
118 |                     JobId = r.JobId,
119 |                     Output = output
120 |                 }));
121 |             };
122 | 
123 |             runner.Ended += (r, exitCode) => {
124 |                 Write(new Message("job-ended", new JobEnded
125 |                 {
126 |                     JobId = r.JobId,
127 |                     ExitCode = exitCode
128 |                 }));
129 | 
130 |                 r.Dispose();
131 | 
132 |                 jobs.TryRemove(r.JobId, out _);                
133 |             };
134 | 
135 |             jobs.TryAdd(runner.JobId, runner);
136 | 
137 |             runner.Start();
138 | 
139 |             // Send a message back with the job commandline arguments.
140 |             Write(new Message("job-started", new JobOutput
141 |             {
142 |                 JobId = message.Data.JobId,
143 |                 Output = message.Data.Props.ToArguments()
144 |             }));
145 |         }
146 | 
147 |         /// 
148 |         /// Invoked when a cancel job message is received.
149 |         /// 
150 |         /// 
151 |         static void OnCancelJob(Message message)
152 |         {
153 |             // youtube-dl does not document any exit codes besides 0 and 1.
154 |             // To avoid leaving orphaned jobs in the frontend, we will report additional codes.
155 |             var exitCode = (int)YoutubeDlRunner.ExitCodes.JobNotFound;
156 | 
157 |             jobs.TryGetValue(message.Data.JobId, out YoutubeDlRunner runner);
158 |             if (runner != null)
159 |             {
160 |                 // Found the job.
161 |                 exitCode = (int)YoutubeDlRunner.ExitCodes.JobCancelled;
162 |                 runner.Cancel();
163 |                 runner.Dispose();
164 |                 jobs.TryRemove(runner.JobId, out _);
165 |             } 
166 | 
167 |             // Tell the frontend the cancellation request was handled.
168 |             Write(new Message("job-ended", new JobEnded
169 |             {
170 |                 JobId = message.Data.JobId,
171 |                 ExitCode = exitCode
172 |             }));
173 |         }
174 |     }
175 | }
176 | 


--------------------------------------------------------------------------------
/src/icons/film-dark.svg:
--------------------------------------------------------------------------------
  1 | 
  2 | 
 13 |   
 15 |   
 17 |     
 18 |       
 20 |         image/svg+xml
 21 |         
 23 |         
 24 |       
 25 |     
 26 |   
 27 |   
 31 |     
 35 |       
 38 |         
 43 |       
 44 |     
 45 |     
 49 |     
 53 |     
 57 |     
 61 |     
 65 |     
 69 |     
 73 |     
 77 |     
 81 |     
 85 |     
 89 |     
 93 |     
 97 |     
101 |     
105 |   
106 |   
111 | 
112 | 


--------------------------------------------------------------------------------
/src/icons/film-black.svg:
--------------------------------------------------------------------------------
  1 | 
  2 | 
 13 |   
 15 |   
 17 |     
 18 |       
 20 |         image/svg+xml
 21 |         
 23 |         
 24 |       
 25 |     
 26 |   
 27 |   
 31 |     
 35 |       
 38 |         
 43 |       
 44 |     
 45 |     
 49 |     
 53 |     
 57 |     
 61 |     
 65 |     
 69 |     
 73 |     
 77 |     
 81 |     
 85 |     
 89 |     
 93 |     
 97 |     
101 |     
105 |   
106 |   
111 | 
112 | 


--------------------------------------------------------------------------------
/src/popup/popup.html:
--------------------------------------------------------------------------------
  1 | 
  2 | 
  3 |   
  4 |     
  5 |     Youtube-DL Button Popup
  6 |     
  7 |     
 11 |     
 13 |   
 14 |   
 15 | 
 16 |     
 17 |     
18 |
19 |
20 | 21 | Grab from URL 22 | 23 |
24 | 25 | 26 | 27 | 28 | 40 |
41 | 48 |
49 | 50 | 51 |
52 | 66 | 67 |
68 |
69 | 72 |
73 | 74 |
75 | 76 | 80 |
81 |
82 |
83 |
84 | 85 | 86 |
87 |
88 |
89 |
90 | 99 |
100 | 102 |
103 | 104 | 108 |
109 |
110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 |
120 |
121 |
122 |
123 | 132 |
133 | 134 |
135 | 136 | 140 |
141 |
142 |
143 |
144 |
145 |
146 |
147 | 148 | 149 |
150 |
151 |
152 | Job list is empty. 153 |
154 |
155 |
156 | 194 | 195 | 196 |
197 | 198 | 202 | 203 | 204 | 208 |
209 | 210 | 212 | 213 | 214 | -------------------------------------------------------------------------------- /src/icons/film-light.svg: -------------------------------------------------------------------------------- 1 | 2 | 17 | 39 | 44 | 49 | 54 | 55 | 57 | 58 | 59 | 61 | 62 | 64 | image/svg+xml 65 | 67 | 68 | 69 | 70 | 71 | 75 | 79 | 82 | 87 | 88 | 89 | 93 | 97 | 101 | 105 | 109 | 113 | 117 | 121 | 125 | 129 | 133 | 137 | 141 | 145 | 149 | 150 | 155 | 156 | -------------------------------------------------------------------------------- /src/icons/film-blue.svg: -------------------------------------------------------------------------------- 1 | 2 | 13 | 15 | 16 | 17 | 19 | 20 | 22 | image/svg+xml 23 | 25 | 26 | 27 | 28 | 29 | 33 | 37 | 40 | 45 | 46 | 47 | 51 | 55 | 59 | 63 | 67 | 71 | 75 | 79 | 83 | 87 | 91 | 95 | 99 | 103 | 107 | 108 | 113 | 117 | 121 | 125 | 129 | 133 | 137 | 141 | 145 | 149 | 153 | 157 | 161 | 165 | 169 | 173 | 174 | -------------------------------------------------------------------------------- /native-app/YoutubeDlRunner.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Diagnostics; 4 | using System.Linq; 5 | using System.Text.RegularExpressions; 6 | 7 | namespace YoutubeDlButton 8 | { 9 | class YoutubeDlRunner : IDisposable 10 | { 11 | // Matches log output that describes a file being downloaded. 12 | private static Regex LogDownloadFile = new Regex( 13 | @"^\[download\] Destination: (.+)$", 14 | RegexOptions.Compiled | RegexOptions.IgnoreCase); 15 | 16 | // Matches log output that describes a file already being downloaded. 17 | private static Regex LogAlreadyDownloadedFile = new Regex( 18 | @"^\[download\] (.+) has already been downloaded$", 19 | RegexOptions.Compiled | RegexOptions.IgnoreCase); 20 | 21 | // Matches log output that describes a file being deleted. 22 | private static Regex LogDeleteFile = new Regex( 23 | @"^Deleting original file (.+) \(pass -k to keep\)$", 24 | RegexOptions.Compiled | RegexOptions.IgnoreCase); 25 | 26 | // Matches log output that describes a new file created by merging. 27 | private static Regex LogMergingInto = new Regex( 28 | @"^\[Merger\] Merging formats into ""(.+)""$", 29 | RegexOptions.Compiled | RegexOptions.IgnoreCase); 30 | 31 | /// 32 | /// Exit codes to report to the frontend. 33 | /// These are not youtube-dl exit codes. 34 | /// 35 | public enum ExitCodes 36 | { 37 | Success = 0, 38 | Error = 253, 39 | JobCancelled = 254, 40 | JobNotFound = 255 41 | } 42 | 43 | private Process youtubeDlProcess; 44 | private Process postProcess; 45 | private bool cancelled = false; 46 | 47 | public int JobId { get; set; } 48 | 49 | public YoutubeDlProps Props { get; set; } 50 | 51 | public List Files = new List(); 52 | 53 | public event Action Output; 54 | public event Action Ended; 55 | 56 | /// 57 | /// Start a new instance of youtube-dl.exe. 58 | /// 59 | public void Start() 60 | { 61 | if (cancelled) 62 | { 63 | throw new InvalidOperationException("Operation was cancelled"); 64 | } 65 | 66 | if (youtubeDlProcess != null) 67 | { 68 | throw new InvalidOperationException("Already started"); 69 | } 70 | 71 | try 72 | { 73 | // Create a cookie jar if cookies are provided. 74 | Props.CreateCookieJar(); 75 | 76 | // Report the command line. 77 | var exePath = Environment.ExpandEnvironmentVariables(Props.ExePath); 78 | var arguments = Props.ToArguments(); 79 | Output?.Invoke(this, "Command line: " + exePath + " " + arguments); 80 | 81 | // Start an instance of youtube-dl. 82 | youtubeDlProcess = Process.Start(new ProcessStartInfo 83 | { 84 | FileName = exePath, 85 | Arguments = arguments, 86 | CreateNoWindow = true, 87 | UseShellExecute = false, 88 | RedirectStandardOutput = true, 89 | RedirectStandardError = true, 90 | // This is necessary to avoid hanging youtube-dl.exe even though we never use it. 91 | RedirectStandardInput = true 92 | }); 93 | 94 | // Get notified when the process exits. 95 | youtubeDlProcess.EnableRaisingEvents = true; 96 | youtubeDlProcess.Exited += OnYoutubeDlProcessExited; 97 | 98 | // Asynchronously read from process output. 99 | youtubeDlProcess.OutputDataReceived += OnYoutubeDlOutputReceived; 100 | youtubeDlProcess.ErrorDataReceived += OnYoutubeDlOutputReceived; 101 | youtubeDlProcess.BeginOutputReadLine(); 102 | youtubeDlProcess.BeginErrorReadLine(); 103 | } 104 | catch (Exception e) 105 | { 106 | // Send the error message back to the addon. 107 | Output?.Invoke(this, string.Format("Error starting youtube-dl process: {0}", e.Message)); 108 | Ended?.Invoke(this, (int)ExitCodes.Error); 109 | } 110 | } 111 | 112 | /// 113 | /// Invoke the post processing script. 114 | /// 115 | public void PostProcess () 116 | { 117 | if (cancelled) 118 | { 119 | throw new InvalidOperationException("Operation was cancelled"); 120 | } 121 | 122 | try 123 | { 124 | string filename = Environment.ExpandEnvironmentVariables(Props.PostProcessScript); 125 | string arguments = string.Join(" ", Files.Select(f => "\"" + f + "\"").ToArray()); 126 | Output?.Invoke(this, string.Format("Starting post process with: {0} {1}", filename, arguments)); 127 | 128 | // Invoke the post processing script. 129 | postProcess = Process.Start(new ProcessStartInfo 130 | { 131 | FileName = filename, 132 | Arguments = arguments, 133 | CreateNoWindow = true, 134 | UseShellExecute = false, 135 | RedirectStandardOutput = true, 136 | RedirectStandardError = true, 137 | RedirectStandardInput = true 138 | }); 139 | 140 | // Get notified when the process exits. 141 | postProcess.EnableRaisingEvents = true; 142 | postProcess.Exited += OnPostProcessDlProcessExited; 143 | 144 | // Asynchronously read from process output. 145 | postProcess.OutputDataReceived += OnPostProcessOutputReceived; 146 | postProcess.ErrorDataReceived += OnPostProcessOutputReceived; 147 | postProcess.BeginOutputReadLine(); 148 | postProcess.BeginErrorReadLine(); 149 | } 150 | catch (Exception e) 151 | { 152 | // Send the error message back to the addon. 153 | Output?.Invoke(this, string.Format("Error starting post process: {0}", e.Message)); 154 | Ended?.Invoke(this, (int)ExitCodes.Error); 155 | 156 | // Delete the cookie jar on error. 157 | Props.RemoveCookieJar(); 158 | } 159 | } 160 | 161 | /// 162 | /// Terminate the youtube-dl.exe process if it is running. The Ended event will not be invoked. 163 | /// 164 | public void Cancel() 165 | { 166 | if (!cancelled) 167 | { 168 | cancelled = true; 169 | 170 | // The caller may assume the process ended. 171 | Output = null; 172 | Ended = null; 173 | 174 | if (!youtubeDlProcess?.HasExited ?? false) 175 | { 176 | // process.Kill() was sufficient for youtube-dl, but yt-dlp uses child processes. 177 | ProcessKiller.KillWithChildren(youtubeDlProcess.Id); 178 | } 179 | 180 | if (!postProcess?.HasExited ?? false) 181 | { 182 | ProcessKiller.KillWithChildren(postProcess.Id); 183 | } 184 | } 185 | } 186 | 187 | /// 188 | /// Invoked when youtube-dl.exe exits. 189 | /// 190 | private void OnYoutubeDlProcessExited(object sender, EventArgs e) 191 | { 192 | try 193 | { 194 | // Begin post-processing if youtube-dl exited cleanly. 195 | if (((youtubeDlProcess?.ExitCode ?? 1) == 0) && !String.IsNullOrEmpty(Props.PostProcessScript)) 196 | { 197 | PostProcess(); 198 | } 199 | else 200 | { 201 | // Invoke the ended callback when the process exits. 202 | var exitCode = ((youtubeDlProcess?.ExitCode ?? 1) == 0) ? 0 : (int)ExitCodes.Error; 203 | Ended?.Invoke(this, exitCode); 204 | } 205 | } 206 | finally 207 | { 208 | // Delete the cookie jar on exit. 209 | Props.RemoveCookieJar(); 210 | } 211 | } 212 | 213 | /// 214 | /// Invoked when the post process script exits. 215 | /// 216 | private void OnPostProcessDlProcessExited(object sender, EventArgs e) 217 | { 218 | // Invoke the ended callback when the process exits. 219 | var exitCode = ((postProcess?.ExitCode ?? 1) == 0) ? 0 : (int)ExitCodes.Error; 220 | Ended?.Invoke(this, postProcess?.ExitCode ?? 1); 221 | } 222 | 223 | /// 224 | /// Invoked when youtube-dl.exe writes to its stdout or stderr. 225 | /// 226 | private void OnYoutubeDlOutputReceived(object sender, DataReceivedEventArgs e) 227 | { 228 | if (!string.IsNullOrEmpty(e.Data)) 229 | { 230 | string output = e.Data.Trim(); 231 | 232 | // Log output indicates a file was downloaded. 233 | Match match = LogDownloadFile.Match(output); 234 | if (match.Success) 235 | { 236 | Files.Add(match.Groups[1].Value); 237 | } 238 | 239 | // Log output indicates a file was already downloaded. 240 | match = LogAlreadyDownloadedFile.Match(output); 241 | if (match.Success) 242 | { 243 | Files.Add(match.Groups[1].Value); 244 | } 245 | 246 | // Log output indicates a file was deleted. 247 | match = LogDeleteFile.Match(output); 248 | if (match.Success) 249 | { 250 | Files.Remove(match.Groups[1].Value); 251 | } 252 | 253 | // Log output indicates a file was created by merging. 254 | match = LogMergingInto.Match(output); 255 | if (match.Success) 256 | { 257 | Files.Add(match.Groups[1].Value); 258 | } 259 | 260 | Debug.WriteLine(output); 261 | Output?.Invoke(this, output); 262 | } 263 | } 264 | 265 | /// 266 | /// Invoked when the post process script writes to its stdout or stderr. 267 | /// 268 | private void OnPostProcessOutputReceived(object sender, DataReceivedEventArgs e) 269 | { 270 | if (!string.IsNullOrEmpty(e.Data)) 271 | { 272 | string output = e.Data.Trim(); 273 | Debug.WriteLine(output); 274 | Output?.Invoke(this, output); 275 | } 276 | } 277 | 278 | public void Dispose() 279 | { 280 | if (youtubeDlProcess != null) 281 | { 282 | youtubeDlProcess.Dispose(); 283 | youtubeDlProcess = null; 284 | } 285 | } 286 | } 287 | } 288 | -------------------------------------------------------------------------------- /src/options/options.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const divToPropsMap = new WeakMap(); 4 | const trToCookieDomainMap = new WeakMap(); 5 | 6 | const el = { 7 | optionsForm: document.getElementById('options-form'), 8 | buttonAddProps: document.getElementById('add-props'), 9 | buttonAddCookieDomain: document.getElementById('add-cookie-domain'), 10 | buttonBackupSettings: document.getElementById('backup-settings'), 11 | fileRestoreSettings: document.getElementById('restore-settings'), 12 | inputExePath: document.getElementById('exe-path'), 13 | inputConcurrentJobsLimit: document.getElementById('concurrent-jobs-limit'), 14 | divPropsList: document.getElementById('props-list'), 15 | divCookieDomainList: document.getElementById('cookie-domain-list'), 16 | templateProps: document.getElementById('props-template'), 17 | templateSendCookieRow: document.getElementById('send-cookie-template') 18 | }; 19 | 20 | browser.storage.local.get({ 21 | exePath: '', 22 | concurrentJobsLimit: 1, 23 | props: [], 24 | sendCookieDomains: [] 25 | }).then(populateSettings); 26 | 27 | // Bind event handlers to the form. 28 | el.buttonAddProps.addEventListener('click', () => createPropsConfig({}).scrollIntoView()); 29 | el.buttonAddCookieDomain.addEventListener('click', () => createCookieDomainConfig({ 30 | videoDomain: '', 31 | extraDomains: [] 32 | }).scrollIntoView()); 33 | el.buttonBackupSettings.addEventListener('click', () => backupSettings()); 34 | el.optionsForm.addEventListener('submit', saveOptions); 35 | el.fileRestoreSettings.addEventListener('change', () => restoreSettings()); 36 | 37 | function populateSettings (results) { 38 | // Restore the options from local stoage. 39 | el.divPropsList.innerText = ''; 40 | el.inputExePath.value = results.exePath || ''; 41 | el.inputConcurrentJobsLimit.value = results.concurrentJobsLimit || 1; 42 | results.props.forEach(createPropsConfig); 43 | results.sendCookieDomains.forEach(createCookieDomainConfig); 44 | } 45 | 46 | function createPropsConfig (props, index = -1) { 47 | let template = document.importNode(el.templateProps.content, true); 48 | let tpl = { 49 | divProps: template.firstElementChild, 50 | buttonDelete: template.querySelector('button.delete'), 51 | buttonMoveDown: template.querySelector('button.move-down'), 52 | buttonMoveUp: template.querySelector('button.move-up'), 53 | checkInheritDefault: template.querySelector('[name="inherit-default"]'), 54 | divIconPreview: template.querySelector('.icon-preview'), 55 | inputCustomArgs: template.querySelector('[name="custom-args"]'), 56 | inputFormat: template.querySelector('[name="format"]'), 57 | inputIcon: template.querySelector('[name="icon"]'), 58 | inputName: template.querySelector('[name="name"]'), 59 | inputPostProcessScript: template.querySelector('[name="post-process-script"]'), 60 | inputSaveIn: template.querySelector('[name="save-in"]'), 61 | inputTemplate: template.querySelector('[name="template"]') 62 | }; 63 | tpl.checkInheritDefault.checked = (props.inheritDefault === undefined) ? true : !!props.inheritDefault; 64 | tpl.inputCustomArgs.value = props.customArgs || ''; 65 | tpl.inputFormat.value = props.format || ''; 66 | tpl.inputIcon.value = props.icon || ''; 67 | tpl.inputName.value = props.name || ''; 68 | tpl.inputPostProcessScript.value = props.postProcessScript || ''; 69 | tpl.inputSaveIn.value = props.saveIn || ''; 70 | tpl.inputTemplate.value = props.template || ''; 71 | 72 | // Special cases for the built in parameter sets. 73 | if (index === 0) { 74 | tpl.divProps.classList.add('default-props'); 75 | tpl.buttonDelete.setAttribute('disabled', 'true'); 76 | tpl.inputName.setAttribute('disabled', 'true'); 77 | tpl.inputSaveIn.setAttribute('required', 'true'); 78 | tpl.checkInheritDefault.checked = false; 79 | } else 80 | if (index === 1) { 81 | tpl.divProps.classList.add('audio-props'); 82 | tpl.buttonDelete.setAttribute('disabled', 'true'); 83 | tpl.inputName.setAttribute('disabled', 'true'); 84 | } else { 85 | tpl.divProps.classList.add('custom-props'); 86 | } 87 | 88 | // Configure the form. 89 | updateIconPreview(tpl); 90 | updatePropsConfigValidation(tpl); 91 | 92 | // Bind event handlers to the form. 93 | tpl.buttonMoveUp.addEventListener('click', () => { 94 | let sibling = tpl.divProps.previousElementSibling; 95 | tpl.divProps.parentNode.insertBefore(tpl.divProps, sibling); 96 | }); 97 | tpl.buttonMoveDown.addEventListener('click', () => { 98 | let sibling = tpl.divProps.nextElementSibling; 99 | sibling.parentNode.insertBefore(sibling, tpl.divProps); 100 | }); 101 | tpl.buttonDelete.addEventListener('click', () => tpl.divProps.parentNode.removeChild(tpl.divProps)); 102 | tpl.inputIcon.addEventListener('change', () => updateIconPreview(tpl)); 103 | tpl.checkInheritDefault.addEventListener('change', () => updatePropsConfigValidation(tpl)); 104 | 105 | el.divPropsList.appendChild(template); 106 | divToPropsMap.set(tpl.divProps, () => ({ 107 | inheritDefault: tpl.checkInheritDefault.checked, 108 | customArgs: tpl.inputCustomArgs.value, 109 | format: tpl.inputFormat.value, 110 | icon: tpl.inputIcon.value, 111 | name: tpl.inputName.value, 112 | postProcessScript: tpl.inputPostProcessScript.value, 113 | saveIn: tpl.inputSaveIn.value, 114 | template: tpl.inputTemplate.value 115 | })); 116 | 117 | return tpl.divProps; 118 | } 119 | 120 | function updateIconPreview (tpl) { 121 | if (tpl.inputIcon.value) { 122 | tpl.divIconPreview.style.backgroundImage = `url("${tpl.inputIcon.value}")`; 123 | } else { 124 | tpl.divIconPreview.style.backgroundImage = null; 125 | } 126 | } 127 | 128 | function updatePropsConfigValidation (tpl) { 129 | const placeholder = 'Inherited from Default'; 130 | if (tpl.checkInheritDefault.checked) { 131 | tpl.divProps.classList.add('inherits-default'); 132 | tpl.inputCustomArgs.setAttribute('placeholder', placeholder); 133 | tpl.inputFormat.removeAttribute('required'); 134 | tpl.inputFormat.setAttribute('placeholder', placeholder); 135 | tpl.inputPostProcessScript.setAttribute('placeholder', placeholder); 136 | tpl.inputTemplate.removeAttribute('required'); 137 | tpl.inputTemplate.setAttribute('placeholder', placeholder); 138 | tpl.inputSaveIn.removeAttribute('required'); 139 | tpl.inputSaveIn.setAttribute('placeholder', placeholder); 140 | } else { 141 | tpl.divProps.classList.remove('inherits-default'); 142 | tpl.inputCustomArgs.removeAttribute('placeholder'); 143 | tpl.inputFormat.removeAttribute('placeholder'); 144 | tpl.inputFormat.setAttribute('required', 'true'); 145 | tpl.inputPostProcessScript.removeAttribute('placeholder'); 146 | tpl.inputTemplate.removeAttribute('placeholder'); 147 | tpl.inputTemplate.setAttribute('required', 'true'); 148 | tpl.inputSaveIn.setAttribute('required', 'true'); 149 | tpl.inputSaveIn.removeAttribute('placeholder'); 150 | } 151 | } 152 | 153 | function createCookieDomainConfig (cookieDomain) { 154 | let template = document.importNode(el.templateSendCookieRow.content, true); 155 | let tpl = { 156 | divCookieDomain: template.firstElementChild, 157 | inputVideoDomain: template.querySelector('[name="video-domain"]'), 158 | inputExtraDomains: template.querySelector('[name="extra-domains"]'), 159 | buttonDelete: template.querySelector('button.delete') 160 | }; 161 | 162 | // Configure the form. 163 | tpl.inputVideoDomain.value = cookieDomain.videoDomain; 164 | tpl.inputExtraDomains.value = cookieDomain.extraDomains.join(', '); 165 | 166 | // Bind event handlers to the form. 167 | tpl.buttonDelete.addEventListener('click', () => tpl.divCookieDomain.parentNode.removeChild(tpl.divCookieDomain)); 168 | 169 | el.divCookieDomainList.appendChild(template); 170 | trToCookieDomainMap.set(tpl.divCookieDomain, () => ({ 171 | videoDomain: tpl.inputVideoDomain.value, 172 | extraDomains: tpl.inputExtraDomains.value 173 | .split(',') 174 | .map(d => d.trim()) 175 | .filter(d => d) 176 | })); 177 | return tpl.divProps; 178 | } 179 | 180 | async function applyCookiePermissions (requested) { 181 | // Aquire all requested host permissions. 182 | if (requested.length) { 183 | if (!await browser.permissions.request({ 184 | origins: requested, 185 | permissions: [ 'cookies' ] 186 | })) { 187 | return false; 188 | } 189 | } 190 | 191 | // Relinquish permission from removed domains. 192 | let permitted = (await browser.permissions.getAll()).origins; 193 | let relinquished = permitted.filter(o => !requested.includes(o)); 194 | await browser.permissions.remove({ 195 | origins: relinquished 196 | }); 197 | 198 | return true; 199 | } 200 | 201 | // Save the options to local storage. 202 | async function saveOptions (event) { 203 | if (event) { 204 | event.preventDefault(); 205 | event.stopPropagation(); 206 | } 207 | 208 | if (!el.optionsForm.checkValidity()) { 209 | return; 210 | } 211 | 212 | let props = [].slice.call(el.divPropsList.children) 213 | .filter(element => element.tagName === 'DIV') 214 | .map(divProps => divToPropsMap.get(divProps)()); 215 | 216 | let sendCookieDomains = [].slice.call(el.divCookieDomainList.children) 217 | .filter(element => element.tagName === 'TR') 218 | .map(trCookieDomain => trToCookieDomainMap.get(trCookieDomain)()) 219 | .filter(cookieDomain => cookieDomain.videoDomain); 220 | 221 | // Aquire or release optional permissions for cookies. 222 | let cookieOrigins = []; 223 | sendCookieDomains.forEach(cookieDomain => { 224 | cookieOrigins.push(`*://${cookieDomain.videoDomain}/*`); 225 | cookieDomain.extraDomains.forEach(extraDomain => { 226 | cookieOrigins.push(`*://${extraDomain}/*`); 227 | }); 228 | }); 229 | if (!(await applyCookiePermissions(cookieOrigins))) { 230 | alert('Settings have not been saved'); 231 | return; 232 | } 233 | 234 | // Save all settings. 235 | await browser.storage.local.set({ 236 | exePath: el.inputExePath.value, 237 | concurrentJobsLimit: Number(el.inputConcurrentJobsLimit.value), 238 | props, 239 | sendCookieDomains 240 | }); 241 | 242 | alert('Settings have been saved'); 243 | } 244 | 245 | // Backup settings to a JSON file which is downloaded. 246 | async function backupSettings () { 247 | // Get the settings to be backed up. 248 | let backupSettings = await browser.storage.local.get({ 249 | exePath: null, 250 | concurrentJobsLimit: 1, 251 | props: [], 252 | sendCookieDomains: [] 253 | }); 254 | 255 | // Wrap the settings in an envelope. 256 | let backupData = {}; 257 | backupData.settings = backupSettings; 258 | backupData.timestamp = new Date(); 259 | backupData.fileName = 'youtubeDlButton.' + [ 260 | String(backupData.timestamp.getFullYear()), 261 | String(backupData.timestamp.getMonth() + 1).padStart(2, '0'), 262 | String(backupData.timestamp.getDate()).padStart(2, '0') 263 | ].join('-') + '.json'; 264 | // Record the current addon version. 265 | let selfInfo = await browser.management.getSelf(); 266 | backupData.addonId = selfInfo.id; 267 | backupData.version = selfInfo.version; 268 | 269 | // Encode the backup as a JSON data URL. 270 | let jsonData = JSON.stringify(backupData, null, 2); 271 | let dataUrl = 'data:application/json;charset=utf-8,' + encodeURIComponent(jsonData); 272 | 273 | // Prompt the user to download the backup. 274 | let a = window.document.createElement('a'); 275 | a.href = dataUrl; 276 | a.download = backupData.fileName; 277 | document.body.appendChild(a); 278 | a.click(); 279 | document.body.removeChild(a); 280 | } 281 | 282 | // Restore settings froma JSON file which is uploaded. 283 | async function restoreSettings () { 284 | let reader = new window.FileReader(); 285 | reader.onload = async () => { 286 | try { 287 | // TODO Validate the backup version, etc. 288 | let backupData = JSON.parse(reader.result); 289 | populateSettings(backupData.settings); 290 | alert('Settings copied from backup; please Save now.'); 291 | } catch (error) { 292 | alert(`Failed to restore: ${error}`); 293 | } 294 | }; 295 | reader.onerror = (error) => { 296 | alert(`Failed to restore: ${error}`); 297 | }; 298 | reader.readAsText(el.fileRestoreSettings.files[0]); 299 | } -------------------------------------------------------------------------------- /src/background/background.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const state = { 4 | port: null, 5 | jobs: [], 6 | jobId: 1, 7 | }; 8 | 9 | const settings = { 10 | exePath: null, 11 | concurrentJobsLimit: 1, 12 | sendCookieDomains: [] 13 | }; 14 | 15 | // Get initial settings values. 16 | browser.storage.local.get(settings).then(results => { 17 | Object.assign(settings, results); 18 | }); 19 | 20 | // --------------------------------------------------------------------------------------------------------------------- 21 | // Events 22 | 23 | /** 24 | * Invoked when settings are changed. 25 | */ 26 | browser.storage.onChanged.addListener((changes, area) => { 27 | let keys = Object.keys(settings); 28 | if (area === 'local') { 29 | Object.keys(changes).forEach(changeKey => { 30 | if (keys.includes(changeKey)) { 31 | settings[changeKey] = changes[changeKey].newValue; 32 | } 33 | }); 34 | } 35 | }); 36 | 37 | /** 38 | * Invoked by messages from popups and content scripts. 39 | */ 40 | browser.runtime.onMessage.addListener((message, sender) => { 41 | // Decorate the message with the sender tab ID. 42 | if (sender.tab) { 43 | message.tabId = sender.tab.id; 44 | } 45 | 46 | switch (message.topic) { 47 | case 'ydb-get-jobs': 48 | return onGetJobs(message); 49 | case 'ydb-create-job': 50 | return onCreateJob(message); 51 | case 'ydb-cancel-job': 52 | return onCancelJob(message); 53 | case 'ydb-retry-job': 54 | return onRetryJob(message); 55 | case 'ydb-clean-jobs': 56 | return onCleanJobs(message); 57 | case 'ydb-update-exe': 58 | return onUpdateExe(message); 59 | } 60 | 61 | return false; 62 | }); 63 | 64 | /** 65 | * Invoked by messages from the native-app port. 66 | */ 67 | function onPortMessage (message) { 68 | switch (message.topic) { 69 | case 'job-output': 70 | onJobOutput(message); 71 | break; 72 | case 'job-ended': 73 | onJobEnded(message); 74 | break; 75 | case 'job-started': 76 | console.log('started job', message.data.output); 77 | break; 78 | } 79 | } 80 | 81 | /** 82 | * Invoked when the native-app is disconnected unexpectedly. 83 | */ 84 | function onPortDisconnect (port) { 85 | if (port.error) { 86 | console.log('disconnected with error', port.error); 87 | } 88 | state.port = null; 89 | 90 | // Fail all ongoing jobs. 91 | state.jobs.forEach(job => { 92 | if ((job.state === 'waiting') || (job.state === 'active')) { 93 | job.ended(1, 'native app disconnected unexpectedly.'); 94 | } 95 | }); 96 | } 97 | 98 | // --------------------------------------------------------------------------------------------------------------------- 99 | // Model 100 | 101 | class Job { 102 | constructor (props) { 103 | this.id = state.jobId++; 104 | this.props = props; 105 | this.cancelRequested = false; 106 | this.state = 'waiting'; 107 | this.output = []; 108 | 109 | this.props.exePath = settings.exePath; 110 | } 111 | 112 | /** 113 | * Update the job state and sort the job queue. 114 | */ 115 | setState (state) { 116 | this.state = state; 117 | } 118 | 119 | /** 120 | * Tell the native app to create a job. 121 | */ 122 | create () { 123 | // Send a create-job message to the native-app. 124 | openPort(); 125 | return browser.storage.local.get({ props: {} }).then(async result => { 126 | // Get a cookie jar for the job. 127 | const cookieFile = (this.props.updateExe) ? null : await getCookieJarForVideo(this.props.videoUrl); 128 | 129 | state.port.postMessage({ 130 | topic: 'create-job', 131 | data: { 132 | jobId: this.id, 133 | // Merge the default props and the job props. 134 | props: Object.assign({ cookieJar: cookieFile }, result.props, this.props) 135 | } 136 | }); 137 | 138 | // Flag this job as active. 139 | this.state = 'active'; 140 | 141 | // Make the icon blue because a job is running. 142 | browser.browserAction.setIcon({ 143 | path: 'icons/film-blue.svg' 144 | }); 145 | }); 146 | } 147 | 148 | /** 149 | * Tell the native-app to cancel the job. 150 | */ 151 | cancel () { 152 | this.cancelRequested = true; 153 | this.append('Cancel requested.'); 154 | if (this.state === 'waiting') { 155 | this.setState('cancelled'); 156 | this.append('Job cancelled.'); 157 | } else { 158 | state.port.postMessage({ 159 | topic: 'cancel-job', 160 | data: { 161 | jobId: this.id, 162 | } 163 | }); 164 | } 165 | } 166 | 167 | /** 168 | * Flag the job as ended. 169 | */ 170 | ended (exitCode, detail) { 171 | if (this.cancelRequested) { 172 | this.setState('cancelled'); 173 | this.append('Job cancelled.'); 174 | } else { 175 | this.setState((exitCode > 0) ? 'errored' : 'ended'); 176 | if (detail) { 177 | this.append(`Job ended with error: ${detail}`); 178 | } 179 | } 180 | } 181 | 182 | /** 183 | * Append a line to the job output. 184 | */ 185 | append (output) { 186 | this.output.push(output); 187 | } 188 | } 189 | 190 | class CookieJar { 191 | constructor () { 192 | this.cookies = []; 193 | } 194 | 195 | isEmpty () { 196 | return (this.cookies.length === 0); 197 | } 198 | 199 | addAll (cookies) { 200 | this.cookies.push(...cookies); 201 | } 202 | 203 | toString () { 204 | let newline = '\r\n', 205 | // Send this for session cookies. 206 | sessionExpirationDate = new Date().getTime() + 3600000; 207 | 208 | return this.cookies.reduce((data, cookie) => { 209 | // yt-dlp will reject leading dot on domain. 210 | let entry = `${cookie.domain}\t`; 211 | entry += `${cookie.hostOnly?'FALSE':'TRUE'}\t`; 212 | entry += `${cookie.path}\t`; 213 | entry += `${cookie.secure?'TRUE':'FALSE'}\t`; 214 | entry += `${cookie.expirationDate||sessionExpirationDate}\t`; 215 | entry += `${cookie.name}\t`; 216 | entry += `${cookie.value}`; 217 | data.push(entry); 218 | return data; 219 | }, [ '# Netscape HTTP Cookie File' ]).join(newline); 220 | } 221 | 222 | } 223 | 224 | // --------------------------------------------------------------------------------------------------------------------- 225 | // Functions 226 | 227 | /** 228 | * Open a port to the native-app. 229 | * Does nothing if the port is already open. 230 | */ 231 | function openPort () { 232 | if (!state.port) { 233 | state.port = browser.runtime.connectNative('youtube_dl_button'); 234 | state.port.onMessage.addListener(onPortMessage); 235 | state.port.onDisconnect.addListener(onPortDisconnect); 236 | } 237 | } 238 | 239 | /** 240 | * Count the number of active jobs. 241 | */ 242 | function countActiveJobs () { 243 | return state.jobs.reduce((count, job) => { 244 | if (job.state === 'active') { 245 | count++; 246 | } 247 | return count; 248 | }, 0); 249 | } 250 | 251 | /** 252 | * Find a job by ID. 253 | */ 254 | function findJobById (id) { 255 | return state.jobs.find(job => job.id === id); 256 | } 257 | 258 | /** 259 | * Find the next job in the waiting state. 260 | */ 261 | function findNextWaitingJob () { 262 | // Search from last (lowest job ID) to first (highest job ID.) 263 | for (let i = (state.jobs.length - 1); i >= 0; i--) { 264 | let job = state.jobs[i]; 265 | if (job.state === 'waiting') { 266 | return job; 267 | } 268 | } 269 | return null; 270 | } 271 | 272 | /** 273 | * Get the list of jobs. 274 | */ 275 | function onGetJobs (message) { 276 | let jobs = state.jobs; 277 | if (message.data.jobId) { 278 | jobs = jobs.filter(job => job.id === message.data.jobId); 279 | } 280 | return Promise.resolve(jobs); 281 | } 282 | 283 | /** 284 | * Create a new job. 285 | */ 286 | function onCreateJob (message) { 287 | let job = new Job(message.data.props); 288 | state.jobs.unshift(job); 289 | 290 | // Start the job if below the concurrent jobs limit. 291 | if (countActiveJobs() < settings.concurrentJobsLimit) { 292 | job.create(); 293 | } 294 | 295 | return Promise.resolve(job); 296 | } 297 | 298 | /** 299 | * Cancel a running job. 300 | */ 301 | function onCancelJob (message) { 302 | let job = findJobById(message.data.jobId); 303 | if (job) { 304 | job.cancel(); 305 | return Promise.resolve(job); 306 | } 307 | return Promise.resolve(false); 308 | } 309 | 310 | /** 311 | * Retry a failed job. 312 | */ 313 | function onRetryJob (message) { 314 | let job = findJobById(message.data.jobId); 315 | if (job) { 316 | if ((job.state === 'errored') || (job.state === 'cancelled')) { 317 | // Put the job back into the queue. 318 | job.setState('waiting'); 319 | 320 | // Start the job if below the concurrent jobs limit. 321 | if (countActiveJobs() < settings.concurrentJobsLimit) { 322 | job.create(); 323 | } 324 | } 325 | return Promise.resolve(job); 326 | } 327 | return Promise.resolve(false); 328 | } 329 | 330 | /** 331 | * Create a job to update youtube-dl. 332 | */ 333 | function onUpdateExe () { 334 | let job = new Job({ 335 | videoUrl: 'Update youtube-dl.exe executable', 336 | updateExe: true 337 | }); 338 | state.jobs.unshift(job); 339 | job.create(); 340 | return Promise.resolve(job); 341 | } 342 | 343 | /** 344 | * Remove ended jobs from the job list. 345 | */ 346 | function onCleanJobs () { 347 | state.jobs 348 | .filter(job => (job.state !== 'waiting' && job.state !== 'active')) 349 | .forEach(job => { 350 | let index = state.jobs.indexOf(job); 351 | state.jobs.splice(index, 1); 352 | }); 353 | 354 | return Promise.resolve(state.jobs); 355 | } 356 | 357 | /** 358 | * Handle job output. 359 | */ 360 | function onJobOutput (message) { 361 | let job = findJobById(message.data.jobId); 362 | if (job) { 363 | console.log('youtube-dl', message.data.jobId, message.data.output); 364 | job.append(message.data.output); 365 | 366 | // Try to parse out the filename from the output. 367 | let match = /^\[ffmpeg\] Merging formats into "(.+)"$/.exec(message.data.output); 368 | if (match) { 369 | job.destination = match[1]; 370 | return; 371 | } 372 | match = /^\[download\] Destination: (.+)$/.exec(message.data.output); 373 | if (match) { 374 | job.destination = match[1]; 375 | return; 376 | } 377 | match = /^\[download\] (.+) has already been downloaded$/.exec(message.data.output); 378 | if (match) { 379 | job.destination = match[1]; 380 | return; 381 | } 382 | match = /^File name is: (.+)$/.exec(message.data.output); 383 | if (match) { 384 | job.destination = match[1]; 385 | return; 386 | } 387 | } 388 | } 389 | 390 | /** 391 | * Handle job that have ended. 392 | */ 393 | function onJobEnded (message) { 394 | let job = findJobById(message.data.jobId); 395 | if (job) { 396 | console.log('job ended', job.id, message.data.exitCode); 397 | job.ended(message.data.exitCode); 398 | } 399 | 400 | // Start a job if below the concurrent jobs limit. 401 | let activeJobCount = countActiveJobs(); 402 | if (activeJobCount < settings.concurrentJobsLimit) { 403 | job = findNextWaitingJob(); 404 | if (job) { 405 | job.create(); 406 | } 407 | } 408 | 409 | // Disconnect the port if there are no jobs. 410 | if (!job && (activeJobCount === 0)) { 411 | console.log('no jobs - disconnecting native-app'); 412 | state.port.disconnect(); 413 | state.port = null; 414 | 415 | // Make the icon dark because the queue is idle. 416 | browser.browserAction.setIcon({ 417 | path: null 418 | }); 419 | } 420 | } 421 | 422 | /** 423 | * Get a cookie jar containing cookies for the video domain. 424 | */ 425 | async function getCookieJarForVideo (videoUrl) { 426 | let cookieJar = new CookieJar(); 427 | try { 428 | let url = new URL(videoUrl); 429 | 430 | // Get the list of domains to include in the cookie jar. 431 | let sendCookieDomains = settings.sendCookieDomains.flatMap(cookieDomain => { 432 | return url.host.endsWith(cookieDomain.videoDomain) ? 433 | [ cookieDomain.videoDomain ].concat(cookieDomain.extraDomains) : 434 | []; 435 | }); 436 | 437 | // Add all the domains to the cookie jar. 438 | for (const domain of sendCookieDomains) { 439 | cookieJar.addAll(await browser.cookies.getAll({ domain })); 440 | } 441 | } catch (error) { 442 | console.log('could not determine domain for cookie jar', error); 443 | } 444 | 445 | return cookieJar.isEmpty() ? null : cookieJar.toString(); 446 | } -------------------------------------------------------------------------------- /src/options/options.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Youtube-DL Button Options 6 | 7 | 11 | 13 | 15 | 16 | 17 |
18 |
19 |

Youtube-DL Button Options

20 |
21 | 22 |
23 |
24 | 25 |
26 | 27 | 29 | 30 | Provide the absolute path to yt-dlp. 31 | 32 | 33 | This is required or the addon cannot function. 34 | 35 |
36 | 37 | 38 |
39 | 40 | 42 | 43 | The number of downloads that can run simultaneously. 44 | 45 |
46 |
47 | 48 | 49 |
50 |
51 |
52 |
Parameter Sets
53 |

54 | Easily invoke yt-dlp using different combinations of settings and switches. The addon 55 | provides two built-in profiles for the Default Download and Audio 56 | Download buttons in the popup. You may also add custom parameter sets 57 | while will appear in the popup. 58 |

59 |
60 |
61 |
62 |
63 |
64 | 67 |
68 |
69 | 70 | 71 |
72 |
73 |
74 |
Browser Integrations
75 |
76 | yt-dlp has implemented support for reading cookies directly from the browser. 77 | See: --cookies-from-browser firefox 78 |
79 |
80 | This add-on requires additional permissions to read cookies. If you enable any cookie integrations, 81 | you will be prompted to grant these permissions when you save the settings. 82 |
83 |
84 |
85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 |
96 |
97 | 100 |
101 |
102 | 103 |
104 | 105 |
106 | 107 |
108 | 111 |
112 | 113 |
114 | 117 | 121 |
122 |
123 |
124 |
125 | 126 | 127 | 281 | 282 | 283 | 298 |
299 |
300 | 301 | 302 | 303 | 304 | -------------------------------------------------------------------------------- /src/popup/popup.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const el = { 4 | inputVideoUrl: document.getElementById('video-url'), 5 | inputMetadataUrl: document.getElementById('metadata-url'), 6 | inputMetadataAuthor: document.getElementById('metadata-author'), 7 | inputMetadataTitle: document.getElementById('metadata-title'), 8 | //buttonCreateJob: document.getElementById('create-job'), 9 | //buttonCreateJobAudio: document.getElementById('create-job-audio'), 10 | buttonMainDowpdown: document.getElementById('toggle-main-dropdown'), 11 | divGrabVideoBar: document.getElementById('grab-video-bar'), 12 | divMainDropdown: document.getElementById('main-dropdown'), 13 | divDropdownAddon: document.getElementById('dropdown-addon'), 14 | //divSwitchSetsList: document.getElementById('switch-sets-list'), 15 | divPropsButtonList: document.getElementById('props-button-list'), 16 | templatePropsButton: document.getElementById('props-button-template'), 17 | settingsPanel: document.getElementById('settings-panel'), 18 | settingsTabs: { 19 | saving: { 20 | header: document.querySelector('.nav-item.saving-tab a'), 21 | content: document.querySelector('.tab-content.saving-tab') 22 | }, 23 | switches: { 24 | header: document.querySelector('.nav-item.switches-tab a'), 25 | content: document.querySelector('.tab-content.switches-tab') 26 | } 27 | }, 28 | inputSaveIn: document.getElementById('save-in'), 29 | buttonClearSaveIn: document.getElementById('clear-save-in'), 30 | inputTemplate: document.getElementById('template'), 31 | buttonClearTemplate: document.getElementById('clear-template'), 32 | inputFormat: document.getElementById('format'), 33 | buttonClearFormat: document.getElementById('clear-format'), 34 | divJobsList: document.getElementById('jobs-list'), 35 | divEmptyList: document.getElementById('empty-list'), 36 | templateJobRow: document.getElementById('job-row-template'), 37 | updateExe: document.getElementById('update-exe'), 38 | buttonCleanUp: document.getElementById('cleanup-jobs') 39 | }; 40 | 41 | // Add-on settings. 42 | const settings = { 43 | exePath: null, 44 | popup: { 45 | showSettings: true, 46 | settingsTab: 'saving' 47 | }, 48 | props: [] 49 | }; 50 | 51 | /** 52 | * A collection of per-domain settings. 53 | */ 54 | class PerDomainSettings { 55 | constructor (data) { 56 | data = data || {}; 57 | this.saveIn = data.saveIn || ''; 58 | this.template = data.template || ''; 59 | this.format = data.format || ''; 60 | } 61 | 62 | isEmpty() { 63 | return !this.saveIn && !this.template && !this.format; 64 | } 65 | } 66 | 67 | // Get the URL of the active tab. 68 | let promises = []; 69 | promises.push(browser.tabs.executeScript({ 70 | file: '/content/scrape.js' 71 | }).then(data => { 72 | let url = new URL(data[0].url), 73 | metadata = data[0].metadata; 74 | 75 | // Store metadata if any is available. 76 | el.inputMetadataUrl.value = url; 77 | el.inputMetadataAuthor.value = metadata.author || null; 78 | el.inputMetadataTitle.value = metadata.title || null; 79 | 80 | // Also get the per-domain settings for this host. 81 | return browser.storage.local.get({ 82 | domains: {} 83 | }).then(results => { 84 | let domainSettings = new PerDomainSettings(results.domains[url.host]); 85 | return { 86 | url, 87 | domainSettings 88 | }; 89 | }); 90 | }).catch(error => { 91 | console.log('failed to get active tab URL', error); 92 | return null; 93 | })); 94 | 95 | // Get the saved settings from local storage. 96 | promises.push(browser.storage.local.get(settings).then(results => { 97 | // Copy settings into settings object. 98 | Object.assign(settings, results); 99 | 100 | let unconfiguredProps = { 101 | saveIn: 'Not Configured', 102 | template: 'Not Configured', 103 | format: 'Not Configured' 104 | }; 105 | let defaultProps = settings.props.length ? settings.props[0] : unconfiguredProps; 106 | el.inputSaveIn.placeholder = `Default: ${defaultProps.saveIn || unconfiguredProps.saveIn}`; 107 | el.inputTemplate.placeholder = `Default: ${defaultProps.template || unconfiguredProps.template}`; 108 | el.inputFormat.placeholder = `Default: ${defaultProps.format || unconfiguredProps.format}`; 109 | 110 | return settings; 111 | })); 112 | 113 | // Finish initialization when all promises resolve. 114 | Promise.all(promises) 115 | .then(results => { 116 | if (results[0]) { 117 | let { url, domainSettings } = results[0]; 118 | 119 | el.inputVideoUrl.value = url.href; 120 | 121 | // Restore per-domain settings if available. 122 | el.inputSaveIn.value = domainSettings.saveIn; 123 | el.inputTemplate.value = domainSettings.template; 124 | el.inputFormat.value = domainSettings.format; 125 | } 126 | }) 127 | .finally(() => { 128 | // Initialize the state of the interface. 129 | openSettingsTab(settings.popup.settingsTab); 130 | 131 | // Initialize the remaining interface elements. 132 | populatePropsEntries(); 133 | 134 | // Update the job list and start polling. 135 | validateCreateJob(); 136 | refreshJobs(); 137 | startPollingJobs(); 138 | }); 139 | 140 | // Interface event setup ----------------------------------------------------------------------------------------------- 141 | 142 | el.inputVideoUrl.addEventListener('input', () => { 143 | validateCreateJob(); 144 | }); 145 | 146 | // Change 147 | el.inputVideoUrl.addEventListener('change', () => { 148 | savePopupSettings(); 149 | }); 150 | el.inputSaveIn.addEventListener('change', () => { 151 | savePopupSettings(); 152 | }); 153 | el.inputTemplate.addEventListener('change', () => { 154 | savePopupSettings(); 155 | }); 156 | el.inputFormat.addEventListener('change', () => { 157 | savePopupSettings(); 158 | }); 159 | 160 | // Click 161 | el.buttonMainDowpdown.addEventListener('click', () => { 162 | toggleMainDropdown(); 163 | }); 164 | el.buttonClearSaveIn.addEventListener('click', () => { 165 | el.inputSaveIn.value = null; 166 | savePopupSettings(); 167 | }); 168 | el.buttonClearTemplate.addEventListener('click', () => { 169 | el.inputTemplate.value = null; 170 | savePopupSettings(); 171 | }); 172 | el.buttonClearFormat.addEventListener('click', () => { 173 | el.inputFormat.value = null; 174 | savePopupSettings(); 175 | }); 176 | el.updateExe.addEventListener('click', () => { 177 | updateExe(); 178 | }); 179 | el.buttonCleanUp.addEventListener('click', () => { 180 | cleanUpJobs(); 181 | }); 182 | 183 | // Add event listeners to settings tab headers. 184 | Object.keys(el.settingsTabs).forEach(key => { 185 | let a = el.settingsTabs[key].header; 186 | a.addEventListener('click', () => { 187 | openSettingsTab(key); 188 | }); 189 | }); 190 | 191 | // Functions ----------------------------------------------------------------------------------------------------------- 192 | 193 | /** 194 | * Save popup settings. 195 | */ 196 | function savePopupSettings () { 197 | return Promise.all([ 198 | // TODO Save other settings. 199 | savePerDomainSettings() 200 | ]); 201 | } 202 | 203 | /** 204 | * Save per-domain settings. 205 | */ 206 | function savePerDomainSettings () { 207 | try { 208 | // Save the per-domain settings. 209 | if (el.inputVideoUrl.value) { 210 | var url = new URL(el.inputVideoUrl.value); 211 | let domainSettings = new PerDomainSettings({ 212 | saveIn: el.inputSaveIn.value, 213 | template: el.inputTemplate.value, 214 | format: el.inputFormat.value 215 | }); 216 | return browser.storage.local.get({ domains: {} }).then(results => { 217 | if (domainSettings.isEmpty()) { 218 | // Remove the saved entry for this domain. 219 | delete results.domains[url.host]; 220 | } else { 221 | // Update the saved entry for this domain. 222 | results.domains[url.host] = domainSettings; 223 | } 224 | return browser.storage.local.set(results); 225 | }); 226 | } 227 | } catch (error) { 228 | console.log('not saving per domain settings - invalid URL'); 229 | } 230 | return Promise.resolve({}); 231 | } 232 | 233 | /** 234 | * Toggle display of the settings tabs panel. 235 | */ 236 | function toggleMainDropdown () { 237 | let dropdown = el.divMainDropdown; 238 | if (dropdown.style.display === 'block') { 239 | dropdown.style.display = 'none'; 240 | window.removeEventListener('click', outsideClick, { 241 | capture: true 242 | }); 243 | } else { 244 | dropdown.style.display = 'block'; 245 | window.addEventListener('click', outsideClick, { 246 | capture: true 247 | }); 248 | } 249 | 250 | function outsideClick (event) { 251 | if ((dropdown.style.display === 'block') && !dropdown.contains(event.target)) { 252 | dropdown.style.display = 'none'; 253 | window.removeEventListener('click', outsideClick, { 254 | capture: true 255 | }); 256 | } 257 | } 258 | } 259 | 260 | /** 261 | * Populate the list of 'switch sets' that quickly configure the popup. 262 | */ 263 | function populatePropsEntries () { 264 | // Append an entry for each switch set from settings. 265 | settings.props.forEach((props, index) => { 266 | addPropsButton(props, index); 267 | }); 268 | 269 | // Add a button to create a job using the parameter set. 270 | function addPropsButton (props, index) { 271 | let template = document.importNode(el.templatePropsButton.content, true); 272 | let tpl = { 273 | button: template.querySelector('button') 274 | }; 275 | 276 | if (index === 0) { 277 | // Default download button 278 | tpl.button.classList.add('btn-dark'); 279 | tpl.button.firstElementChild.classList.add('fa-download'); 280 | } else 281 | if (index === 1) { 282 | // Audio download button 283 | tpl.button.classList.add('btn-secondary'); 284 | tpl.button.firstElementChild.classList.add('fa-music'); 285 | } else { 286 | // Custom props download button 287 | tpl.button.classList.add('btn-secondary', 'custom-props-button'); 288 | tpl.button.removeChild(tpl.button.firstElementChild); 289 | if (props.icon) { 290 | tpl.button.style.backgroundImage = `url("${props.icon}")`; 291 | } 292 | } 293 | 294 | tpl.button.addEventListener('click', () => { 295 | createJob(props); 296 | }); 297 | tpl.button.addEventListener('mousedown', event => { 298 | if (event.button === 1) { 299 | el.inputSaveIn.value = props.saveIn; 300 | el.inputFormat.value = props.format; 301 | el.inputTemplate.value = props.template; 302 | } 303 | }); 304 | 305 | el.divGrabVideoBar.insertBefore(template, el.divDropdownAddon); 306 | } 307 | } 308 | 309 | /** 310 | * Open one of the settings tabs. 311 | */ 312 | function openSettingsTab (tab) { 313 | Object.keys(el.settingsTabs).forEach(key => { 314 | let { header, content } = el.settingsTabs[key]; 315 | if (key === tab) { 316 | header.classList.add('active'); 317 | content.style.display = 'block'; 318 | 319 | // Save the selected tab to popup settings. 320 | if (settings.popup.settingsTab !== tab) { 321 | settings.popup.settingsTab = tab; 322 | browser.storage.local.set({ popup: settings.popup }); 323 | } 324 | } else { 325 | header.classList.remove('active'); 326 | content.style.display = 'none'; 327 | } 328 | }); 329 | } 330 | 331 | /** 332 | * Enable or disable the create job buttons. 333 | */ 334 | function validateCreateJob () { 335 | let disabled = false; 336 | try { 337 | if (el.inputVideoUrl.value) { 338 | // Try to construct an instance of URL(). 339 | new URL(el.inputVideoUrl.value); 340 | } else { 341 | disabled = true; 342 | } 343 | } catch (error) { 344 | disabled = true; 345 | } 346 | 347 | document.querySelectorAll('.props-button').forEach(node => node.disabled = disabled); 348 | } 349 | 350 | /** 351 | * Create a job. 352 | */ 353 | function createJob (props) { 354 | // Ensure that required settings have been configured. 355 | if (!settings.exePath) { 356 | window.alert('You must finish configuring the addon.'); 357 | browser.runtime.openOptionsPage().then(() => window.close()); 358 | return; 359 | } 360 | 361 | let jobProps = { 362 | videoUrl: el.inputVideoUrl.value.trim(), 363 | metadata: { 364 | author: null, 365 | title: null 366 | } 367 | }; 368 | 369 | // Only send metadata if the video URL still matches the scraped URL. 370 | if (el.inputMetadataUrl.value == jobProps.videoUrl) { 371 | jobProps.metadata.author = (el.inputMetadataAuthor.value || null); 372 | jobProps.metadata.title = (el.inputMetadataTitle.value || null); 373 | } 374 | 375 | // Set fallback parameters to either inherited or null depending on config. 376 | let defaultProps = props.inheritDefault ? settings.props[0] : { 377 | saveIn: null, 378 | template: null, 379 | format: null, 380 | customArgs: null, 381 | postProcessScript: null 382 | }; 383 | 384 | // Assign job props in form > parameter set > fallback parameters priority. 385 | jobProps.saveIn = el.inputSaveIn.value || props.saveIn || defaultProps.saveIn; 386 | jobProps.template = el.inputTemplate.value|| props.template || defaultProps.template; 387 | jobProps.format = el.inputFormat.value || props.format || defaultProps.format; 388 | jobProps.customArgs = props.customArgs || defaultProps.customArgs; 389 | jobProps.postProcessScript = props.postProcessScript || defaultProps.postProcessScript; 390 | 391 | // Complain if any of the required parameters are empty. 392 | if (!jobProps.saveIn || !jobProps.template || !jobProps.format) { 393 | window.alert('You must finish configuring the addon.'); 394 | browser.runtime.openOptionsPage().then(() => window.close()); 395 | return; 396 | } 397 | 398 | browser.runtime.sendMessage({ 399 | topic: 'ydb-create-job', 400 | data: { 401 | props: jobProps 402 | } 403 | }); 404 | } 405 | 406 | /** 407 | * Cancel a running job. 408 | */ 409 | function cancelJob (jobId) { 410 | browser.runtime.sendMessage({ 411 | topic: 'ydb-cancel-job', 412 | data: { 413 | jobId 414 | } 415 | }); 416 | } 417 | 418 | /** 419 | * Retry a failed or cancelled job. 420 | */ 421 | function retryJob (jobId) { 422 | browser.runtime.sendMessage({ 423 | topic: 'ydb-retry-job', 424 | data: { 425 | jobId 426 | } 427 | }); 428 | } 429 | 430 | /** 431 | * Update the youtube-dl executable. 432 | */ 433 | function updateExe () { 434 | browser.runtime.sendMessage({ 435 | topic: 'ydb-update-exe', 436 | data: {} 437 | }); 438 | } 439 | 440 | /** 441 | * Remove all completed jobs from the list. 442 | */ 443 | function cleanUpJobs () { 444 | browser.runtime.sendMessage({ 445 | topic: 'ydb-clean-jobs', 446 | data: {} 447 | }).then(updateJobsList); 448 | } 449 | 450 | /** 451 | * Start polling to update the jobs list. 452 | */ 453 | function startPollingJobs () { 454 | window.setTimeout(() => { 455 | refreshJobs(); 456 | startPollingJobs(); 457 | }, 1000); 458 | } 459 | 460 | /** 461 | * Refresh the list of jobs. 462 | */ 463 | function refreshJobs () { 464 | browser.runtime.sendMessage({ 465 | topic: 'ydb-get-jobs', 466 | data: {} 467 | }).then(updateJobsList); 468 | } 469 | 470 | /** 471 | * Update the list of jobs. 472 | */ 473 | function updateJobsList (jobs) { 474 | // Remove missing jobs from the list. 475 | let children = Array.from(el.divJobsList.children); 476 | children.forEach(child => { 477 | if (child.classList.contains('job')) { 478 | if (!jobs.find(job => child.dataset.jobId === String(job.id))) { 479 | child.parentNode.removeChild(child); 480 | } 481 | } 482 | }); 483 | 484 | // Show the empty list notifiation if there are no jobs. 485 | if (jobs.length) { 486 | el.divEmptyList.style.display = 'none'; 487 | } else { 488 | el.divEmptyList.style.display = 'block'; 489 | return; 490 | } 491 | 492 | // Add or update jobs to the list. 493 | jobs.forEach(job => { 494 | let child = children.find(child => child.dataset.jobId === String(job.id)); 495 | if (!child) { 496 | createJobRow(job); 497 | } else { 498 | updateJobRow(child, job); 499 | } 500 | }); 501 | } 502 | 503 | /** 504 | * Create a new job row in the jobs list. 505 | */ 506 | function createJobRow (job) { 507 | // Create the list item. 508 | let template = document.importNode(el.templateJobRow.content, true); 509 | let node = template.firstElementChild; 510 | 511 | let nodeEl = { 512 | aVideoUrl: node.querySelector('.video-url'), 513 | buttonCancelJob: node.querySelector('.cancel-job'), 514 | buttonRetryJob: node.querySelector('.retry-job'), 515 | aViewOutput: node.querySelector('.view-output') 516 | }; 517 | 518 | // Decorate the element with the job ID. 519 | node.dataset.jobId = String(job.id); 520 | 521 | // Add the handler for the cancel job button. 522 | nodeEl.buttonCancelJob.addEventListener('click', () => { 523 | cancelJob(Number(node.dataset.jobId)); 524 | }); 525 | 526 | // Add the handler for the retry job button. 527 | nodeEl.buttonRetryJob.addEventListener('click', () => { 528 | retryJob(Number(node.dataset.jobId)); 529 | }); 530 | 531 | // Add the handler for the view-output button. 532 | nodeEl.aViewOutput.href = '/output/output.html?jobId=' + job.id; 533 | 534 | // Fill the remaining parts of the template. 535 | nodeEl.aVideoUrl.innerText = job.props.videoUrl; 536 | nodeEl.aVideoUrl.href = job.props.videoUrl; 537 | updateJobRow(node, job); 538 | 539 | // Append the job row to the document. 540 | let beforeNode = Array.from(el.divJobsList.children) 541 | .find(child => Number(child.dataset.jobId) < job.id); 542 | 543 | el.divJobsList.insertBefore(template, beforeNode); 544 | } 545 | 546 | /** 547 | * Update an existing job row in the jobs list. 548 | */ 549 | function updateJobRow (node, job) { 550 | let nodeEl = { 551 | divFileName: node.querySelector('.file-name'), 552 | divVideoUrl: node.querySelector('.video-url'), 553 | divOutput: node.querySelector('.output') 554 | }; 555 | 556 | // Decorate the job node with a state attribute. 557 | node.dataset.state = job.state; 558 | 559 | // Display the filename when/if it becomes available. 560 | if (job.destination) { 561 | let filename = fileName(job.destination); 562 | if (nodeEl.divFileName.innerText !== filename) { 563 | nodeEl.divFileName.innerText = filename; 564 | } 565 | } 566 | 567 | // Show the latest line of output from the job. 568 | if (job.output.length) { 569 | nodeEl.divOutput.innerText = job.output[job.output.length - 1]; 570 | } else { 571 | nodeEl.divOutput.innerText = 'Waiting...'; 572 | } 573 | } 574 | 575 | /** 576 | * Try to get the filename component from a path. 577 | */ 578 | function fileName (path) { 579 | let index = path.lastIndexOf('\\'); 580 | if (!~index) { 581 | index = path.lastIndexOf('/'); 582 | } 583 | if (~index) { 584 | return path.substring(index + 1); 585 | } 586 | return path; 587 | } 588 | --------------------------------------------------------------------------------