├── .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 |
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 |
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 |
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 |
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 |
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 |
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 |
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}${PREFIX}externallink>`
13 | )
14 | .replace(
15 | /^#\w*[a-zA-Z]\w*$/g,
16 | (m) => `<${PREFIX}hashtag>${m}${PREFIX}hashtag>`
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}${PREFIX}settime>`;
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 |
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 |
--------------------------------------------------------------------------------