├── .dockerignore ├── .env.sample ├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── docker-compose.yaml ├── docs └── v1.4.0-docker-update-guide.md ├── examples ├── bookmarklet-append-url-to-job.js ├── bookmarklet-download-jobs.js └── nodejs-schedule-download-jobs.js ├── scripts ├── install.bat └── install.sh ├── youtube-dl-express-backend ├── .npmrc ├── exec.js ├── index.js ├── middleware │ ├── authentication.middleware.js │ ├── global-password.middleware.js │ ├── superuser.middleware.js │ └── user.middleware.js ├── models │ ├── activity.model.js │ ├── apikey.model.js │ ├── error.model.js │ ├── job.model.js │ ├── playlist.model.js │ ├── statistic.model.js │ ├── tag.model.js │ ├── uploader.model.js │ ├── user.model.js │ ├── version.model.js │ └── video.model.js ├── package-lock.json ├── package.json ├── parse-env.js ├── pm2.config.json ├── routes │ ├── activity.route.js │ ├── admin.route.js │ ├── auth.route.js │ ├── job.route.js │ ├── playlist.route.js │ ├── statistic.route.js │ ├── transcode.route.js │ ├── uploader.route.js │ ├── user.route.js │ └── video.route.js ├── schemas │ └── statistic.schema.js └── utilities │ ├── api.utility.js │ ├── error.utility.js │ ├── file.utility.js │ ├── import.utility.js │ ├── job.utility.js │ ├── logger.utility.js │ ├── statistic.utility.js │ ├── update.utility.js │ └── video.utility.js └── youtube-dl-react-frontend ├── package-lock.json ├── package.json ├── public ├── default-avatar.svg ├── default-thumbnail.svg ├── favicon.ico ├── index.html ├── logo.svg ├── logo192.png ├── logo512.png ├── manifest.json └── robots.txt └── src ├── App.js ├── App.test.js ├── components ├── AccordionButton │ └── AccordionButton.js ├── Activity │ ├── Activity.js │ └── Activity.scss ├── Admin │ ├── Admin.js │ ├── Admin.scss │ ├── ApiKeyManager │ │ └── ApiKeyManager.js │ ├── ChannelIconDownloader │ │ └── ChannelIconDownloader.js │ ├── HashVerifier │ │ └── HashVerifier.js │ ├── JobDownloader │ │ └── JobDownloader.js │ ├── JobEditor │ │ └── JobEditor.js │ ├── LogFileList │ │ ├── LogFileList.js │ │ └── LogFileList.scss │ ├── RecalcStatistics │ │ └── RecalcStatistics.js │ ├── RetryImports │ │ └── RetryImports.js │ ├── UpdateChecker │ │ └── UpdateChecker.js │ ├── VideoDeleter │ │ └── VideoDeleter.js │ ├── VideoImporter │ │ └── VideoImporter.js │ └── YtdlUpdater │ │ └── YtdlUpdater.js ├── AdvancedSearchButton │ └── AdvancedSearchButton.js ├── AlertModal │ └── AlertModal.js ├── ConfirmModal │ └── ConfirmModal.js ├── Error │ └── Error.js ├── GlobalPasswordForm │ └── GlobalPasswordForm.js ├── ImportSubscriptionsButton │ └── ImportSubscriptionsButton.js ├── Job │ └── Job.js ├── LoginForm │ └── LoginForm.js ├── Logout │ └── Logout.js ├── MiniStatisticsColumn │ └── MiniStatisticsColumn.js ├── Navbar │ ├── AdvancedSearchModal │ │ ├── AdvancedSearchModal.js │ │ └── AdvancedSearchModal.scss │ ├── Navbar.js │ ├── Navbar.scss │ └── ThemeController │ │ ├── ThemeController.js │ │ └── ThemeController.scss ├── Page │ └── Page.js ├── PageLoadWrapper │ └── PageLoadWrapper.js ├── Pageinator │ └── Pageinator.js ├── Playlist │ ├── Playlist.js │ └── Playlist.scss ├── RegisterForm │ └── RegisterForm.js ├── Settings │ ├── AvatarForm │ │ ├── AvatarForm.js │ │ └── AvatarForm.scss │ ├── Settings.js │ └── Settings.scss ├── Statistics │ └── Statistics.js ├── TagsList │ └── TagsList.js ├── TopTagsList │ └── TopTagsList.js ├── Uploader │ └── Uploader.js ├── UploaderList │ ├── UploaderList.js │ └── UploaderList.scss ├── Video │ ├── AudioOnlyModeButton │ │ └── AudioOnlyModeButton.js │ ├── ChatReplay │ │ └── ChatReplay.js │ ├── Comments │ │ ├── Comments.js │ │ └── Comments.scss │ ├── Description │ │ ├── Description.js │ │ ├── Description.scss │ │ ├── allow.js │ │ ├── filters.js │ │ ├── mapDomToReact.js │ │ ├── parseCustomNode.js │ │ └── parseStringToDom.js │ ├── ScreenshotButton │ │ └── ScreenshotButton.js │ ├── TheaterModeButton │ │ └── TheaterModeButton.js │ ├── Video.js │ └── Video.scss ├── VideoList │ ├── VideoList.js │ └── VideoList.scss └── VideoPreview │ ├── VideoPreview.js │ └── VideoPreview.scss ├── contexts ├── advancedsearch.context.js └── user.context.js ├── index.js ├── index.scss ├── logo.svg ├── parse-env.js ├── services └── auth.service.js ├── setupTests.js └── utilities ├── axios.utility.js ├── format.utility.js ├── history.utility.js ├── image.utility.js ├── scroll.utility.js ├── search.utility.js └── user.utility.js /.dockerignore: -------------------------------------------------------------------------------- 1 | #releases 2 | /releases 3 | 4 | # dependencies 5 | /youtube-dl-express-backend/node_modules 6 | /youtube-dl-react-frontend/node_modules 7 | /youtube-dl-react-frontend/.pnp 8 | .pnp.js 9 | 10 | # testing 11 | /youtube-dl-react-frontend/coverage 12 | 13 | # production 14 | /youtube-dl-react-frontend/build 15 | 16 | # misc 17 | .DS_Store 18 | .env.local 19 | .env.development.local 20 | .env.test.local 21 | .env.production.local 22 | .env.manual 23 | .env.docker 24 | 25 | npm-debug.log* 26 | yarn-debug.log* 27 | yarn-error.log* 28 | debug.log* -------------------------------------------------------------------------------- /.env.sample: -------------------------------------------------------------------------------- 1 | # Both frontend and backend environment variables should be set in this file 2 | # For a complete list of options see https://github.com/graham-walker/youtube-dl-react-viewer/#environment-variables 3 | 4 | OUTPUT_DIRECTORY="C:\Output Directory" 5 | SUPERUSER_USERNAME=admin 6 | SUPERUSER_PASSWORD=password 7 | JWT_TOKEN_SECRET=secret 8 | SECURE_COOKIES=false 9 | BACKEND_PORT=5000 10 | YOUTUBE_DL_PATH=yt-dlp 11 | YOUTUBE_DL_UPDATE_COMMAND=python3 -m pip install --no-deps -U yt-dlp 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | #releases 2 | /releases 3 | 4 | # dependencies 5 | /youtube-dl-express-backend/node_modules 6 | /youtube-dl-react-frontend/node_modules 7 | /youtube-dl-react-frontend/.pnp 8 | .pnp.js 9 | 10 | # testing 11 | /youtube-dl-react-frontend/coverage 12 | 13 | # production 14 | /youtube-dl-react-frontend/build 15 | 16 | # misc 17 | .DS_Store 18 | .env.local 19 | .env.development.local 20 | .env.test.local 21 | .env.production.local 22 | .env.manual 23 | .env.docker 24 | .env 25 | 26 | npm-debug.log* 27 | yarn-debug.log* 28 | yarn-error.log* 29 | debug.log* -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:18.16.0-alpine3.18 2 | USER 0 3 | 4 | # Get arguments from docker-compose.yaml 5 | ARG BACKEND_PORT=5000 6 | ARG YOUTUBE_DL_UPDATE_COMMAND 7 | 8 | # Fetch packages required for building, as well as ffmpeg and python3 pip 9 | RUN apk add --no-cache make build-base python3 ffmpeg py3-pip attr 10 | 11 | # Add yt-dlp using the update command if specified 12 | RUN if [ -n "$YOUTUBE_DL_UPDATE_COMMAND" ]; then eval "$YOUTUBE_DL_UPDATE_COMMAND"; fi 13 | 14 | # Copy source code into the docker daemon 15 | WORKDIR /opt/youtube-dl-react-viewer 16 | COPY . . 17 | 18 | # Throw an error if the .env file does not exist since it contains required values 19 | RUN if [ ! -f .env ]; then \ 20 | echo "Error: .env file not found!" >&2; \ 21 | exit 1; \ 22 | fi 23 | 24 | # Ensure the .env file ends with a newline 25 | RUN [ "$(tail -c1 < .env)" != "" ] && echo "" >> .env || true 26 | 27 | # Inform the web app that it is running in a Docker container 28 | RUN echo 'RUNNING_IN_DOCKER=true' >> .env 29 | RUN echo 'REACT_APP_RUNNING_IN_DOCKER=true' >> .env 30 | 31 | # Fetch dependencies and build frontend 32 | WORKDIR /opt/youtube-dl-react-viewer/youtube-dl-express-backend 33 | RUN npm install --unsafe-perm 34 | 35 | WORKDIR /opt/youtube-dl-react-viewer/youtube-dl-react-frontend 36 | RUN npm install --unsafe-perm 37 | RUN npm run build 38 | 39 | # Remove packages used for building 40 | RUN apk del make build-base 41 | 42 | # Change workdir to the backend folder 43 | WORKDIR /opt/youtube-dl-react-viewer/youtube-dl-express-backend 44 | 45 | # Create the entrypoint shell script 46 | RUN echo '#!/bin/sh' >> docker-entrypoint.sh && \ 47 | echo 'node --require dotenv/config index.js' >> docker-entrypoint.sh && \ 48 | chmod 755 docker-entrypoint.sh && \ 49 | mv docker-entrypoint.sh /usr/local/bin/ 50 | 51 | # Expose the backend service port for TCP traffic 52 | EXPOSE ${BACKEND_PORT}/tcp 53 | 54 | # Define a health check for the container 55 | HEALTHCHECK \ 56 | --start-period=10s --interval=30s --timeout=2s --retries=3 \ 57 | CMD nc -zv 127.0.0.1:${BACKEND_PORT} 58 | 59 | # Set the entrypoint for the container to docker-entrypoint.sh 60 | ENTRYPOINT ["docker-entrypoint.sh"] 61 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Graham Walker 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | db: 3 | image: mongo:6.0-jammy 4 | restart: unless-stopped 5 | mem_limit: 512m 6 | networks: 7 | - ytrv_net 8 | volumes: 9 | - ytrv_db:/data/db 10 | app: 11 | image: graham-walker/youtube-dl-react-viewer:1.5.0 12 | build: 13 | context: . 14 | dockerfile: Dockerfile 15 | args: 16 | BACKEND_PORT: ${BACKEND_PORT:-5000} 17 | YOUTUBE_DL_UPDATE_COMMAND: ${YOUTUBE_DL_UPDATE_COMMAND} 18 | pull_policy: never 19 | restart: unless-stopped 20 | mem_limit: 512m 21 | networks: 22 | - ytrv_net 23 | ports: 24 | - "0.0.0.0:${BACKEND_PORT:-5000}:${BACKEND_PORT:-5000}/tcp" 25 | volumes: 26 | - "${OUTPUT_DIRECTORY:?Error: OUTPUT_DIRECTORY is not set or is empty}:/youtube-dl" 27 | depends_on: 28 | - db 29 | env_file: 30 | - .env 31 | volumes: 32 | ytrv_db: 33 | networks: 34 | default: 35 | ytrv_net: 36 | driver: "bridge" 37 | -------------------------------------------------------------------------------- /docs/v1.4.0-docker-update-guide.md: -------------------------------------------------------------------------------- 1 | # v1.4.0 Docker Update Guide 2 | Version 1.4.0 makes significant changes to the Dockerfile to allow for improvements to configuration, storage, and updating: 3 | 4 | - Environment variables are no longer set directly in docker-compose.yaml and now uses the .env file at the project root 5 | - Downloads are no longer stored in the Docker container and now uses a bind mount to store downloads on the host system 6 | 7 | **To update an existing Docker installation from a previous version to 1.4.0:** 8 | 1. Copy downloads out of the container to a new location `docker cp youtube-dl-react-viewer-app-1:/youtube-dl/. "C:\Output Directory"` 9 | 10 | 2. Update to 1.4.0 `git pull && git checkout tags/v1.4.0` 11 | 4. Copy `.env.sample` to `.env` 12 | 5. Edit `.env` and set `OUTPUT_DIRECTORY` to the location you copied files to in the first step and [configure the required environment variables](../README.md#required-environment-variables) 13 | 4. Rebuild and start the container `docker compose build --no-cache && docker compose up -d` 14 | 5. Delete the old downloads volume `docker volume rm youtube-dl-react-viewer_ytrv_downloads` 15 | -------------------------------------------------------------------------------- /examples/bookmarklet-append-url-to-job.js: -------------------------------------------------------------------------------- 1 | /* This bookmarklet appends the URL of the current page to a specified download job 2 | * 3 | * USAGE: 4 | * 1. Create and copy an API key in the admin panel 5 | * 2. Copy the ID of the job you want to append URLs to. Job IDs can be found under advanced options in the job editor in the admin panel 6 | * 3. Replace webAppUrl, apiKey, and jobId in the code below with your values 7 | * 4. Create a new bookmark in your browser and paste the code below into the URL field 8 | */ 9 | 10 | javascript: (function () { 11 | const webAppUrl = 'http://localhost:5000'; /* Replace with your web app URL */ 12 | const apiKey = 'ytdlrv-api-1740349357349-5f3976f694655ece89f0bfac6f798b9f88e3f031942aaac5e82c8eb276aee3a3'; /* Replace with your API key */ 13 | const jobId = '678053b54e5a734c862f66aa'; /* Replace with the Job ID you want URLs appended to */ 14 | 15 | const url = window.location.href; 16 | const title = document.title; 17 | 18 | fetch(`${webAppUrl}/api/admin/jobs/append/${jobId}`, { 19 | method: 'POST', 20 | headers: { 21 | 'Content-Type': 'application/json', 22 | 'X-API-Key': apiKey, 23 | }, 24 | body: JSON.stringify({ url, title }), 25 | }).then(response => response.json()) 26 | .then(res => { 27 | alert((res.success ? '✅ ' + res.success : '') || (res.error ? '❌ ' + res.error : '') || '❌ Unknown error'); 28 | }) 29 | .catch(err => { 30 | if (err?.message === 'NetworkError when attempting to fetch resource.') err.error = 'CORS error'; 31 | alert((err.error ? '❌ ' + err.error : '') || '❌ Unknown error'); 32 | }); 33 | })(); 34 | -------------------------------------------------------------------------------- /examples/bookmarklet-download-jobs.js: -------------------------------------------------------------------------------- 1 | /* This bookmarklet runs any number of specified download jobs 2 | * 3 | * USAGE: 4 | * 1. Create and copy an API key in the admin panel 5 | * 2. Copy the IDs of the jobs you want to download. Job IDs can be found under advanced options in the job editor in the admin panel 6 | * 3. Replace webAppUrl, apiKey, and jobIds in the code below with your values 7 | * 4. Create a new bookmark in your browser and paste the code below into the URL field 8 | */ 9 | 10 | javascript: (function () { 11 | const webAppUrl = 'http://localhost:5000'; /* Replace with your web app URL */ 12 | const apiKey = 'ytdlrv-api-1740349357349-5f3976f694655ece89f0bfac6f798b9f88e3f031942aaac5e82c8eb276aee3a3'; /* Replace with your API key */ 13 | const jobIds = ['678053b54e5a734c862f66aa', '678053fe4e5a734c862f66ab', '678054054e5a734c862f66ac']; /* Replace with the Job IDs you want to download */ 14 | 15 | fetch(`${webAppUrl}/api/admin/jobs/download`, { 16 | method: 'POST', 17 | headers: { 18 | 'Content-Type': 'application/json', 19 | 'X-API-Key': apiKey, 20 | }, 21 | body: JSON.stringify(jobIds), 22 | }).then(response => response.json()) 23 | .then(res => { 24 | alert((res.success ? '✅ ' + res.success : '') || (res.error ? '❌ ' + res.error : '') || '❌ Unknown error'); 25 | }) 26 | .catch(err => { 27 | if (err?.message === 'NetworkError when attempting to fetch resource.') err.error = 'CORS error'; 28 | alert((err.error ? '❌ ' + err.error : '') || '❌ Unknown error'); 29 | }); 30 | })(); 31 | -------------------------------------------------------------------------------- /examples/nodejs-schedule-download-jobs.js: -------------------------------------------------------------------------------- 1 | /* This Node.js script automatically runs download jobs at a specified time using a cron scheduler 2 | * 3 | * USAGE: 4 | * 1. Create and copy an API key in the admin panel 5 | * 2. Copy the IDs of the jobs you want to schedule. Job IDs can be found under advanced options in the job editor in the admin panel 6 | * 3. Install Node.js https://nodejs.org/en 7 | * 4. Create a new folder and inside create a new file named index.js and paste the code below 8 | * 5. Replace webAppUrl, apiKey, jobIds, and cronSchedule in index.js with your values 9 | * 6. Create a new Node.js project `npm init` 10 | * 7. Install the cron package `npm i cron` 11 | * 8. Start the script `node index.js` 12 | */ 13 | 14 | const { CronJob } = require('cron'); 15 | 16 | (async () => { 17 | const webAppUrl = 'http://localhost:5000'; // Replace with your web app URL 18 | const apiKey = 'ytdlrv-api-1740349357349-5f3976f694655ece89f0bfac6f798b9f88e3f031942aaac5e82c8eb276aee3a3'; // Replace with your API key 19 | const jobIds = ['678053b54e5a734c862f66aa', '678053fe4e5a734c862f66ab', '678054054e5a734c862f66ac']; // Replace with the Job IDs you want to schedule 20 | const cronSchedule = '0 0 * * *'; // This will run once every new UTC day by default. See https://crontab.guru/ for how to configure 21 | 22 | new CronJob( 23 | cronSchedule, 24 | scheduleDownloadJobs, 25 | null, 26 | true, 27 | 'UTC', 28 | ); 29 | 30 | async function scheduleDownloadJobs() { 31 | console.log('Running scheduled download jobs'); 32 | try { 33 | const response = await fetch(webAppUrl + '/api/admin/jobs/download', { 34 | method: 'POST', 35 | headers: { 36 | 'Content-Type': 'application/json', 37 | 'Cookie': `key=${apiKey}`, 38 | }, 39 | body: JSON.stringify(jobIds), 40 | }); 41 | 42 | if (!response.ok) throw new Error(`Request failed with status: ${response.status}`); 43 | 44 | const data = await response.json(); 45 | console.log('Success:', data); 46 | } catch (err) { 47 | console.error(err); 48 | } 49 | } 50 | })(); 51 | -------------------------------------------------------------------------------- /scripts/install.bat: -------------------------------------------------------------------------------- 1 | @echo off 2 | cd /d "%~dp0" 3 | cd .. 4 | for /f "tokens=1,2 delims==" %%a in ('type ".env" ^| findstr "^BACKEND_PORT="') do set BACKEND_PORT=%%b 5 | call npm install -g pm2 6 | cd .\youtube-dl-react-frontend 7 | call npm install 8 | call npm run build 9 | cd .. 10 | cd .\youtube-dl-express-backend 11 | call npm install 12 | call npm start 13 | echo. 14 | echo youtube-dl-react-viewer started on http://localhost:%BACKEND_PORT% 15 | echo. 16 | echo Usage: 17 | echo - Start the web app: pm2 start youtube-dl-react-viewer 18 | echo - Stop the web app: pm2 stop youtube-dl-react-viewer 19 | echo - View logs: pm2 logs youtube-dl-react-viewer 20 | pause >nul -------------------------------------------------------------------------------- /scripts/install.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | sudo apt-get update 3 | sudo apt-get install xattr 4 | sudo npm install -g pm2 5 | cd -P -- "$(dirname -- "$0")" 6 | cd .. 7 | BACKEND_PORT=$(grep '^BACKEND_PORT=' .env | cut -d '=' -f2) 8 | cd ./youtube-dl-react-frontend 9 | npm install --unsafe-perm 10 | npm run build 11 | cd .. 12 | cd ./youtube-dl-express-backend 13 | npm install --unsafe-perm 14 | npm start 15 | echo 16 | echo "youtube-dl-react-viewer started on http://localhost:$BACKEND_PORT" 17 | echo 18 | echo "Usage:" 19 | echo " - Start the web app: pm2 start youtube-dl-react-viewer" 20 | echo " - Stop the web app: pm2 stop youtube-dl-react-viewer" 21 | echo " - View logs: pm2 logs youtube-dl-react-viewer" 22 | echo " - Run at pc startup: pm2 startup youtube-dl-react-viewer" 23 | echo -------------------------------------------------------------------------------- /youtube-dl-express-backend/.npmrc: -------------------------------------------------------------------------------- 1 | engine-strict=true -------------------------------------------------------------------------------- /youtube-dl-express-backend/index.js: -------------------------------------------------------------------------------- 1 | import fs from 'fs-extra'; 2 | import mongoose from 'mongoose'; 3 | import express from 'express'; 4 | import cors from 'cors'; 5 | import cookieParser from 'cookie-parser'; 6 | import path from 'path'; 7 | 8 | import authRouter from './routes/auth.route.js'; 9 | import userRouter from './routes/user.route.js'; 10 | import videoRouter from './routes/video.route.js'; 11 | import uploaderRouter from './routes/uploader.route.js'; 12 | import playlistRouter from './routes/playlist.route.js'; 13 | import jobRouter from './routes/job.route.js'; 14 | import statisticRouter from './routes/statistic.route.js'; 15 | import activityRouter from './routes/activity.route.js'; 16 | import adminRouter from './routes/admin.route.js'; 17 | import transcodeRouter from './routes/transcode.route.js'; 18 | 19 | import authenticationMiddleware from './middleware/authentication.middleware.js'; 20 | import globalPasswordMiddleware from './middleware/global-password.middleware.js'; 21 | import superuserMiddleware from './middleware/superuser.middleware.js'; 22 | import userMiddleware from './middleware/user.middleware.js'; 23 | 24 | import User from './models/user.model.js'; 25 | 26 | import { parsedEnv } from './parse-env.js'; 27 | import { logLine, logError } from './utilities/logger.utility.js'; 28 | 29 | import { applyUpdates, recalculateStatistics } from './utilities/update.utility.js'; 30 | 31 | (async () => { 32 | // Connect to the database 33 | await mongoose.connect(parsedEnv.MONGOOSE_URL, { 34 | useNewUrlParser: true, 35 | useCreateIndex: true, 36 | useUnifiedTopology: true, 37 | }); 38 | 39 | // Apply updates when the version number changes 40 | await applyUpdates(); 41 | await recalculateStatistics(); 42 | 43 | // Create the express server 44 | const app = express(); 45 | 46 | app.use(cors()); 47 | app.use(express.json()); 48 | app.use(cookieParser()); 49 | 50 | // Add routes to the server 51 | app.use('/api/auth', globalPasswordMiddleware, authRouter); 52 | app.use('/api/users', [globalPasswordMiddleware, authenticationMiddleware], userRouter); 53 | app.use('/api/videos', [globalPasswordMiddleware, userMiddleware], videoRouter); 54 | app.use('/api/uploaders', [globalPasswordMiddleware, userMiddleware], uploaderRouter); 55 | app.use('/api/playlists', [globalPasswordMiddleware, userMiddleware], playlistRouter); 56 | app.use('/api/jobs', [globalPasswordMiddleware, userMiddleware], jobRouter); 57 | app.use('/api/statistics', globalPasswordMiddleware, statisticRouter); 58 | app.use('/api/activity', [globalPasswordMiddleware, authenticationMiddleware], activityRouter); 59 | app.use('/api/admin', [globalPasswordMiddleware, authenticationMiddleware, superuserMiddleware], adminRouter); 60 | app.use('/api/transcode', globalPasswordMiddleware, transcodeRouter); 61 | 62 | const staticFolders = ['videos', 'thumbnails', 'avatars', 'users/avatars']; 63 | const outputDirectory = parsedEnv.OUTPUT_DIRECTORY; 64 | 65 | // Create the static folders 66 | for (let folder of staticFolders) { 67 | fs.ensureDirSync(path.join(outputDirectory, folder)); 68 | app.use('/static/' + folder, globalPasswordMiddleware, express.static(path.join(outputDirectory, folder), { dotfiles: 'allow' })); 69 | } 70 | 71 | // Admin files 72 | app.use('/static/admin', [globalPasswordMiddleware, authenticationMiddleware, superuserMiddleware, (req, res, next) => { 73 | if (req.url.split('/').length - 1 > 1) return res.sendStatus(404); // Only serve the top level output directory 74 | next(); 75 | }], express.static(outputDirectory)); 76 | 77 | // Clean up deleted files 78 | const deleteQueueFile = path.join(outputDirectory, 'delete_queue.txt'); 79 | try { 80 | if (fs.existsSync(deleteQueueFile)) { 81 | logLine('Removing files for deleted videos...'); 82 | let folders = fs.readFileSync(deleteQueueFile).toString().replaceAll('\r\n', '\n').split('\n').filter(video => video !== ''); 83 | for (let folder of folders) { 84 | if (fs.existsSync(folder)) { 85 | let files = fs.readdirSync(folder, { withFileTypes: true }); 86 | for (let file of files) { 87 | if (file.isFile()) fs.unlinkSync(path.join(folder, file.name)); 88 | } 89 | } 90 | } 91 | fs.unlinkSync(deleteQueueFile); 92 | logLine('Done'); 93 | } 94 | } catch (err) { 95 | logLine('Failed to remove files'); 96 | if (parsedEnv.VERBOSE) logError(err); 97 | } 98 | 99 | // Spoof type 100 | app.use('/spoof/videos', globalPasswordMiddleware, (req, res, next) => { res.contentType('webm'); next(); }, express.static(path.join(outputDirectory, 'videos'))); 101 | 102 | // Serve the react app build in production 103 | if (parsedEnv.NODE_ENV === 'production') { 104 | app.use(express.static('../youtube-dl-react-frontend/build')); 105 | app.get('*', (req, res) => { 106 | res.sendFile(path.join( 107 | path.join(process.cwd(), '../youtube-dl-react-frontend/build/index.html') 108 | )); 109 | }); 110 | } 111 | 112 | // Start the server 113 | const backendPort = parsedEnv.BACKEND_PORT; 114 | app.listen(backendPort, () => { 115 | logLine('Server started on port: ' + backendPort); 116 | }); 117 | 118 | // Create the superuser 119 | const superuserUsername = parsedEnv.SUPERUSER_USERNAME; 120 | const superuserPassword = parsedEnv.SUPERUSER_PASSWORD; 121 | const user = new User({ 122 | username: superuserUsername, 123 | password: superuserPassword, 124 | isSuperuser: true 125 | }); 126 | user.save(function (err) { 127 | if (err && (err.name !== 'MongoError' || err.code !== 11000)) { 128 | throw err; 129 | } 130 | }); 131 | })().catch(err => { 132 | logError(err); 133 | process.exit(1); 134 | }); 135 | -------------------------------------------------------------------------------- /youtube-dl-express-backend/middleware/authentication.middleware.js: -------------------------------------------------------------------------------- 1 | import jwt from 'jsonwebtoken'; 2 | import { parsedEnv } from '../parse-env.js'; 3 | import validateAPIKey from '../utilities/api.utility.js'; 4 | 5 | export default async (req, res, next) => { 6 | // Validate with API 7 | const userId = await validateAPIKey(req); 8 | if (userId) { 9 | req.userId = userId; 10 | return next(); 11 | } 12 | 13 | // Validate with login 14 | const token = req.cookies.token; 15 | 16 | if (!token) return res.sendStatus(401); 17 | 18 | let decoded; 19 | try { 20 | decoded = await jwt.verify(token, parsedEnv.JWT_TOKEN_SECRET); 21 | } catch (err) { 22 | return res.sendStatus(401); 23 | } 24 | if (!decoded.hasOwnProperty('userId')) return res.sendStatus(401); 25 | 26 | req.userId = decoded.userId; 27 | next(); 28 | } 29 | -------------------------------------------------------------------------------- /youtube-dl-express-backend/middleware/global-password.middleware.js: -------------------------------------------------------------------------------- 1 | import jwt from 'jsonwebtoken'; 2 | import { parsedEnv } from '../parse-env.js'; 3 | import validateAPIKey from '../utilities/api.utility.js'; 4 | 5 | export default async (req, res, next) => { 6 | // Allow global password and logout routes 7 | if ( 8 | req.path === '/api/auth/global' 9 | || req.path === '/api/auth/logout' 10 | || req.path === '/global' 11 | || req.path === '/logout' 12 | || parsedEnv.GLOBAL_PASSWORD === '' 13 | ) return next(); 14 | 15 | // Validate with API 16 | if (await validateAPIKey(req)) return next(); 17 | 18 | // Validate with global password 19 | const token = req.cookies.token; 20 | if (!token) return res.sendStatus(401); 21 | 22 | let decoded; 23 | try { 24 | decoded = await jwt.verify(token, parsedEnv.JWT_TOKEN_SECRET); 25 | } catch (err) { 26 | return res.sendStatus(401); 27 | } 28 | if (!decoded.hasOwnProperty('globalPassword') 29 | || decoded.globalPassword !== parsedEnv.GLOBAL_PASSWORD 30 | ) return res.sendStatus(401); 31 | 32 | next(); 33 | } 34 | -------------------------------------------------------------------------------- /youtube-dl-express-backend/middleware/superuser.middleware.js: -------------------------------------------------------------------------------- 1 | import User from '../models/user.model.js'; 2 | 3 | export default async (req, res, next) => { 4 | const userId = req.userId; 5 | if (!userId) return res.sendStatus(401); 6 | 7 | let user; 8 | try { 9 | user = await User.findOne({ _id: userId }, 'isSuperuser'); 10 | } catch (err) { 11 | res.sendStatus(500); 12 | } 13 | if (!user) return res.sendStatus(401); 14 | if (!user.isSuperuser) return res.sendStatus(403); 15 | 16 | next(); 17 | } 18 | -------------------------------------------------------------------------------- /youtube-dl-express-backend/middleware/user.middleware.js: -------------------------------------------------------------------------------- 1 | import jwt from 'jsonwebtoken'; 2 | import { parsedEnv } from '../parse-env.js'; 3 | 4 | import User from '../models/user.model.js'; 5 | import validateAPIKey from '../utilities/api.utility.js'; 6 | 7 | export default async (req, res, next) => { 8 | // Validate with API 9 | const userId = await validateAPIKey(req); 10 | if (userId) { 11 | req.user = await User.findOne({ _id: userId }); 12 | return next(); 13 | } 14 | 15 | // Validate with login 16 | const token = req.cookies.token; 17 | 18 | if (!token) return next(); 19 | 20 | let decoded; 21 | try { 22 | decoded = await jwt.verify(token, parsedEnv.JWT_TOKEN_SECRET); 23 | } catch (err) { 24 | return next(); 25 | } 26 | if (!decoded.hasOwnProperty('userId')) return next(); 27 | 28 | let user; 29 | try { 30 | user = await User.findOne({ _id: decoded.userId }); 31 | } catch (err) { 32 | return next(); 33 | } 34 | 35 | req.user = user; 36 | next(); 37 | } 38 | -------------------------------------------------------------------------------- /youtube-dl-express-backend/models/activity.model.js: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose'; 2 | 3 | const activitySchema = new mongoose.Schema({ 4 | eventType: { type: String, required: true }, 5 | stopTime: { type: Number, default: null }, 6 | userDocument: { type: mongoose.Schema.ObjectId, ref: 'User', required: true }, 7 | videoDocument: { type: mongoose.Schema.ObjectId, ref: 'Video', default: null }, 8 | }, { 9 | timestamps: true, 10 | }); 11 | 12 | export default mongoose.model('Activity', activitySchema); 13 | -------------------------------------------------------------------------------- /youtube-dl-express-backend/models/apikey.model.js: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose'; 2 | import crypto from 'crypto'; 3 | 4 | function generateApiKey() { 5 | return `ytdlrv-api-${new Date().getTime()}-${crypto.randomBytes(32).toString('hex')}`; 6 | } 7 | 8 | const apiKeySchema = new mongoose.Schema({ 9 | key: { type: String, required: true, unique: true, default: generateApiKey }, 10 | name: { type: String, required: true }, 11 | userDocument: { type: mongoose.Schema.ObjectId, ref: 'User', required: true }, 12 | pattern: { 13 | type: String, 14 | required: true, 15 | default: '^.*$', 16 | validate: { 17 | validator: function (value) { 18 | try { 19 | new RegExp(value); 20 | return true; 21 | } catch (e) { 22 | return false; 23 | } 24 | }, 25 | message: props => `${props.value} is not a valid regular expression` 26 | }, 27 | }, 28 | enabled: { type: Boolean, default: true }, 29 | }, { 30 | timestamps: true, 31 | }); 32 | 33 | export default mongoose.model('ApiKey', apiKeySchema); 34 | -------------------------------------------------------------------------------- /youtube-dl-express-backend/models/error.model.js: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose'; 2 | 3 | const errorSchema = new mongoose.Schema({ 4 | videoPath: { type: String, required: true, unique: true }, 5 | errorObject: { type: String, required: true }, 6 | dateDownloaded: { type: String, required: true }, 7 | errorOccurred: { type: String, required: true }, 8 | youtubeDlVersion: { type: String, default: null }, 9 | youtubeDlPath: { type: String, default: null }, 10 | jobDocument: { type: mongoose.Schema.ObjectId, ref: 'Job', required: true }, 11 | formatCode: { type: String, default: null }, 12 | isAudioOnly: { type: Boolean, default: null }, 13 | urls: { type: String, default: null }, 14 | arguments: { type: String, default: null }, 15 | overrideUploader: { type: String, default: null }, 16 | imported: { type: Boolean, default: false }, 17 | scriptVersion: { type: String, required: true }, 18 | }, { 19 | timestamps: true, 20 | }); 21 | 22 | export default mongoose.model('Error', errorSchema); 23 | -------------------------------------------------------------------------------- /youtube-dl-express-backend/models/job.model.js: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose'; 2 | 3 | import statisticSchema from '../schemas/statistic.schema.js'; 4 | 5 | const jobSchema = new mongoose.Schema({ 6 | name: { type: String, required: true, unique: true }, 7 | formatCode: { type: String, required: true }, 8 | isAudioOnly: { type: Boolean, default: false }, 9 | urls: { type: String, default: null }, 10 | arguments: { type: String, required: true }, 11 | overrideUploader: { type: String, default: null }, 12 | lastCompleted: { type: Date, default: null }, 13 | statistics: { type: statisticSchema, default: () => ({}) }, 14 | downloadComments: { type: Boolean, default: false }, 15 | recodeVideo: { type: Boolean, default: false }, 16 | }, { 17 | timestamps: true, 18 | }); 19 | 20 | export default mongoose.model('Job', jobSchema); 21 | -------------------------------------------------------------------------------- /youtube-dl-express-backend/models/playlist.model.js: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose'; 2 | 3 | import statisticSchema from '../schemas/statistic.schema.js'; 4 | 5 | const playlistSchema = new mongoose.Schema({ 6 | extractor: { type: String, required: true }, 7 | id: { type: String, required: true, index: true }, 8 | name: { type: String, required: true }, 9 | description: { type: String, default: null }, 10 | statistics: { type: statisticSchema, default: () => ({}) }, 11 | uploaderName: { type: String, default: null }, 12 | }, { 13 | timestamps: true, 14 | }); 15 | 16 | playlistSchema.index({ extractor: 1, id: 1 }, { unique: true }); 17 | playlistSchema.index({ name: 1 }); 18 | 19 | export default mongoose.model('Playlist', playlistSchema); 20 | -------------------------------------------------------------------------------- /youtube-dl-express-backend/models/statistic.model.js: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose'; 2 | 3 | import statisticSchema from '../schemas/statistic.schema.js'; 4 | 5 | const globalStatisticSchema = new mongoose.Schema({ 6 | accessKey: { type: String, required: true, unique: true, default: 'videos' }, 7 | statistics: { type: statisticSchema, default: () => ({}) }, 8 | }, { 9 | timestamps: true, 10 | }); 11 | 12 | export default mongoose.model('Statistic', globalStatisticSchema); 13 | -------------------------------------------------------------------------------- /youtube-dl-express-backend/models/tag.model.js: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose'; 2 | 3 | const tagSchema = new mongoose.Schema({ 4 | name: { type: String, required: true }, 5 | type: { type: String, required: true }, 6 | parentDocument: { type: mongoose.Schema.ObjectId, required: true }, 7 | count: { type: Number, default: 1 }, 8 | }, { 9 | timestamps: false, 10 | }); 11 | 12 | tagSchema.index({ name: 1, type: 1, parentDocument: 1 }, { unique: true }); 13 | 14 | export default mongoose.model('Tag', tagSchema); 15 | -------------------------------------------------------------------------------- /youtube-dl-express-backend/models/uploader.model.js: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose'; 2 | 3 | import statisticSchema from '../schemas/statistic.schema.js'; 4 | 5 | const uploaderSchema = new mongoose.Schema({ 6 | extractor: { type: String, required: true }, 7 | id: { type: String, required: true, index: true }, 8 | name: { type: String, required: true }, 9 | url: { type: String, default: null }, 10 | statistics: { type: statisticSchema, default: () => ({}) }, 11 | }, { 12 | timestamps: true, 13 | }); 14 | 15 | uploaderSchema.index({ extractor: 1, id: 1 }, { unique: true }); 16 | uploaderSchema.index({ name: 1 }); 17 | 18 | export default mongoose.model('Uploader', uploaderSchema); 19 | -------------------------------------------------------------------------------- /youtube-dl-express-backend/models/user.model.js: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose'; 2 | import bcrypt from 'bcrypt'; 3 | 4 | const saltRounds = 10; 5 | 6 | const playerSettingsSchema = new mongoose.Schema({ 7 | enabled: { type: Boolean, default: false }, 8 | defaultPlaybackRate: { type: Number, default: 1 }, 9 | autoplayVideo: { type: Boolean, default: true }, 10 | enableDefaultTheaterMode: { type: Boolean, default: false }, 11 | enableDefaultAudioOnlyMode: { type: Boolean, default: false }, 12 | keepPlayerControlsVisible: { 13 | type: String, 14 | enum: ['never', 'windowed', 'fullscreen', 'always'], 15 | default: 'never', 16 | }, 17 | playerControlsPosition: { 18 | type: String, 19 | enum: ['on_video', 'under_video'], 20 | default: 'on_video', 21 | }, 22 | playerControlsScale: { type: Number, default: 1.25 }, 23 | defaultVolume: { type: Number, default: 1 }, 24 | volumeControlPosition: { 25 | type: String, 26 | enum: ['vertical', 'inline'], 27 | default: 'vertical', 28 | }, 29 | largePlayButtonEnabled: { type: Boolean, default: true }, 30 | seekButtonsEnabled: { type: Boolean, default: true }, 31 | forwardSeekButtonSeconds: { type: Number, default: 10 }, 32 | backSeekButtonSeconds: { type: Number, default: 10 }, 33 | enableScreenshotButton: { type: Boolean, default: true }, 34 | screenshotButtonBehavior: { 35 | type: String, 36 | enum: ['save', 'copy'], 37 | default: 'save', 38 | }, 39 | showCurrentTime: { type: Boolean, default: true }, 40 | showRemainingTime: { type: Boolean, default: true }, 41 | }); 42 | 43 | const userSchema = new mongoose.Schema({ 44 | isSuperuser: { type: Boolean, default: false }, 45 | avatar: { type: String, default: null }, 46 | username: { 47 | type: String, 48 | required: true, 49 | unique: true, 50 | minlength: 1, 51 | maxlength: 50, 52 | }, 53 | password: { type: String, required: true, minlength: 8 }, 54 | desktopPlayerSettings: { 55 | type: playerSettingsSchema, 56 | default: () => ({ enabled: true }), // Desktop player settings should always be enabled 57 | }, 58 | tabletPlayerSettings: { 59 | type: playerSettingsSchema, 60 | default: () => ({}), 61 | }, 62 | mobilePlayerSettings: { 63 | type: playerSettingsSchema, 64 | default: () => ({}), 65 | }, 66 | hideShorts: { type: Boolean, default: false }, 67 | useLargeLayout: { type: Boolean, default: true }, 68 | fitThumbnails: { type: Boolean, default: true }, 69 | useCircularAvatars: { type: Boolean, default: true }, 70 | useGradientEffect: { type: Boolean, default: true }, 71 | reportBytesUsingIec: { type: Boolean, default: true }, 72 | recordWatchHistory: { type: Boolean, default: true }, 73 | showWatchedHistory: { type: Boolean, default: true }, 74 | resumeVideos: { type: Boolean, default: true }, 75 | enableSponsorblock: { type: Boolean, default: true }, 76 | onlySkipLocked: { type: Boolean, default: false }, 77 | skipSponsor: { type: Boolean, default: true }, 78 | skipSelfpromo: { type: Boolean, default: true }, 79 | skipInteraction: { type: Boolean, default: true }, 80 | skipIntro: { type: Boolean, default: true }, 81 | skipOutro: { type: Boolean, default: true }, 82 | skipPreview: { type: Boolean, default: true }, 83 | skipFiller: { type: Boolean, default: false }, 84 | skipMusicOfftopic: { type: Boolean, default: true }, 85 | enableReturnYouTubeDislike: { type: Boolean, default: false }, 86 | }, { 87 | timestamps: true, 88 | }); 89 | 90 | userSchema.pre('save', async function (next) { 91 | if (this.isNew || this.isModified('password')) { 92 | try { 93 | this.password = await bcrypt.hash(this.password, saltRounds); 94 | } catch (err) { 95 | return next(err); 96 | } 97 | } 98 | next(); 99 | }); 100 | 101 | userSchema.methods.isCorrectPassword = function (password) { 102 | return bcrypt.compare(password, this.password); 103 | } 104 | 105 | export default mongoose.model('User', userSchema); 106 | -------------------------------------------------------------------------------- /youtube-dl-express-backend/models/version.model.js: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose'; 2 | 3 | const versionSchema = new mongoose.Schema({ 4 | accessKey: { type: String, required: true, unique: true, default: 'version' }, 5 | lastUpdateCompleted: { type: Number, default: null }, 6 | recalculateOnRestart: { type: Boolean, default: false }, 7 | }); 8 | 9 | export default mongoose.model('Version', versionSchema); 10 | -------------------------------------------------------------------------------- /youtube-dl-express-backend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "youtube-dl-express-backend", 3 | "version": "1.5.0", 4 | "description": "Web app for yt-dlp, created using the MERN stack", 5 | "main": "index.js", 6 | "scripts": { 7 | "start": "pm2 start pm2.config.json", 8 | "dev": "nodemon --require dotenv/config index.js", 9 | "exec": "node --require dotenv/config exec.js", 10 | "import": "node --require dotenv/config import.js", 11 | "test": "echo \"Error: no test specified\" && exit 1" 12 | }, 13 | "author": "Graham Walker", 14 | "license": "MIT", 15 | "dependencies": { 16 | "@colors/colors": "^1.5.0", 17 | "axios": "^1.4.0", 18 | "bcrypt": "^5.0.0", 19 | "commander": "^6.1.0", 20 | "cookie-parser": "^1.4.5", 21 | "cors": "^2.8.5", 22 | "dotenv": "^8.2.0", 23 | "express": "^4.17.1", 24 | "fluent-ffmpeg": "^2.1.2", 25 | "fs-extra": "^11.1.1", 26 | "jsonwebtoken": "^9.0.0", 27 | "mongoose": "^5.12.13", 28 | "multer": "^1.4.2", 29 | "node-ffprobe": "^3.0.0", 30 | "pm2": "^4.5.0", 31 | "sharp": "^0.32.6", 32 | "slash": "^3.0.0", 33 | "uuid": "^8.3.2" 34 | }, 35 | "engines": { 36 | "node": ">=14.2.0", 37 | "npm": ">=6.0.0" 38 | }, 39 | "type": "module", 40 | "devDependencies": { 41 | "nodemon": "^2.0.6" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /youtube-dl-express-backend/pm2.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "apps": [{ 3 | "name": "youtube-dl-react-viewer", 4 | "script": "index.js", 5 | "node_args": "--require dotenv/config" 6 | }] 7 | } 8 | -------------------------------------------------------------------------------- /youtube-dl-express-backend/routes/activity.route.js: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | 3 | import User from '../models/user.model.js'; 4 | import Video from '../models/video.model.js'; 5 | import Activity from '../models/activity.model.js'; 6 | 7 | const router = express.Router(); 8 | 9 | router.get('/recall/:page', async function (req, res) { 10 | let user; 11 | try { 12 | user = await User.findOne({ _id: req.userId }); 13 | } catch (err) { 14 | return res.sendStatus(401); 15 | } 16 | if (!user) return res.sendStatus(401); 17 | 18 | const page = parseInt(req.params.page) || 0; 19 | let activities; 20 | let count; 21 | try { 22 | activities = await Activity.find({ userDocument: user._id }).sort({ createdAt: -1 }).skip(page * 25).limit(25).populate({ 23 | path: 'videoDocument', 24 | populate: { 25 | path: 'uploaderDocument' 26 | } 27 | }); 28 | if (page === 0) count = await Activity.countDocuments({ userDocument: user._id }); 29 | } 30 | catch (err) { 31 | return res.sendStatus(500); 32 | } 33 | 34 | res.json({ 35 | activities, 36 | count, 37 | user: page === 0 ? user : undefined, 38 | }); 39 | }); 40 | 41 | router.post('/clear', async (req, res) => { 42 | let user; 43 | try { 44 | user = await User.findOne({ _id: req.userId }); 45 | } catch (err) { 46 | return res.sendStatus(401); 47 | } 48 | if (!user) return res.sendStatus(401); 49 | 50 | try { 51 | await Activity.deleteMany({ userDocument: user }); 52 | res.sendStatus(200); 53 | } catch (err) { 54 | res.sendStatus(500); 55 | } 56 | }); 57 | 58 | router.post('/update', async (req, res) => { 59 | let user; 60 | try { 61 | user = await User.findOne({ _id: req.userId }); 62 | } catch (err) { 63 | return res.sendStatus(401); 64 | } 65 | if (!user) return res.sendStatus(401); 66 | 67 | switch (req.body.eventType) { 68 | case 'watched': 69 | if (!user.recordWatchHistory) return res.sendStatus(405); 70 | try { 71 | let activity = await Activity.findOne({ 72 | videoDocument: req.body.videoId, 73 | userDocument: user._id, 74 | eventType: req.body.eventType 75 | }).sort({ createdAt: -1 }).exec(); 76 | let video = await Video.findOne({ _id: activity.videoDocument }, 'duration'); 77 | activity.stopTime = Math.max(Math.min(req.body.stopTime, video.duration), 0); 78 | await activity.save(); 79 | } catch (err) { return res.sendStatus(500); } 80 | break; 81 | default: 82 | return res.sendStatus(500); 83 | } 84 | 85 | res.sendStatus(200); 86 | }); 87 | 88 | export default router; 89 | -------------------------------------------------------------------------------- /youtube-dl-express-backend/routes/auth.route.js: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import jwt from 'jsonwebtoken'; 3 | 4 | import { parsedEnv } from '../parse-env.js'; 5 | import { logError } from '../utilities/logger.utility.js'; 6 | 7 | import { settingsFields } from './user.route.js'; 8 | 9 | import User from '../models/user.model.js'; 10 | 11 | const router = express.Router(); 12 | 13 | router.post('/register', async (req, res) => { 14 | if (!parsedEnv.ENABLE_USER_REGISTRATION) return res.status(500).json({ error: 'Registration is disabled' }); 15 | const { username, password } = req.body; 16 | const user = new User({ username, password }); 17 | try { 18 | await user.save(); 19 | } catch (err) { 20 | logError(err); 21 | if (err.code === 11000 && err.keyPattern.username === 1) { 22 | return res.status(500).json({ error: 'Username is taken' }); 23 | } else { 24 | return res.sendStatus(500); 25 | } 26 | } 27 | res.sendStatus(200); 28 | }); 29 | 30 | router.post('/login', async (req, res) => { 31 | const { username, password } = req.body; 32 | 33 | let user; 34 | try { 35 | user = await User.findOne({ username }, 'username password isSuperuser ' + settingsFields); 36 | } catch (err) { 37 | return res.sendStatus(500); 38 | } 39 | if (!user) return res.status(401).json({ noRedirect: true, error: 'Incorrect username or password' }); 40 | 41 | let same; 42 | try { 43 | same = await user.isCorrectPassword(password); 44 | } catch (err) { 45 | return res.sendStatus(500); 46 | } 47 | if (!same) return res.status(401).json({ noRedirect: true, error: 'Incorrect username or password' }); 48 | 49 | const token = jwt.sign({ userId: user._id, globalPassword: parsedEnv.GLOBAL_PASSWORD }, parsedEnv.JWT_TOKEN_SECRET, { 50 | expiresIn: '30d' 51 | }); 52 | 53 | user = user.toJSON(); 54 | delete user._id; 55 | delete user.password; 56 | 57 | res 58 | .cookie('token', token, { 59 | httpOnly: true, 60 | sameSite: 'strict', 61 | secure: parsedEnv.SECURE_COOKIES, 62 | maxAge: 2 * 365 * 24 * 60 * 60 * 1000, 63 | }) 64 | .json({ user }); 65 | }); 66 | 67 | router.post('/global', async (req, res) => { 68 | const { password } = req.body; 69 | 70 | if (password !== parsedEnv.GLOBAL_PASSWORD) return res.status(401).json({ noRedirect: true, error: 'Incorrect password' }); 71 | 72 | const token = jwt.sign({ globalPassword: password }, parsedEnv.JWT_TOKEN_SECRET, { 73 | expiresIn: '30d' 74 | }); 75 | 76 | res 77 | .cookie('token', token, { 78 | httpOnly: true, 79 | sameSite: 'strict', 80 | secure: parsedEnv.SECURE_COOKIES, 81 | maxAge: 2 * 365 * 24 * 60 * 60 * 1000, 82 | }) 83 | .send(); 84 | }); 85 | 86 | router.post('/logout', async (req, res) => { 87 | res.clearCookie('token').json({ hasGlobalPassword: !!parsedEnv.GLOBAL_PASSWORD }); 88 | }); 89 | 90 | export default router; 91 | -------------------------------------------------------------------------------- /youtube-dl-express-backend/routes/job.route.js: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | 3 | import Video from '../models/video.model.js'; 4 | import Job from '../models/job.model.js'; 5 | 6 | import { search, getRandomVideo } from '../utilities/video.utility.js'; 7 | 8 | const router = express.Router(); 9 | 10 | router.get('/:_id', async (req, res) => { 11 | let job; 12 | try { 13 | job = await Job.findOne({ 14 | _id: req.params._id, 15 | }, 'name statistics').populate('statistics.newestVideo'); 16 | } 17 | catch (err) { 18 | return res.sendStatus(500); 19 | } 20 | if (!job) return res.sendStatus(404); 21 | 22 | res.json({ job: job.toJSON() }); 23 | }); 24 | 25 | router.get('/:_id/:page', async (req, res) => { 26 | const page = parseInt(req.params.page) || 0; 27 | 28 | let job; 29 | try { 30 | job = await Job.findOne({ 31 | _id: req.params._id, 32 | }, '_id'); 33 | } 34 | catch (err) { 35 | return res.sendStatus(500); 36 | } 37 | if (!job) return res.sendStatus(404); 38 | 39 | const filter = req.user?.hideShorts ? { jobDocument: job._id, isShort: false } : { jobDocument: job._id }; 40 | 41 | let videos; 42 | let totals = {}; 43 | try { 44 | videos = await search(req.query, page, req.user, filter, 'dateDownloaded', -1); 45 | totals.count = (await Video.countDocuments(filter)) || 0; 46 | totals.shorts = (await Video.countDocuments(Object.assign({ ...filter }, { isShort: true })) || 0) 47 | } catch (err) { 48 | return res.sendStatus(500); 49 | } 50 | 51 | let randomVideo; 52 | try { 53 | randomVideo = await getRandomVideo({}, filter); 54 | } catch (err) { 55 | return res.sendStatus(500); 56 | } 57 | 58 | res.json({ 59 | videos, 60 | totals, 61 | randomVideo, 62 | }); 63 | }); 64 | 65 | export default router; 66 | -------------------------------------------------------------------------------- /youtube-dl-express-backend/routes/playlist.route.js: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | 3 | import Video from '../models/video.model.js'; 4 | import Playlist from '../models/playlist.model.js'; 5 | 6 | import { search, getRandomVideo } from '../utilities/video.utility.js'; 7 | 8 | const router = express.Router(); 9 | 10 | router.get('/:extractor/:id', async (req, res) => { 11 | let playlist; 12 | try { 13 | playlist = await Playlist.findOne({ 14 | extractor: req.params.extractor, 15 | id: req.params.id, 16 | }, 'extractor name uploaderName statistics description').populate('statistics.newestVideo'); 17 | } 18 | catch (err) { 19 | return res.sendStatus(500); 20 | } 21 | if (!playlist) return res.sendStatus(404); 22 | 23 | res.json({ playlist: playlist.toJSON() }); 24 | }); 25 | 26 | router.get('/:extractor/:id/:page', async (req, res) => { 27 | const page = parseInt(req.params.page) || 0; 28 | 29 | let playlist; 30 | try { 31 | playlist = await Playlist.findOne({ 32 | extractor: req.params.extractor, 33 | id: req.params.id, 34 | }); 35 | } 36 | catch (err) { 37 | return res.sendStatus(500); 38 | } 39 | if (!playlist) return res.sendStatus(404); 40 | 41 | const filter = req.user?.hideShorts ? { playlistDocument: playlist._id, isShort: false } : { playlistDocument: playlist._id }; 42 | 43 | let videos; 44 | let totals = {}; 45 | try { 46 | videos = await search(req.query, page, req.user, filter, 'playlistIndex', 1); 47 | totals.count = (await Video.countDocuments(filter)) || 0; 48 | totals.shorts = (await Video.countDocuments(Object.assign({ ...filter }, { isShort: true })) || 0) 49 | } catch (err) { 50 | return res.sendStatus(500); 51 | } 52 | 53 | let randomVideo; 54 | try { 55 | randomVideo = await getRandomVideo({}, filter); 56 | } catch (err) { 57 | return res.sendStatus(500); 58 | } 59 | 60 | res.json({ 61 | videos, 62 | totals, 63 | randomVideo, 64 | }); 65 | }); 66 | 67 | export default router; 68 | -------------------------------------------------------------------------------- /youtube-dl-express-backend/routes/statistic.route.js: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | 3 | import Statistic from '../models/statistic.model.js'; 4 | 5 | import { applyTags } from '../utilities/statistic.utility.js'; 6 | 7 | const router = express.Router(); 8 | 9 | router.get('/', async (req, res) => { 10 | let statistic; 11 | try { 12 | statistic = await Statistic.findOne({ accessKey: 'videos' }) 13 | .populate({ 14 | path: 'statistics.recordViewCountVideo', 15 | select: '-_id extractor id title uploader duration directory mediumResizedThumbnailFile viewCount width height uploaderDocument', 16 | populate: { path: 'uploaderDocument', select: 'extractor id name' }, 17 | }) 18 | .populate({ 19 | path: 'statistics.recordLikeCountVideo', 20 | select: '-_id extractor id title uploader duration directory mediumResizedThumbnailFile viewCount width height uploaderDocument', 21 | populate: { path: 'uploaderDocument', select: 'extractor id name' }, 22 | }) 23 | .populate({ 24 | path: 'statistics.recordDislikeCountVideo', 25 | select: '-_id extractor id title uploader duration directory mediumResizedThumbnailFile viewCount width height uploaderDocument', 26 | populate: { path: 'uploaderDocument', select: 'extractor id name' }, 27 | }) 28 | .populate({ 29 | path: 'statistics.oldestVideo', 30 | select: '-_id extractor id title uploader duration directory mediumResizedThumbnailFile viewCount width height uploaderDocument', 31 | populate: { path: 'uploaderDocument', select: 'extractor id name' }, 32 | }); 33 | } 34 | catch (err) { 35 | return res.sendStatus(500); 36 | } 37 | if (!statistic) { 38 | try { 39 | statistic = await new Statistic().save(); 40 | } catch (err) { 41 | return res.sendStatus(500); 42 | } 43 | } 44 | 45 | try { 46 | statistic = statistic.toJSON(); 47 | statistic = await applyTags(statistic, { count: -1 }, 10); 48 | } catch (err) { 49 | return res.sendStatus(500); 50 | } 51 | 52 | res.json({ statistic: statistic.statistics }); 53 | }); 54 | 55 | router.get('/tags', async (req, res) => { 56 | let statistic; 57 | try { 58 | statistic = await Statistic.findOne({ accessKey: 'videos' }, '_id'); 59 | } 60 | catch (err) { 61 | return res.sendStatus(500); 62 | } 63 | if (!statistic) { 64 | try { 65 | statistic = await new Statistic().save(); 66 | } catch (err) { 67 | return res.sendStatus(500); 68 | } 69 | } 70 | 71 | try { 72 | statistic = statistic.toJSON(); 73 | statistic.statistics = {}; 74 | statistic = await applyTags(statistic); 75 | } catch (err) { 76 | return res.sendStatus(500); 77 | } 78 | 79 | res.json(statistic.statistics); 80 | }); 81 | 82 | export default router; 83 | -------------------------------------------------------------------------------- /youtube-dl-express-backend/routes/transcode.route.js: -------------------------------------------------------------------------------- 1 | import { Router } from 'express'; 2 | import path from 'path'; 3 | import { parsedEnv } from '../parse-env.js'; 4 | import Video from '../models/video.model.js'; 5 | import ffmpeg from 'fluent-ffmpeg'; 6 | import { logError } from '../utilities/logger.utility.js'; 7 | 8 | ffmpeg.setFfmpegPath(parsedEnv.FFMPEG_PATH); 9 | ffmpeg.setFfprobePath(parsedEnv.FFPROBE_PATH); 10 | 11 | const router = Router(); 12 | 13 | router.get('/:extractor/:id/audio_only', async (req, res) => { 14 | if (!parsedEnv.AUDIO_ONLY_MODE_ENABLED) return res.sendStatus(403); 15 | 16 | const video = await Video.findOne({ extractor: req.params.extractor, id: req.params.id }, '-_id directory duration videoFile.name'); 17 | if (!video) return res.sendStatus(404); 18 | 19 | // Ensure a range header was sent 20 | const range = req.headers.range; 21 | if (!range) return res.status(416).send('Range header required'); 22 | 23 | // Parse the byte range 24 | const byteRange = range.match(/bytes=(\d+)-/); 25 | if (!byteRange) return res.status(400).send('Invalid range header'); 26 | 27 | const startByte = parseInt(byteRange[1], 10); 28 | if (isNaN(startByte)) return res.status(400).send('Invalid start byte'); 29 | 30 | const videoPath = path.join(parsedEnv.OUTPUT_DIRECTORY, 'videos', video.directory, video.videoFile.name); 31 | const duration = video.duration; 32 | const audioBitrate = parsedEnv.AUDIO_ONLY_MODE_BITRATE; 33 | const estimatedTranscodeFilesize = (audioBitrate * duration) / 8; // Divide by 8 to convert bits to bytes 34 | const startTime = (startByte / estimatedTranscodeFilesize) * duration; 35 | 36 | // Try to prevent seeking past the duration 37 | if (startTime >= duration) startTime = duration - 0.5; 38 | 39 | // Set headers 40 | res.status(206); // Partial content 41 | res.setHeader('Content-Type', 'audio/mpeg'); 42 | res.setHeader('Accept-Ranges', 'bytes'); 43 | res.setHeader('Content-Range', `bytes ${startByte}-${estimatedTranscodeFilesize - 1}/${estimatedTranscodeFilesize}`); 44 | 45 | ffmpeg(videoPath) 46 | .seekInput(startTime) // Start converting the video from the start time in seconds 47 | .format('mp3') 48 | .audioCodec('libmp3lame') // MP3 codec 49 | .on('error', (err) => { 50 | if (parsedEnv.VERBOSE && err?.message !== 'Output stream closed') logError(err); 51 | }) 52 | .outputOptions([ 53 | `-b:a ${audioBitrate}`, 54 | '-compression_level 0', // Use constant bitrate (CBR) so the estimate of the filesize is accurate 55 | '-ac 2', 56 | '-ar 44100' 57 | ]) 58 | .pipe(res, { end: true }); 59 | }); 60 | 61 | export default router; 62 | -------------------------------------------------------------------------------- /youtube-dl-express-backend/routes/uploader.route.js: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import multer from 'multer'; 3 | import path from 'path'; 4 | import sharp from 'sharp'; 5 | import fs from 'fs-extra'; 6 | 7 | import { parsedEnv } from '../parse-env.js'; 8 | 9 | import Video from '../models/video.model.js'; 10 | import Uploader from '../models/uploader.model.js'; 11 | 12 | import { search, getRandomVideo } from '../utilities/video.utility.js'; 13 | import { applyTags } from '../utilities/statistic.utility.js'; 14 | import { makeSafe, getTargetSquareSize } from '../utilities/file.utility.js'; 15 | 16 | const router = express.Router(); 17 | const avatarUpload = multer({ storage: multer.memoryStorage() }); 18 | 19 | router.get('/page/:page', async (req, res) => { 20 | const perPage = 18; 21 | let uploaders; 22 | let totalWebsites; 23 | let totalUploaders; 24 | let maxPages; 25 | let page = parseInt(req.params.page); 26 | 27 | if (isNaN(page)) return res.sendStatus(404); 28 | page--; 29 | if (page < 0) return res.sendStatus(404); 30 | 31 | try { 32 | // Uploaders with 0 videos are playlist uploaders. They should not be shown since playlists are not searchable yet 33 | uploaders = await Uploader 34 | .find({}, '-_id extractor id name statistics.totalVideoCount' 35 | + ' statistics.totalVideoFilesize statistics.newestVideoDateUploaded') 36 | .collation({ locale: 'en' }) 37 | .sort({ name: 1 }) 38 | .skip(page * perPage) 39 | .limit(perPage) 40 | .lean() 41 | .exec(); 42 | totalWebsites = (await Uploader.distinct('extractor')).length; 43 | totalUploaders = await Uploader.countDocuments(); 44 | maxPages = Math.ceil(totalUploaders / perPage); 45 | } catch (err) { 46 | return res.sendStatus(500); 47 | } 48 | 49 | res.json({ 50 | uploaders: uploaders, 51 | totalWebsites: totalWebsites, 52 | totalUploaders: totalUploaders, 53 | maxPages: maxPages 54 | }); 55 | }); 56 | 57 | router.get('/:extractor/:id', async (req, res) => { 58 | let uploader; 59 | try { 60 | uploader = await Uploader.findOne({ 61 | extractor: req.params.extractor, 62 | id: req.params.id, 63 | }); 64 | } 65 | catch (err) { 66 | return res.sendStatus(500); 67 | } 68 | if (!uploader) return res.sendStatus(404); 69 | 70 | try { 71 | uploader = uploader.toJSON(); 72 | uploader = await applyTags(uploader, { count: -1 }, 5); 73 | } catch (err) { 74 | res.sendStatus(500); 75 | } 76 | 77 | res.json({ uploader }); 78 | }); 79 | 80 | router.get('/:extractor/:id/:page', async (req, res) => { 81 | const page = parseInt(req.params.page) || 0; 82 | 83 | let uploader; 84 | try { 85 | uploader = await Uploader.findOne({ 86 | extractor: req.params.extractor, 87 | id: req.params.id, 88 | }); 89 | } 90 | catch (err) { 91 | return res.sendStatus(500); 92 | } 93 | if (!uploader) return res.sendStatus(404); 94 | 95 | const filter = req.user?.hideShorts ? { uploaderDocument: uploader._id, isShort: false } : { uploaderDocument: uploader._id }; 96 | 97 | let videos; 98 | let totals = {}; 99 | try { 100 | videos = await search(req.query, page, req.user, filter); 101 | totals.count = (await Video.countDocuments(filter)) || 0; 102 | totals.shorts = (await Video.countDocuments(Object.assign({ ...filter }, { isShort: true })) || 0) 103 | } catch (err) { 104 | return res.sendStatus(500); 105 | } 106 | 107 | let randomVideo; 108 | try { 109 | randomVideo = await getRandomVideo({}, filter); 110 | } catch (err) { 111 | return res.sendStatus(500); 112 | } 113 | 114 | res.json({ 115 | videos, 116 | totals, 117 | randomVideo, 118 | }); 119 | }); 120 | 121 | router.post('/:extractor/:id/upload_avatar', avatarUpload.single('avatar'), async (req, res) => { 122 | if (!req.user || !req.user.isSuperuser) return res.sendStatus(403); 123 | if (!req.file) return res.status(500).json({ error: 'Missing file' }); 124 | 125 | let uploader; 126 | try { 127 | uploader = await Uploader.findOne({ 128 | extractor: req.params.extractor, 129 | id: req.params.id, 130 | }); 131 | } 132 | catch (err) { 133 | return res.sendStatus(500); 134 | } 135 | if (!uploader) return res.sendStatus(404); 136 | 137 | try { 138 | const avatarDirectory = path.join(parsedEnv.OUTPUT_DIRECTORY, 'avatars', makeSafe(uploader.extractor, ' -')); 139 | const avatarFilename = makeSafe(uploader.id, '_') + '.jpg'; 140 | 141 | fs.ensureDirSync(avatarDirectory); 142 | 143 | const targetSize = await getTargetSquareSize(req.file.buffer, 512); 144 | await sharp(req.file.buffer) 145 | .resize({ 146 | fit: sharp.fit.cover, 147 | width: targetSize, 148 | height: targetSize, 149 | }) 150 | .jpeg() 151 | .toFile(path.join(avatarDirectory, avatarFilename)); 152 | } catch (err) { 153 | console.error(err); 154 | return res.status(500).json({ error: 'Failed to upload avatar' }); 155 | } 156 | 157 | return res.sendStatus(200); 158 | }); 159 | 160 | export default router; 161 | -------------------------------------------------------------------------------- /youtube-dl-express-backend/routes/user.route.js: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import multer from 'multer'; 3 | import path from 'path'; 4 | import sharp from 'sharp'; 5 | import fs from 'fs-extra'; 6 | import { v4 as uuidv4 } from 'uuid'; 7 | 8 | import { parsedEnv } from '../parse-env.js'; 9 | 10 | import User from '../models/user.model.js'; 11 | import Activity from '../models/activity.model.js'; 12 | import { getTargetSquareSize } from '../utilities/file.utility.js'; 13 | 14 | const router = express.Router(); 15 | const avatarUpload = multer({ storage: multer.memoryStorage() }); 16 | 17 | export const settingsFields = 'avatar desktopPlayerSettings tabletPlayerSettings mobilePlayerSettings hideShorts useLargeLayout fitThumbnails useCircularAvatars useGradientEffect reportBytesUsingIec recordWatchHistory showWatchedHistory resumeVideos enableSponsorblock onlySkipLocked skipSponsor skipSelfpromo skipInteraction skipIntro skipOutro skipPreview skipFiller skipMusicOfftopic enableReturnYouTubeDislike'; 18 | 19 | router.get('/settings', async (req, res) => { 20 | let user; 21 | try { 22 | user = await User.findOne({ _id: req.userId }, '-_id username isSuperuser ' + settingsFields); 23 | } catch (err) { 24 | return res.sendStatus(500); 25 | } 26 | if (!user) return res.sendStatus(500); 27 | 28 | res.json({ user: user.toJSON() }); 29 | }); 30 | 31 | router.post('/settings', async (req, res) => { 32 | let user; 33 | try { 34 | user = await User.findOne({ _id: req.userId }, 'username password isSuperuser ' + settingsFields); 35 | } catch (err) { 36 | return res.sendStatus(500); 37 | } 38 | if (!user) return res.sendStatus(500); 39 | 40 | user.username = req.body.username; 41 | if (req.body.password) user.password = req.body.password; 42 | user.desktopPlayerSettings = req.body.desktopPlayerSettings; 43 | user.tabletPlayerSettings = req.body.tabletPlayerSettings; 44 | user.mobilePlayerSettings = req.body.mobilePlayerSettings; 45 | user.hideShorts = req.body.hideShorts; 46 | user.useLargeLayout = req.body.useLargeLayout; 47 | user.fitThumbnails = req.body.fitThumbnails; 48 | user.useCircularAvatars = req.body.useCircularAvatars; 49 | user.useGradientEffect = req.body.useGradientEffect; 50 | user.reportBytesUsingIec = req.body.reportBytesUsingIec; 51 | user.recordWatchHistory = req.body.recordWatchHistory; 52 | user.showWatchedHistory = req.body.showWatchedHistory; 53 | user.resumeVideos = req.body.resumeVideos; 54 | user.enableSponsorblock = req.body.enableSponsorblock; 55 | user.onlySkipLocked = req.body.onlySkipLocked; 56 | user.skipSponsor = req.body.skipSponsor; 57 | user.skipSelfpromo = req.body.skipSelfpromo; 58 | user.skipInteraction = req.body.skipInteraction; 59 | user.skipIntro = req.body.skipIntro; 60 | user.skipOutro = req.body.skipOutro; 61 | user.skipPreview = req.body.skipPreview; 62 | user.skipFiller = req.body.skipFiller; 63 | user.skipMusicOfftopic = req.body.skipMusicOfftopic; 64 | user.enableReturnYouTubeDislike = req.body.enableReturnYouTubeDislike; 65 | 66 | if (!user.recordWatchHistory) { 67 | try { 68 | await Activity.deleteMany({ userDocument: user }); 69 | } catch (err) { 70 | return res.sendStatus(500); 71 | } 72 | } 73 | 74 | try { 75 | await user.save(); 76 | } catch (err) { 77 | return res.sendStatus(500); 78 | } 79 | 80 | user = user.toJSON(); 81 | delete user._id; 82 | delete user.password; 83 | delete user.updatedAt; 84 | 85 | res.json(user); 86 | }); 87 | 88 | router.post('/upload_avatar', avatarUpload.single('avatar'), async (req, res) => { 89 | let user; 90 | try { 91 | user = await User.findOne({ _id: req.userId }, 'username password isSuperuser ' + settingsFields); 92 | } catch (err) { 93 | return res.sendStatus(500); 94 | } 95 | if (!user) return res.sendStatus(500); 96 | 97 | if (!req.file) return res.status(500).json({ error: 'Missing file' }); 98 | 99 | const avatarDirectory = path.join(parsedEnv.OUTPUT_DIRECTORY, 'users', 'avatars'); 100 | const avatar = uuidv4() + '.webp'; 101 | try { 102 | fs.ensureDirSync(avatarDirectory); 103 | 104 | // Resize uploaded image 105 | const targetSize = await getTargetSquareSize(req.file.buffer, 256); 106 | await sharp(req.file.buffer) 107 | .resize({ 108 | fit: sharp.fit.cover, 109 | width: targetSize, 110 | height: targetSize, 111 | }) 112 | .webp({ quality: 100, lossless: true }) 113 | .toFile(path.join(avatarDirectory, avatar)); 114 | 115 | // Remove old avatar image 116 | if (user.avatar && fs.existsSync(path.join(avatarDirectory, user.avatar))) fs.unlinkSync(path.join(avatarDirectory, user.avatar)); 117 | 118 | // Save user 119 | user.avatar = avatar; 120 | await user.save(); 121 | } catch (err) { 122 | console.error(err); 123 | return res.status(500).json({ error: 'Failed to upload avatar' }); 124 | } 125 | 126 | return res.status(200).json({ avatar }); 127 | }); 128 | 129 | export default router; 130 | -------------------------------------------------------------------------------- /youtube-dl-express-backend/schemas/statistic.schema.js: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose'; 2 | 3 | const ObjectId = mongoose.Schema.ObjectId; 4 | 5 | const statisticSchema = new mongoose.Schema({ 6 | totalVideoCount: { type: Number, default: 0 }, 7 | totalDuration: { type: Number, default: 0 }, 8 | totalFilesize: { type: Number, default: 0 }, 9 | totalOriginalFilesize: { type: Number, default: 0 }, 10 | totalVideoFilesize: { type: Number, default: 0 }, 11 | totalInfoFilesize: { type: Number, default: 0 }, 12 | totalDescriptionFilesize: { type: Number, default: 0 }, 13 | totalAnnotationsFilesize: { type: Number, default: 0 }, 14 | totalThumbnailFilesize: { type: Number, default: 0 }, 15 | totalResizedThumbnailFilesize: { type: Number, default: 0 }, 16 | totalSubtitleFilesize: { type: Number, default: 0 }, 17 | totalViewCount: { type: Number, default: 0 }, 18 | recordViewCount: { type: Number, default: 0 }, 19 | recordViewCountVideo: { type: ObjectId, ref: 'Video', default: null }, 20 | totalLikeCount: { type: Number, default: 0 }, 21 | recordLikeCount: { type: Number, default: 0 }, 22 | recordLikeCountVideo: { type: ObjectId, ref: 'Video', default: null }, 23 | totalDislikeCount: { type: Number, default: 0 }, 24 | recordDislikeCount: { type: Number, default: 0 }, 25 | recordDislikeCountVideo: { type: ObjectId, ref: 'Video', default: null }, 26 | oldestVideoDateUploaded: { type: Date, default: null }, 27 | newestVideoDateUploaded: { type: Date, default: null }, 28 | oldestVideo: { type: ObjectId, ref: 'Video', default: null }, 29 | newestVideo: { type: ObjectId, ref: 'Video', default: null }, 30 | }, { _id: false }); 31 | 32 | export default statisticSchema; 33 | -------------------------------------------------------------------------------- /youtube-dl-express-backend/utilities/api.utility.js: -------------------------------------------------------------------------------- 1 | import ApiKey from '../models/apikey.model.js'; 2 | 3 | export default async function validateAPIKey(req) { 4 | const key = req.cookies.key || req.headers['x-api-key']; 5 | 6 | let apiKey; 7 | try { 8 | apiKey = await ApiKey.findOne({ 9 | key, 10 | enabled: true, 11 | }, 'userDocument pattern').lean().exec(); 12 | if (!apiKey) throw new Error('Invalid API key'); 13 | } catch (err) { 14 | return null; 15 | } 16 | 17 | if (new RegExp(apiKey.pattern).test(req.baseUrl + req.path)) return apiKey.userDocument.toString(); 18 | return null; 19 | } 20 | -------------------------------------------------------------------------------- /youtube-dl-express-backend/utilities/file.utility.js: -------------------------------------------------------------------------------- 1 | import sharp from 'sharp'; 2 | 3 | export const makeSafe = (text, replaceWith) => { 4 | return encodeURIComponent(text.replace(/[|:&;$%@"<>()+,/\\*?]/g, replaceWith)); 5 | } 6 | 7 | export const getTargetSquareSize = async (imageData, maxSize) => { 8 | const metadata = await sharp(imageData).metadata(); 9 | const smallestSize = Math.min(metadata.width, metadata.height) || maxSize; 10 | return Math.min(smallestSize, maxSize); 11 | } 12 | -------------------------------------------------------------------------------- /youtube-dl-express-backend/utilities/logger.utility.js: -------------------------------------------------------------------------------- 1 | import { parsedEnv } from '../parse-env.js'; 2 | import path from 'path'; 3 | import fs from 'fs-extra'; 4 | import colors from '@colors/colors'; 5 | 6 | const logFile = path.join(parsedEnv.OUTPUT_DIRECTORY, 'console_output.txt'); 7 | fs.removeSync(logFile); 8 | 9 | let history = []; 10 | let historyUpdated = new Date().getTime(); 11 | const historyLimit = 1000; 12 | 13 | const logLine = (msg) => { 14 | msg = `(${new Date().toISOString()}) ${msg}`; 15 | pushHistory(msg, 'log'); 16 | writeLine(msg); 17 | console.log(msg); 18 | } 19 | 20 | const logWarn = (msg) => { 21 | msg = `(${new Date().toISOString()}) Warning: ${msg}`; 22 | pushHistory(msg, 'warning'); 23 | writeLine(msg); 24 | console.warn(msg.yellow); 25 | } 26 | 27 | const logInfo = (msg) => { 28 | msg = `(${new Date().toISOString()}) Info: ${msg}`; 29 | pushHistory(msg, 'info'); 30 | writeLine(msg); 31 | console.log(msg.cyan); 32 | } 33 | 34 | const logError = (msg) => { 35 | try { 36 | if (typeof msg === 'object' && msg.hasOwnProperty('stack')) { 37 | msg = `(${new Date().toISOString()}) Error:\r\n${msg.stack}`; 38 | } else { 39 | msg = `(${new Date().toISOString()}) Error: ${msg.toString()}`; 40 | } 41 | } catch (err) { 42 | msg = `(${new Date().toISOString()}) Error: Failed to print error`; 43 | } 44 | 45 | pushHistory(msg, 'danger'); 46 | writeLine(msg); 47 | console.error(msg.red); 48 | } 49 | 50 | const logStdout = (data, progress = false) => { 51 | try { 52 | let msg = data.toString(); 53 | let progressComplete = false; 54 | 55 | if ( 56 | !progress 57 | && ( 58 | msg.trim().startsWith('[download]') && msg.trim().charAt(16) === '%' 59 | || msg.trim().startsWith('[download] 100%') 60 | ) 61 | ) { progress = true; } 62 | 63 | if (progress 64 | && ( 65 | msg.trim().startsWith('[download] 100%') // Final progress for yt-dlp downloads is '[download] 100%' without decimal 66 | || msg.trim().endsWith('100.00%') // Final progress for database upgrade ends with '100.00%' 67 | ) 68 | ) { progressComplete = true; } 69 | 70 | if (progressComplete) msg += '\r\n'; 71 | 72 | pushHistory(msg, 'secondary', progress, true); 73 | if (!progress || progressComplete) writeLine(msg, true); 74 | 75 | try { 76 | process.stdout.write(msg.gray); 77 | } catch (err) { 78 | console.log(msg.gray); 79 | } 80 | } catch (err) { 81 | console.log('Failed to print stdout'.gray); 82 | } 83 | } 84 | 85 | const pushHistory = (msg, level = 'log', progress = false, stdout = false) => { 86 | if (history.length > 0 && history[history.length - 1].progress && progress) history.pop(); 87 | history.push({ 88 | msg: stdout ? msg : msg + '\r\n', 89 | level, 90 | progress, 91 | }); 92 | if (history.length > historyLimit) history.splice(0, 1); 93 | historyUpdated = new Date().getTime(); 94 | } 95 | 96 | const writeLine = (msg, stdout = false) => { 97 | fs.appendFileSync(logFile, stdout ? msg : msg + '\r\n'); 98 | } 99 | 100 | export { logLine, logWarn, logInfo, logError, logStdout, history, historyUpdated }; 101 | -------------------------------------------------------------------------------- /youtube-dl-react-frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "youtube-dl-react-frontend", 3 | "version": "1.5.0", 4 | "private": true, 5 | "dependencies": { 6 | "@fortawesome/fontawesome-svg-core": "^6.4.0", 7 | "@fortawesome/free-solid-svg-icons": "^6.4.0", 8 | "@fortawesome/react-fontawesome": "^0.2.0", 9 | "@testing-library/jest-dom": "^4.2.4", 10 | "@testing-library/react": "^9.5.0", 11 | "@testing-library/user-event": "^7.2.1", 12 | "axios": "^0.21.1", 13 | "bootstrap": "^5.3.0-alpha3", 14 | "chart.js": "^2.9.4", 15 | "copyfiles": "^2.4.1", 16 | "iso-639-1": "^2.1.4", 17 | "query-string": "^6.13.1", 18 | "react": "^16.13.1", 19 | "react-bootstrap": "^2.7.4", 20 | "react-chartjs-2": "^2.10.0", 21 | "react-dom": "^16.13.1", 22 | "react-infinite-scroll-component": "^5.0.5", 23 | "react-router-bootstrap": "^0.25.0", 24 | "react-router-dom": "^5.2.0", 25 | "react-scripts": "^5.0.1", 26 | "sass": "^1.43.4", 27 | "video.js": "^7.15.3", 28 | "videojs-hotkeys": "^0.2.27" 29 | }, 30 | "scripts": { 31 | "start": "npm run copy-env && react-scripts start", 32 | "build": "npm run copy-env && react-scripts build", 33 | "test": "react-scripts test", 34 | "eject": "react-scripts eject", 35 | "copy-env": "copyfiles -f ../.env ./" 36 | }, 37 | "proxy": "http://localhost:5000/", 38 | "eslintConfig": { 39 | "extends": "react-app" 40 | }, 41 | "browserslist": { 42 | "production": [ 43 | ">0.2%", 44 | "not dead", 45 | "not op_mini all" 46 | ], 47 | "development": [ 48 | "last 1 chrome version", 49 | "last 1 firefox version", 50 | "last 1 safari version" 51 | ] 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /youtube-dl-react-frontend/public/default-avatar.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /youtube-dl-react-frontend/public/default-thumbnail.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /youtube-dl-react-frontend/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/graham-walker/youtube-dl-react-viewer/cfdf0f636ac9b45179e95db059fe77f676f06073/youtube-dl-react-frontend/public/favicon.ico -------------------------------------------------------------------------------- /youtube-dl-react-frontend/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 17 | 18 | 27 | 28 | 29 | 30 |
31 | 41 | 42 | -------------------------------------------------------------------------------- /youtube-dl-react-frontend/public/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /youtube-dl-react-frontend/public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/graham-walker/youtube-dl-react-viewer/cfdf0f636ac9b45179e95db059fe77f676f06073/youtube-dl-react-frontend/public/logo192.png -------------------------------------------------------------------------------- /youtube-dl-react-frontend/public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/graham-walker/youtube-dl-react-viewer/cfdf0f636ac9b45179e95db059fe77f676f06073/youtube-dl-react-frontend/public/logo512.png -------------------------------------------------------------------------------- /youtube-dl-react-frontend/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "ytdl Viewer", 3 | "name": "youtube-dl Viewer", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /youtube-dl-react-frontend/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: / 4 | -------------------------------------------------------------------------------- /youtube-dl-react-frontend/src/App.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Router, Route, Switch, Redirect } from 'react-router-dom'; 3 | import { Container } from 'react-bootstrap'; 4 | import { library } from '@fortawesome/fontawesome-svg-core' 5 | import Navbar from './components/Navbar/Navbar'; 6 | import LoginForm from './components/LoginForm/LoginForm'; 7 | import RegisterForm from './components/RegisterForm/RegisterForm'; 8 | import ErrorPage from './components/Error/Error'; 9 | import VideoList from './components/VideoList/VideoList'; 10 | import GlobalPasswordForm from './components/GlobalPasswordForm/GlobalPasswordForm'; 11 | import Logout from './components/Logout/Logout'; 12 | import SettingsPage from './components/Settings/Settings'; 13 | import AdminPage from './components/Admin/Admin'; 14 | import StatisticsPage from './components/Statistics/Statistics'; 15 | import TagsList from './components/TagsList/TagsList'; 16 | import VideoPage from './components/Video/Video'; 17 | import Page from './components/Page/Page'; 18 | import UploaderPage from './components/Uploader/Uploader'; 19 | import Playlist from './components/Playlist/Playlist'; 20 | import Job from './components/Job/Job'; 21 | import ActivityPage from './components/Activity/Activity'; 22 | import UploaderList from './components/UploaderList/UploaderList'; 23 | import UserProvider from './contexts/user.context'; 24 | import AdvancedSearchProvider from './contexts/advancedsearch.context'; 25 | import history from './utilities/history.utility'; 26 | import { faEye, faCamera, faTachometerAlt, faFile, faExternalLinkAlt, faCaretRight, faUser, faList, faVideo, faClock, faThumbsUp, faThumbsDown, faHourglassEnd, faRandom, faSearch, faFilter, faCalendarAlt, faPlus, faBriefcase, faDownload, faPlay, faHandPaper, faMapMarkerAlt, faTv, faBalanceScale, faHistory, faHeart, faInfoCircle, faDatabase, faCircleHalfStroke, faSun, faMoon, faRotateRight, faRotateLeft, faTrash, faPause, faPencil, faHeadphones, faCheck, faSort, faGlobe, faTag, faChartPie } from '@fortawesome/free-solid-svg-icons'; 27 | import parsedEnv from './parse-env'; 28 | 29 | library.add(faEye, faCamera, faTachometerAlt, faFile, faExternalLinkAlt, faCaretRight, faUser, faList, faVideo, faClock, faThumbsUp, faThumbsDown, faHourglassEnd, faRandom, faSearch, faFilter, faCalendarAlt, faPlus, faBriefcase, faDownload, faPlay, faHandPaper, faMapMarkerAlt, faTv, faBalanceScale, faHistory, faHeart, faInfoCircle, faDatabase, faCircleHalfStroke, faSun, faMoon, faRotateRight, faRotateLeft, faTrash, faPause, faPencil, faHeadphones, faCheck, faSort, faGlobe, faTag, faChartPie); 30 | 31 | function App() { 32 | return ( 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 44 | 49 | } 50 | /> 51 | 55 | 58 | } 59 | /> 60 | 64 | 65 | } 66 | /> 67 | 71 | } 72 | /> 73 | 77 | } 78 | /> 79 | 83 | } 84 | /> 85 | 90 | 95 | 99 | 100 | 101 | 102 | } 103 | /> 104 | 108 | 109 | 110 | 111 | } 112 | /> 113 | 118 | 123 | 128 | 133 | 137 | 138 | 139 | 140 | } 141 | /> 142 | 144 | 145 | } 146 | /> 147 | 148 | 149 | 150 | 151 | 152 | ); 153 | } 154 | 155 | export default App; 156 | -------------------------------------------------------------------------------- /youtube-dl-react-frontend/src/App.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render } from '@testing-library/react'; 3 | import App from './App'; 4 | 5 | test('renders learn react link', () => { 6 | const { getByText } = render(); 7 | const linkElement = getByText(/learn react/i); 8 | expect(linkElement).toBeInTheDocument(); 9 | }); 10 | -------------------------------------------------------------------------------- /youtube-dl-react-frontend/src/components/AccordionButton/AccordionButton.js: -------------------------------------------------------------------------------- 1 | import { Button } from 'react-bootstrap'; 2 | import { useAccordionButton } from 'react-bootstrap/AccordionButton'; 3 | 4 | export default function ToggleButton(props) { 5 | const decoratedOnClick = useAccordionButton(props.eventKey, () => {}); 6 | 7 | return ( 8 | 15 | ); 16 | } -------------------------------------------------------------------------------- /youtube-dl-react-frontend/src/components/Activity/Activity.scss: -------------------------------------------------------------------------------- 1 | .activity-header > * { 2 | display: inline-block; 3 | } 4 | 5 | .activity-body { 6 | margin-left: 1.1rem; 7 | } 8 | -------------------------------------------------------------------------------- /youtube-dl-react-frontend/src/components/Admin/Admin.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import PageLoadWrapper from '../PageLoadWrapper/PageLoadWrapper'; 3 | import { UserContext } from '../../contexts/user.context'; 4 | import axios from '../../utilities/axios.utility'; 5 | 6 | import VideoDeleter from './VideoDeleter/VideoDeleter'; 7 | import UpdateChecker from './UpdateChecker/UpdateChecker'; 8 | import YtdlUpdater from './YtdlUpdater/YtdlUpdater'; 9 | import ChannelIconDownloader from './ChannelIconDownloader/ChannelIconDownloader'; 10 | import HashVerifier from './HashVerifier/HashVerifier'; 11 | import JobDownloader from './JobDownloader/JobDownloader'; 12 | import LogFileList from './LogFileList/LogFileList'; 13 | import RetryImports from './RetryImports/RetryImports'; 14 | import JobEditor from './JobEditor/JobEditor'; 15 | import VideoImporter from './VideoImporter/VideoImporter'; 16 | import RecalcStatistics from './RecalcStatistics/RecalcStatistics'; 17 | import ApiKeyManager from './ApiKeyManager/ApiKeyManager'; 18 | import parsedEnv from '../../parse-env'; 19 | 20 | export default class AdminPage extends Component { 21 | static contextType = UserContext; 22 | 23 | constructor(props) { 24 | super(props) 25 | this.state = { 26 | loading: true, 27 | success: undefined, 28 | error: undefined, 29 | jobs: [], 30 | errors: [], 31 | extractors: [], 32 | adminFiles: [], 33 | youtubeDlPath: undefined, 34 | youtubeDlVersion: undefined, 35 | defaultActivejobId: undefined, 36 | consoleOutput: [], 37 | historyUpdated: 0, 38 | apiKeys: [], 39 | currentUserId: undefined, 40 | }; 41 | } 42 | 43 | componentDidMount() { 44 | document.title = `Admin - ${parsedEnv.REACT_APP_BRAND}`; 45 | 46 | axios 47 | .get('/api/admin').then(res => { 48 | if (res.status === 200) this.setState({ 49 | loading: false, 50 | jobs: res.data.jobs, 51 | errors: res.data.errors, 52 | extractors: res.data.extractors, 53 | adminFiles: res.data.adminFiles, 54 | youtubeDlPath: res.data.youtubeDlPath, 55 | youtubeDlVersion: res.data.youtubeDlVersion, 56 | defaultActivejobId: res.data.jobs[0]?._id, 57 | consoleOutput: res.data.consoleOutput, 58 | historyUpdated: res.data.historyUpdated, 59 | apiKeys: res.data.apiKeys, 60 | currentUserId: res.data.currentUserId, 61 | }); 62 | }).catch(err => { 63 | this.setState({ error: err }); 64 | }); 65 | } 66 | 67 | render() { 68 | return ( 69 | 73 | {!this.state.loading && 74 |
78 |

Admin

79 | {parsedEnv.REACT_APP_CHECK_FOR_UPDATES && } 80 | 81 | this.setState({ youtubeDlVersion: version })} 85 | /> 86 | this.setState({ youtubeDlVersion: version })} 89 | /> 90 | this.setState({ jobs })} /> 91 | 92 | 93 | 94 | 95 | 96 | this.setState({ apiKeys })} currentUserId={this.state.currentUserId} /> 97 | 98 |
99 | } 100 |
101 | ); 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /youtube-dl-react-frontend/src/components/Admin/Admin.scss: -------------------------------------------------------------------------------- 1 | .tab-constrained { 2 | max-width: 160px; 3 | text-overflow: ellipsis; 4 | white-space: nowrap; 5 | overflow: hidden; 6 | } 7 | -------------------------------------------------------------------------------- /youtube-dl-react-frontend/src/components/Admin/ChannelIconDownloader/ChannelIconDownloader.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import { Button, Card, Alert } from 'react-bootstrap'; 3 | import { getErrorMessage } from '../../../utilities/format.utility'; 4 | import { UserContext } from '../../../contexts/user.context'; 5 | import axios from '../../../utilities/axios.utility'; 6 | import { scrollToElement } from '../../../utilities/scroll.utility'; 7 | import parsedEnv from '../../../parse-env'; 8 | 9 | class ChannelIconDownloader extends Component { 10 | static contextType = UserContext; 11 | 12 | constructor(props) { 13 | super(props); 14 | this.state = { 15 | success: undefined, 16 | error: undefined, 17 | }; 18 | this.post = this.post.bind(this); 19 | } 20 | 21 | post(force = false) { 22 | this.setState({ success: undefined, error: undefined }, () => { 23 | axios 24 | .post( 25 | '/api/admin/download_uploader_icons?force=' + force 26 | ).then(res => { 27 | if (res.status === 200) this.setState({ 28 | success: res.data.success, 29 | error: res.data.error 30 | }); 31 | }).catch(err => { 32 | this.setState({ error: getErrorMessage(err) }); 33 | }).finally(() => { 34 | scrollToElement('#uploader-icons-anchor'); 35 | }); 36 | }); 37 | } 38 | 39 | render() { 40 | return ( 41 | <> 42 |
Uploader icons
43 | 44 | 45 | Uploader icons are downloaded using the third-party service unavatar.io. The uploader icon downloader currently only supports YouTube and SoundCloud, other uploader icons must be added manually. Learn more 46 | {!!this.state.success && {this.state.success}} 47 | {!!this.state.error && {this.state.error}} 48 | 56 | 63 | 64 | 65 | 66 | ); 67 | } 68 | } 69 | 70 | export default ChannelIconDownloader; 71 | -------------------------------------------------------------------------------- /youtube-dl-react-frontend/src/components/Admin/HashVerifier/HashVerifier.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import { Button, Card, Alert } from 'react-bootstrap'; 3 | import { getErrorMessage } from '../../../utilities/format.utility'; 4 | import { UserContext } from '../../../contexts/user.context'; 5 | import axios from '../../../utilities/axios.utility'; 6 | import { scrollToElement } from '../../../utilities/scroll.utility'; 7 | 8 | class HashVerifier extends Component { 9 | static contextType = UserContext; 10 | 11 | constructor(props) { 12 | super(props); 13 | this.state = { 14 | success: undefined, 15 | error: undefined, 16 | }; 17 | } 18 | 19 | post(stop = false) { 20 | this.setState({ success: undefined, error: undefined }, () => { 21 | axios 22 | .post( 23 | `/api/admin/verify_hashes?stop=${stop.toString()}` 24 | ).then(res => { 25 | if (res.status === 200) this.setState({ 26 | success: res.data.success, 27 | error: res.data.error 28 | }); 29 | }).catch(err => { 30 | this.setState({ error: getErrorMessage(err) }); 31 | }).finally(() => { 32 | scrollToElement('#file-integrity-anchor'); 33 | }); 34 | }); 35 | } 36 | 37 | render() { 38 | return ( 39 | <> 40 |
File integrity
41 | 42 | 43 | {!!this.state.success && {this.state.success}} 44 | {!!this.state.error && {this.state.error}} 45 | 51 | 58 | 59 | 60 | 61 | ); 62 | } 63 | } 64 | 65 | export default HashVerifier; 66 | -------------------------------------------------------------------------------- /youtube-dl-react-frontend/src/components/Admin/JobDownloader/JobDownloader.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import { Form, Button, Alert, Card } from 'react-bootstrap'; 3 | import { getErrorMessage, getWarningColor } from '../../../utilities/format.utility'; 4 | import { UserContext } from '../../../contexts/user.context'; 5 | import axios from '../../../utilities/axios.utility'; 6 | import { scrollToElement } from '../../../utilities/scroll.utility'; 7 | 8 | class JobDownloader extends Component { 9 | static contextType = UserContext; 10 | 11 | constructor(props) { 12 | super(props); 13 | this.state = { 14 | success: undefined, 15 | error: undefined, 16 | selected: [], 17 | }; 18 | } 19 | 20 | handleInputChange = (e) => { 21 | const { name, options } = e.target; 22 | this.setState({ 23 | [name]: [...options] 24 | .filter(({ selected }) => selected) 25 | .map(({ value }) => value) 26 | }) 27 | } 28 | 29 | onSubmit = (e) => { 30 | e.preventDefault(); 31 | 32 | let formAction = e.target.name; 33 | this.setState({ success: undefined, error: undefined }, () => { 34 | if (formAction === 'download' && this.state.selected.length === 0) { 35 | scrollToElement('#download-anchor'); 36 | return this.setState({ error: 'Select one or more jobs' }); 37 | } 38 | 39 | axios 40 | .post( 41 | `/api/admin/jobs/${formAction}`, 42 | formAction === 'download' 43 | ? this.state.selected 44 | : undefined 45 | ).then(res => { 46 | if (res.status === 200) this.setState({ 47 | success: res.data.success, 48 | error: res.data.error 49 | }); 50 | if (res.data.youtubeDlVersion) this.props.onYoutubeDlVersionChange(res.data.youtubeDlVersion); 51 | }).catch(err => { 52 | this.setState({ error: getErrorMessage(err) }); 53 | }).finally(() => { 54 | scrollToElement('#download-anchor'); 55 | }); 56 | }); 57 | } 58 | 59 | render() { 60 | return ( 61 | <> 62 |
Download
63 | 64 | 65 | {this.props.jobs.length === 0 && You must create a job before you can download videos} 66 | {!!this.state.success && {this.state.success}} 67 | {!!this.state.error && {this.state.error}} 68 |
69 | 70 | 79 | {this.props.jobs.map(job => 80 | 87 | )} 88 | 89 | 90 | 99 | 108 |
109 |
110 |
111 | 112 | ); 113 | } 114 | } 115 | 116 | export default JobDownloader; 117 | -------------------------------------------------------------------------------- /youtube-dl-react-frontend/src/components/Admin/LogFileList/LogFileList.js: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef, useState } from 'react'; 2 | import { Button, Card, Form } from 'react-bootstrap'; 3 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; 4 | import { getErrorMessage } from '../../../utilities/format.utility'; 5 | import axios from '../../../utilities/axios.utility'; 6 | import { scrollToElement } from '../../../utilities/scroll.utility'; 7 | 8 | const LogFileList = (props) => { 9 | 10 | const consoleOutputRef = useRef(); 11 | const [adminFiles, setAdminFiles] = useState(props.adminFiles); 12 | const [consoleOutput, setConsoleOutput] = useState(props.consoleOutput); 13 | const [autoConsoleRefresh, setAutoConsoleRefresh] = useState(localStorage.getItem('autoConsoleRefresh') === null ? true : localStorage.getItem('autoConsoleRefresh') === 'true'); 14 | const intervalRef = useRef(); 15 | const historyUpdatedRef = useRef(props.historyUpdated); 16 | 17 | useEffect(() => { 18 | if (consoleOutputRef.current) consoleOutputRef.current.scrollTop = consoleOutputRef.current.scrollHeight; 19 | 20 | return () => { 21 | if (intervalRef.current) clearInterval(intervalRef.current); 22 | }; 23 | }, []); 24 | 25 | useEffect(() => { 26 | if (autoConsoleRefresh) { 27 | intervalRef.current = setInterval(() => { 28 | updateLogs(true); 29 | }, 1000); 30 | } else { 31 | if (intervalRef.current) clearInterval(intervalRef.current); 32 | } 33 | }, [autoConsoleRefresh]); 34 | 35 | const updateLogs = (auto = false) => { 36 | axios 37 | .get(`/api/admin/logs` 38 | ).then(res => { 39 | setAdminFiles(res.data.adminFiles); 40 | 41 | if (!auto || historyUpdatedRef.current < res.data.historyUpdated) { 42 | setConsoleOutput(res.data.consoleOutput); 43 | if (consoleOutputRef.current) consoleOutputRef.current.scrollTop = consoleOutputRef.current.scrollHeight; 44 | } 45 | historyUpdatedRef.current = res.data.historyUpdated; 46 | 47 | if (!auto) scrollToElement('#logs-anchor'); 48 | }).catch(err => { 49 | console.error(getErrorMessage(err)); 50 | }); 51 | }; 52 | 53 | return ( 54 | <> 55 |
Logs
56 | 57 | 58 | {adminFiles && adminFiles.length > 0 && adminFiles.map((file, i) => { 59 | return 60 | })} 61 | 62 | Console output 63 |
64 |
65 | {consoleOutput && consoleOutput.length > 0 && consoleOutput.map((line, i) => {line.msg})} 66 |
67 |
68 |
69 | 74 | { 81 | const checked = e.target.checked; 82 | setAutoConsoleRefresh(checked); 83 | localStorage.setItem('autoConsoleRefresh', checked.toString()); 84 | if (checked) updateLogs(); 85 | }} 86 | className="ms-2 mt-2" 87 | style={{ float: 'right' }} 88 | /> 89 |
90 |
91 | 92 | ) 93 | } 94 | 95 | export default LogFileList; 96 | -------------------------------------------------------------------------------- /youtube-dl-react-frontend/src/components/Admin/LogFileList/LogFileList.scss: -------------------------------------------------------------------------------- 1 | #logs { 2 | white-space: pre; 3 | font-family: monospace; 4 | border-radius: var(--bs-border-radius); 5 | border: var(--bs-border-width) solid var(--bs-border-color); 6 | overflow: hidden; 7 | background: #000; 8 | 9 | > div { 10 | overflow: auto; 11 | min-height: 300px; 12 | height: 300px; 13 | resize: vertical; 14 | padding: 0.375rem 0.75rem; 15 | 16 | .text-log { 17 | color: #ccc; 18 | } 19 | 20 | .text-secondary { 21 | color: #767676 !important; 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /youtube-dl-react-frontend/src/components/Admin/RecalcStatistics/RecalcStatistics.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { Button, Card, Alert } from 'react-bootstrap'; 3 | import { getErrorMessage } from '../../../utilities/format.utility'; 4 | import axios from '../../../utilities/axios.utility'; 5 | import { scrollToElement } from '../../../utilities/scroll.utility'; 6 | 7 | const RecalcStatistics = (props) => { 8 | const [success, setSuccess] = useState(undefined); 9 | const [error, setError] = useState(undefined); 10 | 11 | const post = (cancel = false) => { 12 | setSuccess(undefined); 13 | setError(undefined); 14 | axios 15 | .post( 16 | `/api/admin/statistics/recalculate/?cancel=${cancel}` 17 | ).then(res => { 18 | setSuccess(res.data.success); 19 | setError(res.data.error); 20 | }).catch(err => { 21 | setError(getErrorMessage(err)); 22 | }).finally(() => { 23 | scrollToElement('#recalculate-statistics-anchor'); 24 | }); 25 | } 26 | 27 | return ( 28 | <> 29 |
Recalculate statistics
30 | 31 | 32 | {!!success && {success}} 33 | {!!error && {error}} 34 | 42 | 50 | 51 | 52 | 53 | ); 54 | } 55 | 56 | export default RecalcStatistics; 57 | -------------------------------------------------------------------------------- /youtube-dl-react-frontend/src/components/Admin/UpdateChecker/UpdateChecker.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import { Alert } from 'react-bootstrap'; 3 | import axios from '../../../utilities/axios.utility'; 4 | import parsedEnv from '../../../parse-env'; 5 | 6 | class UpdateChecker extends Component { 7 | constructor(props) { 8 | super(props); 9 | this.state = { 10 | message: undefined, 11 | variant: undefined, 12 | } 13 | } 14 | 15 | componentDidMount() { 16 | axios.get(parsedEnv.REACT_APP_GITHUB_API_URL, { 17 | headers: { 18 | Accept: 'application/vnd.github.v3+json', 19 | } 20 | }).then(res => { 21 | if (res.status === 200) { 22 | if (res?.data?.tag_name) { 23 | if (this.getVersionScore(res.data.tag_name) > this.getVersionScore(window.appVersion)) { 24 | this.setState({ 25 | message: <>A new version of {parsedEnv.REACT_APP_REPO_NAME} is available 30 | ({res.data.tag_name.slice(1)} > {window.appVersion}), 31 | variant: 'info', 32 | }); 33 | } else if (this.getVersionScore(res.data.tag_name) === this.getVersionScore(window.appVersion)) { 34 | this.setState({ message: `You are using the latest version of ${parsedEnv.REACT_APP_REPO_NAME} (${window.appVersion})`, variant: 'success' }); 35 | } else { 36 | this.setState({ 37 | message: `You are using a development version of ${parsedEnv.REACT_APP_REPO_NAME} (${window.appVersion})`, variant: 'warning' 38 | }); 39 | } 40 | } else { 41 | this.setState({ message: `Failed to check for updates to ${parsedEnv.REACT_APP_REPO_NAME}`, variant: 'danger' }); 42 | } 43 | } 44 | }).catch(err => { 45 | console.error(err); 46 | this.setState({ message: `Failed to check for updates to ${parsedEnv.REACT_APP_REPO_NAME}`, variant: 'danger' }); 47 | }); 48 | } 49 | 50 | getVersionScore = (tagName) => { 51 | if (tagName.startsWith('v')) tagName = tagName.slice(1); 52 | let versionNumbers = tagName.split('.').reverse(); 53 | let score = 0; 54 | let scale = 1; 55 | for (let i = 0; i < versionNumbers.length; i++) { 56 | score += parseInt(versionNumbers[i]) * scale; 57 | scale *= 100; 58 | } 59 | return score; 60 | } 61 | 62 | render() { 63 | return ( 64 | this.state.message ? {this.state.message} : <> 65 | ); 66 | } 67 | } 68 | 69 | export default UpdateChecker; 70 | -------------------------------------------------------------------------------- /youtube-dl-react-frontend/src/components/Admin/YtdlUpdater/YtdlUpdater.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import { Button, Card, Alert } from 'react-bootstrap'; 3 | import { getErrorMessage } from '../../../utilities/format.utility'; 4 | import { UserContext } from '../../../contexts/user.context'; 5 | import axios from '../../../utilities/axios.utility'; 6 | import { scrollToElement } from '../../../utilities/scroll.utility'; 7 | 8 | class YtdlUpdater extends Component { 9 | static contextType = UserContext; 10 | 11 | constructor(props) { 12 | super(props); 13 | this.state = { 14 | success: undefined, 15 | error: undefined, 16 | }; 17 | } 18 | 19 | componentDidMount() { 20 | if (!this.props.youtubeDlPath) { 21 | this.setState({ error: 'Environment variable YOUTUBE_DL_PATH is not set' }); 22 | } 23 | } 24 | 25 | post() { 26 | this.setState({ success: undefined, error: undefined }, () => { 27 | axios 28 | .post( 29 | `/api/admin/youtube-dl/update` 30 | ).then(res => { 31 | if (res.status === 200) { 32 | this.setState({ 33 | success: res.data.success, 34 | error: res.data.error 35 | }); 36 | if (res.data.youtubeDlVersion) this.props.onYoutubeDlVersionChange(res.data.youtubeDlVersion); 37 | } 38 | }).catch(err => { 39 | this.setState({ error: getErrorMessage(err) }); 40 | }).finally(() => { 41 | scrollToElement('#youtube-dl-anchor'); 42 | }); 43 | }); 44 | } 45 | 46 | render() { 47 | return ( 48 | <> 49 |
yt-dlp
50 | 51 | 52 | {!!this.state.success && {this.state.success}} 53 | {!!this.state.error && {this.state.error}} 54 |

Using: {this.props.youtubeDlPath.replaceAll('\\', '/').split('/').pop()} {this.props.youtubeDlVersion}

55 | 63 |
64 |
65 | 66 | ); 67 | } 68 | } 69 | 70 | export default YtdlUpdater; 71 | -------------------------------------------------------------------------------- /youtube-dl-react-frontend/src/components/AdvancedSearchButton/AdvancedSearchButton.js: -------------------------------------------------------------------------------- 1 | import { useContext } from 'react'; 2 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; 3 | import { AdvancedSearchContext } from '../../contexts/advancedsearch.context'; 4 | import { Button } from 'react-bootstrap'; 5 | 6 | const AdvancedSearchButton = props => { 7 | const advancedSearchContext = useContext(AdvancedSearchContext); 8 | 9 | return ( 10 | 21 | ); 22 | } 23 | 24 | export default AdvancedSearchButton; 25 | -------------------------------------------------------------------------------- /youtube-dl-react-frontend/src/components/AlertModal/AlertModal.js: -------------------------------------------------------------------------------- 1 | import { Modal, Button } from 'react-bootstrap'; 2 | 3 | const AlertModal = ({ show, onHide, title, message, confirmText }) => { 4 | return ( 5 | 6 | 7 | {title || 'Alert'} 8 | 9 | 10 | {message} 11 | 12 | 13 | 16 | 17 | 18 | ); 19 | }; 20 | 21 | export default AlertModal; 22 | -------------------------------------------------------------------------------- /youtube-dl-react-frontend/src/components/ConfirmModal/ConfirmModal.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import { Modal, Button, Form } from 'react-bootstrap'; 3 | 4 | const ConfirmModal = ({ show, onHide, onConfirm, title, message, checkboxText, confirmText, cancelText }) => { 5 | const [checked, setChecked] = useState(false); 6 | 7 | useEffect(() => { 8 | setChecked(false); 9 | }, [show]); 10 | 11 | return ( 12 | 13 | 14 | {title || 'Confirm'} 15 | 16 | 17 | {message || (checkboxText ? '' : 'Are you sure?')} 18 | {checkboxText && 19 | setChecked(e.target.checked)} 25 | /> 26 | } 27 | 28 | 29 | 30 | 33 | 36 | 37 | 38 | ); 39 | }; 40 | 41 | export default ConfirmModal; 42 | -------------------------------------------------------------------------------- /youtube-dl-react-frontend/src/components/Error/Error.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const ErrorPage = props => { 4 | let err = props.error; 5 | if (err.response) { 6 | if (err.response.data.hasOwnProperty('error')) { 7 | err = `${err.response.status} - ${err.response.statusText}: ${err.response.data.error}`; 8 | } else { 9 | err = `${err.response.status} - ${err.response.statusText}`; 10 | } 11 | } else { 12 | if (err.message) err = err.message; 13 | } 14 | 15 | return

{err ? err : 'Unknown Error'}

; 16 | } 17 | 18 | export default ErrorPage; 19 | -------------------------------------------------------------------------------- /youtube-dl-react-frontend/src/components/GlobalPasswordForm/GlobalPasswordForm.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import { Card, Form, Alert, Button } from 'react-bootstrap'; 3 | import { UserContext } from '../../contexts/user.context'; 4 | import AuthService from '../../services/auth.service'; 5 | import { getErrorMessage } from '../../utilities/format.utility'; 6 | 7 | export default class GlobalPasswordForm extends Component { 8 | static contextType = UserContext; 9 | 10 | constructor(props) { 11 | super(props) 12 | this.state = { 13 | globalPassword: '', 14 | error: undefined, 15 | }; 16 | } 17 | 18 | handleInputChange = (e) => { 19 | const { value, name } = e.target; 20 | this.setState({ [name]: value }); 21 | } 22 | 23 | onSubmit = (e) => { 24 | e.preventDefault(); 25 | 26 | AuthService.globalLogin(this.state.globalPassword).catch(err => { 27 | this.setState({ error: getErrorMessage(err) }) 28 | }); 29 | } 30 | 31 | render() { 32 | return ( 33 | <> 34 | Global Site Password 35 | {!!this.state.error && 36 | {this.state.error} 37 | } 38 |
39 | 40 | Password 41 | 49 | 50 | 51 |
52 | 53 | ); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /youtube-dl-react-frontend/src/components/ImportSubscriptionsButton/ImportSubscriptionsButton.js: -------------------------------------------------------------------------------- 1 | import React, { useRef, useState } from 'react'; 2 | import { Dropdown, Alert } from 'react-bootstrap'; 3 | 4 | const ImportSubscriptionsButton = ({ emit, className }) => { 5 | const fileInputRef = useRef(null); 6 | const parseMethod = useRef(null); 7 | const [errorMessage, setErrorMessage] = useState(null); 8 | 9 | const handleSelect = (method) => { 10 | parseMethod.current = method; 11 | switch (method) { 12 | case 'youtube_subscriptions_csv': 13 | fileInputRef.current.accept = '.csv'; 14 | break; 15 | case 'newpipe_subscriptions_json': 16 | fileInputRef.current.accept = '.json'; 17 | break; 18 | default: 19 | fileInputRef.current.accept = '*'; 20 | } 21 | fileInputRef.current.click(); 22 | }; 23 | 24 | const handleFileChange = (event) => { 25 | const file = event.target.files[0]; 26 | if (!file) return; 27 | 28 | const reader = new FileReader(); 29 | reader.onload = (e) => { 30 | const content = e.target.result; 31 | if (parseMethod.current === 'youtube_subscriptions_csv') { 32 | parseYoutubeSubscriptionsCsv(content); 33 | } else if (parseMethod.current === 'newpipe_subscriptions_json') { 34 | ParseNewpipeSubscriptionsJson(content); 35 | } 36 | }; 37 | 38 | reader.readAsText(file); 39 | }; 40 | 41 | const parseYoutubeSubscriptionsCsv = (content) => { 42 | try { 43 | let rows = content.split('\n') 44 | let header = rows.shift().split(','); 45 | if (header[1] !== 'Channel Url' || header[2] !== 'Channel Title') throw new Error('Wrong headers'); 46 | let subscriptionsText = ''; 47 | for (let row of rows) { 48 | if (!row) continue; 49 | row = row.split(','); 50 | subscriptionsText += `# ${row[2]}\n${row[1].replace('http://', 'https://')}\n\n`; 51 | } 52 | 53 | setErrorMessage(null); 54 | emit(subscriptionsText.replace(/\n*$/, '\n')); 55 | } catch (err) { 56 | console.error(err); 57 | setErrorMessage('Invalid YouTube subscriptions.csv'); 58 | } 59 | }; 60 | 61 | const ParseNewpipeSubscriptionsJson = (content) => { 62 | try { 63 | const parsed = JSON.parse(content); 64 | 65 | if (!parsed.hasOwnProperty('subscriptions') || !Array.isArray(parsed.subscriptions)) throw new Error('Missing subscriptions property'); 66 | 67 | let subscriptionsText = ''; 68 | for (let row of parsed.subscriptions) { 69 | if (!row.hasOwnProperty('url') || !row.hasOwnProperty('name')) throw new Error('Missing row property'); 70 | subscriptionsText += `# ${row.name}\n${row.url}\n\n`; 71 | } 72 | 73 | setErrorMessage(null); 74 | emit(subscriptionsText.replace(/\n*$/, '\n')); 75 | } catch (err) { 76 | console.error(err); 77 | setErrorMessage('Invalid NewPipe subscriptions.json'); 78 | } 79 | }; 80 | 81 | return ( 82 |
83 | {errorMessage && {errorMessage}} 84 | 85 | Import subscriptions 86 | 87 | handleSelect('youtube_subscriptions_csv')}> 88 | YouTube subscriptions.csv 89 | 90 | handleSelect('newpipe_subscriptions_json')}> 91 | NewPipe subscriptions.json 92 | 93 | 94 | 95 | 96 | 103 |
104 | ); 105 | }; 106 | 107 | export default ImportSubscriptionsButton; 108 | -------------------------------------------------------------------------------- /youtube-dl-react-frontend/src/components/Job/Job.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import { Row, Col, Image } from 'react-bootstrap'; 3 | import PageLoadWrapper from '../PageLoadWrapper/PageLoadWrapper'; 4 | import VideoList from '../VideoList/VideoList'; 5 | import { UserContext } from '../../contexts/user.context'; 6 | import { 7 | dateToTimeSinceString, 8 | abbreviateNumber 9 | } from '../../utilities/format.utility'; 10 | import { getImage, defaultImage } from '../../utilities/image.utility'; 11 | import axios from '../../utilities/axios.utility'; 12 | import parsedEnv from '../../parse-env'; 13 | 14 | export default class Job extends Component { 15 | static contextType = UserContext; 16 | 17 | constructor(props) { 18 | super(props); 19 | this.state = { 20 | loading: true, 21 | error: undefined, 22 | job: undefined, 23 | }; 24 | } 25 | 26 | componentDidMount() { 27 | axios 28 | .get(`/api/jobs/${this.props.match.params._id}`) 29 | .then(res => { 30 | if (res.status === 200) { 31 | this.setState({ 32 | loading: false, 33 | job: res.data.job, 34 | }, () => { 35 | document.title = `${this.state.job.name} - ${parsedEnv.REACT_APP_BRAND}`; 36 | }); 37 | } 38 | }).catch(err => { 39 | this.setState({ error: err }); 40 | }); 41 | } 42 | 43 | render() { 44 | const job = this.state.job; 45 | const statistics = job?.statistics; 46 | const name = job?.name; 47 | const video = statistics?.newestVideo; 48 | 49 | return ( 50 | 54 | {!this.state.loading && <> 55 | 56 | 60 |
61 |
64 | defaultImage(e, 'thumbnail')} 67 | style={{ borderRadius: this.context.getSetting('useCircularAvatars') ? '0.5rem' : 0 }} 68 | /> 69 |
70 |

{name}

71 |

72 | 73 | {abbreviateNumber(statistics.totalVideoCount)} video{statistics.totalVideoCount !== 1 && 's'} 74 | 75 | <> · 76 | 77 | {abbreviateNumber(statistics.totalViewCount)} view{statistics.totalViewCount !== 1 && 's'} 78 | 79 | <> · 80 | 81 | Updated {dateToTimeSinceString(new Date(statistics.newestVideoDateUploaded))} 82 | 83 |

84 |
85 | 86 | 87 | 95 | 96 |
97 | } 98 |
99 | ); 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /youtube-dl-react-frontend/src/components/LoginForm/LoginForm.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import { Card, Form, Alert, Button } from 'react-bootstrap'; 3 | import { UserContext } from '../../contexts/user.context'; 4 | import AuthService from '../../services/auth.service'; 5 | import { getErrorMessage } from '../../utilities/format.utility'; 6 | import history from '../../utilities/history.utility'; 7 | import queryString from 'query-string'; 8 | import { Link } from 'react-router-dom'; 9 | 10 | export default class LoginForm extends Component { 11 | static contextType = UserContext; 12 | 13 | constructor(props) { 14 | super(props); 15 | this.state = { 16 | username: '', 17 | password: '', 18 | error: undefined, 19 | newuser: false, 20 | }; 21 | } 22 | 23 | componentDidMount() { 24 | if (!this.props.location) return; // Will be undefined when the form is used in the dropdown 25 | let parsed = queryString.parse(this.props.location.search); 26 | if (parsed.newuser) this.setState({ username: parsed.newuser, newuser: parsed.newuser }); 27 | } 28 | 29 | handleInputChange = (e) => { 30 | let { value, name } = e.target; 31 | if (this.props.inDropdown) name = name.slice(0, -'_dropdown'.length) 32 | this.setState({ [name]: value }); 33 | } 34 | 35 | onSubmit = (e) => { 36 | e.preventDefault(); 37 | 38 | AuthService.login(this.state.username, this.state.password).then(() => { 39 | this.context.updateUser(AuthService.getCurrentUser()); 40 | document.dispatchEvent(new MouseEvent('click')); 41 | window.location.pathname === '/login' ? history.push('/') : history.go(0); 42 | }).catch(err => { 43 | this.setState({ error: getErrorMessage(err), newuser: false }) 44 | }); 45 | } 46 | 47 | render() { 48 | return ( 49 | <> 50 | Login 51 | {!!this.state.newuser && 52 | User registered, please login 53 | } 54 | {!!this.state.error && 55 | {this.state.error} 56 | } 57 |
58 | 59 | Username 60 | 69 | 70 | 71 | Password 72 | 80 | 81 | 82 | {!this.props.inDropdown && 83 | 91 | } 92 |
93 | 94 | ); 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /youtube-dl-react-frontend/src/components/Logout/Logout.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import { Redirect } from 'react-router-dom'; 3 | import PageLoadWrapper from '../PageLoadWrapper/PageLoadWrapper'; 4 | import { UserContext } from '../../contexts/user.context'; 5 | import axios from '../../utilities/axios.utility'; 6 | 7 | export default class Logout extends Component { 8 | static contextType = UserContext; 9 | constructor(props) { 10 | super(props); 11 | this.state = { 12 | loading: true, 13 | error: undefined, 14 | redirectTo: undefined, 15 | }; 16 | } 17 | 18 | componentDidMount() { 19 | document.dispatchEvent(new MouseEvent('click')); 20 | 21 | axios.post('/api/auth/logout').then(res => { 22 | localStorage.removeItem('authenticated'); 23 | localStorage.removeItem('user'); 24 | 25 | this.context.updateUser(); 26 | this.setState({ 27 | loading: false, 28 | redirectTo: res.data.hasGlobalPassword ? '/global' : '/' 29 | }) 30 | }).catch(err => { 31 | this.setState({ error: err }); 32 | }); 33 | } 34 | 35 | render() { 36 | return ( 37 | 41 | {!this.state.loading && } 42 | 43 | ); 44 | } 45 | 46 | } 47 | -------------------------------------------------------------------------------- /youtube-dl-react-frontend/src/components/MiniStatisticsColumn/MiniStatisticsColumn.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Col } from 'react-bootstrap'; 3 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; 4 | 5 | const MiniStatisticColumn = props => { 6 | return ( 7 | 8 | {props.title} 9 | 10 | {props.statistic} 11 | 12 | 13 | ); 14 | } 15 | 16 | export default MiniStatisticColumn; 17 | -------------------------------------------------------------------------------- /youtube-dl-react-frontend/src/components/Navbar/AdvancedSearchModal/AdvancedSearchModal.scss: -------------------------------------------------------------------------------- 1 | #advanced-search-form, 2 | #mass-delete-form { 3 | gap: 1rem 0.5rem; 4 | 5 | .input-group { 6 | display: flex; 7 | align-items: center; 8 | margin-bottom: 0 !important; 9 | 10 | &.half { 11 | width: calc(50% - 0.25rem); 12 | } 13 | 14 | .input-group-text svg { 15 | margin-right: 0.25rem; 16 | } 17 | 18 | label { 19 | margin-bottom: 0; 20 | margin-right: 0.5rem; 21 | white-space: nowrap; 22 | } 23 | } 24 | } 25 | 26 | @media screen and (max-width: 991px) { 27 | #advanced-search-form, 28 | #mass-delete-form { 29 | .input-group.half { 30 | width: 100%; 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /youtube-dl-react-frontend/src/components/Navbar/Navbar.scss: -------------------------------------------------------------------------------- 1 | .navbar { 2 | display: flex; 3 | flex-direction: column; 4 | justify-content: center; 5 | 6 | // All buttons 7 | .btn.btn-link { 8 | color: var(--bs-nav-link-color) !important; 9 | 10 | &:hover { 11 | color: var(--bs-nav-link-hover-color) !important; 12 | } 13 | } 14 | 15 | // All links 16 | .nav-link.active { 17 | position: relative; 18 | 19 | &::before { 20 | content: ""; 21 | position: absolute; 22 | left: 0; 23 | right: 0; 24 | bottom: -0.5rem; 25 | height: 0.25rem; 26 | background: $logo-red; 27 | background: linear-gradient(to right, $logo-blue, $logo-red); 28 | } 29 | } 30 | 31 | // Version tag 32 | .version-tag { 33 | align-items: center; 34 | .badge { 35 | background: $white !important; 36 | color: var(--bs-body-color) !important; 37 | } 38 | } 39 | 40 | // Disable gradient effect 41 | .no-gradient .nav-link.active::before { 42 | background: $logo-red !important; 43 | } 44 | 45 | // Style nav sections 46 | .nav-segment { 47 | width: 33.333%; 48 | } 49 | 50 | @media only screen and (max-width: 1599px) { 51 | .nav-segment { 52 | width: auto; 53 | } 54 | } 55 | 56 | #desktop-nav { 57 | .navbar-brand { 58 | padding-left: calc(36px + 0.5rem); 59 | position: relative; 60 | 61 | @media only screen and (max-width: 349px) { 62 | .brand-text { 63 | display: none; 64 | } 65 | } 66 | 67 | .brand-image { 68 | left: 0; 69 | position: absolute; 70 | top: 50%; 71 | transform: translateY(-50%); 72 | } 73 | } 74 | .nav-item { 75 | white-space: nowrap; 76 | min-width: 3rem; 77 | text-align: center; 78 | } 79 | } 80 | 81 | #search-nav { 82 | justify-content: center; 83 | min-width: 0; 84 | flex-shrink: 1; 85 | 86 | form { 87 | min-width: 0; 88 | flex-shrink: 1; 89 | 90 | .form-control { 91 | min-width: 0; 92 | } 93 | } 94 | } 95 | 96 | #mobile-nav { 97 | .nav-item, 98 | .dropdown, 99 | button { 100 | flex: 1; 101 | text-align: center; 102 | } 103 | 104 | button { 105 | padding: 0; 106 | border-radius: 0; 107 | background: transparent; 108 | border: 0; 109 | color: var(--bs-nav-link-color) !important; 110 | 111 | &:hover { 112 | color: var(--bs-nav-link-hover-color) !important; 113 | } 114 | } 115 | } 116 | 117 | #account-nav { 118 | justify-content: end; 119 | 120 | @media only screen and (max-width: 991px) { 121 | min-width: 0; 122 | flex-shrink: 1; 123 | 124 | .nav-link.active::before { 125 | bottom: 0 !important; 126 | } 127 | } 128 | 129 | #user-dropdown { 130 | padding: 0; 131 | 132 | @media only screen and (max-width: 419px) { 133 | .username { 134 | display: none; 135 | } 136 | 137 | img { 138 | margin-left: 0 !important; 139 | } 140 | } 141 | } 142 | 143 | .dropdown { 144 | min-width: 0; 145 | 146 | .dropdown-toggle.nav-link { 147 | display: flex; 148 | align-items: center; 149 | 150 | span { 151 | max-width: 132px; 152 | overflow: hidden; 153 | display: inline-block; 154 | text-overflow: ellipsis; 155 | } 156 | } 157 | } 158 | } 159 | 160 | // Display mobile nav edge to edge 161 | @media only screen and (max-width: 991px) { 162 | .container { 163 | max-width: unset; 164 | } 165 | } 166 | } 167 | 168 | // Dark theme overrides 169 | [data-bs-theme="dark"] { 170 | .version-tag .badge { 171 | background: $black !important; 172 | } 173 | } 174 | -------------------------------------------------------------------------------- /youtube-dl-react-frontend/src/components/Navbar/ThemeController/ThemeController.js: -------------------------------------------------------------------------------- 1 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; 2 | import { Dropdown } from "react-bootstrap"; 3 | 4 | export default function ThemeController(props) { 5 | const { theme, onThemeChange } = props; 6 | 7 | let icon = 'circle-half-stroke'; 8 | if (theme === 'light') icon = 'sun'; 9 | if (theme === 'dark') icon = 'moon'; 10 | 11 | return ( 12 | 13 | 14 | 15 | 16 | 17 | 18 | onThemeChange('light')}> Light 19 | onThemeChange('dark')}> Dark 20 | onThemeChange('auto')}> Auto 21 | 22 | 23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /youtube-dl-react-frontend/src/components/Navbar/ThemeController/ThemeController.scss: -------------------------------------------------------------------------------- 1 | .theme-controller.dropdown-toggle { 2 | padding: 0.333rem; 3 | border: 0; 4 | background: transparent !important; 5 | width: 40px; 6 | text-align: right; 7 | color: var(--bs-nav-link-color) !important; 8 | 9 | &:hover, 10 | &.show { 11 | color: var(--bs-nav-link-hover-color) !important; 12 | } 13 | } 14 | 15 | #mobile-nav .theme-controller { 16 | width: 100%; 17 | height: 100%; 18 | padding: 0; 19 | text-align: center; 20 | border-radius: 0; 21 | } 22 | -------------------------------------------------------------------------------- /youtube-dl-react-frontend/src/components/Page/Page.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const Page = props => { 4 | return ( 5 |
9 | {!!props.title &&

{props.title}

} 10 |
11 |
12 | {props.children} 13 |
14 |
15 |
16 | ); 17 | } 18 | 19 | export default Page; 20 | -------------------------------------------------------------------------------- /youtube-dl-react-frontend/src/components/PageLoadWrapper/PageLoadWrapper.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Spinner } from 'react-bootstrap'; 3 | import ErrorPage from '../Error/Error'; 4 | 5 | const PageLoadWrapper = props => { 6 | if (props.loading) { 7 | if (props.error) { 8 | return ; 9 | } else { 10 | return ( 11 |
12 | 13 |
14 | ); 15 | } 16 | } else { 17 | return props.children; 18 | } 19 | } 20 | 21 | export default PageLoadWrapper; 22 | -------------------------------------------------------------------------------- /youtube-dl-react-frontend/src/components/Pageinator/Pageinator.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import { LinkContainer } from 'react-router-bootstrap'; 3 | import { Pagination } from 'react-bootstrap'; 4 | 5 | export default class Paginator extends Component { 6 | componentDidUpdate() { 7 | window.scrollTo(0, 0); 8 | } 9 | 10 | render() { 11 | const page = parseInt(this.props.page) || 1; 12 | let items = []; 13 | const range = 2; 14 | let start = page - range; 15 | let end = page + range; 16 | 17 | if (start <= 0) { 18 | end = end - start + 1; 19 | start = 1; 20 | } 21 | if (end > this.props.maxPages) { 22 | end = this.props.maxPages; 23 | start = Math.max(end - (range * 2), 1); 24 | } 25 | 26 | items.push( 27 | 28 | 29 | 30 | ); 31 | items.push( 32 | 33 | 34 | 35 | ); 36 | for (let i = start; i <= end; i++) { 37 | items.push( 38 | 39 | 40 | {i} 41 | 42 | 43 | ); 44 | } 45 | items.push( 46 | 47 | 48 | 49 | ); 50 | items.push( 51 | 52 | 53 | 54 | ); 55 | 56 | if (this.props.bottom) { 57 | return ( 58 | <> 59 | {items} 60 |

61 | Page {page} of {this.props.maxPages} 62 |

63 | 64 | ); 65 | } else { 66 | return ( 67 | <> 68 |

69 | Page {page} of {this.props.maxPages} 70 |

71 | {items} 72 | 73 | ); 74 | } 75 | 76 | } 77 | } -------------------------------------------------------------------------------- /youtube-dl-react-frontend/src/components/Playlist/Playlist.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import { Row, Col, Image } from 'react-bootstrap'; 3 | import PageLoadWrapper from '../PageLoadWrapper/PageLoadWrapper'; 4 | import VideoList from '../VideoList/VideoList'; 5 | import { UserContext } from '../../contexts/user.context'; 6 | import { 7 | dateToTimeSinceString, 8 | abbreviateNumber 9 | } from '../../utilities/format.utility'; 10 | import { getImage, defaultImage } from '../../utilities/image.utility'; 11 | import axios from '../../utilities/axios.utility'; 12 | import Description from '../Video/Description/Description'; 13 | import parsedEnv from '../../parse-env'; 14 | 15 | export default class Playlist extends Component { 16 | static contextType = UserContext; 17 | 18 | constructor(props) { 19 | super(props); 20 | this.state = { 21 | loading: true, 22 | error: undefined, 23 | playlist: undefined, 24 | }; 25 | } 26 | 27 | componentDidMount() { 28 | axios 29 | .get(`/api/playlists/${this.props.match.params.extractor}/${this.props.match.params.id}`) 30 | .then(res => { 31 | if (res.status === 200) { 32 | this.setState({ 33 | loading: false, 34 | playlist: res.data.playlist, 35 | }); 36 | document.title = `${res.data.playlist.name} - ${parsedEnv.REACT_APP_BRAND}`; 37 | } 38 | }).catch(err => { 39 | this.setState({ error: err }); 40 | }); 41 | } 42 | 43 | render() { 44 | const playlist = this.state.playlist; 45 | const statistics = playlist?.statistics; 46 | const uploaderName = playlist?.uploaderName; 47 | const video = statistics?.newestVideo; 48 | 49 | return ( 50 | 54 | {!this.state.loading && <> 55 | 56 | 60 |
61 |
64 | defaultImage(e, 'thumbnail')} 67 | style={{borderRadius: this.context.getSetting('useCircularAvatars') ? '0.5rem' : 0}} 68 | /> 69 |
70 |

{playlist.name}

71 |

72 | 73 | {abbreviateNumber(statistics.totalVideoCount)} video{statistics.totalVideoCount !== 1 && 's'} 74 | 75 | <> · 76 | 77 | {abbreviateNumber(statistics.totalViewCount)} view{statistics.totalViewCount !== 1 && 's'} 78 | 79 | <> · 80 | 81 | Updated {dateToTimeSinceString(new Date(statistics.newestVideoDateUploaded))} 82 | 83 |

84 | {(!!uploaderName || playlist.description) &&
} 85 | {!!uploaderName &&

{uploaderName}

} 86 | {playlist.description && } 87 |
88 | 89 | 90 | 98 | 99 |
100 | } 101 |
102 | ); 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /youtube-dl-react-frontend/src/components/Playlist/Playlist.scss: -------------------------------------------------------------------------------- 1 | .playlist-header { 2 | background: linear-gradient(rgba(var(--bs-light-rgb), 1), transparent); 3 | padding: 1rem; 4 | border-radius: 1rem; 5 | 6 | position: sticky; 7 | top: calc(56px + 1.5rem); 8 | 9 | .playlist-image { 10 | width: 100%; 11 | padding-bottom: 56.25%; 12 | overflow: hidden; 13 | position: relative; 14 | 15 | img { 16 | position: absolute; 17 | top: 0; 18 | left: 0; 19 | width: 100% !important; 20 | height: 100%; 21 | object-fit: contain; 22 | } 23 | } 24 | } 25 | 26 | @media screen and (min-width: 992px) { 27 | .playlist-column { 28 | max-width: 400px; 29 | 30 | .playlist-header { 31 | height: 80vh; 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /youtube-dl-react-frontend/src/components/RegisterForm/RegisterForm.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import { Card, Form, Alert, Button } from 'react-bootstrap'; 3 | import AuthService from '../../services/auth.service'; 4 | import { getErrorMessage } from '../../utilities/format.utility'; 5 | import { Redirect } from 'react-router-dom'; 6 | 7 | export default class LoginForm extends Component { 8 | constructor(props) { 9 | super(props) 10 | this.state = { 11 | username: '', 12 | password: '', 13 | verifyPassword: '', 14 | error: undefined, 15 | success: undefined, 16 | }; 17 | } 18 | 19 | handleInputChange = (e) => { 20 | const { value, name } = e.target; 21 | this.setState({ [name]: value }); 22 | } 23 | 24 | onSubmit = (e) => { 25 | e.preventDefault(); 26 | 27 | this.setState({ success: undefined }, () => { 28 | if (this.state.password !== this.state.verifyPassword) { 29 | return this.setState({ error: 'Passwords do not match' }); 30 | } 31 | if (this.state.password.length < 8) { 32 | return this.setState({ error: 'Password must be at least 8 characters' }); 33 | } 34 | 35 | AuthService.register(this.state.username, this.state.password).then(() => { 36 | this.setState({ 37 | success: true, 38 | error: undefined 39 | }); 40 | }).catch(err => { 41 | this.setState({ error: getErrorMessage(err) }); 42 | }); 43 | }); 44 | } 45 | 46 | render() { 47 | return ( 48 | this.state.success 49 | ? 50 | : <> 51 | Register 52 | {!!this.state.error && 53 | {this.state.error} 54 | } 55 | 56 |
57 | 58 | Username 59 | 68 | 69 | 70 | Password 71 | 79 | 80 | 81 | 89 | 90 | 91 |
92 |
93 | 94 | ); 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /youtube-dl-react-frontend/src/components/Settings/AvatarForm/AvatarForm.js: -------------------------------------------------------------------------------- 1 | import React, { useContext } from 'react'; 2 | import { Image, Form } from 'react-bootstrap'; 3 | import { defaultImage } from '../../../utilities/image.utility'; 4 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; 5 | import { UserContext } from '../../../contexts/user.context'; 6 | 7 | const AvatarForm = (props) => { 8 | const userContext = useContext(UserContext); 9 | 10 | return ( 11 | <> 12 | {props.label && 13 |
14 | 15 | {props.label} 16 | 17 |
18 | } 19 | 20 | { defaultImage(e, 'avatar') }} 25 | roundedCircle={userContext.getSetting('useCircularAvatars')} 26 | /> 27 | 33 |
34 | 35 | 36 | 37 |
38 | 39 | ); 40 | } 41 | 42 | export default AvatarForm; 43 | -------------------------------------------------------------------------------- /youtube-dl-react-frontend/src/components/Settings/AvatarForm/AvatarForm.scss: -------------------------------------------------------------------------------- 1 | .avatar-form-group { 2 | position: relative; 3 | display: inline-block; 4 | 5 | .form-control, 6 | .avatar-focus-outline, 7 | .avatar-edit-overlay { 8 | position: absolute; 9 | top: 0; 10 | left: 0; 11 | bottom: 0; 12 | right: 0; 13 | } 14 | 15 | .form-control { 16 | opacity: 0; 17 | } 18 | 19 | .avatar-focus-outline { 20 | pointer-events: none; 21 | transition: box-shadow 0.15s ease-in-out; 22 | } 23 | .form-control:focus + .avatar-focus-outline { 24 | --bs-btn-focus-shadow-rgb: 49, 132, 253; 25 | --bs-btn-focus-box-shadow: 0 0 0 0.25rem rgba(var(--bs-btn-focus-shadow-rgb), 0.5); 26 | box-shadow: var(--bs-btn-focus-box-shadow); 27 | } 28 | 29 | .avatar-edit-overlay { 30 | display: flex; 31 | align-items: center; 32 | justify-content: center; 33 | opacity: 0; 34 | background: rgba(0, 0, 0, 0.66); 35 | transition: 0.333s ease opacity; 36 | margin: 0; 37 | 38 | &:hover { 39 | opacity: 1; 40 | cursor: pointer; 41 | } 42 | 43 | svg { 44 | font-size: 2rem; 45 | } 46 | } 47 | .form-control:focus + .avatar-focus-outline + .avatar-edit-overlay { 48 | opacity: 1; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /youtube-dl-react-frontend/src/components/Settings/Settings.scss: -------------------------------------------------------------------------------- 1 | .pip { 2 | width: 1rem; 3 | height: 1rem; 4 | border-radius: 50%; 5 | display: inline-block; 6 | margin-right: 0.25rem; 7 | border: var(--bs-border-width) solid var(--bs-border-color); 8 | } 9 | -------------------------------------------------------------------------------- /youtube-dl-react-frontend/src/components/TopTagsList/TopTagsList.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Link } from 'react-router-dom'; 3 | import { Badge } from 'react-bootstrap'; 4 | import { createSearchLink } from '../../utilities/search.utility'; 5 | 6 | const TopTagsList = props => { 7 | const As = props.as || 'span'; 8 | const tags = props.tags.sort((a, b) => b.count - a.count); 9 | 10 | const list = tags.map((tag, i) => 11 | 12 | 15 | 19 | {tag.name} ({tag.count.toLocaleString()}) 20 | 21 | 22 | 23 | ); 24 | 25 | return ( 26 | props.inline 27 | ? list 28 | :
{list}
29 | ); 30 | } 31 | 32 | export default TopTagsList; 33 | -------------------------------------------------------------------------------- /youtube-dl-react-frontend/src/components/UploaderList/UploaderList.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import { Link } from 'react-router-dom'; 3 | import { Row, Col, Image } from 'react-bootstrap'; 4 | import PageLoadWrapper from '../PageLoadWrapper/PageLoadWrapper'; 5 | import Pageinator from '../Pageinator/Pageinator'; 6 | import { UserContext } from '../../contexts/user.context'; 7 | import { bytesToSizeString, dateToTimeSinceString } from '../../utilities/format.utility'; 8 | import axios from '../../utilities/axios.utility'; 9 | import { getImage, defaultImage } from '../../utilities/image.utility'; 10 | import parsedEnv from '../../parse-env'; 11 | 12 | export default class UploaderList extends Component { 13 | static contextType = UserContext; 14 | 15 | constructor(props) { 16 | super(props) 17 | this.state = { 18 | uploaders: [], 19 | totalWebsites: undefined, 20 | totalUploaders: undefined, 21 | maxPages: undefined, 22 | loading: true, 23 | error: undefined, 24 | }; 25 | } 26 | 27 | componentDidMount() { 28 | document.title = `Uploaders - ${parsedEnv.REACT_APP_BRAND}`; 29 | this.getUploaders(); 30 | } 31 | 32 | componentDidUpdate(prevProps) { 33 | if (this.props.location.pathname !== prevProps.location.pathname) { 34 | this.getUploaders(); 35 | } 36 | } 37 | 38 | getUploaders = () => { 39 | axios 40 | .get(`/api/uploaders/page/${this.props.match.params.page ? `${this.props.match.params.page}` : '1'}`) 41 | .then(res => { 42 | if (res.status === 200) { 43 | this.setState({ 44 | loading: false, 45 | totalWebsites: res.data.totalWebsites, 46 | uploaders: res.data.uploaders, 47 | totalUploaders: res.data.totalUploaders, 48 | maxPages: res.data.maxPages, 49 | }); 50 | } 51 | }).catch(err => { 52 | this.setState({ error: err }); 53 | }); 54 | } 55 | 56 | render() { 57 | const uploaders = this.state.uploaders.map(uploader => 58 | 65 | 66 |
67 | 68 | { defaultImage(e, 'avatar') }} 73 | roundedCircle={this.context.getSetting('useCircularAvatars')} 74 | className="me-3" 75 | /> 76 | 77 |
78 | 82 |
{uploader.name}
83 | 84 |

85 | 86 | {uploader.statistics.totalVideoCount.toLocaleString()} video{(uploader.statistics.totalVideoCount !== 1) && 's'} 87 | · 88 | {bytesToSizeString(uploader.statistics.totalVideoFilesize, this.context.getSetting('reportBytesUsingIec'))} 89 | 90 |

91 |

92 | 93 | Last upload was {dateToTimeSinceString(new Date(uploader.statistics.newestVideoDateUploaded))} 94 | 95 |

96 |

97 | 98 | Uploads to {uploader.extractor} 99 | 100 |

101 |
102 |
103 | 104 | ); 105 | 106 | return ( 107 | 111 | {!this.state.loading && <> 112 |

113 | {this.state.totalUploaders.toLocaleString()} Uploader 114 | {this.state.totalUploaders !== 1 && 's'} 115 |

116 |
117 | From {this.state.totalWebsites} different websites 118 |
119 | {this.state.uploaders.length === 0 120 | ?

No results found

121 | : <> 122 | 123 | 129 | {uploaders} 130 | 131 | 132 | 133 | } 134 | } 135 |
136 | ); 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /youtube-dl-react-frontend/src/components/UploaderList/UploaderList.scss: -------------------------------------------------------------------------------- 1 | .uploader-title-link { 2 | -webkit-line-clamp: 2; 3 | -webkit-box-orient: vertical; 4 | text-overflow: ellipsis; 5 | word-break: break-word; 6 | display: -webkit-box; 7 | overflow: hidden; 8 | margin-bottom: 0; 9 | text-decoration: none; 10 | 11 | &:hover { 12 | text-decoration: underline; 13 | } 14 | 15 | h5 { 16 | margin-bottom: 0.1em; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /youtube-dl-react-frontend/src/components/Video/AudioOnlyModeButton/AudioOnlyModeButton.js: -------------------------------------------------------------------------------- 1 | import videojs from 'video.js'; 2 | import { icon } from '@fortawesome/fontawesome-svg-core'; 3 | import { faHeadphones } from '@fortawesome/free-solid-svg-icons'; 4 | 5 | const VjsButton = videojs.getComponent('Button'); 6 | 7 | class AudioOnlyModeButton extends VjsButton { 8 | constructor(player, options) { 9 | super(player, options); 10 | 11 | this.controlText('Audio Only Mode'); 12 | 13 | this.el().classList.add('custom-player-audio-only-mode-button'); 14 | this.el().innerHTML = icon(faHeadphones, { classes: ['custom-player-button-icon'] }).html[0]; 15 | } 16 | 17 | handleClick() { 18 | if (this.options_.onClick) { 19 | this.options_.onClick(); 20 | } 21 | } 22 | 23 | updateText(AudioOnly) { 24 | this.controlText(AudioOnly ? 'Disable Audio Only Mode' : 'Audio Only Mode'); 25 | } 26 | } 27 | 28 | // Register the button component 29 | videojs.registerComponent('AudioOnlyModeButton', AudioOnlyModeButton); 30 | 31 | export default AudioOnlyModeButton; 32 | -------------------------------------------------------------------------------- /youtube-dl-react-frontend/src/components/Video/ChatReplay/ChatReplay.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, useContext, useRef } from 'react'; 2 | import { Image, Button } from 'react-bootstrap'; 3 | import { defaultImage } from '../../../utilities/image.utility'; 4 | import { videoDurationToOverlay } from '../../../utilities/format.utility'; 5 | import { UserContext } from '../../../contexts/user.context'; 6 | import axios from '../../../utilities/axios.utility'; 7 | import { Spinner } from 'react-bootstrap'; 8 | import parsedEnv from '../../../parse-env'; 9 | 10 | const ChatReplay = (props) => { 11 | const [loading, setLoading] = useState(true); 12 | const [comments, setComments] = useState(null); 13 | const [rendered, setRendered] = useState([]); 14 | const [lastCommentIndex, setLastCommentIndex] = useState(null); 15 | const [visible, setVisible] = useState(localStorage.getItem('showChatReplay') === null ? false : localStorage.getItem('showChatReplay') === 'true'); 16 | const [error, setError] = useState(null); 17 | 18 | const userContext = useContext(UserContext); 19 | 20 | const chatRef = useRef(null); 21 | 22 | useEffect(() => { 23 | axios 24 | .get(`/api/videos/${props.extractor}/${props.id}/livechat`) 25 | .then(res => { 26 | setLoading(false); 27 | setComments(res.data.comments); 28 | }) 29 | .catch(err => { 30 | setLoading(false); 31 | setError(err?.response?.data?.error || 'Failed to load chat replay'); 32 | }); 33 | // eslint-disable-next-line react-hooks/exhaustive-deps 34 | }, []); 35 | 36 | useEffect(() => { 37 | if (!!comments && visible) { 38 | let latestCommentIndex = comments.findLastIndex(comment => comment.offset <= props.time); 39 | if (lastCommentIndex !== latestCommentIndex) { 40 | setLastCommentIndex(latestCommentIndex); 41 | if (latestCommentIndex === -1) { 42 | setRendered([]); 43 | } else { 44 | setRendered(comments.slice(Math.max(0, latestCommentIndex - 50), latestCommentIndex)); 45 | } 46 | } 47 | } 48 | // eslint-disable-next-line react-hooks/exhaustive-deps 49 | }, [props.time, comments, visible]); 50 | 51 | useEffect(() => { 52 | if (chatRef.current) chatRef.current.scrollTop = chatRef.current.scrollHeight; 53 | }, [rendered]); 54 | 55 | useEffect(() => { 56 | localStorage.setItem('showChatReplay', visible.toString()); 57 | }, [visible]); 58 | 59 | return ( 60 |
61 | {!visible 62 | ? 63 | : <> 64 |

Chat replay

65 | {loading 66 | ?
67 | : <> 68 |
69 | {rendered.length > 0 70 | ? rendered.map(comment => 71 |
72 | { defaultImage(e, 'avatar') }} 77 | roundedCircle={userContext.getSetting('useCircularAvatars')} 78 | /> 79 |
80 |

81 | {comment.name} 82 | {comment.date && {videoDurationToOverlay(comment.offset)}} 83 |

{comment.message}

84 |

85 |
86 |
87 | ) 88 | : (!!comments 89 | ?

No comments yet

90 | :

{error}

91 | )} 92 |
93 | 94 | 95 | } 96 | 97 | } 98 |
99 | ); 100 | } 101 | 102 | export default ChatReplay; 103 | -------------------------------------------------------------------------------- /youtube-dl-react-frontend/src/components/Video/Comments/Comments.scss: -------------------------------------------------------------------------------- 1 | .comment-replies { 2 | 3 | img { 4 | width: 24px; 5 | height: 24px; 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /youtube-dl-react-frontend/src/components/Video/Description/Description.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { parseStringToDom } from './parseStringToDom'; 3 | import { mapDomToReact } from './mapDomToReact'; 4 | 5 | const Description = ({ text, player, type }) => { 6 | try { 7 | const comments = parseStringToDom(text).map((node, idx) => mapDomToReact(node, idx, player)); 8 | 9 | return (
{comments}
); 10 | } catch (err) { 11 | console.log(err); 12 | return (
Failed to render {type === 'comment' ? 'comment' : 'description'}
); 13 | } 14 | }; 15 | 16 | export default Description; 17 | -------------------------------------------------------------------------------- /youtube-dl-react-frontend/src/components/Video/Description/Description.scss: -------------------------------------------------------------------------------- 1 | .video-description { 2 | white-space: pre-line; 3 | word-break: break-word; 4 | overflow-wrap: break-word; 5 | overflow: hidden; 6 | } 7 | 8 | .fake-link { 9 | color: rgba(var(--bs-link-color-rgb), var(--bs-link-opacity, 1)); 10 | text-decoration: none; 11 | background-color: transparent; 12 | 13 | &:hover { 14 | --bs-link-color-rgb: var(--bs-link-hover-color-rgb); 15 | text-decoration: underline; 16 | cursor: pointer; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /youtube-dl-react-frontend/src/components/Video/Description/allow.js: -------------------------------------------------------------------------------- 1 | import { allowAny, exact } from './filters'; 2 | 3 | export const allow = [ 4 | { 5 | tag: 'a', 6 | allowAttributes: [ 7 | ['href', allowAny], 8 | ['target', exact('_blank')] 9 | ] 10 | }, 11 | { 12 | tag: 'br' 13 | }, 14 | { 15 | tag: 'hr' 16 | } 17 | ]; 18 | -------------------------------------------------------------------------------- /youtube-dl-react-frontend/src/components/Video/Description/filters.js: -------------------------------------------------------------------------------- 1 | export const allowAny = () => true; 2 | export const oneOf = (list) => (v) => list.includes(v); 3 | export const exact = (v1) => (v2) => v1 === v2; 4 | export const matches = (regex) => (v) => regex.test(v); 5 | -------------------------------------------------------------------------------- /youtube-dl-react-frontend/src/components/Video/Description/mapDomToReact.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { allow } from "./allow"; 3 | import { parseCustomNode } from "./parseCustomNode"; 4 | 5 | export const mapDomToReact = (node, idx, player) => { 6 | if (node.nodeType === Node.TEXT_NODE) { 7 | return {node.data}; 8 | } 9 | 10 | const customNode = parseCustomNode(node, idx, player); 11 | 12 | if (customNode) return customNode; 13 | 14 | const allowed = allow.find( 15 | (item) => item.tag.toUpperCase() === node.nodeName 16 | ); 17 | 18 | if (!allowed) { 19 | const inner = node.innerHTML; 20 | const outer = node.outerHTML; 21 | 22 | const idxOfInner = outer.indexOf(inner); 23 | 24 | const startTag = outer.slice(0, idxOfInner); 25 | const endTag = outer.slice(idxOfInner + inner.length); 26 | 27 | return ( 28 | 29 | {startTag} 30 | {[...node.childNodes].map((n, i) => mapDomToReact(n, i))} 31 | {endTag} 32 | 33 | ); 34 | } 35 | 36 | const El = node.nodeName.toLowerCase(); 37 | 38 | const props = {}; 39 | 40 | for (const { name, value } of node.attributes) { 41 | const match = allowed.allowAttributes?.find(([key]) => key === name); 42 | 43 | if (!match) continue; 44 | 45 | const [, validate] = match; 46 | 47 | if (validate(value)) { 48 | props[name] = value; 49 | } 50 | } 51 | 52 | return node.textContent ? ( 53 | 54 | {[...node.childNodes].map((n, i) => mapDomToReact(n, i))} 55 | 56 | ) : ( 57 | 58 | ); 59 | }; 60 | -------------------------------------------------------------------------------- /youtube-dl-react-frontend/src/components/Video/Description/parseCustomNode.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Link } from 'react-router-dom'; 3 | 4 | const ExternalLink = ({ url }) => ( 5 | 6 | {url} 7 | 8 | ); 9 | 10 | export const PREFIX = 'custom-'; 11 | 12 | export const parseCustomNode = (node, idx, player) => { 13 | const nodeName = node.nodeName.toLowerCase(); 14 | 15 | if (!nodeName.startsWith(PREFIX)) return null; 16 | 17 | switch (nodeName.toLowerCase().slice(PREFIX.length)) { 18 | case 'externallink': 19 | return ; 20 | case 'hashtag': 21 | return 22 | {node.textContent} 23 | ; 24 | case 'settime': 25 | return { 26 | if (player) { 27 | window.scrollTo(0, 0); 28 | player.currentTime(parseInt(node.attributes.time.value)); 29 | player.play(); 30 | } 31 | }}> 32 | {node.textContent} 33 | 34 | default: 35 | return null; 36 | } 37 | }; 38 | -------------------------------------------------------------------------------- /youtube-dl-react-frontend/src/components/Video/Description/parseStringToDom.js: -------------------------------------------------------------------------------- 1 | import { PREFIX } from './parseCustomNode'; 2 | 3 | export const parseStringToDom = (str) => { 4 | const wrapped = str 5 | .split(/(\s+)/) 6 | .map((fragment, idx) => { 7 | return idx % 2 8 | ? fragment 9 | : fragment 10 | .replace( 11 | /^https?:\/\/\S+$/g, 12 | (m) => `<${PREFIX}externallink>${m}` 13 | ) 14 | .replace( 15 | /^#\w*[a-zA-Z]\w*$/g, 16 | (m) => `<${PREFIX}hashtag>${m}` 17 | ).replace( 18 | /(?:^|\s)([0-5]?\d(?::(?:[0-5]?\d)){1,2})/g, 19 | (match, p1) => { 20 | let seconds = 0; 21 | let i = 1; 22 | for (let unit of p1.split(':').reverse()) { 23 | seconds += unit * i; 24 | i *= 60; 25 | } 26 | return `<${PREFIX}settime time="${seconds}">${p1}`; 27 | } 28 | ); 29 | }) 30 | .join(''); 31 | 32 | const dom = new DOMParser().parseFromString(`${wrapped}`, 'text/html'); 33 | 34 | return [...dom.body.childNodes]; 35 | }; 36 | -------------------------------------------------------------------------------- /youtube-dl-react-frontend/src/components/Video/ScreenshotButton/ScreenshotButton.js: -------------------------------------------------------------------------------- 1 | import videojs from 'video.js'; 2 | import { icon } from '@fortawesome/fontawesome-svg-core'; 3 | import { faCamera } from '@fortawesome/free-solid-svg-icons'; 4 | import { Tooltip } from 'bootstrap'; 5 | 6 | const VjsButton = videojs.getComponent('Button'); 7 | 8 | class ScreenshotButton extends VjsButton { 9 | constructor(player, options) { 10 | super(player, options); 11 | 12 | this.el().classList.add('custom-player-screenshot-button'); 13 | this.el().innerHTML = icon(faCamera, { classes: ['custom-player-button-icon'] }).html[0]; 14 | 15 | const tooltipInstance = new Tooltip(this.el(), { trigger: 'manual', title: 'Copied to clipboard!' }); 16 | 17 | this.controlText('Screenshot'); // `this.controlText` must be called after `new Tooltip` otherwise bootstrap overrides the button title 18 | 19 | this.on('click', () => { 20 | captureScreenshot(player, options.behavior, tooltipInstance); 21 | }); 22 | } 23 | } 24 | 25 | const captureScreenshot = (player, behavior, tooltipInstance) => { 26 | const video = player.el().querySelector('video'); 27 | const canvas = document.createElement('canvas'); 28 | const ctx = canvas.getContext('2d'); 29 | 30 | canvas.width = video.videoWidth; 31 | canvas.height = video.videoHeight; 32 | ctx.drawImage(video, 0, 0, canvas.width, canvas.height); 33 | 34 | const imageUrl = canvas.toDataURL('image/png'); 35 | 36 | if (behavior === 'save') { 37 | const a = document.createElement('a'); 38 | a.href = imageUrl; 39 | a.download = `${window.location.pathname.split('/')[2]}${window.location.pathname.split('/')[3]}_${Math.round(player.currentTime())}s.png`; 40 | document.body.appendChild(a); 41 | a.click(); 42 | document.body.removeChild(a); 43 | } else if (behavior === 'copy') { 44 | canvas.toBlob(blob => { 45 | const item = new ClipboardItem({ 'image/png': blob }); 46 | navigator.clipboard.write([item]).then(() => { 47 | tooltipInstance.show(); 48 | setTimeout(() => { 49 | tooltipInstance.hide(); 50 | }, 1000); 51 | }).catch(err => { 52 | console.error(err); 53 | }); 54 | }, 'image/png'); 55 | } 56 | }; 57 | 58 | videojs.registerComponent('ScreenshotButton', ScreenshotButton); 59 | 60 | export default ScreenshotButton; 61 | -------------------------------------------------------------------------------- /youtube-dl-react-frontend/src/components/Video/TheaterModeButton/TheaterModeButton.js: -------------------------------------------------------------------------------- 1 | import videojs from 'video.js'; 2 | import { icon } from '@fortawesome/fontawesome-svg-core'; 3 | import { faTv } from '@fortawesome/free-solid-svg-icons'; 4 | 5 | const VjsButton = videojs.getComponent('Button'); 6 | 7 | class TheaterModeButton extends VjsButton { 8 | constructor(player, options) { 9 | super(player, options); 10 | 11 | this.controlText('Theater Mode'); 12 | 13 | this.el().classList.add('custom-player-theater-mode-button'); 14 | this.el().innerHTML = icon(faTv, { classes: ['custom-player-button-icon'] }).html[0]; 15 | } 16 | 17 | handleClick() { 18 | if (this.options_.onClick) { 19 | this.options_.onClick(); 20 | } 21 | } 22 | 23 | updateText(theaterMode) { 24 | this.controlText(theaterMode ? 'Exit Theater Mode' : 'Theater Mode'); 25 | } 26 | } 27 | 28 | // Register the button component 29 | videojs.registerComponent('TheaterModeButton', TheaterModeButton); 30 | 31 | export default TheaterModeButton; 32 | -------------------------------------------------------------------------------- /youtube-dl-react-frontend/src/components/VideoList/VideoList.scss: -------------------------------------------------------------------------------- 1 | #video-list-controls { 2 | display: flex; 3 | flex-wrap: wrap; 4 | margin-bottom: 1.5rem; 5 | justify-content: space-between; 6 | 7 | .video-list-sort, 8 | .video-list-search, 9 | .video-list-random { 10 | width: 33.333%; 11 | display: flex; 12 | } 13 | 14 | .video-list-search { 15 | justify-content: center; 16 | } 17 | 18 | .video-list-random { 19 | justify-content: end; 20 | } 21 | 22 | &.two-controls { 23 | @media screen and (max-width: 991px) { 24 | .video-list-sort, 25 | .video-list-random { 26 | width: 50%; 27 | } 28 | } 29 | 30 | @media screen and (max-width: 767px) { 31 | .video-list-sort { 32 | margin-bottom: 1rem; 33 | 34 | > div { 35 | width: 100%; 36 | } 37 | } 38 | 39 | .video-list-sort, 40 | .video-list-random { 41 | width: 100%; 42 | } 43 | } 44 | } 45 | 46 | &.three-controls:not(.has-sidebar) { 47 | @media screen and (max-width: 991px) { 48 | .video-list-sort { 49 | width: 100%; 50 | margin-bottom: 1rem; 51 | 52 | > div { 53 | width: 100%; 54 | } 55 | } 56 | 57 | .video-list-search, 58 | .video-list-random { 59 | width: 50%; 60 | } 61 | 62 | .video-list-search { 63 | justify-content: start; 64 | } 65 | } 66 | } 67 | 68 | &.three-controls.has-sidebar { 69 | @media screen and (max-width: 1599px) { 70 | .video-list-sort { 71 | width: 100%; 72 | margin-bottom: 1rem; 73 | } 74 | 75 | .video-list-search, 76 | .video-list-random { 77 | width: 50%; 78 | } 79 | 80 | .video-list-search { 81 | justify-content: start; 82 | } 83 | } 84 | 85 | @media screen and (max-width: 1199px) { 86 | .video-list-sort > div { 87 | width: 100%; 88 | } 89 | } 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /youtube-dl-react-frontend/src/components/VideoPreview/VideoPreview.scss: -------------------------------------------------------------------------------- 1 | .video-duration-overlay { 2 | position: absolute; 3 | bottom: 0; 4 | right: 0; 5 | font-size: 0.875rem; 6 | line-height: 0.875rem; 7 | padding: 2px 4px; 8 | margin: 4px; 9 | border-radius: 2px; 10 | letter-spacing: 0.5px; 11 | background-color: rgba($color: #000, $alpha: 0.8); 12 | color: #fff; 13 | } 14 | 15 | .video-title-link { 16 | -webkit-line-clamp: 2; 17 | -webkit-box-orient: vertical; 18 | text-overflow: ellipsis; 19 | word-break: break-word; 20 | display: -webkit-box; 21 | overflow: hidden; 22 | line-height: 1.15em; 23 | padding-bottom: 0.1em; 24 | text-decoration: none; 25 | 26 | &:hover { 27 | text-decoration: underline; 28 | } 29 | } 30 | 31 | .video-uploader-link { 32 | -webkit-line-clamp: 1; 33 | -webkit-box-orient: vertical; 34 | text-overflow: ellipsis; 35 | word-break: break-all; 36 | display: -webkit-box; 37 | overflow: hidden; 38 | color: $text-muted !important; 39 | text-decoration: none; 40 | 41 | &:hover { 42 | text-decoration: underline; 43 | } 44 | } 45 | 46 | .video-badge { 47 | position: absolute; 48 | top: 0; 49 | margin: 4px; 50 | 51 | &.bg-dark { 52 | background-color: #000 !important; 53 | } 54 | } 55 | 56 | .badge-left { 57 | left: 0; 58 | } 59 | 60 | .badge-right { 61 | right: 0; 62 | } 63 | 64 | .video-watchtime { 65 | background-color: #000; 66 | position: relative; 67 | width: 100%; 68 | height: 0.25rem; 69 | 70 | .video-watchtimebar { 71 | background: $logo-red; 72 | background: linear-gradient(to right, $logo-blue, $logo-red); 73 | position: absolute; 74 | top: 0; 75 | bottom: 0; 76 | left: 0; 77 | 78 | &.no-gradient { 79 | background: $logo-red !important; 80 | } 81 | } 82 | } 83 | 84 | .video-thumbnail { 85 | width: 100%; 86 | padding-bottom: 56.25%; 87 | overflow: hidden; 88 | position: relative; 89 | 90 | img { 91 | position: absolute; 92 | top: 0; 93 | left: 50%; 94 | transform: translateX(-50%); 95 | width: auto; 96 | height: 100%; 97 | } 98 | 99 | &.force-16-9 img { 100 | width: 100%; 101 | object-fit: cover; 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /youtube-dl-react-frontend/src/contexts/advancedsearch.context.js: -------------------------------------------------------------------------------- 1 | import React, { createContext, Component } from 'react'; 2 | import axios from '../utilities/axios.utility'; 3 | import history from '../utilities/history.utility'; 4 | 5 | const initialQuery = { 6 | search: '', 7 | uploader: '', 8 | playlist: '', 9 | job: '', 10 | extractor: '', 11 | uploadStart: '', 12 | uploadEnd: '', 13 | sort: 'relevance', 14 | }; 15 | 16 | export const AdvancedSearchContext = createContext(); 17 | 18 | class AdvancedSearchProvider extends Component { 19 | state = { 20 | show: false, 21 | query: { ...initialQuery }, 22 | jobs: [], 23 | extractors: [], 24 | setShow: (show) => { 25 | this.setState({ show }); 26 | }, 27 | setNewQuery: (query) => { 28 | this.setState({ query: Object.assign({ ...initialQuery }, query) }); 29 | }, 30 | setQueryParam: (name, value) => { 31 | this.setState((prevState) => ({ query: { ...prevState.query, [name]: value } })); 32 | }, 33 | }; 34 | 35 | componentDidMount() { 36 | this.getSearchOptions(); 37 | this.getStateFromSearch(); 38 | } 39 | 40 | componentDidUpdate(prevProps, prevState) { 41 | if (prevState.show !== this.state.show && this.state.show) { 42 | this.getSearchOptions(); 43 | this.getStateFromSearch(); 44 | } 45 | } 46 | 47 | getStateFromSearch() { 48 | if (history.location.pathname === '/' || history.location.pathname === '/videos') { 49 | this.setState({ query: Object.assign({ ...initialQuery }, Object.fromEntries(new URLSearchParams(window.location.search))) }); 50 | } 51 | } 52 | 53 | getSearchOptions() { 54 | axios 55 | .get(`/api/videos/advanced_search_options`) 56 | .then(res => { 57 | if (res.status === 200) { 58 | this.setState({ 59 | jobs: res.data.jobs, 60 | extractors: res.data.extractors, 61 | }); 62 | } 63 | }).catch(err => { 64 | console.error(err); 65 | }); 66 | } 67 | 68 | render() { 69 | return ( 70 | 71 | {this.props.children} 72 | 73 | ); 74 | } 75 | } 76 | 77 | export default AdvancedSearchProvider; 78 | -------------------------------------------------------------------------------- /youtube-dl-react-frontend/src/contexts/user.context.js: -------------------------------------------------------------------------------- 1 | import React, { createContext, Component } from 'react'; 2 | import AuthService from '../services/auth.service'; 3 | import axios from '../utilities/axios.utility'; 4 | import { getDefaultUserSettings } from '../utilities/user.utility'; 5 | 6 | const defaultUserSettings = getDefaultUserSettings(); 7 | 8 | export const UserContext = createContext(); 9 | 10 | class UserProvider extends Component { 11 | state = { 12 | user: AuthService.getCurrentUser(), 13 | updateUser: (user) => { 14 | this.setState({ user }); 15 | }, 16 | getSetting: (settingKey) => { 17 | const user = this.state?.user; 18 | if (user && user.hasOwnProperty(settingKey)) { 19 | return user[settingKey]; 20 | } else { 21 | return defaultUserSettings?.[settingKey] ?? undefined; 22 | } 23 | }, 24 | getPlayerSetting: (settingKey) => { 25 | const user = this.state?.user; 26 | 27 | // Get the current viewport 28 | let viewport = window.innerWidth >= 1200 ? 'desktop' : window.innerWidth >= 768 ? 'tablet' : 'mobile'; 29 | while (!user?.[viewport + 'PlayerSettings'].enabled) { // Fallback to largest enabled viewport 30 | if (viewport === 'mobile') { 31 | viewport = 'tablet'; 32 | continue; 33 | } 34 | if (viewport === 'tablet') { 35 | viewport = 'desktop'; 36 | continue; 37 | } 38 | if (viewport === 'desktop') break; 39 | } 40 | const viewportKey = viewport + 'PlayerSettings'; 41 | 42 | if (user && user?.[viewportKey]?.hasOwnProperty(settingKey)) { 43 | return user[viewportKey][settingKey]; 44 | } else { 45 | return defaultUserSettings?.[viewportKey]?.[settingKey] ?? undefined; 46 | } 47 | }, 48 | getAvatar: () => { 49 | return this.state.getSetting('avatar') ? ('/static/users/avatars/' + this.state.getSetting('avatar')) : '/default-avatar.svg'; 50 | }, 51 | }; 52 | 53 | componentDidMount() { 54 | if (AuthService.getCurrentUser()) axios.get('/api/users/settings') 55 | .then(res => { 56 | this.setState(res.data); 57 | }) 58 | .catch(err => { 59 | console.error(err); 60 | }); 61 | } 62 | 63 | render() { 64 | return ( 65 | 66 | {this.props.children} 67 | 68 | ); 69 | } 70 | } 71 | 72 | export default UserProvider; 73 | -------------------------------------------------------------------------------- /youtube-dl-react-frontend/src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import './index.scss'; 4 | import '../node_modules/videojs-hotkeys/videojs.hotkeys.min.js'; 5 | import '../node_modules/video.js/dist/video-js.min.css' 6 | import App from './App'; 7 | import parsedEnv from './parse-env'; 8 | 9 | document.title = parsedEnv.REACT_APP_BRAND; 10 | window.appVersion = '1.5.0'; 11 | 12 | ReactDOM.render( 13 | 14 | 15 | , 16 | document.getElementById('root') 17 | ); 18 | -------------------------------------------------------------------------------- /youtube-dl-react-frontend/src/index.scss: -------------------------------------------------------------------------------- 1 | @import "../node_modules/bootstrap/scss/functions"; 2 | @import "../node_modules/bootstrap/scss/variables"; 3 | 4 | $grid-breakpoints: ( 5 | xs: 0, 6 | sm: 576px, 7 | md: 768px, 8 | lg: 992px, 9 | xl: 1200px, 10 | xxl: 1400px, 11 | xxxl: 1600px, 12 | xxxxl: 1800px, 13 | ); 14 | 15 | // Container widths should be at least 30px less than the breakpoint, divisible by 12, and divisible by 10 16 | $container-max-widths: ( 17 | sm: 540px, 18 | md: 720px, 19 | lg: 960px, 20 | xl: 1140px, 21 | xxl: 1320px, 22 | xxxl: 1560px, 23 | xxxxl: 1740px, 24 | ); 25 | 26 | $dark: #000; // Make dark OLED dark 27 | 28 | $logo-red: #ff002c; 29 | $logo-blue: #0d6efd; 30 | 31 | @import "../node_modules/bootstrap/scss/bootstrap.scss"; 32 | 33 | @import "./components/Admin/Admin.scss"; 34 | @import "./components/Playlist/Playlist.scss"; 35 | @import "./components/Video/Video.scss"; 36 | @import "./components/Video/Description/Description.scss"; 37 | @import "./components/VideoPreview/VideoPreview.scss"; 38 | @import "./components/UploaderList/UploaderList.scss"; 39 | @import "./components/Navbar/Navbar.scss"; 40 | @import "./components/Activity/Activity.scss"; 41 | @import "./components/Video/Comments/Comments.scss"; 42 | @import "./components/Navbar/ThemeController/ThemeController.scss"; 43 | @import "./components/Settings/Settings.scss"; 44 | @import "./components/Admin/LogFileList/LogFileList.scss"; 45 | @import "./components/Settings/AvatarForm/AvatarForm.scss"; 46 | @import "./components/Navbar/AdvancedSearchModal/AdvancedSearchModal.scss"; 47 | @import "./components/VideoList/VideoList.scss"; 48 | 49 | html { 50 | width: 100%; 51 | width: 100vw; 52 | overflow-x: hidden; 53 | } 54 | 55 | body { 56 | overflow: initial !important; 57 | padding-right: 0 !important; 58 | } 59 | 60 | a:not(.nav-link):not(.dropdown-item):not(.btn):not(.navbar-brand):not(.page-link) { 61 | text-decoration: none; 62 | 63 | &:hover { 64 | text-decoration: underline; 65 | } 66 | } 67 | 68 | small { 69 | font-size: 80%; 70 | } 71 | 72 | code { 73 | background: #1F1F1F !important; 74 | color: #CCCCCC !important; 75 | padding: 0.15rem 0.35rem; 76 | border-radius: 0.25rem; 77 | } 78 | 79 | // Bootstrap 80 | .btn-primary { 81 | color: #fff !important; 82 | } 83 | 84 | .btn-link { 85 | text-decoration: none; 86 | 87 | &:hover { 88 | text-decoration: underline; 89 | } 90 | } 91 | 92 | .badge.bg-light { 93 | color: var(--bs-body-color) !important; 94 | } 95 | 96 | // Bootstrap legacy components 97 | .media-container { 98 | display: flex; 99 | align-items: flex-start; 100 | 101 | .media-body { 102 | flex: 1 1; 103 | } 104 | } 105 | 106 | .form-inline { 107 | display: flex; 108 | flex-flow: row wrap; 109 | align-items: center; 110 | } 111 | 112 | // Dark theme overrides 113 | [data-bs-theme="dark"] { 114 | --bs-body-bg: #{$dark} !important; 115 | --bs-light-rgb: 10, 10, 10 !important; 116 | --bs-dark-rgb: 214, 218, 222 !important; 117 | } 118 | 119 | // Helpers 120 | @media (min-width: 768px) { 121 | .w-md-auto { 122 | width: auto !important; 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /youtube-dl-react-frontend/src/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /youtube-dl-react-frontend/src/parse-env.js: -------------------------------------------------------------------------------- 1 | const parsedEnv = { 2 | REACT_APP_BRAND: process.env.REACT_APP_BRAND || 'youtube-dl Viewer', 3 | REACT_APP_CHECK_FOR_UPDATES: process.env.REACT_APP_CHECK_FOR_UPDATES === undefined ? true : process.env.REACT_APP_CHECK_FOR_UPDATES.toLowerCase() === 'true', 4 | REACT_APP_SHOW_VERSION_TAG: process.env.REACT_APP_SHOW_VERSION_TAG === undefined ? true : process.env.REACT_APP_SHOW_VERSION_TAG.toLowerCase() === 'true', 5 | REACT_APP_LIGHT_THEME_LOGO: process.env.REACT_APP_LIGHT_THEME_LOGO ?? '/logo.svg', 6 | REACT_APP_DARK_THEME_LOGO: process.env.REACT_APP_DARK_THEME_LOGO ?? '/logo.svg', 7 | REACT_APP_LOAD_EXTERNAL_THUMBNAILS: process.env.REACT_APP_LOAD_EXTERNAL_THUMBNAILS === undefined ? false : process.env.REACT_APP_LOAD_EXTERNAL_THUMBNAILS.toLowerCase() === 'true', 8 | REACT_APP_OUT_OF_DATE_COLOR_DAYS: process.env.REACT_APP_OUT_OF_DATE_COLOR_DAYS === undefined ? 30 : parseInt(process.env.REACT_APP_OUT_OF_DATE_COLOR_DAYS), 9 | REACT_APP_REPO_NAME: process.env.REACT_APP_REPO_NAME || 'youtube-dl-react-viewer', 10 | REACT_APP_REPO_URL: process.env.REACT_APP_REPO_URL || 'https://github.com/graham-walker/youtube-dl-react-viewer', 11 | REACT_APP_GITHUB_API_URL: process.env.REACT_APP_GITHUB_API_URL || 'https://api.github.com/repos/graham-walker/youtube-dl-react-viewer/releases/latest', 12 | REACT_APP_LATEST_RELEASE_LINK: process.env.REACT_APP_LATEST_RELEASE_LINK || 'https://github.com/graham-walker/youtube-dl-react-viewer/releases/latest', 13 | REACT_APP_RUNNING_IN_DOCKER: process.env.REACT_APP_RUNNING_IN_DOCKER === undefined ? false : process.env.REACT_APP_RUNNING_IN_DOCKER.toLowerCase() === 'true', 14 | }; 15 | 16 | if (parsedEnv.REACT_APP_REPO_URL.endsWith('/')) parsedEnv.REACT_APP_REPO_URL = parsedEnv.REACT_APP_REPO_URL.slice(0, -1); 17 | if (parsedEnv.REACT_APP_GITHUB_API_URL.endsWith('/')) parsedEnv.REACT_APP_GITHUB_API_URL = parsedEnv.REACT_APP_GITHUB_API_URL.slice(0, -1); 18 | if (parsedEnv.REACT_APP_LATEST_RELEASE_LINK.endsWith('/')) parsedEnv.REACT_APP_LATEST_RELEASE_LINK = parsedEnv.REACT_APP_LATEST_RELEASE_LINK.slice(0, -1); 19 | 20 | export default parsedEnv; 21 | -------------------------------------------------------------------------------- /youtube-dl-react-frontend/src/services/auth.service.js: -------------------------------------------------------------------------------- 1 | import axios from '../utilities/axios.utility'; 2 | import history from '../utilities/history.utility'; 3 | 4 | class AuthService { 5 | globalLogin(password) { 6 | return axios 7 | .post('api/auth/global', { 8 | password 9 | }) 10 | .then(() => { 11 | localStorage.setItem('authenticated', true); 12 | document.dispatchEvent(new MouseEvent('click')); 13 | history.push('/'); 14 | }); 15 | } 16 | 17 | register(username, password) { 18 | return axios.post('/api/auth/register', { 19 | username, 20 | password 21 | }); 22 | } 23 | 24 | login(username, password) { 25 | return axios.post('/api/auth/login', 26 | { 27 | username, 28 | password 29 | } 30 | ) 31 | .then(res => { 32 | localStorage.setItem('user', JSON.stringify(res.data.user)); 33 | return res; 34 | }); 35 | } 36 | 37 | isAuthenticated() { 38 | return localStorage.getItem('authenticated') === 'true'; 39 | } 40 | 41 | getCurrentUser() { 42 | return JSON.parse(localStorage.getItem('user')); 43 | } 44 | } 45 | 46 | const authService = new AuthService(); 47 | 48 | export default authService; 49 | -------------------------------------------------------------------------------- /youtube-dl-react-frontend/src/setupTests.js: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom/extend-expect'; 6 | -------------------------------------------------------------------------------- /youtube-dl-react-frontend/src/utilities/axios.utility.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import history from './history.utility'; 3 | 4 | axios.interceptors.response.use(res => { 5 | return res; 6 | }, err => { 7 | if (err.response.status === 401 && !err.response.data?.noRedirect) { 8 | history.push('/logout'); 9 | } else { 10 | return Promise.reject(err); 11 | } 12 | }); 13 | 14 | export default axios; 15 | -------------------------------------------------------------------------------- /youtube-dl-react-frontend/src/utilities/format.utility.js: -------------------------------------------------------------------------------- 1 | import parsedEnv from "../parse-env"; 2 | 3 | /** 4 | * Converts a date object into a string that represents the time since the date in the largest possible unit. 5 | * @param {Date} date Date object 6 | * @return {String} Time since the date 7 | */ 8 | export const dateToTimeSinceString = (date) => { 9 | var seconds = Math.floor((new Date() - date) / 1000); 10 | var units = ['century', 'decade', 'year', 'month', 'day', 'hour', 'minute', 'second']; 11 | var intervals = [3154000000, 315400000, 31540000, 2592000, 86400, 3600, 60, 1]; 12 | for (let i in units) { 13 | var time = Math.floor(seconds / intervals[i]); 14 | if (time >= 1) return `${time} ${units[i]}${time === 1 ? '' : 's'} ago`; 15 | } 16 | return 'just now'; 17 | } 18 | 19 | /** 20 | * Converts a numerical amount of bytes into a easily readable string. 21 | * @param {Number} bytes Amount of bytes 22 | * @param {Boolean} iec 23 | * @return {String} Readable bytes string 24 | */ 25 | export const bytesToSizeString = (bytes, iec) => { 26 | var thresh = iec ? 1024 : 1000; 27 | if (Math.abs(bytes) < thresh) { 28 | return bytes + ' B'; 29 | } 30 | var units = iec 31 | ? ['KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB'] 32 | : ['kB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']; 33 | var u = -1; 34 | do { 35 | bytes /= thresh; 36 | ++u; 37 | } while (Math.abs(bytes) >= thresh && u < units.length - 1); 38 | return bytes.toFixed(1) + ' ' + units[u]; 39 | } 40 | 41 | /** 42 | * Converts a video's duration from seconds into ............................. 43 | * @param {Object} video Video object 44 | * @return {String} Duration string 45 | */ 46 | export const videoDurationToOverlay = (duration) => { 47 | var hours = Math.floor(duration / 3600); 48 | var minutes = Math.floor(duration % 3600 / 60); 49 | var seconds = Math.floor(duration % 3600 % 60); 50 | var string = ''; 51 | if (hours > 0) string += hours + ':'; 52 | if (minutes < 10 && hours > 0) string += '0'; 53 | string += minutes + ':'; 54 | if (seconds < 10) string += '0'; 55 | string += seconds; 56 | return string; 57 | } 58 | 59 | /** 60 | * Converts an amount of seconds into a string that represents the time units........................................... 61 | * @param {Number} seconds Amount of seconds 62 | * @return {String} Text 63 | */ 64 | export const secondsToDetailedString = (seconds, round = false) => { 65 | var units = ['Century', 'Decade', 'Year', 'Month', 'Day', 'Hour', 'Minute', 'Second']; 66 | var intervals = [3154000000, 315400000, 31540000, 2592000, 86400, 3600, 60, 1]; 67 | var string = ''; 68 | for (let i in units) { 69 | var time = Math.floor(seconds / intervals[i]); 70 | if (time >= 1) { 71 | if (round) { 72 | time = seconds / intervals[i]; 73 | string = `${+time.toFixed(2)} ${units[i]}${time === 1 ? '' : 's'}`; 74 | break; 75 | } else { 76 | string += `${time} ${units[i]}${time === 1 ? '' : 's'}, `; 77 | seconds -= time * intervals[i]; 78 | } 79 | } 80 | } 81 | if (string.endsWith(', ')) string = string.slice(0, -2); 82 | return string ? string : '0 Seconds'; 83 | } 84 | 85 | export const abbreviateNumber = (value) => { 86 | let newValue = value; 87 | const suffixes = ['', 'K', 'M', 'B', 'T']; 88 | let suffixNum = 0; 89 | while (newValue >= 1000) { 90 | newValue /= 1000; 91 | suffixNum++; 92 | } 93 | newValue = +newValue.toFixed(1); 94 | if (newValue.toString().split('.')[0].length > 1) newValue = Math.floor(newValue); 95 | newValue += suffixes[suffixNum]; 96 | return newValue; 97 | } 98 | 99 | export const resolutionToBadge = (width, height, ignoreSmall = true) => { 100 | try { 101 | let size = Math.min(width, height); 102 | if (size < 1080) { 103 | if (ignoreSmall) { 104 | return undefined; 105 | } else { 106 | return size + 'p'; 107 | } 108 | } else if (size < 1440) { 109 | return 'HD'; 110 | } else if (size < 2160) { 111 | return 'QHD'; 112 | } else if (size < 4320) { 113 | return '4K'; 114 | } else if (size === 4320) { 115 | return '8K'; 116 | } else { 117 | return size + 'p'; 118 | } 119 | } catch (err) { 120 | console.error(err); 121 | return '0p'; 122 | } 123 | } 124 | 125 | export const getErrorMessage = (err, defaultMessage = 'Unknown error') => { 126 | if (err.response && err.response.data.hasOwnProperty('error')) { 127 | return err.response.data.error; 128 | } else { 129 | if (err.message) { 130 | return err.message; 131 | } 132 | return defaultMessage; 133 | } 134 | } 135 | 136 | export const getWarningColor = (job, prefix = '') => { 137 | if (!job.lastCompleted) return ''; 138 | const depth = parsedEnv.REACT_APP_OUT_OF_DATE_COLOR_DAYS; 139 | const daysSince = depth - Math.min(Math.round(Math.abs((new Date(job.lastCompleted) - new Date()) / 145440000)), depth); 140 | const percent = daysSince / depth; 141 | if (percent === 0) return prefix + 'text-danger'; 142 | if (percent <= 0.5) return prefix + 'text-warning'; 143 | return prefix + 'text-success'; 144 | } 145 | 146 | export const capitalizeFirstLetter = (string) => { 147 | if (!string) return string; 148 | return string[0].toUpperCase() + string.slice(1); 149 | } 150 | -------------------------------------------------------------------------------- /youtube-dl-react-frontend/src/utilities/history.utility.js: -------------------------------------------------------------------------------- 1 | import { createBrowserHistory } from 'history'; 2 | export default createBrowserHistory(); 3 | -------------------------------------------------------------------------------- /youtube-dl-react-frontend/src/utilities/image.utility.js: -------------------------------------------------------------------------------- 1 | export const getImage = (video, type, size = 'small') => { 2 | switch (type) { 3 | case 'thumbnail': 4 | if (!video || !video.hasOwnProperty('directory')) { 5 | return 'default-thumbnail.svg'; 6 | } 7 | switch (size) { 8 | case 'small': 9 | if (!video.hasOwnProperty('smallResizedThumbnailFile') || !video.smallResizedThumbnailFile) { 10 | return 'default-thumbnail.svg'; 11 | } 12 | return `/static/thumbnails/${encodeURIComponent(video.directory)}` 13 | + `/${encodeURIComponent(video.smallResizedThumbnailFile.name)}`; 14 | case 'medium': 15 | if (!video.hasOwnProperty('mediumResizedThumbnailFile') || !video.mediumResizedThumbnailFile) { 16 | return 'default-thumbnail.svg'; 17 | } 18 | return `/static/thumbnails/${encodeURIComponent(video.directory)}` 19 | + `/${encodeURIComponent(video.mediumResizedThumbnailFile.name)}`; 20 | default: 21 | throw new Error('Invalid size'); 22 | } 23 | case 'avatar': 24 | if (!video) return 'default-avatar.svg'; 25 | if (video.hasOwnProperty('name')) video = { uploaderDocument: video }; // An uploader is being passed instead of a video 26 | if ( 27 | !video.hasOwnProperty('uploaderDocument') 28 | || !video.uploaderDocument.hasOwnProperty('extractor') 29 | || !video.uploaderDocument.hasOwnProperty('id') 30 | ) return 'default-avatar.svg'; 31 | return `/static/avatars/${makeSafe(video.uploaderDocument.extractor, ' -')}/${makeSafe(video.uploaderDocument.id, '_')}.jpg`; 32 | default: 33 | throw new Error('Invalid type'); 34 | } 35 | } 36 | 37 | export const defaultImage = (e, type) => { 38 | switch (type) { 39 | case 'thumbnail': 40 | e.target.style.objectFit = 'cover'; 41 | e.target.src = '/default-thumbnail.svg'; 42 | break; 43 | case 'avatar': 44 | e.target.src = '/default-avatar.svg'; 45 | break; 46 | default: 47 | throw new Error('Invalid type'); 48 | } 49 | } 50 | 51 | const makeSafe = (text, replaceWith) => { 52 | return encodeURIComponent(text.replace(/[|:&;$%@"<>()+,/\\*?]/g, replaceWith)); 53 | } 54 | -------------------------------------------------------------------------------- /youtube-dl-react-frontend/src/utilities/scroll.utility.js: -------------------------------------------------------------------------------- 1 | export const scrollToElement = (element) => { 2 | if (typeof element === 'string') element = document.querySelector(element); 3 | if (element) { 4 | window.scrollTo({ 5 | top: element.getBoundingClientRect().top + window.pageYOffset - document.querySelector('nav.navbar').offsetHeight - parseFloat(getComputedStyle(document.documentElement).fontSize), 6 | behavior: 'smooth', 7 | }); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /youtube-dl-react-frontend/src/utilities/search.utility.js: -------------------------------------------------------------------------------- 1 | import queryString from 'query-string'; 2 | 3 | export const createSearchLink = (str) => { 4 | return `/videos?${queryString.stringify({ search: `"${str}"` })}`; 5 | } 6 | -------------------------------------------------------------------------------- /youtube-dl-react-frontend/src/utilities/user.utility.js: -------------------------------------------------------------------------------- 1 | const defaultPlayerSettings = { 2 | enabled: false, 3 | defaultPlaybackRate: 1, 4 | autoplayVideo: true, 5 | enableDefaultTheaterMode: false, 6 | enableDefaultAudioOnlyMode: false, 7 | keepPlayerControlsVisible: 'never', 8 | playerControlsPosition: 'on_video', 9 | playerControlsScale: 1.25, 10 | defaultVolume: 1, 11 | volumeControlPosition: 'vertical', 12 | largePlayButtonEnabled: true, 13 | seekButtonsEnabled: true, 14 | forwardSeekButtonSeconds: 10, 15 | backSeekButtonSeconds: 10, 16 | enableScreenshotButton: true, 17 | screenshotButtonBehavior: 'save', 18 | showCurrentTime: true, 19 | showRemainingTime: true, 20 | } 21 | 22 | const defaultUserSettings = { 23 | isSuperuser: false, 24 | avatar: null, 25 | username: '', 26 | password: '', 27 | desktopPlayerSettings: { ...defaultPlayerSettings, enabled: true }, 28 | tabletPlayerSettings: { ...defaultPlayerSettings }, 29 | mobilePlayerSettings: { ...defaultPlayerSettings }, 30 | hideShorts: false, 31 | useLargeLayout: true, 32 | fitThumbnails: true, 33 | useCircularAvatars: true, 34 | useGradientEffect: true, 35 | reportBytesUsingIec: true, 36 | recordWatchHistory: true, 37 | showWatchedHistory: true, 38 | resumeVideos: true, 39 | enableSponsorblock: true, 40 | onlySkipLocked: false, 41 | skipSponsor: true, 42 | skipSelfpromo: true, 43 | skipInteraction: true, 44 | skipIntro: true, 45 | skipOutro: true, 46 | skipPreview: true, 47 | skipFiller: false, 48 | skipMusicOfftopic: true, 49 | enableReturnYouTubeDislike: false, 50 | } 51 | 52 | export const getDefaultUserSettings = () => { 53 | return JSON.parse(JSON.stringify(defaultUserSettings)); 54 | } 55 | --------------------------------------------------------------------------------