├── .dockerignore ├── public ├── loading.gif ├── main.css └── main.js ├── Dockerfile ├── app.js ├── views └── index.pug ├── package.json ├── LICENSE ├── routes ├── save.js └── search.js ├── README.md └── .gitignore /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | npm-debug.log 3 | videos 4 | dev -------------------------------------------------------------------------------- /public/loading.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hwgilbert16/youtube-archiver/HEAD/public/loading.gif -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:14 2 | 3 | WORKDIR /usr/src/app 4 | 5 | COPY package*.json ./ 6 | 7 | RUN mkdir videos 8 | RUN npm install 9 | 10 | COPY . . 11 | 12 | EXPOSE 3000 13 | 14 | CMD ["node", "app.js"] -------------------------------------------------------------------------------- /public/main.css: -------------------------------------------------------------------------------- 1 | body { 2 | text-align: center; 3 | font-family: Verdana, sans-serif; 4 | } 5 | 6 | #search input { 7 | width: 25%; 8 | } 9 | 10 | #qualityList button { 11 | margin-left: 20px; 12 | margin-top: 5px; 13 | } 14 | 15 | h1 { 16 | font-size: 50px; 17 | } -------------------------------------------------------------------------------- /app.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const app = express(); 3 | const bodyParser = require('body-parser'); 4 | 5 | const save = require('./routes/save'); 6 | const search = require('./routes/search'); 7 | 8 | app.use(bodyParser.urlencoded({extended: false})); 9 | app.use(bodyParser.json()) 10 | 11 | //set static files directory 12 | app.use(express.static('public')) 13 | app.set('view engine', 'pug'); 14 | 15 | app.use('/save', save); 16 | app.use('/search', search); 17 | 18 | // render root page 19 | app.get('/', (req, res) => { 20 | res.render('index'); 21 | }); 22 | 23 | app.listen(3000, '0.0.0.0'); -------------------------------------------------------------------------------- /views/index.pug: -------------------------------------------------------------------------------- 1 | doctype html 2 | html 3 | head 4 | title YouTube Video Archiver 5 | link(rel="stylesheet" href="main.css") 6 | body 7 | h1 YouTube Video Archiver 8 | br 9 | form(id="search") 10 | label Enter the video URL 11 | br 12 | br 13 | input(type='text', name='videoURL', placeholder='Video URL' required) 14 | br 15 | br 16 | button(type='submit', id='searchButton') Search for Video 17 | br 18 | br 19 | div(id="results") 20 | div(id="options") 21 | ul(id="qualityList") 22 | script(src="https://code.jquery.com/jquery-3.6.0.min.js") 23 | script(src="main.js") -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "youtube-archiver", 3 | "version": "1.0.0", 4 | "description": "An easy and quick way to archive YouTube videos through a web interface.", 5 | "main": "app.js", 6 | "scripts": { 7 | "start": "node app.js" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "git+https://github.com/hwgilbert16/youtube-archiver.git" 12 | }, 13 | "author": "hwgilbert16", 14 | "license": "MIT", 15 | "bugs": { 16 | "url": "https://github.com/hwgilbert16/youtube-archiver/issues" 17 | }, 18 | "homepage": "https://github.com/hwgilbert16/youtube-archiver#readme", 19 | "dependencies": { 20 | "body-parser": "^1.19.0", 21 | "express": "^4.17.1", 22 | "ffmpeg-static": "^4.4.0", 23 | "filter-obj": "^2.0.2", 24 | "pug": "^3.0.2", 25 | "ytdl-core": "^4.8.3" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 zerxal 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 | -------------------------------------------------------------------------------- /routes/save.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const router = express.Router(); 3 | const ytdl = require('ytdl-core'); 4 | const ffmpeg = require('ffmpeg-static'); 5 | const cp = require('child_process'); 6 | 7 | router.post('/', (req, res) => { 8 | const url = req.body.videoURL; 9 | const itag = req.body.videoItag; 10 | const fileName = req.body.videoName; 11 | 12 | // create video and audio streams 13 | const video = ytdl(url, {quality: itag}); 14 | const audio = ytdl(url, {quality: 'highestaudio', filter: 'audioonly'}); 15 | 16 | // spawn ffmpeg process 17 | const ffmpegProcess = cp.spawn(ffmpeg, [ 18 | // Remove ffmpeg's console spamming 19 | '-loglevel', '8', '-hide_banner', 20 | // Redirect/Enable progress messages 21 | '-progress', 'pipe:3', 22 | // Set inputs 23 | '-i', 'pipe:4', 24 | '-i', 'pipe:5', 25 | // Map audio & video from streams 26 | '-map', '0:a', 27 | '-map', '1:v', 28 | // Keep encoding 29 | '-c:v', 'copy', 30 | // Define output file 31 | `videos/${fileName}.mkv` 32 | ], { 33 | windowsHide: true, 34 | stdio: [ 35 | /* Standard: stdin, stdout, stderr */ 36 | 'inherit', 'inherit', 'inherit', 37 | /* Custom: pipe:3, pipe:4, pipe:5 */ 38 | 'pipe', 'pipe', 'pipe', 39 | ], 40 | }); 41 | 42 | // pipe audio and video streams to ffmpeg 43 | audio.pipe(ffmpegProcess.stdio[4]); 44 | video.pipe(ffmpegProcess.stdio[5]); 45 | 46 | // send response to client on successful video download 47 | ffmpegProcess.on('close', () => { 48 | res.json({successfulDownload: true}); 49 | }); 50 | 51 | }); 52 | 53 | module.exports = router; -------------------------------------------------------------------------------- /routes/search.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const router = express.Router(); 3 | const ytdl = require('ytdl-core'); 4 | const filterObject = require('filter-obj'); 5 | 6 | router.post('/', (req, res) => { 7 | const url = req.body.videoURL; 8 | 9 | ytdl.getBasicInfo(url, {filter: 'videoonly'}).then((info) => { 10 | // get the adaptiveFormats section from the getBasicInfo response 11 | let qualityOptionsRaw = info.player_response.streamingData.adaptiveFormats; 12 | const videoDetails = info.player_response.videoDetails; 13 | let qualityOptions = []; 14 | 15 | // loop through each quality option to filter 16 | for (let i = 0; i < qualityOptionsRaw.length; i++) { 17 | // filter response to only include mimeType and qualityLabel 18 | const filtered = filterObject(qualityOptionsRaw[i], ['mimeType', 'qualityLabel', 'itag']); 19 | 20 | // check if it is mp4 21 | if (filtered.mimeType.includes('video/mp4') || filtered.mimeType.includes('av01')) { 22 | // add quality to qualityOptions array if matches filter 23 | qualityOptions.push({"itag": filtered.itag, "qualityLabel": filtered.qualityLabel, "mimeType": filtered.mimeType}); 24 | } 25 | } 26 | 27 | // pack together all of the video info 28 | const videoInfo = { 29 | videoTitle: videoDetails.title, 30 | videoAuthor: videoDetails.author, 31 | videoThumbnail: videoDetails.thumbnail.thumbnails[videoDetails.thumbnail.thumbnails.length - 1].url, 32 | videoQualityOptions: qualityOptions, 33 | videoURL: url 34 | }; 35 | 36 | res.json(videoInfo); 37 | }).catch(() => { 38 | const eMessage = { 39 | "error": "error_encountered" 40 | }; 41 | 42 | res.json(eMessage); 43 | }); 44 | 45 | }) 46 | 47 | module.exports = router; -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # youtube-archiver 2 | An easy and quick way to archive YouTube videos through a web interface. It's intended to be a self-hosted site for people who wish to quickly download YouTube videos without having to worry about a CLI or finding a website online. 3 | 4 | Files downloaded from the website are saved to a user-selected directory on the host machine, where you can then access the video files. 5 | 6 | ![alt text](https://i.gyazo.com/22faa366b617cbffa9b9337c3e4d7f38.png "Image of page") 7 | 8 | Getting Started 9 | ----- 10 | youtube-archiver uses Docker for its simplicity. Make sure that you have it installed before installation. Links for installation are below. 11 | 12 | - [Linux](https://docs.docker.com/linux/started/) 13 | - [Windows](https://docs.docker.com/windows/started) 14 | - [MacOS (OS X)](https://docs.docker.com/mac/started/) 15 | 16 | youtube-archiver is intended to be run in a server environment, however, it should run fine on a local machine if needed. 17 | 18 | ### Installation 19 | 20 | This application is **not** intended to be exposed to the public internet. The POST route for saving of videos does not have authentication and an individual with malice attempt could very easily fill the storage of your server. 21 | 22 | Pull the image from Docker Hub. This may take a couple minutes. 23 | 24 | ``` 25 | docker pull hwgilbert16/youtube-archiver 26 | ``` 27 | 28 | Once the image has been downloaded, create a writeable directory to store video files in. This can be anywhere on your host machine. 29 | 30 | Replace (directory) with the directory to store video files and (port) for which port to bind to the container's internal port. 31 | 32 | ``` 33 | docker run -d --restart=always -v (directory):/usr/src/app/videos -p (port):3000 hwgilbert16/youtube-archiver 34 | ``` 35 | If you do not wish the container to start on boot you can leave out `--restart=always` 36 | 37 | Regardless, files in your selected video directory will persist between system reboots and reboots of the container. 38 | 39 | An example of the run command: 40 | 41 | ``` 42 | docker run -d --restart=always -v ~/videos:/usr/src/app/videos -p 80:3000 hwgilbert16/youtube-archiver 43 | ``` 44 | The site can be accessed at the IP address of the host machine. If your selected port is not 80, make sure you specify the port in the URL. 45 | 46 | ``` 47 | 1.2.3.4:1234 48 | ``` 49 | 50 | Contributing 51 | ----- 52 | 53 | ### Issues 54 | 55 | If you discover an issue or other flaw in this repository it would be greatly appreciated if you could report it in [issues](https://github.com/hwgilbert16/youtube-archiver/issues). While there is no specific template for issues, please make sure you describe the issue in detail and verbosely. 56 | 57 | ### Pull Requests 58 | 59 | In a pull request, please make sure you are explicitly clear in the problem you are trying to address and why the problem needs to be addressed. Make sure there are no spelling mistakes and that it reads well. 60 | 61 | License 62 | ----- 63 | youtube-archiver is licensed under the MIT License. See [LICENSE.md](https://github.com/hwgilbert16/youtube-archiver/blob/main/LICENSE) for more details. 64 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # TypeScript v1 declaration files 45 | typings/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Microbundle cache 57 | .rpt2_cache/ 58 | .rts2_cache_cjs/ 59 | .rts2_cache_es/ 60 | .rts2_cache_umd/ 61 | 62 | # Optional REPL history 63 | .node_repl_history 64 | 65 | # Output of 'npm pack' 66 | *.tgz 67 | 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | # dotenv environment variables file 72 | .env 73 | .env.test 74 | 75 | # parcel-bundler cache (https://parceljs.org/) 76 | .cache 77 | 78 | # Next.js build output 79 | .next 80 | 81 | # Nuxt.js build / generate output 82 | .nuxt 83 | dist 84 | 85 | # Gatsby files 86 | .cache/ 87 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 88 | # https://nextjs.org/blog/next-9-1#public-directory-support 89 | # public 90 | 91 | # vuepress build output 92 | .vuepress/dist 93 | 94 | # Serverless directories 95 | .serverless/ 96 | 97 | # FuseBox cache 98 | .fusebox/ 99 | 100 | # DynamoDB Local files 101 | .dynamodb/ 102 | 103 | # TernJS port file 104 | .tern-port 105 | 106 | ## JetBrains 107 | 108 | # User-specific stuff 109 | .idea/**/workspace.xml 110 | .idea/**/tasks.xml 111 | .idea/**/usage.statistics.xml 112 | .idea/**/dictionaries 113 | .idea/**/shelf 114 | 115 | # AWS User-specific 116 | .idea/**/aws.xml 117 | 118 | # Generated files 119 | .idea/**/contentModel.xml 120 | 121 | # Sensitive or high-churn files 122 | .idea/**/dataSources/ 123 | .idea/**/dataSources.ids 124 | .idea/**/dataSources.local.xml 125 | .idea/**/sqlDataSources.xml 126 | .idea/**/dynamic.xml 127 | .idea/**/uiDesigner.xml 128 | .idea/**/dbnavigator.xml 129 | 130 | # Gradle 131 | .idea/**/gradle.xml 132 | .idea/**/libraries 133 | 134 | # Gradle and Maven with auto-import 135 | # When using Gradle or Maven with auto-import, you should exclude module files, 136 | # since they will be recreated, and may cause churn. Uncomment if using 137 | # auto-import. 138 | # .idea/artifacts 139 | # .idea/compiler.xml 140 | # .idea/jarRepositories.xml 141 | # .idea/modules.xml 142 | # .idea/*.iml 143 | # .idea/modules 144 | # *.iml 145 | # *.ipr 146 | 147 | # CMake 148 | cmake-build-*/ 149 | 150 | # Mongo Explorer plugin 151 | .idea/**/mongoSettings.xml 152 | 153 | # File-based project format 154 | *.iws 155 | 156 | # IntelliJ 157 | out/ 158 | 159 | # mpeltonen/sbt-idea plugin 160 | .idea_modules/ 161 | 162 | # JIRA plugin 163 | atlassian-ide-plugin.xml 164 | 165 | # Cursive Clojure plugin 166 | .idea/replstate.xml 167 | 168 | # Crashlytics plugin (for Android Studio and IntelliJ) 169 | com_crashlytics_export_strings.xml 170 | crashlytics.properties 171 | crashlytics-build.properties 172 | fabric.properties 173 | 174 | # Editor-based Rest Client 175 | .idea/httpRequests 176 | 177 | # Android studio 3.1+ serialized cache file 178 | .idea/caches/build_file_checksums.ser 179 | 180 | .idea/ 181 | videos/ 182 | dev/ -------------------------------------------------------------------------------- /public/main.js: -------------------------------------------------------------------------------- 1 | // click handler for search button 2 | $('#search').submit((e) => { 3 | e.preventDefault(); 4 | 5 | let formData = { 6 | 'videoURL': $('#search').serializeArray()[0].value 7 | }; 8 | 9 | // ajax post request for video info 10 | $.ajax({ 11 | type: 'POST', 12 | data: JSON.stringify(formData), 13 | contentType: 'application/json', 14 | dataType: 'json', 15 | url: '/search', 16 | beforeSend: whileLoading, 17 | success: renderReceivedData 18 | }).done(() => { 19 | $('#loadingGif').remove(); 20 | }); 21 | }); 22 | 23 | /*------------------------*/ 24 | 25 | function whileLoading() { 26 | // remove previous rendered data if it exists 27 | if ($('#successfulDownload')) { 28 | $('#results').empty(); 29 | $('#qualityList').empty(); 30 | $('#options').empty(); 31 | 32 | const ul = createSetAttributes('ul', {id: 'qualityList'}); 33 | $('#options').append(ul); 34 | } 35 | 36 | // hides search button 37 | $('#searchButton').hide(); 38 | 39 | // renders loading gif while video info is loading 40 | const loadingGif = new Image(220, 145); 41 | setAttributes(loadingGif, {src: 'loading.gif', id: 'loadingGif'}); 42 | 43 | $("#results").append(loadingGif); 44 | } 45 | 46 | /*------------------------*/ 47 | 48 | // renders received data from server 49 | function renderReceivedData(data) { 50 | $('#searchButton').show(); 51 | 52 | // checks whether the response back succeeded 53 | if (data.videoThumbnail) { 54 | const img = new Image(640, 360); 55 | setAttributes(img, {src: data.videoThumbnail}); 56 | const title = createSetAttributes('h3', {textContent: data.videoTitle}) 57 | const author = createSetAttributes('p', {textContent: `Video by ${data.videoAuthor}`}); 58 | $("#results").append(img, title, author); 59 | 60 | const br = document.createElement('br'); 61 | 62 | // add horizontal line and download options paragraph 63 | $("#results").append(document.createElement('hr')); 64 | $("#qualityList").append(document.createElement('p').textContent = "Download Options", br); 65 | 66 | // loop to output download options 67 | for (let i = 0; i < data.videoQualityOptions.length; i++) { 68 | const button = createSetAttributes('button', { 69 | textContent: data.videoQualityOptions[i].qualityLabel, 70 | id: data.videoQualityOptions[i].itag 71 | }); 72 | $('#qualityList').append(button); 73 | } 74 | 75 | addButtonClickHandlers(data); 76 | } else { 77 | handleError(); 78 | } 79 | } 80 | 81 | /*------------------------*/ 82 | 83 | function addButtonClickHandlers(data) { 84 | // randomly generate a 10 digit string for the video name 85 | // had issues with giving the video title as the file name 86 | const videoName = Math.random().toString(20).substr(2, 10); 87 | 88 | // add click handler to all of the added buttons 89 | for (let i = 0; i < data.videoQualityOptions.length; i++) { 90 | const itag = data.videoQualityOptions[i].itag; 91 | $(`#${itag}`).click(() => { 92 | const saveData = { 93 | videoURL: data.videoURL, 94 | videoItag: itag, 95 | videoName 96 | }; 97 | 98 | $.ajax({ 99 | type: 'POST', 100 | data: JSON.stringify(saveData), 101 | contentType: 'application/json', 102 | dataType: 'json', 103 | url: '/save', 104 | beforeSend: () => { 105 | 106 | const videoNameLine = createSetAttributes('p', {textContent: `Downloading ${videoName}.mkv`}); 107 | 108 | const loadingGif = new Image(220, 145); 109 | setAttributes(loadingGif, {src: 'loading.gif', id: 'loadingGif'}) 110 | 111 | $("#options").append(videoNameLine, loadingGif); 112 | $("#qualityList").remove(); 113 | }, 114 | success: (data) => { 115 | $('#options').empty(); 116 | 117 | const successfulDownload = createSetAttributes('p', { 118 | textContent: `${videoName}.mkv downloaded successfully`, 119 | id: 'successfulDownload' 120 | }) 121 | 122 | $('#options').append(successfulDownload); 123 | }, 124 | error: () => { 125 | handleError(); 126 | } 127 | }) 128 | }) 129 | } 130 | } 131 | 132 | /*------------------------*/ 133 | // Helper functions 134 | 135 | function setAttributes(element, attributes) { 136 | for(const key in attributes) { 137 | element.setAttribute(key, attributes[key]); 138 | } 139 | } 140 | 141 | function createSetAttributes(element, attributes) { 142 | element = document.createElement(element); 143 | 144 | for(const key in attributes) { 145 | if (key === 'textContent') { 146 | element.textContent = attributes[key]; 147 | continue; 148 | } 149 | 150 | element.setAttribute(key, attributes[key]); 151 | } 152 | 153 | return element; 154 | } 155 | 156 | function handleError() { 157 | const errorLine = createSetAttributes('p', { 158 | textContent: "We weren't able to find the video or something went wrong. Make sure the video public, the URL is correct, and the URL has an https:// in front of it" 159 | }); 160 | 161 | $("#options").append(errorLine); 162 | } 163 | 164 | $(document).ajaxError(() => { 165 | handleError(); 166 | }); --------------------------------------------------------------------------------