Compare commits

..

1 Commits

Author SHA1 Message Date
Isaac Abadi
51512a60da Fixed docker build issues 2022-05-01 00:22:28 -04:00
355 changed files with 21557 additions and 79946 deletions

View File

@@ -1,39 +0,0 @@
// README at: https://github.com/devcontainers/templates/tree/main/src/javascript-node
{
"name": "Node.js",
// Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile
"image": "mcr.microsoft.com/devcontainers/javascript-node:0-18-bullseye",
"features": {
"ghcr.io/devcontainers-contrib/features/jshint:2": {},
"ghcr.io/devcontainers-contrib/features/angular-cli:2": {},
"ghcr.io/devcontainers/features/github-cli:1": {}
},
// Use 'forwardPorts' to make a list of ports inside the container available locally.
"forwardPorts": [4200, 17442],
// Use 'postCreateCommand' to run commands after the container is created.
"postCreateCommand": "npm install && cd backend && npm install",
// Configure tool-specific properties.
"customizations": {
// Configure properties specific to VS Code.
"vscode": {
// Add the IDs of extensions you want installed when the container is created.
"extensions": [
"ms-python.python",
"Angular.ng-template",
"dbaeumer.vscode-eslint",
"waderyan.gitblame",
"42Crunch.vscode-openapi",
"christian-kohler.npm-intellisense",
"redhat.vscode-yaml",
"hbenl.vscode-mocha-test-adapter",
"DavidAnson.vscode-markdownlint"
]
}
}
// Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
// "remoteUser": "root"
}

View File

@@ -1,18 +0,0 @@
version: 2
updates:
- package-ecosystem: "docker"
directory: "/"
schedule:
interval: "daily"
- package-ecosystem: "github-actions"
directory: "/.github/workflows"
schedule:
interval: "daily"
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "daily"
- package-ecosystem: "npm"
directory: "/backend/"
schedule:
interval: "daily"

View File

@@ -13,11 +13,11 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: checkout code
uses: actions/checkout@v4
uses: actions/checkout@v2
- name: setup node
uses: actions/setup-node@v4
uses: actions/setup-node@v2
with:
node-version: '16'
node-version: '12'
cache: 'npm'
- name: install dependencies
run: |
@@ -33,7 +33,7 @@ jobs:
run: echo "::set-output name=date::$(date +'%Y-%m-%d')"
- name: create-json
id: create-json
uses: jsdaniell/create-json@v1.2.2
uses: jsdaniell/create-json@1.1.2
with:
name: "version.json"
json: '{"type": "autobuild", "tag": "N/A", "commit": "${{ steps.vars.outputs.sha_short }}", "date": "${{ steps.date.outputs.date }}"}'
@@ -55,7 +55,7 @@ jobs:
Copy-Item -Path ./backend/*.js -Destination ./build/youtubedl-material
Copy-Item -Path ./backend/*.json -Destination ./build/youtubedl-material
- name: upload build artifact
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v1
with:
name: youtubedl-material
path: build
@@ -65,7 +65,7 @@ jobs:
if: contains(github.ref, '/tags/v')
steps:
- name: checkout code
uses: actions/checkout@v4
uses: actions/checkout@v2
- name: create release
id: create_release
uses: actions/create-release@v1
@@ -81,7 +81,7 @@ jobs:
draft: true
prerelease: false
- name: download build artifact
uses: actions/download-artifact@v4
uses: actions/download-artifact@v1
with:
name: youtubedl-material
path: ${{runner.temp}}/youtubedl-material

View File

@@ -1,38 +0,0 @@
name: No Response
# Both `issue_comment` and `scheduled` event types are required for this Action
# to work properly.
on:
issue_comment:
types: [created]
schedule:
# Schedule for five minutes after the hour, every hour
- cron: '5 * * * *'
# By specifying the access of one of the scopes, all of those that are not
# specified are set to 'none'.
permissions:
issues: write
jobs:
noResponse:
runs-on: ubuntu-latest
if: ${{ github.repository == 'Tzahi12345/YoutubeDL-Material' }}
steps:
- uses: lee-dohm/no-response@v0.5.0
with:
token: ${{ github.token }}
# Comment to post when closing an Issue for lack of response. Set to `false` to disable
closeComment: >
This issue has been automatically closed because there has been no response
to our request for more information from the original author. With only the
information that is currently in the issue, we don't have enough information
to take action. Please reach out if you have or find the answers we need so
that we can investigate further. We will re-open this issue if you provide us
with the requested information with a comment under this issue.
Thank you for your understanding and for trying to help make this application
a better one!
# Number of days of inactivity before an issue is closed for lack of response.
daysUntilClose: 21
# Label requiring a response.
responseRequiredLabel: "💬 response-needed"

View File

@@ -30,7 +30,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v2
with:
# We must fetch at least the immediate parents so that if this is
# a pull request then we can checkout the head.
@@ -43,7 +43,7 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v2
uses: github/codeql-action/init@v1
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
@@ -54,7 +54,7 @@ jobs:
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@v2
uses: github/codeql-action/autobuild@v1
# Command-line programs to run using the OS shell.
# 📚 https://git.io/JvXDl
@@ -68,4 +68,4 @@ jobs:
# make release
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v2
uses: github/codeql-action/analyze@v1

View File

@@ -1,38 +0,0 @@
name: docker-pr
on:
pull_request:
branches: [master]
jobs:
build-and-push:
runs-on: ubuntu-latest
steps:
- name: checkout code
uses: actions/checkout@v4
- name: Set hash
id: vars
run: echo "::set-output name=sha_short::$(git rev-parse --short HEAD)"
- name: Get current date
id: date
run: echo "::set-output name=date::$(date +'%Y-%m-%d')"
- name: create-json
id: create-json
uses: jsdaniell/create-json@v1.2.2
with:
name: "version.json"
json: '{"type": "docker", "tag": "nightly", "commit": "${{ steps.vars.outputs.sha_short }}", "date": "${{ steps.date.outputs.date }}"}'
dir: 'backend/'
- name: setup platform emulator
uses: docker/setup-qemu-action@v3
- name: setup multi-arch docker build
uses: docker/setup-buildx-action@v3
- name: build & push images
uses: docker/build-push-action@v5
with:
context: .
file: ./Dockerfile
platforms: linux/amd64,linux/arm/v7,linux/arm64/v8
#platforms: linux/amd64
push: false
tags: tzahi12345/youtubedl-material:nightly-pr

View File

@@ -6,81 +6,40 @@ on:
tags:
description: 'Docker tags'
required: true
release:
types: [published]
jobs:
build-and-push:
runs-on: ubuntu-latest
steps:
- name: checkout code
uses: actions/checkout@v4
uses: actions/checkout@v2
- name: Set hash
id: vars
run: echo "::set-output name=sha_short::$(git rev-parse --short HEAD)"
- name: Get current date
id: date
run: echo "::set-output name=date::$(date +'%Y-%m-%d')"
- name: create-json
id: create-json
uses: jsdaniell/create-json@v1.2.2
uses: jsdaniell/create-json@1.1.2
with:
name: "version.json"
json: '{"type": "docker", "tag": "latest", "commit": "${{ steps.vars.outputs.sha_short }}", "date": "${{ steps.date.outputs.date }}"}'
dir: 'backend/'
- name: Set image tag
id: tags
run: |
if [ "${{ github.event.inputs.tags }}" != "" ]; then
echo "::set-output name=tags::${{ github.event.inputs.tags }}"
elif [ ${{ github.event.action }} == "release" ]; then
echo "::set-output name=tags::${{ github.event.release.tag_name }}"
else
echo "Unknown workflow trigger: ${{ github.event.action }}! Cannot determine default tag."
exit 1
fi
- name: Generate Docker image metadata
id: docker-meta
uses: docker/metadata-action@v5
with:
images: |
${{ secrets.DOCKERHUB_USERNAME }}/${{ secrets.DOCKERHUB_REPO }}
ghcr.io/${{ github.repository_owner }}/${{ secrets.DOCKERHUB_REPO }}
tags: |
type=raw,value=${{ steps.tags.outputs.tags }}
type=raw,value=latest
- name: setup platform emulator
uses: docker/setup-qemu-action@v3
uses: docker/setup-qemu-action@v1
- name: setup multi-arch docker build
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v1
- name: Login to DockerHub
uses: docker/login-action@v3
uses: docker/login-action@v1
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: build & push images
uses: docker/build-push-action@v5
uses: docker/build-push-action@v2
with:
context: .
file: ./Dockerfile
platforms: linux/amd64,linux/arm64/v8
platforms: linux/amd64,linux/arm,linux/arm64/v8
push: true
tags: ${{ steps.docker-meta.outputs.tags }}
labels: ${{ steps.docker-meta.outputs.labels }}
tags: ${{ github.event.inputs.tags }}

View File

@@ -3,84 +3,40 @@ name: docker
on:
push:
branches: [master]
paths-ignore:
- '.github/**'
- '.vscode/**'
- 'chrome-extension/**'
- 'releases/**'
- '**/**.md'
- '**.crx'
- '**.pem'
- '.dockerignore'
- '.gitignore'
schedule:
- cron: '34 4 * * 2'
workflow_dispatch:
jobs:
build-and-push:
runs-on: ubuntu-latest
steps:
- name: checkout code
uses: actions/checkout@v4
uses: actions/checkout@v2
- name: Set hash
id: vars
run: echo "::set-output name=sha_short::$(git rev-parse --short HEAD)"
- name: Get current date
id: date
run: echo "::set-output name=date::$(date +'%Y-%m-%d')"
- name: create-json
id: create-json
uses: jsdaniell/create-json@v1.2.2
uses: jsdaniell/create-json@1.1.2
with:
name: "version.json"
json: '{"type": "docker", "tag": "${{secrets.DOCKERHUB_MASTER_TAG}}", "commit": "${{ steps.vars.outputs.sha_short }}", "date": "${{ steps.date.outputs.date }}"}'
json: '{"type": "docker", "tag": "nightly", "commit": "${{ steps.vars.outputs.sha_short }}", "date": "${{ steps.date.outputs.date }}"}'
dir: 'backend/'
- name: setup platform emulator
uses: docker/setup-qemu-action@v3
uses: docker/setup-qemu-action@v1
- name: setup multi-arch docker build
uses: docker/setup-buildx-action@v3
- name: Generate Docker image metadata
id: docker-meta
uses: docker/metadata-action@v5
# Defaults:
# DOCKERHUB_USERNAME : tzahi12345
# DOCKERHUB_REPO : youtubedl-material
# DOCKERHUB_MASTER_TAG: nightly
with:
images: |
${{ secrets.DOCKERHUB_USERNAME }}/${{ secrets.DOCKERHUB_REPO }}
ghcr.io/${{ github.repository_owner }}/${{ secrets.DOCKERHUB_REPO }}
tags: |
type=raw,${{secrets.DOCKERHUB_MASTER_TAG}}-{{ date 'YYYY-MM-DD' }}
type=raw,${{secrets.DOCKERHUB_MASTER_TAG}}
type=sha,prefix=sha-,format=short
uses: docker/setup-buildx-action@v1
- name: Login to DockerHub
uses: docker/login-action@v3
uses: docker/login-action@v1
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: build & push images
uses: docker/build-push-action@v5
uses: docker/build-push-action@v2
with:
context: .
file: ./Dockerfile
platforms: linux/amd64,linux/arm64/v8,linux/arm/v7
platforms: linux/amd64,linux/arm,linux/arm64/v8
push: true
tags: ${{ steps.docker-meta.outputs.tags }}
labels: ${{ steps.docker-meta.outputs.labels }}
tags: tzahi12345/youtubedl-material:nightly

View File

@@ -1,40 +0,0 @@
name: Tests
'on':
push:
branches:
- master
pull_request:
types:
- opened
- synchronize
- reopened
jobs:
test:
name: 'Backend - mocha'
runs-on: ubuntu-latest
strategy:
matrix:
node:
- 16
steps:
- uses: actions/setup-node@v4
with:
node-version: '${{ matrix.node }}'
- uses: actions/checkout@v4
- name: 'Cache node_modules'
uses: actions/cache@v3
with:
path: ~/.npm
key: ${{ runner.os }}-node-v${{ matrix.node }}-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-v${{ matrix.node }}-
working-directory: ./backend
- uses: FedericoCarboni/setup-ffmpeg@v3
id: setup-ffmpeg
- name: Install Dependencies
run: npm install
working-directory: ./backend
- name: Run All Node.js Tests
run: npm run test
working-directory: ./backend

View File

@@ -1,11 +0,0 @@
{
"recommendations": [
"angular.ng-template",
"dbaeumer.vscode-eslint",
"waderyan.gitblame",
"42crunch.vscode-openapi",
"redhat.vscode-yaml",
"christian-kohler.npm-intellisense",
"hbenl.vscode-mocha-test-adapter"
]
}

14
.vscode/launch.json vendored
View File

@@ -4,20 +4,6 @@
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "Dev: Debug Backend",
"request": "launch",
"runtimeArgs": [
"run-script",
"debug"
],
"runtimeExecutable": "npm",
"skipFiles": [
"<node_internals>/**"
],
"type": "node",
"cwd": "${workspaceFolder}/backend"
},
{
"type": "node",
"request": "attach",

View File

@@ -1,8 +0,0 @@
{
"mochaExplorer.files": "backend/test/**/*.js",
"mochaExplorer.cwd": "backend",
"mochaExplorer.globImplementation": "vscode",
"mochaExplorer.env": {
// "YTDL_MODE": "debug"
}
}

45
.vscode/tasks.json vendored
View File

@@ -1,60 +1,25 @@
{
"version": "2.0.0",
"windows": {
"options": {
"shell": {
"executable": "cmd.exe",
"args": [
"/d", "/c"
]
}
}
},
"tasks": [
{
"type": "npm",
"script": "start",
"problemMatcher": [],
"label": "Dev: start frontend",
"detail": "ng serve",
"presentation": {
"echo": true,
"reveal": "always",
"focus": true,
"panel": "shared",
"showReuseMessage": true,
"clear": false
}
"detail": "ng serve"
},
{
"label": "Dev: start backend",
"type": "shell",
"command": "node app.js",
"command": "set YTDL_MODE=debug && node app.js",
"options": {
"cwd": "./backend",
"env": {
"YTDL_MODE": "debug"
}
"cwd": "./backend"
},
"presentation": {
"echo": true,
"reveal": "always",
"focus": true,
"panel": "shared",
"showReuseMessage": true,
"clear": false
"panel": "new"
},
"problemMatcher": [],
"dependsOn": ["Dev: post-build"]
},
{
"label": "Dev: post-build",
"type": "shell",
"command": "node src/postbuild.mjs"
},
{
"label": "Dev: run all",
"dependsOn": ["Dev: start backend", "Dev: start frontend"]
"problemMatcher": []
}
]
}

View File

@@ -1,38 +0,0 @@
<h1>Development</h1>
- [First time...](#first-time)
- [Setup](#setup)
- [Startup](#startup)
- [Debugging the backend (VSC)](#debugging-the-backend-vsc)
- [Deploy changes](#deploy-changes)
- [Frontend](#frontend)
- [Backend](#backend)
# First time...
## Setup
Checkout the repository and navigate to the `youtubedl-material` directory.
```bash
vim ./src/assets/default.json # Edit settings for your local environment. This config file is just the dev config file, if YTDL_MODE is not set to "debug", then ./backend/appdata/default.json will be used
npm -g install pm2 # Install pm2
npm install # Install dependencies for the frontend
cd ./backend
npm install # Install dependencies for the backend
cd ..
npm run build # Build the frontend
```
This step have to be done only once.
## Startup
Navigate to the `youtubedl-material/backend` directory and run `npm start`.
# Debugging the backend (VSC)
Open the `youtubedl-material` directory in Visual Studio Code and run the launch configuration `Dev: Debug Backend`.
# Deploy changes
## Frontend
Navigate to the `youtubedl-material` directory and run `npm run build`. Restart the backend.
## Backend
Simply restart the backend.

View File

@@ -1,97 +1,58 @@
# Fetching our utils
FROM ubuntu:22.04 AS utils
FROM ubuntu:20.04 AS ffmpeg
RUN apt-get update && apt-get -y install curl xz-utils
COPY docker-build.sh .
RUN sh ./docker-build.sh
FROM ubuntu:20.04 as frontend
ENV DEBIAN_FRONTEND=noninteractive
# Use script due local build compability
COPY docker-utils/*.sh .
RUN chmod +x *.sh
RUN sh ./ffmpeg-fetch.sh
RUN sh ./fetch-twitchdownloader.sh
RUN apt-get update
RUN apt-get -y install curl gnupg
RUN curl -sL https://deb.nodesource.com/setup_12.x | bash -
RUN apt-get -y install nodejs
# Create our Ubuntu 22.04 with node 16.14.2 (that specific version is required as per: https://stackoverflow.com/a/72855258/8088021)
# Go to 20.04
FROM ubuntu:22.04 AS base
ARG TARGETPLATFORM
ARG DEBIAN_FRONTEND=noninteractive
ENV UID=1000
ENV GID=1000
ENV USER=youtube
ENV NO_UPDATE_NOTIFIER=true
ENV PM2_HOME=/app/pm2
ENV ALLOW_CONFIG_MUTATIONS=true
ENV npm_config_cache=/app/.npm
# Use NVM to get specific node version
ENV NODE_VERSION=16.14.2
RUN groupadd -g $GID $USER && useradd --system -m -g $USER --uid $UID $USER && \
apt update && \
apt install -y --no-install-recommends curl ca-certificates tzdata libicu70 libatomic1 && \
apt clean && \
rm -rf /var/lib/apt/lists/*
RUN mkdir /usr/local/nvm
ENV PATH="/usr/local/nvm/versions/node/v${NODE_VERSION}/bin/:${PATH}"
ENV NVM_DIR=/usr/local/nvm
RUN curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.5/install.sh | bash
RUN . "$NVM_DIR/nvm.sh" && nvm install ${NODE_VERSION}
RUN . "$NVM_DIR/nvm.sh" && nvm use v${NODE_VERSION}
RUN . "$NVM_DIR/nvm.sh" && nvm alias default v${NODE_VERSION}
# Build frontend
ARG BUILDPLATFORM
FROM --platform=${BUILDPLATFORM} node:16 as frontend
RUN npm install -g @angular/cli
WORKDIR /build
COPY [ "package.json", "package-lock.json", "angular.json", "tsconfig.json", "/build/" ]
COPY [ "package.json", "package-lock.json", "/build/" ]
RUN npm install
COPY [ "angular.json", "tsconfig.json", "/build/" ]
COPY [ "src/", "/build/src/" ]
RUN npm install && \
npm run build && \
ls -al /build/backend/public
RUN npm uninstall -g @angular/cli
RUN rm -rf node_modules
RUN npm run build
#--------------#
FROM ubuntu:20.04
ENV UID=1000 \
GID=1000 \
USER=youtube \
NO_UPDATE_NOTIFIER=true
RUN groupadd -g $GID $USER && useradd --system -g $USER --uid $UID $USER
RUN apt-get update && apt-get -y install curl
RUN curl -sL https://deb.nodesource.com/setup_12.x | bash -
RUN apt-get update && apt-get -y install \
nodejs \
python2 \
python3 \
atomicparsley
# Install backend deps
FROM base as backend
WORKDIR /app
COPY [ "backend/","/app/" ]
RUN npm config set strict-ssl false && \
npm install --prod && \
ls -al
COPY --from=ffmpeg /usr/local/bin/ffmpeg /usr/local/bin/ffmpeg
COPY --from=ffmpeg /usr/local/bin/ffprobe /usr/local/bin/ffprobe
COPY --chown=$UID:$GID [ "backend/package.json", "backend/package-lock.json", "/app/" ]
ENV PM2_HOME=/app/pm2
RUN npm install pm2 -g && \
npm install && chown -R $UID:$GID ./
#FROM base as python
# armv7 need build from source
#WORKDIR /app
#COPY docker-utils/GetTwitchDownloader.py .
#RUN apt update && \
# apt install -y --no-install-recommends python3-minimal python-is-python3 python3-pip python3-dev build-essential libffi-dev && \
# apt clean && \
# rm -rf /var/lib/apt/lists/*
#RUN pip install PyGithub requests
#RUN python GetTwitchDownloader.py
# Final image
FROM base
RUN npm install -g pm2 && \
apt update && \
apt install -y --no-install-recommends gosu python3-minimal python-is-python3 python3-pip atomicparsley build-essential && \
pip install pycryptodomex && \
apt remove -y --purge build-essential && \
apt autoremove -y --purge && \
apt clean && \
rm -rf /var/lib/apt/lists/*
WORKDIR /app
# User 1000 already exist from base image
COPY --chown=$UID:$GID --from=utils [ "/usr/local/bin/ffmpeg", "/usr/local/bin/ffmpeg" ]
COPY --chown=$UID:$GID --from=utils [ "/usr/local/bin/ffprobe", "/usr/local/bin/ffprobe" ]
COPY --chown=$UID:$GID --from=utils [ "/usr/local/bin/TwitchDownloaderCLI", "/usr/local/bin/TwitchDownloaderCLI"]
COPY --chown=$UID:$GID --from=backend ["/app/","/app/"]
COPY --chown=$UID:$GID --from=frontend [ "/build/backend/public/", "/app/public/" ]
#COPY --chown=$UID:$GID --from=python ["/app/TwitchDownloaderCLI","/usr/local/bin/TwitchDownloaderCLI"]
RUN chmod +x /app/fix-scripts/*.sh
# Add some persistence data
#VOLUME ["/app/appdata"]
COPY --chown=$UID:$GID [ "/backend/", "/app/" ]
EXPOSE 17442
ENTRYPOINT [ "/app/entrypoint.sh" ]
CMD [ "npm","start" ]
# ENTRYPOINT [ "/app/entrypoint.sh" ]
CMD [ "pm2-runtime", "pm2.config.js" ]

View File

@@ -1,2 +0,0 @@
FROM tzahi12345/youtubedl-material:latest
CMD [ "npm", "start" ]

1
Procfile Normal file
View File

@@ -0,0 +1 @@
web: npm start --prefix backend

View File

@@ -97,11 +97,6 @@ paths:
summary: Get all files
description: Gets all files and playlists stored in the db
operationId: get-getAllFiles
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/GetAllFilesRequest'
responses:
'200':
description: OK
@@ -111,37 +106,6 @@ paths:
$ref: '#/components/schemas/GetAllFilesResponse'
security:
- Auth query parameter: []
/api/rss:
get:
tags:
- files
summary: Generates an RSS feed
description: Generates an RSS feed for downloaded files
operationId: get-rss
parameters:
- in: query
name: params
schema:
allOf:
- $ref: '#/components/schemas/GetAllFilesRequest'
- type: object
properties:
uuid:
type: string
description: user uid
default: null
style: form
explode: true
responses:
'200':
description: OK
content:
text/plain:
schema:
type: string
description: RSS feed
security:
- Auth query parameter: []
/api/getFile:
post:
tags:
@@ -165,27 +129,6 @@ paths:
description: User is not authorized to view the file.
security:
- Auth query parameter: []
/api/updateFile:
post:
tags:
- files
summary: Updates file database object
description: Updates a file db object using its uid and a change object.
operationId: post-updateFile
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/UpdateFileRequest'
responses:
'200':
description: OK
content:
application/json:
schema:
$ref: '#/components/schemas/SuccessObject'
security:
- Auth query parameter: []
/api/enableSharing:
post:
tags:
@@ -293,48 +236,6 @@ paths:
$ref: '#/components/schemas/UnsubscribeResponse'
security:
- Auth query parameter: []
/api/checkSubscription:
post:
tags:
- subscriptions
summary: Run a check for videos for a subscription
description: Runs a subscription check
operationId: post-api-checksubscription
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/CheckSubscriptionRequest'
responses:
'200':
description: OK
content:
application/json:
schema:
$ref: '#/components/schemas/SuccessObject'
security:
- Auth query parameter: []
/api/cancelCheckSubscription:
post:
tags:
- subscriptions
summary: Cancels check for videos for a subscription
description: Cancels subscription check
operationId: post-api-checksubscription
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/CheckSubscriptionRequest'
responses:
'200':
description: OK
content:
application/json:
schema:
$ref: '#/components/schemas/SuccessObject'
security:
- Auth query parameter: []
/api/deleteSubscriptionFile:
post:
tags:
@@ -620,69 +521,6 @@ paths:
description: If the archive dir is not found, 404 is sent as a response
security:
- Auth query parameter: []
/api/deleteArchiveItems:
post:
tags:
- archive
summary: Delete item from archive
description: 'Deletes an item from the archive'
operationId: post-api-deleteArchiveItems
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/DeleteArchiveItemsRequest'
responses:
'200':
description: OK
content:
application/json:
schema:
$ref: '#/components/schemas/SuccessObject'
security:
- Auth query parameter: []
/api/importArchive:
post:
tags:
- archive
summary: Imports archive
description: 'Imports an existing archive.txt file'
operationId: post-api-importArchive
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/ImportArchiveRequest'
responses:
'200':
description: OK
content:
application/json:
schema:
$ref: '#/components/schemas/SuccessObject'
security:
- Auth query parameter: []
/api/uploadCookies:
post:
tags:
- downloader
summary: Upload cookies
description: 'Uploads cookies file to be used during downloading'
operationId: post-api-uploadCookies
requestBody:
content:
multipart/form-data:
schema:
$ref: '#/components/schemas/UploadCookiesRequest'
responses:
'200':
description: OK
content:
application/json:
schema:
$ref: '#/components/schemas/SuccessObject'
security:
- Auth query parameter: []
/api/updaterStatus:
get:
tags:
@@ -947,7 +785,7 @@ paths:
content:
application/json:
schema:
$ref: '#/components/schemas/RestartDownloadResponse'
$ref: '#/components/schemas/SuccessObject'
requestBody:
content:
application/json:
@@ -1003,10 +841,17 @@ paths:
- Auth query parameter: []
tags:
- downloader
/api/clearDownloads:
/api/clearFinishedDownloads:
post:
summary: Clear multiple downloads
operationId: post-api-clear-downloads
tags:
- downloader
summary: Clear finished downloads
operationId: post-api-clear-finished-downloads
requestBody:
content:
application/json:
schema:
type: object
responses:
'200':
description: OK
@@ -1014,17 +859,8 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/SuccessObject'
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/ClearDownloadsRequest'
description: ''
description: "Clears multiple downloads based on a given filter."
security:
- Auth query parameter: []
tags:
- downloader
/api/getTask:
post:
summary: Get info for one task
@@ -1671,8 +1507,6 @@ components:
properties:
success:
type: boolean
error:
type: string
FileType:
type: string
enum:
@@ -1725,10 +1559,6 @@ components:
type: string
description: Height of the video, if known
example: '1080'
maxHeight:
type: string
description: Max height that should be used, useful for playlists. selectedHeight will override this.
example: '1080'
maxBitrate:
type: string
description: Specify ffmpeg/avconv audio quality
@@ -1737,9 +1567,6 @@ components:
$ref: '#/components/schemas/FileType'
cropFileSettings:
$ref: '#/components/schemas/CropFileSettings'
ignoreArchive:
type: boolean
description: If using youtube-dl archive, download will ignore it
DownloadResponse:
type: object
properties:
@@ -1764,13 +1591,6 @@ components:
properties:
download:
$ref: '#/components/schemas/Download'
RestartDownloadResponse:
allOf:
- $ref: '#/components/schemas/SuccessObject'
- type: object
properties:
new_download_uid:
type: string
GetAllDownloadsRequest:
type: object
properties:
@@ -1787,27 +1607,18 @@ components:
type: array
items:
$ref: '#/components/schemas/Download'
ClearDownloadsRequest:
type: object
properties:
clear_finished:
type: boolean
clear_paused:
type: boolean
clear_errors:
type: boolean
GetTaskRequest:
type: object
properties:
task_key:
$ref: '#/components/schemas/TaskType'
type: string
required:
- task_key
UpdateTaskScheduleRequest:
type: object
properties:
task_key:
$ref: '#/components/schemas/TaskType'
type: string
new_schedule:
$ref: '#/components/schemas/Schedule'
required:
@@ -1817,22 +1628,12 @@ components:
type: object
properties:
task_key:
$ref: '#/components/schemas/TaskType'
type: string
new_data:
type: object
required:
- task_key
- new_data
UpdateTaskOptionsRequest:
type: object
properties:
task_key:
$ref: '#/components/schemas/TaskType'
new_options:
type: object
required:
- task_key
- new_options
GetTaskResponse:
type: object
properties:
@@ -1889,51 +1690,6 @@ components:
description: All video playlists
items:
$ref: '#/components/schemas/Playlist'
GetAllFilesRequest:
type: object
properties:
sort:
$ref: '#/components/schemas/Sort'
range:
type: array
items:
type: number
description: Two elements allowed, start index and end index
minItems: 2
maxItems: 2
default: null
text_search:
type: string
description: Filter files by title
default: null
file_type_filter:
$ref: '#/components/schemas/FileTypeFilter'
favorite_filter:
type: boolean
description: If set to true, only gets favorites
default: false
sub_id:
type: string
description: Include if you want to filter by subscription
default: null
Sort:
type: object
properties:
by:
type: string
description: Property to sort by
default: registered
order:
type: number
description: 1 for ascending, -1 for descending
default: -1
FileTypeFilter:
type: string
enum:
- audio_only
- video_only
- both
default: both
GetAllFilesResponse:
required:
- files
@@ -1971,18 +1727,6 @@ components:
type: boolean
file:
$ref: '#/components/schemas/DatabaseFile'
UpdateFileRequest:
required:
- uid
- change_obj
type: object
properties:
uid:
type: string
description: Video UID
change_obj:
type: object
description: Object with fields to update as keys and their new values
SharingToggle:
required:
- uid
@@ -1996,6 +1740,7 @@ components:
required:
- name
- url
- streamingOnly
type: object
properties:
name:
@@ -2023,11 +1768,11 @@ components:
type: string
UnsubscribeRequest:
required:
- sub_id
- sub
type: object
properties:
sub_id:
type: string
sub:
$ref: '#/components/schemas/SubscriptionRequestData'
deleteMode:
type: boolean
description: Defaults to false
@@ -2040,13 +1785,6 @@ components:
type: boolean
error:
type: string
CheckSubscriptionRequest:
required:
- sub_id
type: object
properties:
sub_id:
type: string
DeleteAllFilesResponse:
type: object
properties:
@@ -2058,11 +1796,16 @@ components:
description: Number of files removed
DeleteSubscriptionFileRequest:
required:
- file_uid
- file
- sub
type: object
properties:
file:
type: string
file_uid:
type: string
sub:
$ref: '#/components/schemas/SubscriptionRequestData'
deleteForever:
type: boolean
description: 'If true, does not remove id from archive. Only valid if youtube-dl archive is enabled in settings.'
@@ -2110,6 +1853,7 @@ components:
- uids
- playlistName
- thumbnailURL
- type
type: object
properties:
playlistName:
@@ -2118,6 +1862,8 @@ components:
type: array
items:
type: string
type:
$ref: '#/components/schemas/FileType'
thumbnailURL:
type: string
CreatePlaylistResponse:
@@ -2147,17 +1893,15 @@ components:
required:
- playlist
- success
- type
type: object
properties:
playlist:
$ref: '#/components/schemas/Playlist'
type:
$ref: '#/components/schemas/FileType'
success:
type: boolean
file_objs:
type: array
description: File objects for every uid in the playlist's uids property, in the same order
items:
$ref: '#/components/schemas/DatabaseFile'
GetPlaylistsRequest:
type: object
properties:
@@ -2182,10 +1926,13 @@ components:
DeletePlaylistRequest:
required:
- playlist_id
- type
type: object
properties:
playlist_id:
type: string
type:
$ref: '#/components/schemas/FileType'
DownloadFileRequest:
type: object
properties:
@@ -2202,83 +1949,17 @@ components:
type:
$ref: '#/components/schemas/FileType'
DownloadArchiveRequest:
type: object
properties:
type:
$ref: '#/components/schemas/FileType'
sub_id:
type: string
Archive:
required:
- extractor
- id
- type
- title
- timestamp
- uid
- sub
type: object
properties:
extractor:
type: string
id:
type: string
type:
$ref: '#/components/schemas/FileType'
title:
type: string
user_uid:
type: string
sub_id:
type: string
timestamp:
type: number
uid:
type: string
DeleteArchiveItemsRequest:
type: object
required:
- archives
properties:
archives:
type: array
items:
$ref: '#/components/schemas/Archive'
ImportArchiveRequest:
type: object
required:
- archive
- type
properties:
archive:
type: string
type:
$ref: '#/components/schemas/FileType'
sub_id:
type: string
GetArchivesRequest:
type: object
properties:
type:
$ref: '#/components/schemas/FileType'
sub_id:
type: string
GetArchivesResponse:
type: object
required:
- archives
properties:
archives:
type: array
items:
$ref: '#/components/schemas/Archive'
UploadCookiesRequest:
type: object
required:
- cookies
properties:
cookies:
type: string
format: binary
sub:
required:
- archive_dir
type: object
properties:
archive_dir:
type: string
UpdaterStatus:
required:
- details
@@ -2299,6 +1980,8 @@ components:
tag:
type: string
DBInfoResponse:
required:
- db_info
type: object
properties:
using_local_db:
@@ -2320,8 +2003,6 @@ components:
$ref: '#/components/schemas/TableInfo'
download_queue:
$ref: '#/components/schemas/TableInfo'
archives:
$ref: '#/components/schemas/TableInfo'
TransferDBResponse:
required:
- success
@@ -2472,6 +2153,7 @@ components:
type: boolean
result:
allOf:
- $ref: '#/components/schemas/file'
- type: object
properties:
formats:
@@ -2621,7 +2303,6 @@ components:
- upload_date
- uploader
- url
- favorite
type: object
properties:
id:
@@ -2630,9 +2311,6 @@ components:
type: string
thumbnailURL:
type: string
description: Backup if thumbnailPath is not defined
thumbnailPath:
type: string
isAudio:
type: boolean
duration:
@@ -2644,35 +2322,14 @@ components:
type: string
size:
type: number
description: In bytes
path:
type: string
upload_date:
type: string
uid:
type: string
user_uid:
type: string
sharingEnabled:
type: boolean
category:
$ref: '#/components/schemas/Category'
view_count:
type: number
local_view_count:
type: number
sub_id:
type: string
registered:
type: number
height:
type: number
description: In pixels, only for videos
abr:
type: number
description: In Kbps
favorite:
type: boolean
Playlist:
required:
- uids
@@ -2702,10 +2359,6 @@ components:
type: number
user_uid:
type: string
auto:
type: boolean
sharingEnabled:
type: boolean
Download:
required:
- url
@@ -2732,8 +2385,6 @@ components:
type: boolean
paused:
type: boolean
cancelled:
type: boolean
finished_step:
type: boolean
url:
@@ -2752,18 +2403,12 @@ components:
type: string
description: Error text, set if download fails.
nullable: true
error_type:
type: string
description: Error type, may or may not be set in case of an error
nullable: true
user_uid:
type: string
sub_id:
type: string
sub_name:
type: string
prefetched_info:
type: object
Task:
required:
- key
@@ -2777,8 +2422,6 @@ components:
type: object
properties:
key:
$ref: '#/components/schemas/TaskType'
title:
type: string
last_ran:
type: number
@@ -2793,20 +2436,7 @@ components:
error:
type: string
schedule:
$ref: '#/components/schemas/Schedule'
options:
type: object
TaskType:
type: string
enum:
- backup_local_db
- missing_files_check
- missing_db_records
- duplicate_files_check
- youtubedl_update_check
- delete_old_files
- import_legacy_archives
- rebuild_database
Schedule:
required:
- type
@@ -2831,8 +2461,6 @@ components:
type: number
timestamp:
type: number
tz:
type: string
DBBackup:
required:
- name
@@ -2875,6 +2503,7 @@ components:
- url
- type
- user_uid
- streamingOnly
- isPlaylist
- videos
type: object
@@ -2890,10 +2519,10 @@ components:
user_uid:
type: string
nullable: true
streamingOnly:
type: boolean
isPlaylist:
type: boolean
child_process:
type: object
archive:
type: string
timerange:
@@ -2902,10 +2531,6 @@ components:
type: string
custom_output:
type: string
downloading:
type: boolean
paused:
type: boolean
videos:
type: array
items:
@@ -2920,6 +2545,28 @@ components:
type: string
passhash:
type: string
files:
type: object
properties:
audio:
type: array
items:
$ref: '#/components/schemas/file'
video:
type: array
items:
$ref: '#/components/schemas/file'
playlists:
type: object
properties:
audio:
type: array
items:
$ref: '#/components/schemas/file'
video:
type: array
items:
$ref: '#/components/schemas/file'
subscriptions:
type: array
items:
@@ -2945,7 +2592,6 @@ components:
- sharing
- advanced_download
- downloads_manager
- tasks_manager
YesNo:
type: string
enum:
@@ -3028,44 +2674,6 @@ components:
type: string
date:
type: string
Notification:
required:
- uid
- type
- text
- read
- timestamp
type: object
properties:
type:
$ref: '#/components/schemas/NotificationType'
uid:
type: string
user_uid:
type: string
action:
type: array
items:
$ref: '#/components/schemas/NotificationAction'
read:
type: boolean
data:
type: object
timestamp:
type: number
NotificationAction:
type: string
enum:
- play
- retry_download
- view_download_error
- view_tasks
NotificationType:
type: string
enum:
- download_complete
- download_error
- task_finished
BaseChangePermissionsRequest:
required:
- permission
@@ -3197,29 +2805,6 @@ components:
type: array
items:
$ref: '#/components/schemas/UserPermission'
DeleteNotificationRequest:
required:
- uid
type: object
properties:
uid:
type: string
SetNotificationsToReadRequest:
required:
- uids
type: object
properties:
uids:
type: array
items:
type: string
GetNotificationsResponse:
type: object
properties:
notifications:
type: array
items:
$ref: '#/components/schemas/Notification'
securitySchemes:
Auth query parameter:
name: apiKey

View File

@@ -6,15 +6,25 @@
[![GitHub issues badge](https://img.shields.io/github/issues/Tzahi12345/YoutubeDL-Material)](https://github.com/Tzahi12345/YoutubeDL-Material/issues)
[![License badge](https://img.shields.io/github/license/Tzahi12345/YoutubeDL-Material)](https://github.com/Tzahi12345/YoutubeDL-Material/blob/master/LICENSE.md)
YoutubeDL-Material is a Material Design frontend for [youtube-dl](https://rg3.github.io/youtube-dl/). It's coded using [Angular 15](https://angular.io/) for the frontend, and [Node.js](https://nodejs.org/) on the backend.
YoutubeDL-Material is a Material Design frontend for [youtube-dl](https://rg3.github.io/youtube-dl/). It's coded using [Angular 11](https://angular.io/) for the frontend, and [Node.js](https://nodejs.org/) on the backend.
Now with [Docker](#Docker) support!
<hr>
### USAGE OF THE NIGHTLY BUILDS IS HIGHLY RECOMMENDED.
For much better scaling with large datasets please run your YTDL-M instance with a MongoDB backend rather than the json file-based default.
It will fix a lot of performance problems (especially with datasets in the tens of thousands videos/audios)!
The (closed) issues as well as the project's Wiki will give you good starting points for your journey!
For MongoDB specifically there is [this little guide](https://github.com/Tzahi12345/YoutubeDL-Material/wiki/Setting-a-MongoDB-backend-to-use-as-database-provider-for-YTDL-M).
<hr>
## Getting Started
Check out the prerequisites, and go to the [installation](#Installing) section. Easy as pie!
Check out the prerequisites, and go to the installation section. Easy as pie!
Here's an image of what it'll look like once you're done:
@@ -28,28 +38,13 @@ Dark mode:
NOTE: If you would like to use Docker, you can skip down to the [Docker](#Docker) section for a setup guide.
Required dependencies:
* Node.js 16
* Python
Optional dependencies:
* AtomicParsley (for embedding thumbnails, package name `atomicparsley`)
* [Twitch Downloader CLI](https://github.com/lay295/TwitchDownloader) (for downloading Twitch VOD chats)
<details>
<summary>Debian/Ubuntu</summary>
Debian/Ubuntu:
```bash
curl -fsSL https://deb.nodesource.com/setup_16.x | sudo -E bash -
sudo apt-get install nodejs youtube-dl ffmpeg unzip python npm
```
</details>
<details>
<summary>CentOS 7</summary>
CentOS 7:
```bash
sudo yum install epel-release
@@ -57,16 +52,15 @@ sudo yum localinstall --nogpgcheck https://download1.rpmfusion.org/free/el/rpmfu
sudo yum install centos-release-scl-rh
sudo yum install rh-nodejs12
scl enable rh-nodejs12 bash
curl -fsSL https://rpm.nodesource.com/setup_16.x | sudo bash -
sudo yum install nodejs youtube-dl ffmpeg ffmpeg-devel
```
</details>
Optional dependencies:
* AtomicParsley (for embedding thumbnails, package name `atomicparsley`)
### Installing
If you are using Docker, skip to the [Docker](#Docker) section. Otherwise, continue:
1. First, download the [latest release](https://github.com/Tzahi12345/YoutubeDL-Material/releases/latest)!
2. Drag the `youtubedl-material` directory to an easily accessible directory. Navigate to the `appdata` folder and edit the `default.json` file.
@@ -85,9 +79,7 @@ If you'd like to install YoutubeDL-Material, go to the Installation section. If
To deploy, simply clone the repository, and go into the `youtubedl-material` directory. Type `npm install` and all the dependencies will install. Then type `cd backend` and again type `npm install` to install the dependencies for the backend.
Once you do that, you're almost up and running. All you need to do is edit the configuration in `youtubedl-material/appdata`, go back into the `youtubedl-material` directory, and type `npm run build`. This will build the app, and put the output files in the `youtubedl-material/backend/public` folder.
Lastly, type `npm -g install pm2` to install pm2 globally.
Once you do that, you're almost up and running. All you need to do is edit the configuration in `youtubedl-material/appdata`, go back into the `youtubedl-material` directory, and type `npm build`. This will build the app, and put the output files in the `youtubedl-material/backend/public` folder.
The frontend is now complete. The backend is much easier. Just go into the `backend` folder, and type `npm start`.
@@ -99,7 +91,7 @@ Alternatively, you can port forward the port specified in the config (defaults t
### Host-specific instructions
If you're on a Synology NAS, unRAID, Raspberry Pi 4 or any other possible special case you can check if there's known issues or instructions both in the issue tracker and in the [Wiki!](https://github.com/Tzahi12345/YoutubeDL-Material/wiki#environment-specific-guideshelp)
If you're on a Synology NAS, unRAID or any other possible special case you can check if there's known issues or instructions both in the issue tracker and in the [Wiki!](https://github.com/Tzahi12345/YoutubeDL-Material/wiki#environment-specific-guideshelp)
### Setup
@@ -110,6 +102,8 @@ If you are looking to setup YoutubeDL-Material with Docker, this section is for
3. Run `docker-compose up` to start it up. If successful, it should say "HTTP(S): Started on port 17443" or something similar. This tells you the *container-internal* port of the application. Please check your `docker-compose.yml` file for the *external* port. If you downloaded the file as described above, it defaults to **8998**.
4. Make sure you can connect to the specified URL + *external* port, and if so, you are done!
NOTE: It is currently recommended that you use the `nightly` tag on Docker. To do so, simply update the docker-compose.yml `image` field so that it points to `tzahi12345/youtubedl-material:nightly`.
### Custom UID/GID
By default, the Docker container runs as non-root with UID=1000 and GID=1000. To set this to your own UID/GID, simply update the `environment` section in your `docker-compose.yml` like so:
@@ -120,12 +114,6 @@ environment:
GID: YOUR_GID
```
## MongoDB
For much better scaling with large datasets please run your YoutubeDL-Material instance with MongoDB backend rather than the json file-based default. It will fix a lot of performance problems (especially with datasets in the tens of thousands videos/audios)!
[Tutorial](https://github.com/Tzahi12345/YoutubeDL-Material/wiki/Setting-a-MongoDB-backend-to-use-as-database-provider-for-YTDL-M).
## API
[API Docs](https://youtubedl-material.stoplight.io/docs/youtubedl-material/Public%20API%20v1.yaml)

View File

@@ -2,16 +2,16 @@
## Supported Versions
If you would like to see the latest updates, use the `nightly` tag on Docker.
Currently all work on this project goes into the nightly builds.
4.2's RELEASE build is now quite old and should be considered legacy.
We urge users to use the nightly releases, because the project
constantly sees fixes.
If you'd like to stick with more stable releases, use the `latest` tag on Docker or download the [latest release here](https://github.com/Tzahi12345/YoutubeDL-Material/releases/latest).
| Version | Supported |
| -------------------- | ------------------ |
| 4.3 Docker Nightlies | :white_check_mark: |
| 4.3 Release | :white_check_mark: |
| 4.2 Release | :x: |
| < 4.2 | :x: |
| Version | Supported |
| ------------- | ------------------ |
| 4.2 Nightlies | :white_check_mark: |
| 4.2 Release | :x: |
| < 4.2 | :x: |
## Reporting a Vulnerability

View File

@@ -30,8 +30,7 @@
"src/backend"
],
"styles": [
"src/styles.scss",
"src/bootstrap.min.css"
"src/styles.scss"
],
"scripts": [],
"vendorChunk": true,
@@ -66,14 +65,6 @@
}
]
},
"codespaces": {
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.codespaces.ts"
}
]
},
"es": {
"localize": ["es"]
}
@@ -91,9 +82,6 @@
},
"es": {
"browserTarget": "youtube-dl-material:build:es"
},
"codespaces": {
"browserTarget": "youtube-dl-material:build:codespaces"
}
}
},
@@ -130,8 +118,7 @@
"src/backend"
],
"styles": [
"src/styles.scss",
"src/bootstrap.min.css"
"src/styles.scss"
],
"scripts": []
},
@@ -164,8 +151,7 @@
"tsConfig": "src/tsconfig.spec.json",
"scripts": [],
"styles": [
"src/styles.scss",
"src/bootstrap.min.css"
"src/styles.scss"
],
"assets": [
"src/assets",
@@ -193,6 +179,7 @@
}
}
},
"defaultProject": "youtube-dl-material",
"schematics": {
"@schematics/angular:component": {
"prefix": "app",
@@ -201,8 +188,5 @@
"@schematics/angular:directive": {
"prefix": "app"
}
},
"cli": {
"analytics": false
}
}

View File

@@ -2,7 +2,6 @@
"name": "YoutubeDL-Material",
"description": "An open-source and self-hosted YouTube downloader based on Google's Material Design specifications.",
"repository": "https://github.com/Tzahi12345/YoutubeDL-Material",
"stack": "container",
"logo": "https://i.imgur.com/GPzvPiU.png",
"keywords": ["youtube-dl", "youtubedl-material", "nodejs"]
}

49
armhf.Dockerfile Normal file
View File

@@ -0,0 +1,49 @@
FROM alpine:3.12 as frontend
RUN apk add --no-cache \
npm \
curl
RUN npm install -g @angular/cli
WORKDIR /build
RUN curl -L https://github.com/balena-io/qemu/releases/download/v3.0.0%2Bresin/qemu-3.0.0+resin-arm.tar.gz | tar zxvf - -C . && mv qemu-3.0.0+resin-arm/qemu-arm-static .
COPY [ "package.json", "package-lock.json", "/build/" ]
RUN npm install
COPY [ "angular.json", "tsconfig.json", "/build/" ]
COPY [ "src/", "/build/src/" ]
RUN ng build --prod
#--------------#
FROM arm32v7/alpine:3.12
COPY --from=frontend /build/qemu-arm-static /usr/bin
ENV UID=1000 \
GID=1000 \
USER=youtube
RUN addgroup -S $USER -g $GID && adduser -D -S $USER -G $USER -u $UID
RUN apk add --no-cache \
ffmpeg \
npm \
python2 \
su-exec \
&& apk add --no-cache --repository http://dl-cdn.alpinelinux.org/alpine/edge/testing/ \
atomicparsley
WORKDIR /app
COPY --chown=$UID:$GID [ "backend/package.json", "backend/package-lock.json", "/app/" ]
RUN npm install && chown -R $UID:$GID ./
COPY --chown=$UID:$GID --from=frontend [ "/build/backend/public/", "/app/public/" ]
COPY --chown=$UID:$GID [ "/backend/", "/app/" ]
EXPOSE 17442
ENTRYPOINT [ "/app/entrypoint.sh" ]
CMD [ "node", "app.js" ]

View File

@@ -1,7 +1,8 @@
const { v4: uuid } = require('uuid');
const { uuid } = require('uuidv4');
const fs = require('fs-extra');
const { promisify } = require('util');
const auth_api = require('./authentication/auth');
const winston = require('winston');
const path = require('path');
const compression = require('compression');
const multer = require('multer');
@@ -17,8 +18,11 @@ const URL = require('url').URL;
const CONSTS = require('./consts')
const read_last_lines = require('read-last-lines');
const ps = require('ps-node');
const Feed = require('feed').Feed;
const session = require('express-session');
// needed if bin/details somehow gets deleted
if (!fs.existsSync(CONSTS.DETAILS_BIN_PATH)) fs.writeJSONSync(CONSTS.DETAILS_BIN_PATH, {"version":"2000.06.06","path":"node_modules\\youtube-dl\\bin\\youtube-dl.exe","exec":"youtube-dl.exe","downloader":"youtube-dl"})
const youtubedl = require('youtube-dl');
const logger = require('./logger');
const config_api = require('./config.js');
@@ -28,9 +32,6 @@ const subscriptions_api = require('./subscriptions');
const categories_api = require('./categories');
const twitch_api = require('./twitch');
const youtubedl_api = require('./youtube-dl');
const archive_api = require('./archive');
const files_api = require('./files');
const notifications_api = require('./notifications');
var app = express();
@@ -67,9 +68,7 @@ db.defaults(
configWriteFlag: false,
downloads: {},
subscriptions: [],
files_to_db_migration_complete: false,
tasks_manager_role_migration_complete: false,
archives_migration_complete: false
files_to_db_migration_complete: false
}).write();
users_db.defaults(
@@ -102,6 +101,7 @@ let backendPort = null;
let useDefaultDownloadingAgent = null;
let customDownloadingAgent = null;
let allowSubscriptions = null;
let archivePath = path.join(__dirname, 'appdata', 'archives');
// other needed values
let url_domain = null;
@@ -148,19 +148,22 @@ if (fs.existsSync('version.json')) {
// don't overwrite config if it already happened.. NOT
// let alreadyWritten = db.get('configWriteFlag').value();
let writeConfigMode = process.env.write_ytdl_config;
// checks if config exists, if not, a config is auto generated
config_api.configExistsCheck();
setAndLoadConfig();
if (writeConfigMode) {
setAndLoadConfig();
} else {
loadConfig();
}
app.use(bodyParser.urlencoded({ extended: false }));
app.use(bodyParser.json());
// use passport
app.use(auth_api.passport.initialize());
app.use(session({ secret: uuid(), resave: true, saveUninitialized: true }))
app.use(auth_api.passport.session());
// actual functions
@@ -171,10 +174,10 @@ async function checkMigrations() {
if (!simplified_db_migration_complete) {
logger.info('Beginning migration: 4.1->4.2+')
let success = await simplifyDBFileStructure();
success = success && await files_api.addMetadataPropertyToDB('view_count');
success = success && await files_api.addMetadataPropertyToDB('description');
success = success && await files_api.addMetadataPropertyToDB('height');
success = success && await files_api.addMetadataPropertyToDB('abr');
success = success && await db_api.addMetadataPropertyToDB('view_count');
success = success && await db_api.addMetadataPropertyToDB('description');
success = success && await db_api.addMetadataPropertyToDB('height');
success = success && await db_api.addMetadataPropertyToDB('abr');
// sets migration to complete
db.set('simplified_db_migration_complete', true).write();
if (success) { logger.info('4.1->4.2+ migration complete!'); }
@@ -185,31 +188,13 @@ async function checkMigrations() {
if (!new_db_system_migration_complete) {
logger.info('Beginning migration: 4.2->4.3+')
let success = await db_api.importJSONToDB(db.value(), users_db.value());
await tasks_api.setupTasks(); // necessary as tasks were not properly initialized at first
// sets migration to complete
db.set('new_db_system_migration_complete', true).write();
if (success) { logger.info('4.2->4.3+ migration complete!'); }
else { logger.error('Migration failed: 4.2->4.3+'); }
}
const tasks_manager_role_migration_complete = db.get('tasks_manager_role_migration_complete').value();
if (!tasks_manager_role_migration_complete) {
logger.info('Checking if tasks manager role permissions exist for admin user...');
const success = await auth_api.changeRolePermissions('admin', 'tasks_manager', 'yes');
if (success) logger.info('Task manager permissions check complete!');
else logger.error('Failed to auto add tasks manager permissions to admin role!');
db.set('tasks_manager_role_migration_complete', true).write();
}
const archives_migration_complete = db.get('archives_migration_complete').value();
if (!archives_migration_complete) {
logger.info('Checking if archives have been migrated...');
const imported_archives = await archive_api.importArchives();
if (imported_archives) logger.info('Archives migration complete!');
else logger.error('Failed to migrate archives!');
db.set('archives_migration_complete', true).write();
}
return true;
}
@@ -264,6 +249,14 @@ async function startServer() {
});
}
async function restartServer(is_update = false) {
logger.info(`${is_update ? 'Update complete! ' : ''}Restarting server...`);
// the following line restarts the server through nodemon
fs.writeFileSync(`restart${is_update ? '_update' : '_general'}.json`, 'internal use only');
process.exit(1);
}
async function updateServer(tag) {
// no tag provided means update to the latest version
if (!tag) {
@@ -304,7 +297,7 @@ async function updateServer(tag) {
updating: true,
'details': 'Update complete! Restarting server...'
}
utils.restartServer(true);
restartServer(true);
}, err => {
logger.error(err);
updaterStatus = {
@@ -499,9 +492,8 @@ async function setAndLoadConfig() {
}
async function setConfigFromEnv() {
const config_items = getEnvConfigItems();
if (!config_items || config_items.length === 0) return true;
const success = config_api.setConfigItems(config_items);
let config_items = getEnvConfigItems();
let success = config_api.setConfigItems(config_items);
if (success) {
logger.info('Config items set using ENV variables.');
await utils.wait(100);
@@ -516,11 +508,13 @@ async function loadConfig() {
loadConfigValues();
// connect to DB
if (!config_api.getConfigItem('ytdl_use_local_db'))
await db_api.connectToDB();
await db_api.connectToDB();
db_api.database_initialized = true;
db_api.database_initialized_bs.next(true);
// creates archive path if missing
await fs.ensureDir(archivePath);
// check migrations
await checkMigrations();
@@ -531,10 +525,15 @@ async function loadConfig() {
if (allowSubscriptions) {
// set downloading to false
let subscriptions = await subscriptions_api.getAllSubscriptions();
subscriptions.forEach(async sub => subscriptions_api.writeSubscriptionMetadata(sub));
subscriptions_api.updateSubscriptionPropertyMultiple(subscriptions, {downloading: false, child_process: null});
subscriptions_api.updateSubscriptionPropertyMultiple(subscriptions, {downloading: false});
// runs initially, then runs every ${subscriptionCheckInterval} seconds
subscriptions_api.watchSubscriptionsInterval();
const watchSubscriptionsInterval = function() {
watchSubscriptions();
const subscriptionsCheckInterval = config_api.getConfigItem('ytdl_subscriptions_check_interval');
setTimeout(watchSubscriptionsInterval, subscriptionsCheckInterval*1000);
}
watchSubscriptionsInterval();
}
// start the server here
@@ -561,11 +560,69 @@ function loadConfigValues() {
url_domain = new URL(url);
let logger_level = config_api.getConfigItem('ytdl_logger_level');
utils.updateLoggerLevel(logger_level);
const possible_levels = ['error', 'warn', 'info', 'verbose', 'debug'];
if (!possible_levels.includes(logger_level)) {
logger.error(`${logger_level} is not a valid logger level! Choose one of the following: ${possible_levels.join(', ')}.`)
logger_level = 'info';
}
logger.level = logger_level;
winston.loggers.get('console').level = logger_level;
logger.transports[2].level = logger_level;
}
function calculateSubcriptionRetrievalDelay(subscriptions_amount) {
// frequency is once every 5 mins by default
const subscriptionsCheckInterval = config_api.getConfigItem('ytdl_subscriptions_check_interval');
let interval_in_ms = subscriptionsCheckInterval * 1000;
const subinterval_in_ms = interval_in_ms/subscriptions_amount;
return subinterval_in_ms;
}
async function watchSubscriptions() {
let subscriptions = await subscriptions_api.getAllSubscriptions();
if (!subscriptions) return;
const valid_subscriptions = subscriptions.filter(sub => !sub.paused);
let subscriptions_amount = valid_subscriptions.length;
let delay_interval = calculateSubcriptionRetrievalDelay(subscriptions_amount);
let current_delay = 0;
const multiUserMode = config_api.getConfigItem('ytdl_multi_user_mode');
for (let i = 0; i < valid_subscriptions.length; i++) {
let sub = valid_subscriptions[i];
// don't check the sub if the last check for the same subscription has not completed
if (subscription_timeouts[sub.id]) {
logger.verbose(`Subscription: skipped checking ${sub.name} as the last check for ${sub.name} has not completed.`);
continue;
}
if (!sub.name) {
logger.verbose(`Subscription: skipped check for subscription with uid ${sub.id} as name has not been retrieved yet.`);
continue;
}
logger.verbose('Watching ' + sub.name + ' with delay interval of ' + delay_interval);
setTimeout(async () => {
const multiUserModeChanged = config_api.getConfigItem('ytdl_multi_user_mode') !== multiUserMode;
if (multiUserModeChanged) {
logger.verbose(`Skipping subscription ${sub.name} due to multi-user mode change.`);
return;
}
await subscriptions_api.getVideosForSub(sub, sub.user_uid);
subscription_timeouts[sub.id] = false;
}, current_delay);
subscription_timeouts[sub.id] = true;
current_delay += delay_interval;
const subscriptionsCheckInterval = config_api.getConfigItem('ytdl_subscriptions_check_interval');
if (current_delay >= subscriptionsCheckInterval * 1000) current_delay = 0;
}
}
function getOrigin() {
if (process.env.CODESPACES) return `https://${process.env.CODESPACE_NAME}-4200.${process.env.GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN}`;
return url_domain.origin;
}
@@ -590,11 +647,37 @@ function generateEnvVarConfigItem(key) {
return {key: key, value: process['env'][key]};
}
// currently only works for single urls
async function getUrlInfos(url) {
let startDate = Date.now();
let result = [];
return new Promise(resolve => {
youtubedl.exec(url, ['--dump-json'], {maxBuffer: Infinity}, (err, output) => {
let new_date = Date.now();
let difference = (new_date - startDate)/1000;
logger.debug(`URL info retrieval delay: ${difference} seconds.`);
if (err) {
logger.error(`Error during retrieving formats for ${url}: ${err}`);
resolve(null);
}
let try_putput = null;
try {
try_putput = JSON.parse(output);
result = try_putput;
} catch(e) {
logger.error(`Failed to retrieve available formats for url: ${url}`);
}
resolve(result);
});
});
}
// youtube-dl functions
async function startYoutubeDL() {
// auto update youtube-dl
await youtubedl_api.checkForYoutubeDLUpdate();
const update_available = await youtubedl_api.checkForYoutubeDLUpdate();
if (update_available) await youtubedl_api.updateYoutubeDL(update_available);
}
app.use(function(req, res, next) {
@@ -614,7 +697,7 @@ app.use(function(req, res, next) {
next();
} else if (req.query.apiKey && config_api.getConfigItem('ytdl_use_api_key') && req.query.apiKey === config_api.getConfigItem('ytdl_api_key')) {
next();
} else if (req.path.includes('/api/stream/') || req.path.includes('/api/thumbnail/') || req.path.includes('/api/rss') || req.path.includes('/api/telegramRequest')) {
} else if (req.path.includes('/api/stream/') || req.path.includes('/api/thumbnail/')) {
next();
} else {
logger.verbose(`Rejecting request - invalid API use for endpoint: ${req.path}. API key received: ${req.query.apiKey}`);
@@ -635,7 +718,7 @@ const optionalJwt = async function (req, res, next) {
const uuid = using_body ? req.body.uuid : req.query.uuid;
const uid = using_body ? req.body.uid : req.query.uid;
const playlist_id = using_body ? req.body.playlist_id : req.query.playlist_id;
const file = !playlist_id ? auth_api.getUserVideo(uuid, uid, true) : await files_api.getPlaylist(playlist_id, uuid, true);
const file = !playlist_id ? auth_api.getUserVideo(uuid, uid, true) : await db_api.getPlaylist(playlist_id, uuid, true);
if (file) {
req.can_watch = true;
return next();
@@ -681,13 +764,13 @@ app.get('/api/versionInfo', (req, res) => {
app.post('/api/restartServer', optionalJwt, (req, res) => {
// delayed by a little bit so that the client gets a response
setTimeout(() => {utils.restartServer()}, 100);
setTimeout(() => {restartServer()}, 100);
res.send({success: true});
});
app.get('/api/getDBInfo', optionalJwt, async (req, res) => {
const db_info = await db_api.getDBStats();
res.send(db_info);
res.send({db_info: db_info});
});
app.post('/api/transferDB', optionalJwt, async (req, res) => {
@@ -727,13 +810,11 @@ app.post('/api/downloadFile', optionalJwt, async function(req, res) {
additionalArgs: req.body.additionalArgs,
customOutput: req.body.customOutput,
selectedHeight: req.body.selectedHeight,
maxHeight: req.body.maxHeight,
customQualityConfiguration: req.body.customQualityConfiguration,
youtubeUsername: req.body.youtubeUsername,
youtubePassword: req.body.youtubePassword,
ui_uid: req.body.ui_uid,
cropFileSettings: req.body.cropFileSettings,
ignoreArchive: req.body.ignoreArchive
cropFileSettings: req.body.cropFileSettings
};
const download = await downloader_api.createDownload(url, type, options, user_uid);
@@ -759,7 +840,6 @@ app.post('/api/generateArgs', optionalJwt, async function(req, res) {
additionalArgs: req.body.additionalArgs,
customOutput: req.body.customOutput,
selectedHeight: req.body.selectedHeight,
maxHeight: req.body.maxHeight,
customQualityConfiguration: req.body.customQualityConfiguration,
youtubeUsername: req.body.youtubeUsername,
youtubePassword: req.body.youtubePassword,
@@ -838,40 +918,45 @@ app.post('/api/getFile', optionalJwt, async function (req, res) {
app.post('/api/getAllFiles', optionalJwt, async function (req, res) {
// these are returned
const sort = req.body.sort;
const range = req.body.range;
const text_search = req.body.text_search;
const file_type_filter = req.body.file_type_filter;
const favorite_filter = req.body.favorite_filter;
const sub_id = req.body.sub_id;
let files = null;
let playlists = null;
let sort = req.body.sort;
let range = req.body.range;
let text_search = req.body.text_search;
let file_type_filter = req.body.file_type_filter;
const uuid = req.isAuthenticated() ? req.user.uid : null;
const {files, file_count} = await files_api.getAllFiles(sort, range, text_search, file_type_filter, favorite_filter, sub_id, uuid);
const filter_obj = {user_uid: uuid};
const regex = true;
if (text_search) {
if (regex) {
filter_obj['title'] = {$regex: `.*${text_search}.*`, $options: 'i'};
} else {
filter_obj['$text'] = { $search: utils.createEdgeNGrams(text_search) };
}
}
if (file_type_filter === 'audio_only') filter_obj['isAudio'] = true;
else if (file_type_filter === 'video_only') filter_obj['isAudio'] = false;
files = await db_api.getRecords('files', filter_obj, false, sort, range, text_search);
let file_count = await db_api.getRecords('files', filter_obj, true);
playlists = await db_api.getRecords('playlists', {user_uid: uuid});
const categories = await categories_api.getCategoriesAsPlaylists(files);
if (categories) {
playlists = playlists.concat(categories);
}
files = JSON.parse(JSON.stringify(files));
res.send({
files: files,
file_count: file_count,
playlists: playlists
});
});
app.post('/api/updateFile', optionalJwt, async function (req, res) {
const uid = req.body.uid;
const change_obj = req.body.change_obj;
const file = await db_api.updateRecord('files', {uid: uid}, change_obj);
if (!file) {
res.send({
success: false,
error: 'File could not be found'
});
} else {
res.send({
success: true
});
}
});
app.post('/api/checkConcurrentStream', async (req, res) => {
const uid = req.body.uid;
@@ -989,6 +1074,9 @@ app.post('/api/disableSharing', optionalJwt, async function(req, res) {
await db_api.updateRecord('files', {uid: uid}, {sharingEnabled: false})
} else if (is_playlist) {
await db_api.updateRecord(`playlists`, {id: uid}, {sharingEnabled: false});
} else if (type === 'subscription') {
// TODO: Implement. Main blocker right now is subscription videos are not stored in the DB, they are searched for every
// time they are requested from the subscription directory.
} else {
// error
success = false;
@@ -1003,7 +1091,7 @@ app.post('/api/disableSharing', optionalJwt, async function(req, res) {
});
});
app.post('/api/incrementViewCount', async (req, res) => {
app.post('/api/incrementViewCount', optionalJwt, async (req, res) => {
let file_uid = req.body.file_uid;
let sub_id = req.body.sub_id;
let uuid = req.body.uuid;
@@ -1012,7 +1100,7 @@ app.post('/api/incrementViewCount', async (req, res) => {
uuid = req.user.uid;
}
const file_obj = await files_api.getVideo(file_uid, uuid, sub_id);
const file_obj = await db_api.getVideo(file_uid, uuid, sub_id);
const current_view_count = file_obj && file_obj['local_view_count'] ? file_obj['local_view_count'] : 0;
const new_view_count = current_view_count + 1;
@@ -1120,10 +1208,10 @@ app.post('/api/subscribe', optionalJwt, async (req, res) => {
app.post('/api/unsubscribe', optionalJwt, async (req, res) => {
let deleteMode = req.body.deleteMode
let sub_id = req.body.sub_id;
let sub = req.body.sub;
let user_uid = req.isAuthenticated() ? req.user.uid : null;
let result_obj = subscriptions_api.unsubscribe(sub_id, deleteMode, user_uid);
let result_obj = subscriptions_api.unsubscribe(sub, deleteMode, user_uid);
if (result_obj.success) {
res.send({
success: result_obj.success
@@ -1138,9 +1226,12 @@ app.post('/api/unsubscribe', optionalJwt, async (req, res) => {
app.post('/api/deleteSubscriptionFile', optionalJwt, async (req, res) => {
let deleteForever = req.body.deleteForever;
let file = req.body.file;
let file_uid = req.body.file_uid;
let sub = req.body.sub;
let user_uid = req.isAuthenticated() ? req.user.uid : null;
let success = await files_api.deleteFile(file_uid, deleteForever);
let success = await subscriptions_api.deleteSubscriptionFile(sub, file, deleteForever, file_uid, user_uid);
if (success) {
res.send({
@@ -1173,7 +1264,7 @@ app.post('/api/getSubscription', optionalJwt, async (req, res) => {
subscription = JSON.parse(JSON.stringify(subscription));
// get sub videos
if (subscription.name) {
if (subscription.name && !subscription.streamingOnly) {
var parsed_files = await db_api.getRecords('files', {sub_id: subscription.id}); // subscription.videos;
subscription['videos'] = parsed_files;
// loop through files for extra processing
@@ -1183,6 +1274,19 @@ app.post('/api/getSubscription', optionalJwt, async (req, res) => {
if (file && file['url'].includes('twitch.tv')) file['chat_exists'] = fs.existsSync(file['path'].substring(0, file['path'].length - 4) + '.twitch_chat.json');
}
res.send({
subscription: subscription,
files: parsed_files
});
} else if (subscription.name && subscription.streamingOnly) {
// return list of videos
let parsed_files = [];
if (subscription.videos) {
for (let i = 0; i < subscription.videos.length; i++) {
const video = subscription.videos[i];
parsed_files.push(new utils.File(video.title, video.title, video.thumbnail, false, video.duration, video.url, video.uploader, video.size, null, null, video.upload_date, video.view_count, video.height, video.abr));
}
}
res.send({
subscription: subscription,
files: parsed_files
@@ -1193,49 +1297,21 @@ app.post('/api/getSubscription', optionalJwt, async (req, res) => {
});
app.post('/api/downloadVideosForSubscription', optionalJwt, async (req, res) => {
const subID = req.body.subID;
let subID = req.body.subID;
let user_uid = req.isAuthenticated() ? req.user.uid : null;
const sub = subscriptions_api.getSubscription(subID);
subscriptions_api.getVideosForSub(sub.id);
let sub = subscriptions_api.getSubscription(subID, user_uid);
subscriptions_api.getVideosForSub(sub, user_uid);
res.send({
success: true
});
});
app.post('/api/updateSubscription', optionalJwt, async (req, res) => {
const updated_sub = req.body.subscription;
const success = subscriptions_api.updateSubscription(updated_sub);
res.send({
success: success
});
});
app.post('/api/checkSubscription', optionalJwt, async (req, res) => {
let sub_id = req.body.sub_id;
let updated_sub = req.body.subscription;
let user_uid = req.isAuthenticated() ? req.user.uid : null;
const success = subscriptions_api.getVideosForSub(sub_id, user_uid);
res.send({
success: success
});
});
app.post('/api/cancelCheckSubscription', optionalJwt, async (req, res) => {
let sub_id = req.body.sub_id;
let user_uid = req.isAuthenticated() ? req.user.uid : null;
const success = subscriptions_api.cancelCheckSubscription(sub_id, user_uid);
res.send({
success: success
});
});
app.post('/api/cancelSubscriptionCheck', optionalJwt, async (req, res) => {
let sub_id = req.body.sub_id;
let user_uid = req.isAuthenticated() ? req.user.uid : null;
const success = subscriptions_api.getVideosForSub(sub_id, user_uid);
let success = subscriptions_api.updateSubscription(updated_sub, user_uid);
res.send({
success: success
});
@@ -1255,8 +1331,9 @@ app.post('/api/getSubscriptions', optionalJwt, async (req, res) => {
app.post('/api/createPlaylist', optionalJwt, async (req, res) => {
let playlistName = req.body.playlistName;
let uids = req.body.uids;
let type = req.body.type;
const new_playlist = await files_api.createPlaylist(playlistName, uids, req.isAuthenticated() ? req.user.uid : null);
const new_playlist = await db_api.createPlaylist(playlistName, uids, type, req.isAuthenticated() ? req.user.uid : null);
res.send({
new_playlist: new_playlist,
@@ -1269,13 +1346,13 @@ app.post('/api/getPlaylist', optionalJwt, async (req, res) => {
let uuid = req.body.uuid ? req.body.uuid : (req.user && req.user.uid ? req.user.uid : null);
let include_file_metadata = req.body.include_file_metadata;
const playlist = await files_api.getPlaylist(playlist_id, uuid);
const playlist = await db_api.getPlaylist(playlist_id, uuid);
const file_objs = [];
if (playlist && include_file_metadata) {
for (let i = 0; i < playlist['uids'].length; i++) {
const uid = playlist['uids'][i];
const file_obj = await files_api.getVideo(uid, uuid);
const file_obj = await db_api.getVideo(uid, uuid);
if (file_obj) file_objs.push(file_obj);
// TODO: remove file from playlist if could not be found
}
@@ -1284,6 +1361,7 @@ app.post('/api/getPlaylist', optionalJwt, async (req, res) => {
res.send({
playlist: playlist,
file_objs: file_objs,
type: playlist && playlist.type,
success: !!playlist
});
});
@@ -1294,7 +1372,7 @@ app.post('/api/getPlaylists', optionalJwt, async (req, res) => {
let playlists = await db_api.getRecords('playlists', {user_uid: uuid});
if (include_categories) {
const categories = await categories_api.getCategoriesAsPlaylists();
const categories = await categories_api.getCategoriesAsPlaylists(files);
if (categories) {
playlists = playlists.concat(categories);
}
@@ -1313,7 +1391,7 @@ app.post('/api/addFileToPlaylist', optionalJwt, async (req, res) => {
playlist.uids.push(file_uid);
let success = await files_api.updatePlaylist(playlist);
let success = await db_api.updatePlaylist(playlist);
res.send({
success: success
});
@@ -1321,7 +1399,7 @@ app.post('/api/addFileToPlaylist', optionalJwt, async (req, res) => {
app.post('/api/updatePlaylist', optionalJwt, async (req, res) => {
let playlist = req.body.playlist;
let success = await files_api.updatePlaylist(playlist, req.user && req.user.uid);
let success = await db_api.updatePlaylist(playlist, req.user && req.user.uid);
res.send({
success: success
});
@@ -1349,9 +1427,10 @@ app.post('/api/deletePlaylist', optionalJwt, async (req, res) => {
app.post('/api/deleteFile', optionalJwt, async (req, res) => {
const uid = req.body.uid;
const blacklistMode = req.body.blacklistMode;
const uuid = req.isAuthenticated() ? req.user.uid : null;
let wasDeleted = false;
wasDeleted = await files_api.deleteFile(uid, blacklistMode);
wasDeleted = await db_api.deleteFile(uid, uuid, blacklistMode);
res.send(wasDeleted);
});
@@ -1383,7 +1462,7 @@ app.post('/api/deleteAllFiles', optionalJwt, async (req, res) => {
for (let i = 0; i < files.length; i++) {
let wasDeleted = false;
wasDeleted = await files_api.deleteFile(files[i].uid, blacklistMode);
wasDeleted = await db_api.deleteFile(files[i].uid, uuid, blacklistMode);
if (wasDeleted) {
delete_count++;
}
@@ -1409,10 +1488,10 @@ app.post('/api/downloadFileFromServer', optionalJwt, async (req, res) => {
if (playlist_id) {
zip_file_generated = true;
const playlist_files_to_download = [];
const playlist = await files_api.getPlaylist(playlist_id, uuid);
const playlist = await db_api.getPlaylist(playlist_id, uuid);
for (let i = 0; i < playlist['uids'].length; i++) {
const playlist_file_uid = playlist['uids'][i];
const file_obj = await files_api.getVideo(playlist_file_uid, uuid);
const file_obj = await db_api.getVideo(playlist_file_uid, uuid);
playlist_files_to_download.push(file_obj);
}
@@ -1426,7 +1505,7 @@ app.post('/api/downloadFileFromServer', optionalJwt, async (req, res) => {
// generate zip
file_path_to_download = await utils.createContainerZipFile(sub['name'], sub_files_to_download);
} else {
const file_obj = await files_api.getVideo(uid, uuid, sub_id)
const file_obj = await db_api.getVideo(uid, uuid, sub_id)
file_path_to_download = file_obj.path;
}
if (!path.isAbsolute(file_path_to_download)) file_path_to_download = path.join(__dirname, file_path_to_download);
@@ -1444,69 +1523,20 @@ app.post('/api/downloadFileFromServer', optionalJwt, async (req, res) => {
});
});
app.post('/api/getArchives', optionalJwt, async (req, res) => {
const uuid = req.isAuthenticated() ? req.user.uid : null;
const sub_id = req.body.sub_id;
const filter_obj = {user_uid: uuid, sub_id: sub_id};
const type = req.body.type;
// we do this for file types because if type is null, that means get files of all types
if (type) filter_obj['type'] = type;
const archives = await db_api.getRecords('archives', filter_obj);
res.send({
archives: archives
});
});
app.post('/api/downloadArchive', optionalJwt, async (req, res) => {
const uuid = req.isAuthenticated() ? req.user.uid : null;
const sub_id = req.body.sub_id;
const type = req.body.type;
let sub = req.body.sub;
let archive_dir = sub.archive;
const archive_text = await archive_api.generateArchive(type, uuid, sub_id);
let full_archive_path = path.join(archive_dir, 'archive.txt');
if (archive_text !== null && archive_text !== undefined) {
res.setHeader('Content-type', "application/octet-stream");
res.setHeader('Content-disposition', 'attachment; filename=archive.txt');
res.send(archive_text);
if (await fs.pathExists(full_archive_path)) {
res.sendFile(full_archive_path);
} else {
res.sendStatus(400);
res.sendStatus(404);
}
});
app.post('/api/importArchive', optionalJwt, async (req, res) => {
const uuid = req.isAuthenticated() ? req.user.uid : null;
const archive = req.body.archive;
const sub_id = req.body.sub_id;
const type = req.body.type;
const archive_text = Buffer.from(archive.split(',')[1], 'base64').toString();
const imported_count = await archive_api.importArchiveFile(archive_text, type, uuid, sub_id);
res.send({
success: !!imported_count,
imported_count: imported_count
});
});
app.post('/api/deleteArchiveItems', optionalJwt, async (req, res) => {
const uuid = req.isAuthenticated() ? req.user.uid : null;
const archives = req.body.archives;
let success = true;
for (const archive of archives) {
success &= await archive_api.removeFromArchive(archive['extractor'], archive['id'], archive['type'], uuid, archive['sub_id']);
}
res.send({
success: success
});
});
var upload_multer = multer({ dest: __dirname + '/appdata/' });
app.post('/api/uploadCookies', upload_multer.single('cookies'), async (req, res) => {
const new_path = path.join(__dirname, 'appdata', 'cookies.txt');
@@ -1573,13 +1603,12 @@ app.get('/api/stream', optionalJwt, async (req, res) => {
const multiUserMode = config_api.getConfigItem('ytdl_multi_user_mode');
if (!multiUserMode || req.isAuthenticated() || req.can_watch) {
file_obj = await files_api.getVideo(uid, uuid, sub_id);
file_obj = await db_api.getVideo(uid, uuid, sub_id);
if (file_obj) file_path = file_obj['path'];
else file_path = null;
}
if (!fs.existsSync(file_path)) {
logger.error(`File ${file_path} could not be found! UID: ${uid}, ID: ${file_obj && file_obj.id}`);
return;
logger.error(`File ${file_path} could not be found! UID: ${uid}, ID: ${file_obj.id}`);
}
const stat = fs.statSync(file_path);
const fileSize = stat.size;
@@ -1647,15 +1676,9 @@ app.post('/api/download', optionalJwt, async (req, res) => {
}
});
app.post('/api/clearDownloads', optionalJwt, async (req, res) => {
app.post('/api/clearFinishedDownloads', optionalJwt, async (req, res) => {
const user_uid = req.isAuthenticated() ? req.user.uid : null;
const clear_finished = req.body.clear_finished;
const clear_paused = req.body.clear_paused;
const clear_errors = req.body.clear_errors;
let success = true;
if (clear_finished) success &= await db_api.removeAllRecords('download_queue', {finished: true, user_uid: user_uid});
if (clear_paused) success &= await db_api.removeAllRecords('download_queue', {paused: true, user_uid: user_uid});
if (clear_errors) success &= await db_api.removeAllRecords('download_queue', {error: {$ne: null}, user_uid: user_uid});
const success = db_api.removeAllRecords('download_queue', {finished: true, user_uid: user_uid});
res.send({success: success});
});
@@ -1699,8 +1722,8 @@ app.post('/api/resumeAllDownloads', optionalJwt, async (req, res) => {
app.post('/api/restartDownload', optionalJwt, async (req, res) => {
const download_uid = req.body.download_uid;
const new_download = await downloader_api.restartDownload(download_uid);
res.send({success: !!new_download, new_download_uid: new_download ? new_download['uid'] : null});
const success = await downloader_api.restartDownload(download_uid);
res.send({success: success});
});
app.post('/api/cancelDownload', optionalJwt, async (req, res) => {
@@ -1714,10 +1737,6 @@ app.post('/api/cancelDownload', optionalJwt, async (req, res) => {
app.post('/api/getTasks', optionalJwt, async (req, res) => {
const tasks = await db_api.getRecords('tasks');
for (let task of tasks) {
if (!tasks_api.TASKS[task['key']]) {
logger.verbose(`Task ${task['key']} does not exist!`);
continue;
}
if (task['schedule']) task['next_invocation'] = tasks_api.TASKS[task['key']]['job'].nextInvocation().getTime();
}
res.send({tasks: tasks});
@@ -1781,18 +1800,8 @@ app.post('/api/updateTaskData', optionalJwt, async (req, res) => {
res.send({success: success});
});
app.post('/api/updateTaskOptions', optionalJwt, async (req, res) => {
const task_key = req.body.task_key;
const new_options = req.body.new_options;
const success = await db_api.updateRecord('tasks', {key: task_key}, {options: new_options});
res.send({success: success});
});
app.post('/api/getDBBackups', optionalJwt, async (req, res) => {
const backup_dir = path.join('appdata', 'db_backup');
fs.ensureDirSync(backup_dir);
const db_backups = [];
const candidate_backups = await utils.recFindByExt(backup_dir, 'bak', null, [], false);
@@ -1860,48 +1869,19 @@ app.post('/api/clearAllLogs', optionalJwt, async function(req, res) {
});
app.post('/api/getFileFormats', optionalJwt, async (req, res) => {
const url = req.body.url;
const result = await downloader_api.getVideoInfoByURL(url);
let url = req.body.url;
let result = await getUrlInfos(url);
res.send({
result: result && result.length === 1 ? result[0] : null,
success: result && result.length === 0
result: result,
success: !!result
})
});
// user authentication
app.post('/api/auth/register', optionalJwt, async (req, res) => {
const userid = req.body.userid;
const username = req.body.username;
const plaintextPassword = req.body.password;
if (userid !== 'admin' && !config_api.getConfigItem('ytdl_allow_registration') && !req.isAuthenticated() && (!req.user || !exports.userHasPermission(req.user.uid, 'settings'))) {
logger.error(`Registration failed for user ${userid}. Registration is disabled.`);
res.sendStatus(409);
return;
}
if (plaintextPassword === "") {
logger.error(`Registration failed for user ${userid}. A password must be provided.`);
res.sendStatus(409);
return;
}
if (!userid || !username) {
logger.error(`Registration failed for user ${userid}. Username or userid is invalid.`);
}
const new_user = await auth_api.registerUser(userid, username, plaintextPassword);
if (!new_user) {
res.sendStatus(409);
return;
}
res.send({
user: new_user
});
});
app.post('/api/auth/register'
, optionalJwt
, auth_api.registerUser);
app.post('/api/auth/login'
, auth_api.passport.authenticate(['local', 'ldapauth'], {})
, auth_api.generateJWT
@@ -1953,7 +1933,18 @@ app.post('/api/updateUser', optionalJwt, async (req, res) => {
app.post('/api/deleteUser', optionalJwt, async (req, res) => {
let uid = req.body.uid;
try {
const success = await auth_api.deleteUser(uid);
let success = false;
let usersFileFolder = config_api.getConfigItem('ytdl_users_base_path');
const user_folder = path.join(__dirname, usersFileFolder, uid);
const user_db_obj = await db_api.getRecord('users', {uid: uid});
if (user_db_obj) {
// user exists, let's delete
await fs.remove(user_folder);
await db_api.removeRecord('users', {uid: uid});
success = true;
} else {
logger.error(`Could not find user with uid ${uid}`);
}
res.send({success: success});
} catch (err) {
logger.error(err);
@@ -1991,112 +1982,6 @@ app.post('/api/changeRolePermissions', optionalJwt, async (req, res) => {
res.send({success: success});
});
// notifications
app.post('/api/getNotifications', optionalJwt, async (req, res) => {
const uuid = req.isAuthenticated() ? req.user.uid : null;
const notifications = await db_api.getRecords('notifications', {user_uid: uuid});
res.send({notifications: notifications});
});
// set notifications to read
app.post('/api/setNotificationsToRead', optionalJwt, async (req, res) => {
const uuid = req.isAuthenticated() ? req.user.uid : null;
const success = await db_api.updateRecords('notifications', {user_uid: uuid}, {read: true});
res.send({success: success});
});
app.post('/api/deleteNotification', optionalJwt, async (req, res) => {
const uid = req.isAuthenticated() ? req.user.uid : null;
const success = await db_api.removeRecord('notifications', {uid: uid});
res.send({success: success});
});
app.post('/api/deleteAllNotifications', optionalJwt, async (req, res) => {
const uuid = req.isAuthenticated() ? req.user.uid : null;
const success = await db_api.removeAllRecords('notifications', {user_uid: uuid});
res.send({success: success});
});
app.post('/api/telegramRequest', async (req, res) => {
if (!req.body.message && !req.body.message.text) {
logger.error('Invalid Telegram request received!');
res.sendStatus(400);
return;
}
const text = req.body.message.text;
const regex_exp = /https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*)?/gi;
const url_regex = new RegExp(regex_exp);
if (text.match(url_regex)) {
downloader_api.createDownload(text, 'video', {}, req.query.user_uid ? req.query.user_uid : null);
res.sendStatus(200);
} else {
logger.error('Invalid Telegram request received! Make sure you only send a valid URL.');
notifications_api.sendTelegramNotification({title: 'Invalid Telegram Request', body: 'Make sure you only send a valid URL.', url: text});
res.sendStatus(400);
}
});
// rss feed
app.get('/api/rss', async function (req, res) {
if (!config_api.getConfigItem('ytdl_enable_rss_feed')) {
logger.error('RSS feed is disabled! It must be enabled in the settings before it can be generated.');
res.sendStatus(403);
return;
}
// these are returned
const sort = req.query.sort ? JSON.parse(decodeURIComponent(req.query.sort)) : {by: 'registered', order: -1};
const range = req.query.range ? req.query.range.map(range_num => parseInt(range_num)) : null;
const text_search = req.query.text_search ? decodeURIComponent(req.query.text_search) : null;
const file_type_filter = req.query.file_type_filter;
const favorite_filter = req.query.favorite_filter === 'true';
const sub_id = req.query.sub_id ? decodeURIComponent(req.query.sub_id) : null;
const uuid = req.query.uuid ? decodeURIComponent(req.query.uuid) : null;
const {files} = await files_api.getAllFiles(sort, range, text_search, file_type_filter, favorite_filter, sub_id, uuid);
const feed = new Feed({
title: 'Downloads',
description: 'YoutubeDL-Material downloads',
id: utils.getBaseURL(),
link: utils.getBaseURL(),
image: 'https://github.com/Tzahi12345/YoutubeDL-Material/blob/master/src/assets/images/logo_128px.png',
favicon: 'https://raw.githubusercontent.com/Tzahi12345/YoutubeDL-Material/master/src/favicon.ico',
generator: 'YoutubeDL-Material'
});
files.forEach(file => {
feed.addItem({
title: file.title,
link: `${utils.getBaseURL()}/#/player;uid=${file.uid}`,
description: file.description,
author: [
{
name: file.uploader,
link: file.url
}
],
contributor: [],
date: file.timestamp,
// https://stackoverflow.com/a/45415677/8088021
image: file.thumbnailURL.replace('&', '&amp;')
});
});
res.send(feed.rss2());
});
// web server
app.use(function(req, res, next) {
//if the request is not html then move along
var accept = req.accepts('html', 'json', 'xml');
@@ -2112,8 +1997,6 @@ app.use(function(req, res, next) {
let index_path = path.join(__dirname, 'public', 'index.html');
res.setHeader('Content-Type', 'text/html');
fs.createReadStream(index_path).pipe(res);
});

View File

@@ -23,12 +23,7 @@
"download_only_mode": false,
"allow_autoplay": true,
"enable_downloads_manager": true,
"allow_playlist_categorization": true,
"force_autoplay": false,
"enable_notifications": true,
"enable_all_notifications": true,
"allowed_notification_types": [],
"enable_rss_feed": false
"allow_playlist_categorization": true
},
"API": {
"use_API_key": false,
@@ -36,23 +31,10 @@
"use_youtube_API": false,
"youtube_API_key": "",
"use_twitch_API": false,
"twitch_client_ID": "",
"twitch_client_secret": "",
"twitch_API_key": "",
"twitch_auto_download_chat": false,
"use_sponsorblock_API": false,
"generate_NFO_files": false,
"use_ntfy_API": false,
"ntfy_topic_URL": "",
"use_gotify_API": false,
"gotify_server_URL": "",
"gotify_app_token": "",
"use_telegram_API": false,
"telegram_bot_token": "",
"telegram_chat_id": "",
"telegram_webhook_proxy": "",
"webhook_URL": "",
"discord_webhook_URL": "",
"slack_webhook_URL": ""
"generate_NFO_files": false
},
"Themes": {
"default_theme": "default",
@@ -81,7 +63,7 @@
"mongodb_connection_string": "mongodb://127.0.0.1:27017/?compressors=zlib"
},
"Advanced": {
"default_downloader": "yt-dlp",
"default_downloader": "youtube-dl",
"use_default_downloading_agent": true,
"custom_downloading_agent": "",
"multi_user_mode": false,

View File

@@ -1,91 +0,0 @@
const path = require('path');
const fs = require('fs-extra');
const { v4: uuid } = require('uuid');
const db_api = require('./db');
exports.generateArchive = async (type = null, user_uid = null, sub_id = null) => {
const filter = {user_uid: user_uid, sub_id: sub_id};
if (type) filter['type'] = type;
const archive_items = await db_api.getRecords('archives', filter);
const archive_item_lines = archive_items.map(archive_item => `${archive_item['extractor']} ${archive_item['id']}`);
return archive_item_lines.join('\n');
}
exports.addToArchive = async (extractor, id, type, title, user_uid = null, sub_id = null) => {
const archive_item = createArchiveItem(extractor, id, type, title, user_uid, sub_id);
const success = await db_api.insertRecordIntoTable('archives', archive_item, {extractor: extractor, id: id, type: type});
return success;
}
exports.removeFromArchive = async (extractor, id, type, user_uid = null, sub_id = null) => {
const success = await db_api.removeAllRecords('archives', {extractor: extractor, id: id, type: type, user_uid: user_uid, sub_id: sub_id});
return success;
}
exports.existsInArchive = async (extractor, id, type, user_uid, sub_id) => {
const archive_item = await db_api.getRecord('archives', {extractor: extractor, id: id, type: type, user_uid: user_uid, sub_id: sub_id});
return !!archive_item;
}
exports.importArchiveFile = async (archive_text, type, user_uid = null, sub_id = null) => {
let archive_import_count = 0;
const lines = archive_text.split('\n');
for (let line of lines) {
const archive_line_parts = line.trim().split(' ');
// should just be the extractor and the video ID
if (archive_line_parts.length !== 2) {
continue;
}
const extractor = archive_line_parts[0];
const id = archive_line_parts[1];
if (!extractor || !id) continue;
// we can't do a bulk write because we need to avoid duplicate archive items existing in db
const archive_item = createArchiveItem(extractor, id, type, null, user_uid, sub_id);
await db_api.insertRecordIntoTable('archives', archive_item, {extractor: extractor, id: id, type: type, sub_id: sub_id, user_uid: user_uid});
archive_import_count++;
}
return archive_import_count;
}
exports.importArchives = async () => {
const imported_archives = [];
const dirs_to_check = await db_api.getFileDirectoriesAndDBs();
// run through check list and check each file to see if it's missing from the db
for (let i = 0; i < dirs_to_check.length; i++) {
const dir_to_check = dirs_to_check[i];
if (!dir_to_check['archive_path']) continue;
const files_to_import = [
path.join(dir_to_check['archive_path'], `archive_${dir_to_check['type']}.txt`),
path.join(dir_to_check['archive_path'], `blacklist_${dir_to_check['type']}.txt`)
]
for (const file_to_import of files_to_import) {
const file_exists = await fs.pathExists(file_to_import);
if (!file_exists) continue;
const archive_text = await fs.readFile(file_to_import, 'utf8');
await exports.importArchiveFile(archive_text, dir_to_check.type, dir_to_check.user_uid, dir_to_check.sub_id);
imported_archives.push(file_to_import);
}
}
return imported_archives;
}
const createArchiveItem = (extractor, id, type, title = null, user_uid = null, sub_id = null) => {
return {
extractor: extractor,
id: id,
type: type,
title: title,
user_uid: user_uid ? user_uid : null,
sub_id: sub_id ? sub_id : null,
timestamp: Date.now() / 1000,
uid: uuid()
}
}

View File

@@ -1,13 +1,11 @@
const config_api = require('../config');
const CONSTS = require('../consts');
const consts = require('../consts');
const logger = require('../logger');
const db_api = require('../db');
const jwt = require('jsonwebtoken');
const { v4: uuid } = require('uuid');
const { uuid } = require('uuidv4');
const bcrypt = require('bcryptjs');
const fs = require('fs-extra');
const path = require('path');
var LocalStrategy = require('passport-local').Strategy;
var LdapStrategy = require('passport-ldapauth');
@@ -18,7 +16,7 @@ var JwtStrategy = require('passport-jwt').Strategy,
let SERVER_SECRET = null;
let JWT_EXPIRATION = null;
let opts = null;
let saltRounds = 10;
let saltRounds = null;
exports.initialize = function () {
/*************************
@@ -33,14 +31,9 @@ exports.initialize = function () {
});
}
// Sometimes this value is not properly typed: https://github.com/Tzahi12345/YoutubeDL-Material/issues/813
saltRounds = 10;
JWT_EXPIRATION = config_api.getConfigItem('ytdl_jwt_expiration');
if (!(+JWT_EXPIRATION)) {
logger.warn(`JWT expiration value improperly set to ${JWT_EXPIRATION}, auto setting to 1 day.`);
JWT_EXPIRATION = 86400;
} else {
JWT_EXPIRATION = +JWT_EXPIRATION;
}
SERVER_SECRET = null;
if (db_api.users_db.get('jwt_secret').value()) {
@@ -68,7 +61,14 @@ exports.initialize = function () {
const setupRoles = async () => {
const required_roles = {
admin: {
permissions: CONSTS.AVAILABLE_PERMISSIONS
permissions: [
'filemanager',
'settings',
'subscriptions',
'sharing',
'advanced_download',
'downloads_manager'
]
},
user: {
permissions: [
@@ -106,41 +106,55 @@ exports.passport.deserializeUser(function(user, done) {
/***************************************
* Register user with hashed password
**************************************/
exports.registerUser = async function(req, res) {
var userid = req.body.userid;
var username = req.body.username;
var plaintextPassword = req.body.password;
exports.registerUser = async (userid, username, plaintextPassword) => {
const hash = await bcrypt.hash(plaintextPassword, saltRounds);
const new_user = generateUserObject(userid, username, hash);
// check if user exists
if (await db_api.getRecord('users', {uid: userid})) {
// user id is taken!
logger.error('Registration failed: UID is already taken!');
return null;
} else if (await db_api.getRecord('users', {name: username})) {
// user name is taken!
logger.error('Registration failed: User name is already taken!');
return null;
} else {
// add to db
await db_api.insertRecordIntoTable('users', new_user);
logger.verbose(`New user created: ${new_user.name}`);
return new_user;
if (userid !== 'admin' && !config_api.getConfigItem('ytdl_allow_registration') && !req.isAuthenticated() && (!req.user || !exports.userHasPermission(req.user.uid, 'settings'))) {
res.sendStatus(409);
logger.error(`Registration failed for user ${userid}. Registration is disabled.`);
return;
}
}
exports.deleteUser = async (uid) => {
let success = false;
let usersFileFolder = config_api.getConfigItem('ytdl_users_base_path');
const user_folder = path.join(__dirname, usersFileFolder, uid);
const user_db_obj = await db_api.getRecord('users', {uid: uid});
if (user_db_obj) {
// user exists, let's delete
await fs.remove(user_folder);
await db_api.removeRecord('users', {uid: uid});
success = true;
} else {
logger.error(`Could not find user with uid ${uid}`);
if (plaintextPassword === "") {
res.sendStatus(400);
logger.error(`Registration failed for user ${userid}. A password must be provided.`);
return;
}
return success;
bcrypt.hash(plaintextPassword, saltRounds)
.then(async function(hash) {
let new_user = generateUserObject(userid, username, hash);
// check if user exists
if (await db_api.getRecord('users', {uid: userid})) {
// user id is taken!
logger.error('Registration failed: UID is already taken!');
res.status(409).send('UID is already taken!');
} else if (await db_api.getRecord('users', {name: username})) {
// user name is taken!
logger.error('Registration failed: User name is already taken!');
res.status(409).send('User name is already taken!');
} else {
// add to db
await db_api.insertRecordIntoTable('users', new_user);
logger.verbose(`New user created: ${new_user.name}`);
res.send({
user: new_user
});
}
})
.then(function(result) {
})
.catch(function(err) {
logger.error(err);
if( err.code == 'ER_DUP_ENTRY' ) {
res.status(409).send('UserId already taken');
} else {
res.sendStatus(409);
}
});
}
/***************************************
@@ -157,12 +171,8 @@ exports.deleteUser = async (uid) => {
exports.login = async (username, password) => {
// even if we're using LDAP, we still want users to be able to login using internal credentials
const user = await db_api.getRecord('users', {name: username});
if (!user) {
if (config_api.getConfigItem('ytdl_auth_method') === 'internal') logger.error(`User ${username} not found`);
return false;
}
if (!user) { logger.error(`User ${username} not found`); return false }
if (user.auth_method && user.auth_method !== 'internal') { return false }
return await bcrypt.compare(password, user.passhash) ? user : false;
}
@@ -221,7 +231,7 @@ exports.returnAuthResponse = async function(req, res) {
user: req.user,
token: req.token,
permissions: await exports.userPermissions(req.user.uid),
available_permissions: CONSTS.AVAILABLE_PERMISSIONS
available_permissions: consts['AVAILABLE_PERMISSIONS']
});
}
@@ -305,7 +315,7 @@ exports.getUserVideos = async function(user_uid, type) {
}
exports.getUserVideo = async function(user_uid, file_uid, requireSharing = false) {
let file = await db_api.getRecord('files', {uid: file_uid});
let file = await db_api.getRecord('files', {file_uid: file_uid});
// prevent unauthorized users from accessing the file info
if (file && !file['sharingEnabled'] && requireSharing) file = null;
@@ -347,6 +357,7 @@ exports.userHasPermission = async function(user_uid, permission) {
logger.error('Invalid role ' + role);
return false;
}
const role_permissions = (await db_api.getRecords('roles'))['permissions'];
const user_has_explicit_permission = user_obj['permissions'].includes(permission);
const permission_in_overrides = user_obj['permission_overrides'].includes(permission);
@@ -361,8 +372,7 @@ exports.userHasPermission = async function(user_uid, permission) {
}
// no overrides, let's check if the role has the permission
const role_has_permission = await exports.roleHasPermissions(role, permission);
if (role_has_permission) {
if (role_permissions.includes(permission)) {
return true;
} else {
logger.verbose(`User ${user_uid} failed to get permission ${permission}`);
@@ -370,16 +380,6 @@ exports.userHasPermission = async function(user_uid, permission) {
}
}
exports.roleHasPermissions = async function(role, permission) {
const role_obj = await db_api.getRecord('roles', {key: role})
if (!role) {
logger.error(`Role ${role} does not exist!`);
}
const role_permissions = role_obj['permissions'];
if (role_permissions && role_permissions.includes(permission)) return true;
else return false;
}
exports.userPermissions = async function(user_uid) {
let user_permissions = [];
const user_obj = await db_api.getRecord('users', ({uid: user_uid}));
@@ -392,8 +392,8 @@ exports.userPermissions = async function(user_uid) {
const role_obj = await db_api.getRecord('roles', {key: role});
const role_permissions = role_obj['permissions'];
for (let i = 0; i < CONSTS.AVAILABLE_PERMISSIONS.length; i++) {
let permission = CONSTS.AVAILABLE_PERMISSIONS[i];
for (let i = 0; i < consts['AVAILABLE_PERMISSIONS'].length; i++) {
let permission = consts['AVAILABLE_PERMISSIONS'][i];
const user_has_explicit_permission = user_obj['permissions'].includes(permission);
const permission_in_overrides = user_obj['permission_overrides'].includes(permission);

View File

@@ -32,8 +32,10 @@ async function categorize(file_jsons) {
return null;
}
for (const file_json of file_jsons) {
for (const category of categories) {
for (let i = 0; i < file_jsons.length; i++) {
const file_json = file_jsons[i];
for (let j = 0; j < categories.length; j++) {
const category = categories[j];
const rules = category['rules'];
// if rules for current category apply, then that is the selected category
@@ -53,18 +55,17 @@ async function getCategories() {
return categories ? categories : null;
}
async function getCategoriesAsPlaylists() {
async function getCategoriesAsPlaylists(files = null) {
const categories_as_playlists = [];
const available_categories = await getCategories();
if (available_categories) {
if (available_categories && files) {
for (let category of available_categories) {
const files_that_match = await db_api.getRecords('files', {'category.uid': category['uid']});
const files_that_match = utils.addUIDsToCategory(category, files);
if (files_that_match && files_that_match.length > 0) {
category['thumbnailURL'] = files_that_match[0].thumbnailURL;
category['thumbnailPath'] = files_that_match[0].thumbnailPath;
category['duration'] = files_that_match.reduce((a, b) => a + utils.durationStringToNumber(b.duration), 0);
category['id'] = category['uid'];
category['auto'] = true;
categories_as_playlists.push(category);
}
}

View File

@@ -1,26 +1,22 @@
const logger = require('./logger');
const fs = require('fs');
const { BehaviorSubject } = require('rxjs');
exports.CONFIG_ITEMS = require('./consts.js')['CONFIG_ITEMS'];
exports.descriptors = {}; // to get rid of file locks when needed, TODO: move to youtube-dl.js
let CONFIG_ITEMS = require('./consts.js')['CONFIG_ITEMS'];
const debugMode = process.env.YTDL_MODE === 'debug';
let configPath = debugMode ? '../src/assets/default.json' : 'appdata/default.json';
exports.config_updated = new BehaviorSubject();
exports.initialize = () => {
function initialize() {
ensureConfigFileExists();
ensureConfigItemsExist();
}
function ensureConfigItemsExist() {
const config_keys = Object.keys(exports.CONFIG_ITEMS);
const config_keys = Object.keys(CONFIG_ITEMS);
for (let i = 0; i < config_keys.length; i++) {
const config_key = config_keys[i];
exports.getConfigItem(config_key);
getConfigItem(config_key);
}
}
@@ -61,17 +57,17 @@ function getElementNameInConfig(path) {
/**
* Check if config exists. If not, write default config to config path
*/
exports.configExistsCheck = () => {
function configExistsCheck() {
let exists = fs.existsSync(configPath);
if (!exists) {
exports.setConfigFile(DEFAULT_CONFIG);
setConfigFile(DEFAULT_CONFIG);
}
}
/*
* Gets config file and returns as a json
*/
exports.getConfigFile = () => {
function getConfigFile() {
try {
let raw_data = fs.readFileSync(configPath);
let parsed_data = JSON.parse(raw_data);
@@ -82,40 +78,35 @@ exports.getConfigFile = () => {
}
}
exports.setConfigFile = (config) => {
function setConfigFile(config) {
try {
const old_config = exports.getConfigFile();
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
const changes = exports.findChangedConfigItems(old_config, config);
if (changes.length > 0) {
for (const change of changes) exports.config_updated.next(change);
}
return true;
} catch(e) {
return false;
}
}
exports.getConfigItem = (key) => {
let config_json = exports.getConfigFile();
if (!exports.CONFIG_ITEMS[key]) {
function getConfigItem(key) {
let config_json = getConfigFile();
if (!CONFIG_ITEMS[key]) {
logger.error(`Config item with key '${key}' is not recognized.`);
return null;
}
let path = exports.CONFIG_ITEMS[key]['path'];
let path = CONFIG_ITEMS[key]['path'];
const val = Object.byString(config_json, path);
if (val === undefined && Object.byString(DEFAULT_CONFIG, path) !== undefined) {
logger.warn(`Cannot find config with key '${key}'. Creating one with the default value...`);
exports.setConfigItem(key, Object.byString(DEFAULT_CONFIG, path));
setConfigItem(key, Object.byString(DEFAULT_CONFIG, path));
return Object.byString(DEFAULT_CONFIG, path);
}
return Object.byString(config_json, path);
}
exports.setConfigItem = (key, value) => {
function setConfigItem(key, value) {
let success = false;
let config_json = exports.getConfigFile();
let path = exports.CONFIG_ITEMS[key]['path'];
let config_json = getConfigFile();
let path = CONFIG_ITEMS[key]['path'];
let element_name = getElementNameInConfig(path);
let parent_path = getParentPath(path);
let parent_object = Object.byString(config_json, parent_path);
@@ -127,18 +118,20 @@ exports.setConfigItem = (key, value) => {
parent_parent_object[parent_parent_single_key] = {};
parent_object = Object.byString(config_json, parent_path);
}
if (value === 'false') value = false;
if (value === 'true') value = true;
parent_object[element_name] = value;
success = exports.setConfigFile(config_json);
if (value === 'false' || value === 'true') {
parent_object[element_name] = (value === 'true');
} else {
parent_object[element_name] = value;
}
success = setConfigFile(config_json);
return success;
}
};
exports.setConfigItems = (items) => {
function setConfigItems(items) {
let success = false;
let config_json = exports.getConfigFile();
let config_json = getConfigFile();
for (let i = 0; i < items.length; i++) {
let key = items[i].key;
let value = items[i].value;
@@ -148,7 +141,7 @@ exports.setConfigItems = (items) => {
value = (value === 'true');
}
let item_path = exports.CONFIG_ITEMS[key]['path'];
let item_path = CONFIG_ITEMS[key]['path'];
let item_parent_path = getParentPath(item_path);
let item_element_name = getElementNameInConfig(item_path);
@@ -156,41 +149,28 @@ exports.setConfigItems = (items) => {
item_parent_object[item_element_name] = value;
}
success = exports.setConfigFile(config_json);
success = setConfigFile(config_json);
return success;
}
exports.globalArgsRequiresSafeDownload = () => {
const globalArgs = exports.getConfigItem('ytdl_custom_args').split(',,');
function globalArgsRequiresSafeDownload() {
const globalArgs = getConfigItem('ytdl_custom_args').split(',,');
const argsThatRequireSafeDownload = ['--write-sub', '--write-srt', '--proxy'];
const failedArgs = globalArgs.filter(arg => argsThatRequireSafeDownload.includes(arg));
return failedArgs && failedArgs.length > 0;
}
exports.findChangedConfigItems = (old_config, new_config, path = '', changedConfigItems = [], depth = 0) => {
if (typeof old_config === 'object' && typeof new_config === 'object' && depth < 3) {
for (const key in old_config) {
if (Object.prototype.hasOwnProperty.call(new_config, key)) {
exports.findChangedConfigItems(old_config[key], new_config[key], `${path}${path ? '.' : ''}${key}`, changedConfigItems, depth + 1);
}
}
} else {
if (JSON.stringify(old_config) !== JSON.stringify(new_config)) {
const key = getConfigItemKeyByPath(path);
changedConfigItems.push({
key: key ? key : path.split('.')[path.split('.').length - 1], // return key in CONFIG_ITEMS or the object key
old_value: JSON.parse(JSON.stringify(old_config)),
new_value: JSON.parse(JSON.stringify(new_config))
});
}
}
return changedConfigItems;
}
function getConfigItemKeyByPath(path) {
const found_item = Object.values(exports.CONFIG_ITEMS).find(item => item.path === path);
if (found_item) return found_item['key'];
else return null;
module.exports = {
getConfigItem: getConfigItem,
setConfigItem: setConfigItem,
setConfigItems: setConfigItems,
getConfigFile: getConfigFile,
setConfigFile: setConfigFile,
configExistsCheck: configExistsCheck,
CONFIG_ITEMS: CONFIG_ITEMS,
initialize: initialize,
descriptors: {},
globalArgsRequiresSafeDownload: globalArgsRequiresSafeDownload
}
const DEFAULT_CONFIG = {
@@ -205,6 +185,7 @@ const DEFAULT_CONFIG = {
"default_file_output": "",
"use_youtubedl_archive": false,
"custom_args": "",
"safe_download_override": false,
"include_thumbnail": true,
"include_metadata": true,
"max_concurrent_downloads": 5,
@@ -215,34 +196,20 @@ const DEFAULT_CONFIG = {
"file_manager_enabled": true,
"allow_quality_select": true,
"download_only_mode": false,
"force_autoplay": false,
"allow_autoplay": true,
"enable_downloads_manager": true,
"allow_playlist_categorization": true,
"enable_notifications": true,
"enable_all_notifications": true,
"allowed_notification_types": [],
"enable_rss_feed": false,
"allow_playlist_categorization": true
},
"API": {
"use_API_key": false,
"API_key": "",
"use_youtube_API": false,
"youtube_API_key": "",
"use_twitch_API": false,
"twitch_API_key": "",
"twitch_auto_download_chat": false,
"use_sponsorblock_API": false,
"generate_NFO_files": false,
"use_ntfy_API": false,
"ntfy_topic_URL": "",
"use_gotify_API": false,
"gotify_server_URL": "",
"gotify_app_token": "",
"use_telegram_API": false,
"telegram_bot_token": "",
"telegram_chat_id": "",
"telegram_webhook_proxy": "",
"webhook_URL": "",
"discord_webhook_URL": "",
"slack_webhook_URL": "",
"generate_NFO_files": false
},
"Themes": {
"default_theme": "default",
@@ -271,7 +238,7 @@ const DEFAULT_CONFIG = {
"mongodb_connection_string": "mongodb://127.0.0.1:27017/?compressors=zlib"
},
"Advanced": {
"default_downloader": "yt-dlp",
"default_downloader": "youtube-dl",
"use_default_downloading_agent": true,
"custom_downloading_agent": "",
"multi_user_mode": false,

View File

@@ -30,6 +30,10 @@ exports.CONFIG_ITEMS = {
'key': 'ytdl_custom_args',
'path': 'YoutubeDLMaterial.Downloader.custom_args'
},
'ytdl_safe_download_override': {
'key': 'ytdl_safe_download_override',
'path': 'YoutubeDLMaterial.Downloader.safe_download_override'
},
'ytdl_include_thumbnail': {
'key': 'ytdl_include_thumbnail',
'path': 'YoutubeDLMaterial.Downloader.include_thumbnail'
@@ -64,9 +68,9 @@ exports.CONFIG_ITEMS = {
'key': 'ytdl_download_only_mode',
'path': 'YoutubeDLMaterial.Extra.download_only_mode'
},
'ytdl_force_autoplay': {
'key': 'ytdl_force_autoplay',
'path': 'YoutubeDLMaterial.Extra.force_autoplay'
'ytdl_allow_autoplay': {
'key': 'ytdl_allow_autoplay',
'path': 'YoutubeDLMaterial.Extra.allow_autoplay'
},
'ytdl_enable_downloads_manager': {
'key': 'ytdl_enable_downloads_manager',
@@ -76,22 +80,6 @@ exports.CONFIG_ITEMS = {
'key': 'ytdl_allow_playlist_categorization',
'path': 'YoutubeDLMaterial.Extra.allow_playlist_categorization'
},
'ytdl_enable_notifications': {
'key': 'ytdl_enable_notifications',
'path': 'YoutubeDLMaterial.Extra.enable_notifications'
},
'ytdl_enable_all_notifications': {
'key': 'ytdl_enable_all_notifications',
'path': 'YoutubeDLMaterial.Extra.enable_all_notifications'
},
'ytdl_allowed_notification_types': {
'key': 'ytdl_allowed_notification_types',
'path': 'YoutubeDLMaterial.Extra.allowed_notification_types'
},
'ytdl_enable_rss_feed': {
'key': 'ytdl_enable_rss_feed',
'path': 'YoutubeDLMaterial.Extra.enable_rss_feed'
},
// API
'ytdl_use_api_key': {
@@ -110,6 +98,14 @@ exports.CONFIG_ITEMS = {
'key': 'ytdl_youtube_api_key',
'path': 'YoutubeDLMaterial.API.youtube_API_key'
},
'ytdl_use_twitch_api': {
'key': 'ytdl_use_twitch_api',
'path': 'YoutubeDLMaterial.API.use_twitch_API'
},
'ytdl_twitch_api_key': {
'key': 'ytdl_twitch_api_key',
'path': 'YoutubeDLMaterial.API.twitch_API_key'
},
'ytdl_twitch_auto_download_chat': {
'key': 'ytdl_twitch_auto_download_chat',
'path': 'YoutubeDLMaterial.API.twitch_auto_download_chat'
@@ -122,54 +118,6 @@ exports.CONFIG_ITEMS = {
'key': 'ytdl_generate_nfo_files',
'path': 'YoutubeDLMaterial.API.generate_NFO_files'
},
'ytdl_use_ntfy_API': {
'key': 'ytdl_use_ntfy_API',
'path': 'YoutubeDLMaterial.API.use_ntfy_API'
},
'ytdl_ntfy_topic_url': {
'key': 'ytdl_ntfy_topic_url',
'path': 'YoutubeDLMaterial.API.ntfy_topic_URL'
},
'ytdl_use_gotify_API': {
'key': 'ytdl_use_gotify_API',
'path': 'YoutubeDLMaterial.API.use_gotify_API'
},
'ytdl_gotify_server_url': {
'key': 'ytdl_gotify_server_url',
'path': 'YoutubeDLMaterial.API.gotify_server_URL'
},
'ytdl_gotify_app_token': {
'key': 'ytdl_gotify_app_token',
'path': 'YoutubeDLMaterial.API.gotify_app_token'
},
'ytdl_use_telegram_API': {
'key': 'ytdl_use_telegram_API',
'path': 'YoutubeDLMaterial.API.use_telegram_API'
},
'ytdl_telegram_bot_token': {
'key': 'ytdl_telegram_bot_token',
'path': 'YoutubeDLMaterial.API.telegram_bot_token'
},
'ytdl_telegram_chat_id': {
'key': 'ytdl_telegram_chat_id',
'path': 'YoutubeDLMaterial.API.telegram_chat_id'
},
'ytdl_telegram_webhook_proxy': {
'key': 'ytdl_telegram_webhook_proxy',
'path': 'YoutubeDLMaterial.API.telegram_webhook_proxy'
},
'ytdl_webhook_url': {
'key': 'ytdl_webhook_url',
'path': 'YoutubeDLMaterial.API.webhook_URL'
},
'ytdl_discord_webhook_url': {
'key': 'ytdl_discord_webhook_url',
'path': 'YoutubeDLMaterial.API.discord_webhook_URL'
},
'ytdl_slack_webhook_url': {
'key': 'ytdl_slack_webhook_url',
'path': 'YoutubeDLMaterial.API.slack_webhook_URL'
},
// Themes
@@ -269,94 +217,9 @@ exports.AVAILABLE_PERMISSIONS = [
'subscriptions',
'sharing',
'advanced_download',
'downloads_manager',
'tasks_manager'
'downloads_manager'
];
exports.DETAILS_BIN_PATH = 'appdata/youtube-dl.json'
exports.OUTDATED_YOUTUBEDL_VERSION = "2020.00.00";
exports.DETAILS_BIN_PATH = 'node_modules/youtube-dl/bin/details'
// args that have a value after it (e.g. -o <output> or -f <format>)
const YTDL_ARGS_WITH_VALUES = [
'--default-search',
'--config-location',
'--proxy',
'--socket-timeout',
'--source-address',
'--geo-verification-proxy',
'--geo-bypass-country',
'--geo-bypass-ip-block',
'--playlist-start',
'--playlist-end',
'--playlist-items',
'--match-title',
'--reject-title',
'--max-downloads',
'--min-filesize',
'--max-filesize',
'--date',
'--datebefore',
'--dateafter',
'--min-views',
'--max-views',
'--match-filter',
'--age-limit',
'--download-archive',
'-r',
'--limit-rate',
'-R',
'--retries',
'--fragment-retries',
'--buffer-size',
'--http-chunk-size',
'--external-downloader',
'--external-downloader-args',
'-a',
'--batch-file',
'-o',
'--output',
'--output-na-placeholder',
'--autonumber-start',
'--load-info-json',
'--cookies',
'--cache-dir',
'--encoding',
'--user-agent',
'--referer',
'--add-header',
'--sleep-interval',
'--max-sleep-interval',
'-f',
'--format',
'--merge-output-format',
'--sub-format',
'--sub-lang',
'-u',
'--username',
'-p',
'--password',
'-2',
'--twofactor',
'--video-password',
'--ap-mso',
'--ap-username',
'--ap-password',
'--audio-format',
'--audio-quality',
'--recode-video',
'--postprocessor-args',
'--metadata-from-title',
'--fixup',
'--ffmpeg-location',
'--exec',
'--convert-subs'
];
exports.SUBSCRIPTION_BACKUP_PATH = 'subscription_backup.json'
// we're using a Set here for performance
exports.YTDL_ARGS_WITH_VALUES = new Set(YTDL_ARGS_WITH_VALUES);
exports.ICON_URL = 'https://i.imgur.com/IKOlr0N.png';
exports.CURRENT_VERSION = 'v4.3.2';
exports.CURRENT_VERSION = 'v4.2';

View File

@@ -1,17 +1,18 @@
const fs = require('fs-extra')
const path = require('path')
var fs = require('fs-extra')
var path = require('path')
const { MongoClient } = require("mongodb");
const _ = require('lodash');
const { uuid } = require('uuidv4');
const config_api = require('./config');
const utils = require('./utils')
var utils = require('./utils')
const logger = require('./logger');
const low = require('lowdb')
const FileSync = require('lowdb/adapters/FileSync');
const { BehaviorSubject } = require('rxjs');
const local_adapter = new FileSync('./appdata/local_db.json');
const local_db = low(local_adapter);
let local_db = null;
let database = null;
exports.database_initialized = false;
exports.database_initialized_bs = new BehaviorSubject(false);
@@ -57,13 +58,6 @@ const tables = {
name: 'tasks',
primary_key: 'key'
},
notifications: {
name: 'notifications',
primary_key: 'uid'
},
archives: {
name: 'archives'
},
test: {
name: 'test'
}
@@ -71,6 +65,10 @@ const tables = {
const tables_list = Object.keys(tables);
const local_db_defaults = {}
tables_list.forEach(table => {local_db_defaults[table] = []});
local_db.defaults(local_db_defaults).write();
let using_local_db = null;
function setDB(input_db, input_users_db) {
@@ -79,21 +77,16 @@ function setDB(input_db, input_users_db) {
exports.users_db = input_users_db
}
exports.initialize = (input_db, input_users_db, db_name = 'local_db.json') => {
exports.initialize = (input_db, input_users_db) => {
setDB(input_db, input_users_db);
// must be done here to prevent getConfigItem from being called before init
using_local_db = config_api.getConfigItem('ytdl_use_local_db');
const local_adapter = new FileSync(`./appdata/${db_name}`);
local_db = low(local_adapter);
const local_db_defaults = {}
tables_list.forEach(table => {local_db_defaults[table] = []});
local_db.defaults(local_db_defaults).write();
}
exports.connectToDB = async (retries = 5, no_fallback = false, custom_connection_string = null) => {
using_local_db = config_api.getConfigItem('ytdl_use_local_db'); // verify
if (using_local_db && !custom_connection_string) return;
const success = await exports._connectToDB(custom_connection_string);
if (success) return true;
@@ -157,7 +150,6 @@ exports._connectToDB = async (custom_connection_string = null) => {
await database.collection(table).createIndex(text_search);
}
});
using_local_db = false; // needs to happen for tests (in normal operation using_local_db is guaranteed false)
return true;
} catch(err) {
logger.error(err);
@@ -168,9 +160,82 @@ exports._connectToDB = async (custom_connection_string = null) => {
}
}
exports.setVideoProperty = async (file_uid, assignment_obj) => {
// TODO: check if video exists, throw error if not
await exports.updateRecord('files', {uid: file_uid}, assignment_obj);
exports.registerFileDB = async (file_path, type, user_uid = null, category = null, sub_id = null, cropFileSettings = null, file_object = null) => {
if (!file_object) file_object = generateFileObject(file_path, type);
if (!file_object) {
logger.error(`Could not find associated JSON file for ${type} file ${file_path}`);
return false;
}
utils.fixVideoMetadataPerms(file_path, type);
// add thumbnail path
file_object['thumbnailPath'] = utils.getDownloadedThumbnail(file_path);
// if category exists, only include essential info
if (category) file_object['category'] = {name: category['name'], uid: category['uid']};
// modify duration
if (cropFileSettings) {
file_object['duration'] = (cropFileSettings.cropFileEnd || file_object.duration) - cropFileSettings.cropFileStart;
}
if (user_uid) file_object['user_uid'] = user_uid;
if (sub_id) file_object['sub_id'] = sub_id;
const file_obj = await registerFileDBManual(file_object);
// remove metadata JSON if needed
if (!config_api.getConfigItem('ytdl_include_metadata')) {
utils.deleteJSONFile(file_path, type)
}
return file_obj;
}
async function registerFileDBManual(file_object) {
// add additional info
file_object['uid'] = uuid();
file_object['registered'] = Date.now();
path_object = path.parse(file_object['path']);
file_object['path'] = path.format(path_object);
exports.insertRecordIntoTable('files', file_object, {path: file_object['path']})
return file_object;
}
function generateFileObject(file_path, type) {
var jsonobj = utils.getJSON(file_path, type);
if (!jsonobj) {
return null;
} else if (!jsonobj['_filename']) {
logger.error(`Failed to get filename from info JSON! File ${jsonobj['title']} could not be added.`);
return null;
}
const ext = (type === 'audio') ? '.mp3' : '.mp4'
const true_file_path = utils.getTrueFileName(jsonobj['_filename'], type);
// console.
var stats = fs.statSync(true_file_path);
const file_id = utils.removeFileExtension(path.basename(file_path));
var title = jsonobj.title;
var url = jsonobj.webpage_url;
var uploader = jsonobj.uploader;
var upload_date = utils.formatDateString(jsonobj.upload_date);
var size = stats.size;
var thumbnail = jsonobj.thumbnail;
var duration = jsonobj.duration;
var isaudio = type === 'audio';
var description = jsonobj.description;
var file_obj = new utils.File(file_id, title, thumbnail, isaudio, duration, url, uploader, size, true_file_path, upload_date, description, jsonobj.view_count, jsonobj.height, jsonobj.abr);
return file_obj;
}
function getAppendedBasePathSub(sub, base_path) {
return path.join(base_path, (sub.isPlaylist ? 'playlists/' : 'channels/'), sub.name);
}
exports.getFileDirectoriesAndDBs = async () => {
@@ -189,16 +254,13 @@ exports.getFileDirectoriesAndDBs = async () => {
dirs_to_check.push({
basePath: path.join(usersFileFolder, user.uid, 'audio'),
user_uid: user.uid,
type: 'audio',
archive_path: utils.getArchiveFolder('audio', user.uid)
type: 'audio'
});
// add user's video dir to check list
dirs_to_check.push({
basePath: path.join(usersFileFolder, user.uid, 'video'),
user_uid: user.uid,
type: 'video',
archive_path: utils.getArchiveFolder('video', user.uid)
type: 'video'
});
}
} else {
@@ -208,15 +270,13 @@ exports.getFileDirectoriesAndDBs = async () => {
// add audio dir to check list
dirs_to_check.push({
basePath: audioFolderPath,
type: 'audio',
archive_path: utils.getArchiveFolder('audio')
type: 'audio'
});
// add video dir to check list
dirs_to_check.push({
basePath: videoFolderPath,
type: 'video',
archive_path: utils.getArchiveFolder('video')
type: 'video'
});
}
@@ -237,14 +297,261 @@ exports.getFileDirectoriesAndDBs = async () => {
: path.join(subscriptions_base_path, subscription_to_check.isPlaylist ? 'playlists/' : 'channels/', subscription_to_check.name),
user_uid: subscription_to_check.user_uid,
type: subscription_to_check.type,
sub_id: subscription_to_check['id'],
archive_path: utils.getArchiveFolder(subscription_to_check.type, subscription_to_check.user_uid, subscription_to_check)
sub_id: subscription_to_check['id']
});
}
return dirs_to_check;
}
exports.importUnregisteredFiles = async () => {
const imported_files = [];
const dirs_to_check = await exports.getFileDirectoriesAndDBs();
// run through check list and check each file to see if it's missing from the db
for (let i = 0; i < dirs_to_check.length; i++) {
const dir_to_check = dirs_to_check[i];
// recursively get all files in dir's path
const files = await utils.getDownloadedFilesByType(dir_to_check.basePath, dir_to_check.type);
for (let j = 0; j < files.length; j++) {
const file = files[j];
// check if file exists in db, if not add it
const files_with_same_url = await exports.getRecords('files', {url: file.url, sub_id: dir_to_check.sub_id});
const file_is_registered = !!(files_with_same_url.find(file_with_same_url => path.resolve(file_with_same_url.path) === path.resolve(file.path)));
if (!file_is_registered) {
// add additional info
const file_obj = await exports.registerFileDB(file['path'], dir_to_check.type, dir_to_check.user_uid, null, dir_to_check.sub_id, null);
if (file_obj) {
imported_files.push(file_obj['uid']);
logger.verbose(`Added discovered file to the database: ${file.id}`);
} else {
logger.error(`Failed to import ${file['path']} automatically.`);
}
}
}
}
return imported_files;
}
exports.addMetadataPropertyToDB = async (property_key) => {
try {
const dirs_to_check = await exports.getFileDirectoriesAndDBs();
const update_obj = {};
for (let i = 0; i < dirs_to_check.length; i++) {
const dir_to_check = dirs_to_check[i];
// recursively get all files in dir's path
const files = await utils.getDownloadedFilesByType(dir_to_check.basePath, dir_to_check.type, true);
for (let j = 0; j < files.length; j++) {
const file = files[j];
if (file[property_key]) {
update_obj[file.uid] = {[property_key]: file[property_key]};
}
}
}
return await exports.bulkUpdateRecords('files', 'uid', update_obj);
} catch(err) {
logger.error(err);
return false;
}
}
exports.createPlaylist = async (playlist_name, uids, type, user_uid = null) => {
const first_video = await exports.getVideo(uids[0]);
const thumbnailToUse = first_video['thumbnailURL'];
let new_playlist = {
name: playlist_name,
uids: uids,
id: uuid(),
thumbnailURL: thumbnailToUse,
type: type,
registered: Date.now(),
randomize_order: false
};
new_playlist.user_uid = user_uid ? user_uid : undefined;
await exports.insertRecordIntoTable('playlists', new_playlist);
const duration = await exports.calculatePlaylistDuration(new_playlist);
await exports.updateRecord('playlists', {id: new_playlist.id}, {duration: duration});
return new_playlist;
}
exports.getPlaylist = async (playlist_id, user_uid = null, require_sharing = false) => {
let playlist = await exports.getRecord('playlists', {id: playlist_id});
if (!playlist) {
playlist = await exports.getRecord('categories', {uid: playlist_id});
if (playlist) {
// category found
const files = await exports.getFiles(user_uid);
utils.addUIDsToCategory(playlist, files);
}
}
// converts playlists to new UID-based schema
if (playlist && playlist['fileNames'] && !playlist['uids']) {
playlist['uids'] = [];
logger.verbose(`Converting playlist ${playlist['name']} to new UID-based schema.`);
for (let i = 0; i < playlist['fileNames'].length; i++) {
const fileName = playlist['fileNames'][i];
const uid = await exports.getVideoUIDByID(fileName, user_uid);
if (uid) playlist['uids'].push(uid);
else logger.warn(`Failed to convert file with name ${fileName} to its UID while converting playlist ${playlist['name']} to the new UID-based schema. The original file is likely missing/deleted and it will be skipped.`);
}
exports.updatePlaylist(playlist, user_uid);
}
// prevent unauthorized users from accessing the file info
if (require_sharing && !playlist['sharingEnabled']) return null;
return playlist;
}
exports.updatePlaylist = async (playlist) => {
let playlistID = playlist.id;
const duration = await exports.calculatePlaylistDuration(playlist);
playlist.duration = duration;
return await exports.updateRecord('playlists', {id: playlistID}, playlist);
}
exports.setPlaylistProperty = async (playlist_id, assignment_obj, user_uid = null) => {
let success = await exports.updateRecord('playlists', {id: playlist_id}, assignment_obj);
if (!success) {
success = await exports.updateRecord('categories', {uid: playlist_id}, assignment_obj);
}
if (!success) {
logger.error(`Could not find playlist or category with ID ${playlist_id}`);
}
return success;
}
exports.calculatePlaylistDuration = async (playlist, playlist_file_objs = null) => {
if (!playlist_file_objs) {
playlist_file_objs = [];
for (let i = 0; i < playlist['uids'].length; i++) {
const uid = playlist['uids'][i];
const file_obj = await exports.getVideo(uid);
if (file_obj) playlist_file_objs.push(file_obj);
}
}
return playlist_file_objs.reduce((a, b) => a + utils.durationStringToNumber(b.duration), 0);
}
exports.deleteFile = async (uid, uuid = null, blacklistMode = false) => {
const file_obj = await exports.getVideo(uid, uuid);
const type = file_obj.isAudio ? 'audio' : 'video';
const folderPath = path.dirname(file_obj.path);
const ext = type === 'audio' ? 'mp3' : 'mp4';
const name = file_obj.id;
const filePathNoExtension = utils.removeFileExtension(file_obj.path);
var jsonPath = `${file_obj.path}.info.json`;
var altJSONPath = `${filePathNoExtension}.info.json`;
var thumbnailPath = `${filePathNoExtension}.webp`;
var altThumbnailPath = `${filePathNoExtension}.jpg`;
jsonPath = path.join(__dirname, jsonPath);
altJSONPath = path.join(__dirname, altJSONPath);
let jsonExists = await fs.pathExists(jsonPath);
let thumbnailExists = await fs.pathExists(thumbnailPath);
if (!jsonExists) {
if (await fs.pathExists(altJSONPath)) {
jsonExists = true;
jsonPath = altJSONPath;
}
}
if (!thumbnailExists) {
if (await fs.pathExists(altThumbnailPath)) {
thumbnailExists = true;
thumbnailPath = altThumbnailPath;
}
}
let fileExists = await fs.pathExists(file_obj.path);
if (config_api.descriptors[uid]) {
try {
for (let i = 0; i < config_api.descriptors[uid].length; i++) {
config_api.descriptors[uid][i].destroy();
}
} catch(e) {
}
}
let useYoutubeDLArchive = config_api.getConfigItem('ytdl_use_youtubedl_archive');
if (useYoutubeDLArchive) {
const archive_path = uuid ? path.join(usersFileFolder, uuid, 'archives', `archive_${type}.txt`) : path.join('appdata', 'archives', `archive_${type}.txt`);
// get ID from JSON
var jsonobj = await (type === 'audio' ? utils.getJSONMp3(name, folderPath) : utils.getJSONMp4(name, folderPath));
let id = null;
if (jsonobj) id = jsonobj.id;
// use subscriptions API to remove video from the archive file, and write it to the blacklist
if (await fs.pathExists(archive_path)) {
const line = id ? await utils.removeIDFromArchive(archive_path, id) : null;
if (blacklistMode && line) await writeToBlacklist(type, line);
} else {
logger.info('Could not find archive file for audio files. Creating...');
await fs.close(await fs.open(archive_path, 'w'));
}
}
if (jsonExists) await fs.unlink(jsonPath);
if (thumbnailExists) await fs.unlink(thumbnailPath);
await exports.removeRecord('files', {uid: uid});
if (fileExists) {
await fs.unlink(file_obj.path);
if (await fs.pathExists(jsonPath) || await fs.pathExists(file_obj.path)) {
return false;
} else {
return true;
}
} else {
// TODO: tell user that the file didn't exist
return true;
}
}
// Video ID is basically just the file name without the base path and file extension - this method helps us get away from that
exports.getVideoUIDByID = async (file_id, uuid = null) => {
const file_obj = await exports.getRecord('files', {id: file_id});
return file_obj ? file_obj['uid'] : null;
}
exports.getVideo = async (file_uid) => {
return await exports.getRecord('files', {uid: file_uid});
}
exports.getFiles = async (uuid = null) => {
return await exports.getRecords('files', {user_uid: uuid});
}
exports.setVideoProperty = async (file_uid, assignment_obj) => {
// TODO: check if video exists, throw error if not
await exports.updateRecord('files', {uid: file_uid}, assignment_obj);
}
// Basic DB functions
// Create
@@ -252,7 +559,7 @@ exports.getFileDirectoriesAndDBs = async () => {
exports.insertRecordIntoTable = async (table, doc, replaceFilter = null) => {
// local db override
if (using_local_db) {
if (replaceFilter) local_db.get(table).remove((doc) => _.isMatch(doc, replaceFilter)).write();
if (replaceFilter) local_db.get(table).remove(replaceFilter).write();
local_db.get(table).push(doc).write();
return true;
}
@@ -323,7 +630,7 @@ exports.bulkInsertRecordsIntoTable = async (table, docs) => {
exports.getRecord = async (table, filter_obj) => {
// local db override
if (using_local_db) {
return exports.applyFilterLocalDB(local_db.get(table), filter_obj, 'find').value();
return applyFilterLocalDB(local_db.get(table), filter_obj, 'find').value();
}
return await database.collection(table).findOne(filter_obj);
@@ -332,7 +639,7 @@ exports.getRecord = async (table, filter_obj) => {
exports.getRecords = async (table, filter_obj = null, return_count = false, sort = null, range = null) => {
// local db override
if (using_local_db) {
let cursor = filter_obj ? exports.applyFilterLocalDB(local_db.get(table), filter_obj, 'filter').value() : local_db.get(table).value();
let cursor = filter_obj ? applyFilterLocalDB(local_db.get(table), filter_obj, 'filter').value() : local_db.get(table).value();
if (sort) {
cursor = cursor.sort((a, b) => (a[sort['by']] > b[sort['by']] ? sort['order'] : sort['order']*-1));
}
@@ -355,16 +662,10 @@ exports.getRecords = async (table, filter_obj = null, return_count = false, sort
// Update
exports.updateRecord = async (table, filter_obj, update_obj, nested_mode = false) => {
exports.updateRecord = async (table, filter_obj, update_obj) => {
// local db override
if (using_local_db) {
if (nested_mode) {
// if object is nested we need to handle it differently
update_obj = utils.convertFlatObjectToNestedObject(update_obj);
exports.applyFilterLocalDB(local_db.get(table), filter_obj, 'find').merge(update_obj).write();
return true;
}
exports.applyFilterLocalDB(local_db.get(table), filter_obj, 'find').assign(update_obj).write();
applyFilterLocalDB(local_db.get(table), filter_obj, 'find').assign(update_obj).write();
return true;
}
@@ -377,14 +678,7 @@ exports.updateRecord = async (table, filter_obj, update_obj, nested_mode = false
exports.updateRecords = async (table, filter_obj, update_obj) => {
// local db override
if (using_local_db) {
exports.applyFilterLocalDB(local_db.get(table), filter_obj, 'filter').each((record) => {
const props_to_update = Object.keys(update_obj);
for (let i = 0; i < props_to_update.length; i++) {
const prop_to_update = props_to_update[i];
const prop_value = update_obj[prop_to_update];
record[prop_to_update] = prop_value;
}
}).write();
applyFilterLocalDB(local_db.get(table), filter_obj, 'filter').assign(update_obj).write();
return true;
}
@@ -392,19 +686,7 @@ exports.updateRecords = async (table, filter_obj, update_obj) => {
return !!(output['result']['ok']);
}
exports.removePropertyFromRecord = async (table, filter_obj, remove_obj) => {
// local db override
if (using_local_db) {
const props_to_remove = Object.keys(remove_obj);
exports.applyFilterLocalDB(local_db.get(table), filter_obj, 'find').unset(props_to_remove).write();
return true;
}
const output = await database.collection(table).updateOne(filter_obj, {$unset: remove_obj});
return !!(output['result']['ok']);
}
exports.bulkUpdateRecordsByKey = async (table, key_label, update_obj) => {
exports.bulkUpdateRecords = async (table, key_label, update_obj) => {
// local db override
if (using_local_db) {
local_db.get(table).each((record) => {
@@ -441,7 +723,7 @@ exports.bulkUpdateRecordsByKey = async (table, key_label, update_obj) => {
exports.pushToRecordsArray = async (table, filter_obj, key, value) => {
// local db override
if (using_local_db) {
exports.applyFilterLocalDB(local_db.get(table), filter_obj, 'find').get(key).push(value).write();
applyFilterLocalDB(local_db.get(table), filter_obj, 'find').get(key).push(value).write();
return true;
}
@@ -452,7 +734,7 @@ exports.pushToRecordsArray = async (table, filter_obj, key, value) => {
exports.pullFromRecordsArray = async (table, filter_obj, key, value) => {
// local db override
if (using_local_db) {
exports.applyFilterLocalDB(local_db.get(table), filter_obj, 'find').get(key).pull(value).write();
applyFilterLocalDB(local_db.get(table), filter_obj, 'find').get(key).pull(value).write();
return true;
}
@@ -465,7 +747,7 @@ exports.pullFromRecordsArray = async (table, filter_obj, key, value) => {
exports.removeRecord = async (table, filter_obj) => {
// local db override
if (using_local_db) {
exports.applyFilterLocalDB(local_db.get(table), filter_obj, 'remove').write();
applyFilterLocalDB(local_db.get(table), filter_obj, 'remove').write();
return true;
}
@@ -476,7 +758,7 @@ exports.removeRecord = async (table, filter_obj) => {
// exports.removeRecordsByUIDBulk = async (table, uids) => {
// // local db override
// if (using_local_db) {
// exports.applyFilterLocalDB(local_db.get(table), filter_obj, 'remove').write();
// applyFilterLocalDB(local_db.get(table), filter_obj, 'remove').write();
// return true;
// }
@@ -540,7 +822,7 @@ exports.removeAllRecords = async (table = null, filter_obj = null) => {
if (using_local_db) {
for (let i = 0; i < tables_to_remove.length; i++) {
const table_to_remove = tables_to_remove[i];
if (filter_obj) exports.applyFilterLocalDB(local_db.get(table), filter_obj, 'remove').write();
if (filter_obj) applyFilterLocalDB(local_db.get(table), filter_obj, 'remove').write();
else local_db.assign({[table_to_remove]: []}).write();
logger.debug(`Successfully removed records from ${table_to_remove}`);
}
@@ -652,7 +934,6 @@ exports.importJSONToDB = async (db_json, users_json) => {
const createFilesRecords = (files, subscriptions) => {
for (let i = 0; i < subscriptions.length; i++) {
const subscription = subscriptions[i];
if (!subscription['videos']) continue;
subscription['videos'] = subscription['videos'].map(file => ({ ...file, sub_id: subscription['id'], user_uid: subscription['user_uid'] ? subscription['user_uid'] : undefined}));
files = files.concat(subscriptions[i]['videos']);
}
@@ -713,7 +994,7 @@ exports.backupDB = async () => {
const backup_file_name = `${using_local_db ? 'local' : 'remote'}_db.json.${Date.now()/1000}.bak`;
const path_to_backups = path.join(backup_dir, backup_file_name);
logger.info(`Backing up ${using_local_db ? 'local' : 'remote'} DB to ${path_to_backups}`);
logger.verbose(`Backing up ${using_local_db ? 'local' : 'remote'} DB to ${path_to_backups}`);
const table_to_records = {};
for (let i = 0; i < tables_list.length; i++) {
@@ -760,11 +1041,10 @@ exports.transferDB = async (local_to_remote) => {
table_to_records[table] = await exports.getRecords(table);
}
logger.info('Backup up DB...');
await exports.backupDB(); // should backup always
using_local_db = !local_to_remote;
if (local_to_remote) {
logger.debug('Backup up DB...');
await exports.backupDB();
const db_connected = await exports.connectToDB(5, true);
if (!db_connected) {
logger.error('Failed to transfer database - could not connect to MongoDB. Verify that your connection URL is valid.');
@@ -796,13 +1076,8 @@ exports.transferDB = async (local_to_remote) => {
This function is necessary to emulate mongodb's ability to search for null or missing values.
A filter of null or undefined for a property will find docs that have that property missing, or have it
null or undefined. We want that same functionality for the local DB as well
error: {$ne: null}
^ ^
| |
filter_prop filter_prop_value
*/
exports.applyFilterLocalDB = (db_path, filter_obj, operation) => {
const applyFilterLocalDB = (db_path, filter_obj, operation) => {
const filter_props = Object.keys(filter_obj);
const return_val = db_path[operation](record => {
if (!filter_props) return true;
@@ -811,28 +1086,14 @@ exports.applyFilterLocalDB = (db_path, filter_obj, operation) => {
const filter_prop = filter_props[i];
const filter_prop_value = filter_obj[filter_prop];
if (filter_prop_value === undefined || filter_prop_value === null) {
filtered &= record[filter_prop] === undefined || record[filter_prop] === null;
filtered &= record[filter_prop] === undefined || record[filter_prop] === null
} else {
if (typeof filter_prop_value === 'object') {
if ('$regex' in filter_prop_value) {
if (filter_prop_value['$regex']) {
filtered &= (record[filter_prop].search(new RegExp(filter_prop_value['$regex'], filter_prop_value['$options'])) !== -1);
} else if ('$ne' in filter_prop_value) {
filtered &= filter_prop in record && record[filter_prop] !== filter_prop_value['$ne'];
} else if ('$lt' in filter_prop_value) {
filtered &= filter_prop in record && record[filter_prop] < filter_prop_value['$lt'];
} else if ('$gt' in filter_prop_value) {
filtered &= filter_prop in record && record[filter_prop] > filter_prop_value['$gt'];
} else if ('$lte' in filter_prop_value) {
filtered &= filter_prop in record && record[filter_prop] <= filter_prop_value['$lt'];
} else if ('$gte' in filter_prop_value) {
filtered &= filter_prop in record && record[filter_prop] >= filter_prop_value['$gt'];
}
} else {
// handle case of nested property check
if (filter_prop.includes('.'))
filtered &= utils.searchObjectByString(record, filter_prop) === filter_prop_value;
else
filtered &= record[filter_prop] === filter_prop_value;
filtered &= record[filter_prop] === filter_prop_value;
}
}
}
@@ -841,7 +1102,14 @@ exports.applyFilterLocalDB = (db_path, filter_obj, operation) => {
return return_val;
}
// should only be used for tests
exports.setLocalDBMode = (mode) => {
using_local_db = mode;
}
// archive helper functions
async function writeToBlacklist(type, line) {
const archivePath = path.join(__dirname, 'appdata', 'archives');
let blacklistPath = path.join(archivePath, (type === 'audio') ? 'blacklist_audio.txt' : 'blacklist_video.txt');
// adds newline to the beginning of the line
line.replace('\n', '');
line.replace('\r', '');
line = '\n' + line;
await fs.appendFile(blacklistPath, line);
}

View File

@@ -1,54 +1,34 @@
const fs = require('fs-extra');
const { v4: uuid } = require('uuid');
const { uuid } = require('uuidv4');
const path = require('path');
const mergeFiles = require('merge-files');
const NodeID3 = require('node-id3')
const Mutex = require('async-mutex').Mutex;
const youtubedl = require('youtube-dl');
const logger = require('./logger');
const youtubedl_api = require('./youtube-dl');
const config_api = require('./config');
const twitch_api = require('./twitch');
const { create } = require('xmlbuilder2');
const categories_api = require('./categories');
const utils = require('./utils');
const db_api = require('./db');
const files_api = require('./files');
const notifications_api = require('./notifications');
const archive_api = require('./archive');
const mutex = new Mutex();
let should_check_downloads = true;
const download_to_child_process = {};
const archivePath = path.join(__dirname, 'appdata', 'archives');
if (db_api.database_initialized) {
exports.setupDownloads();
setupDownloads();
} else {
db_api.database_initialized_bs.subscribe(init => {
if (init) exports.setupDownloads();
if (init) setupDownloads();
});
}
/*
This file handles all the downloading functionality.
To download a file, we go through 4 steps. Here they are with their respective index & function:
0: Create the download
- createDownload()
1: Get info for the download (we need this step for categories and archive functionality)
- collectInfo()
2: Download the file
- downloadQueuedFile()
3: Complete
- N/A
We use checkDownloads() to move downloads through the steps and call their respective functions.
*/
exports.createDownload = async (url, type, options, user_uid = null, sub_id = null, sub_name = null, prefetched_info = null, paused = false) => {
exports.createDownload = async (url, type, options, user_uid = null, sub_id = null, sub_name = null) => {
return await mutex.runExclusive(async () => {
const download = {
url: url,
@@ -57,11 +37,10 @@ exports.createDownload = async (url, type, options, user_uid = null, sub_id = nu
user_uid: user_uid,
sub_id: sub_id,
sub_name: sub_name,
prefetched_info: prefetched_info,
options: options,
uid: uuid(),
step_index: 0,
paused: paused,
paused: false,
running: false,
finished_step: true,
error: null,
@@ -84,11 +63,8 @@ exports.pauseDownload = async (download_uid) => {
} else if (download['finished']) {
logger.info(`Download ${download_uid} could not be paused before completing.`);
return false;
} else {
logger.info(`Pausing download ${download_uid}`);
}
killActiveDownload(download);
return await db_api.updateRecord('download_queue', {uid: download_uid}, {paused: true, running: false});
}
@@ -109,10 +85,10 @@ exports.resumeDownload = async (download_uid) => {
exports.restartDownload = async (download_uid) => {
const download = await db_api.getRecord('download_queue', {uid: download_uid});
await exports.clearDownload(download_uid);
const new_download = await exports.createDownload(download['url'], download['type'], download['options'], download['user_uid']);
const success = !!(await exports.createDownload(download['url'], download['type'], download['options'], download['user_uid']));
should_check_downloads = true;
return new_download;
return success;
}
exports.cancelDownload = async (download_uid) => {
@@ -123,28 +99,19 @@ exports.cancelDownload = async (download_uid) => {
} else if (download['finished']) {
logger.info(`Download ${download_uid} could not be cancelled before completing.`);
return false;
} else {
logger.info(`Cancelling download ${download_uid}`);
}
killActiveDownload(download);
await handleDownloadError(download_uid, 'Cancelled', 'cancelled');
return await db_api.updateRecord('download_queue', {uid: download_uid}, {cancelled: true});
return await db_api.updateRecord('download_queue', {uid: download_uid}, {cancelled: true, running: false});
}
exports.clearDownload = async (download_uid) => {
return await db_api.removeRecord('download_queue', {uid: download_uid});
}
async function handleDownloadError(download_uid, error_message, error_type = null) {
if (!download_uid) return;
const download = await db_api.getRecord('download_queue', {uid: download_uid});
if (!download || download['error']) return;
notifications_api.sendDownloadErrorNotification(download, download['user_uid'], error_message, error_type);
await db_api.updateRecord('download_queue', {uid: download['uid']}, {error: error_message, finished: true, running: false, error_type: error_type});
async function handleDownloadError(download_uid, error_message) {
await db_api.updateRecord('download_queue', {uid: download_uid}, {error: error_message, finished: true, running: false});
}
exports.setupDownloads = async () => {
async function setupDownloads() {
await fixDownloadState();
setInterval(checkDownloads, 1000);
}
@@ -187,33 +154,18 @@ async function checkDownloads() {
if (max_concurrent_downloads < 0 || running_downloads_count >= max_concurrent_downloads) break;
if (waiting_download['finished_step'] && !waiting_download['finished']) {
if (waiting_download['sub_id']) {
const sub_missing = !(await db_api.getRecord('subscriptions', {id: waiting_download['sub_id']}));
if (sub_missing) {
handleDownloadError(waiting_download['uid'], `Download failed as subscription with id '${waiting_download['sub_id']}' is missing!`, 'sub_id_missing');
continue;
}
}
// move to next step
running_downloads_count++;
if (waiting_download['step_index'] === 0) {
exports.collectInfo(waiting_download['uid']);
collectInfo(waiting_download['uid']);
} else if (waiting_download['step_index'] === 1) {
exports.downloadQueuedFile(waiting_download['uid']);
downloadQueuedFile(waiting_download['uid']);
}
}
}
}
function killActiveDownload(download) {
const child_process = download_to_child_process[download['uid']];
if (download['step_index'] === 2 && child_process) {
youtubedl_api.killYoutubeDLProcess(child_process);
delete download_to_child_process[download['uid']];
}
}
exports.collectInfo = async (download_uid) => {
async function collectInfo(download_uid) {
const download = await db_api.getRecord('download_queue', {uid: download_uid});
if (download['paused']) {
return;
@@ -234,43 +186,26 @@ exports.collectInfo = async (download_uid) => {
let args = await exports.generateArgs(url, type, options, download['user_uid']);
// get video info prior to download
let info = download['prefetched_info'] ? download['prefetched_info'] : await exports.getVideoInfoByURL(url, args, download_uid);
let info = await getVideoInfoByURL(url, args, download_uid);
if (!info || info.length === 0) {
if (!info) {
// info failed, error presumably already recorded
return;
}
// in subscriptions we don't care if archive mode is enabled, but we already removed archived videos from subs by this point
const useYoutubeDLArchive = config_api.getConfigItem('ytdl_use_youtubedl_archive');
if (useYoutubeDLArchive && !options.ignoreArchive && info.length === 1) {
const info_obj = info[0];
const exists_in_archive = await archive_api.existsInArchive(info['extractor'], info_obj['id'], type, download['user_uid'], download['sub_id']);
if (exists_in_archive) {
const error = `File '${info_obj['title']}' already exists in archive! Disable the archive or override to continue downloading.`;
logger.warn(error);
if (download_uid) {
await handleDownloadError(download_uid, error, 'exists_in_archive');
return;
}
}
}
let category = null;
// check if it fits into a category. If so, then get info again using new args
if (info.length === 1 || config_api.getConfigItem('ytdl_allow_playlist_categorization')) category = await categories_api.categorize(info);
if (!Array.isArray(info) || config_api.getConfigItem('ytdl_allow_playlist_categorization')) category = await categories_api.categorize(info);
// set custom output if the category has one and re-retrieve info so the download manager has the right file name
if (category && category['custom_output']) {
options.customOutput = category['custom_output'];
options.noRelativePath = true;
args = await exports.generateArgs(url, type, options, download['user_uid']);
info = await exports.getVideoInfoByURL(url, args, download_uid);
info = await getVideoInfoByURL(url, args, download_uid);
}
const stripped_category = category ? {name: category['name'], uid: category['uid']} : null;
// setup info required to calculate download progress
const expected_file_size = utils.getExpectedFileSize(info);
@@ -278,22 +213,24 @@ exports.collectInfo = async (download_uid) => {
const files_to_check_for_progress = [];
// store info in download for future use
for (let info_obj of info) files_to_check_for_progress.push(utils.removeFileExtension(info_obj['_filename']));
if (Array.isArray(info)) {
for (let info_obj of info) files_to_check_for_progress.push(utils.removeFileExtension(info_obj['_filename']));
} else {
files_to_check_for_progress.push(utils.removeFileExtension(info['_filename']));
}
const title = info.length > 1 ? info[0]['playlist_title'] || info[0]['playlist'] : info[0]['title'];
const playlist_title = Array.isArray(info) ? info[0]['playlist_title'] || info[0]['playlist'] : null;
await db_api.updateRecord('download_queue', {uid: download_uid}, {args: args,
finished_step: true,
running: false,
options: options,
files_to_check_for_progress: files_to_check_for_progress,
expected_file_size: expected_file_size,
title: title,
category: stripped_category,
prefetched_info: null
title: playlist_title ? playlist_title : info['title']
});
}
exports.downloadQueuedFile = async(download_uid, customDownloadHandler = null) => {
async function downloadQueuedFile(download_uid) {
const download = await db_api.getRecord('download_queue', {uid: download_uid});
if (download['paused']) {
return;
@@ -302,7 +239,6 @@ exports.downloadQueuedFile = async(download_uid, customDownloadHandler = null) =
return new Promise(async resolve => {
const audioFolderPath = config_api.getConfigItem('ytdl_audio_folder_path');
const videoFolderPath = config_api.getConfigItem('ytdl_video_folder_path');
const usersFolderPath = config_api.getConfigItem('ytdl_users_base_path');
await db_api.updateRecord('download_queue', {uid: download_uid}, {step_index: 2, finished_step: false, running: true});
const url = download['url'];
@@ -310,143 +246,139 @@ exports.downloadQueuedFile = async(download_uid, customDownloadHandler = null) =
const options = download['options'];
const args = download['args'];
const category = download['category'];
let fileFolderPath = type === 'audio' ? audioFolderPath : videoFolderPath;
let fileFolderPath = type === 'audio' ? audioFolderPath : videoFolderPath; // TODO: fix
if (options.customFileFolderPath) {
fileFolderPath = options.customFileFolderPath;
} else if (download['user_uid']) {
fileFolderPath = path.join(usersFolderPath, download['user_uid'], type);
}
fs.ensureDirSync(fileFolderPath);
const start_time = Date.now();
const download_checker = setInterval(() => checkDownloadPercent(download['uid']), 1000);
const file_objs = [];
// download file
let {child_process, callback} = await youtubedl_api.runYoutubeDL(url, args, customDownloadHandler);
if (child_process) download_to_child_process[download['uid']] = child_process;
const {parsed_output, err} = await callback;
clearInterval(download_checker);
let end_time = Date.now();
let difference = (end_time - start_time)/1000;
logger.debug(`${type === 'audio' ? 'Audio' : 'Video'} download delay: ${difference} seconds.`);
if (!parsed_output) {
const errored_download = await db_api.getRecord('download_queue', {uid: download_uid});
if (errored_download && errored_download['paused']) return;
logger.error(err.toString());
await handleDownloadError(download_uid, err.toString(), 'unknown_error');
resolve(false);
return;
} else if (parsed_output) {
if (parsed_output.length === 0 || parsed_output[0].length === 0) {
// ERROR!
const error_message = `No output received for video download, check if it exists in your archive.`;
await handleDownloadError(download_uid, error_message, 'no_output');
logger.warn(error_message);
youtubedl.exec(url, args, {maxBuffer: Infinity}, async function(err, output) {
const file_objs = [];
let end_time = Date.now();
let difference = (end_time - start_time)/1000;
logger.debug(`${type === 'audio' ? 'Audio' : 'Video'} download delay: ${difference} seconds.`);
clearInterval(download_checker);
if (err) {
logger.error(err.stderr);
await handleDownloadError(download_uid, err.stderr);
resolve(false);
return;
}
for (const output_json of parsed_output) {
if (!output_json) {
continue;
} else if (output) {
if (output.length === 0 || output[0].length === 0) {
// ERROR!
const error_message = `No output received for video download, check if it exists in your archive.`;
await handleDownloadError(download_uid, error_message);
logger.warn(error_message);
resolve(false);
return;
}
// get filepath with no extension
const filepath_no_extension = utils.removeFileExtension(output_json['_filename']);
const ext = type === 'audio' ? '.mp3' : '.mp4';
var full_file_path = filepath_no_extension + ext;
var file_name = filepath_no_extension.substring(fileFolderPath.length, filepath_no_extension.length);
if (type === 'video' && url.includes('twitch.tv/videos/') && url.split('twitch.tv/videos/').length > 1
&& config_api.getConfigItem('ytdl_twitch_auto_download_chat')) {
let vodId = url.split('twitch.tv/videos/')[1];
vodId = vodId.split('?')[0];
twitch_api.downloadTwitchChatByVODID(vodId, file_name, type, download['user_uid']);
}
// renames file if necessary due to bug
if (!fs.existsSync(output_json['_filename']) && fs.existsSync(output_json['_filename'] + '.webm')) {
for (let i = 0; i < output.length; i++) {
let output_json = null;
try {
fs.renameSync(output_json['_filename'] + '.webm', output_json['_filename']);
logger.info('Renamed ' + file_name + '.webm to ' + file_name);
output_json = JSON.parse(output[i]);
} catch(e) {
logger.error(`Failed to rename file ${output_json['_filename']} to its appropriate extension.`);
output_json = null;
}
}
if (type === 'audio') {
let tags = {
title: output_json['title'],
artist: output_json['artist'] ? output_json['artist'] : output_json['uploader']
if (!output_json) {
continue;
}
let success = NodeID3.write(tags, utils.removeFileExtension(output_json['_filename']) + '.mp3');
if (!success) logger.error('Failed to apply ID3 tag to audio file ' + output_json['_filename']);
// get filepath with no extension
const filepath_no_extension = utils.removeFileExtension(output_json['_filename']);
const ext = type === 'audio' ? '.mp3' : '.mp4';
var full_file_path = filepath_no_extension + ext;
var file_name = filepath_no_extension.substring(fileFolderPath.length, filepath_no_extension.length);
if (type === 'video' && url.includes('twitch.tv/videos/') && url.split('twitch.tv/videos/').length > 1
&& config_api.getConfigItem('ytdl_use_twitch_api') && config_api.getConfigItem('ytdl_twitch_auto_download_chat')) {
let vodId = url.split('twitch.tv/videos/')[1];
vodId = vodId.split('?')[0];
twitch_api.downloadTwitchChatByVODID(vodId, file_name, type, download['user_uid']);
}
// renames file if necessary due to bug
if (!fs.existsSync(output_json['_filename']) && fs.existsSync(output_json['_filename'] + '.webm')) {
try {
fs.renameSync(output_json['_filename'] + '.webm', output_json['_filename']);
logger.info('Renamed ' + file_name + '.webm to ' + file_name);
} catch(e) {
logger.error(`Failed to rename file ${output_json['_filename']} to its appropriate extension.`);
}
}
if (type === 'audio') {
let tags = {
title: output_json['title'],
artist: output_json['artist'] ? output_json['artist'] : output_json['uploader']
}
let success = NodeID3.write(tags, utils.removeFileExtension(output_json['_filename']) + '.mp3');
if (!success) logger.error('Failed to apply ID3 tag to audio file ' + output_json['_filename']);
}
if (config_api.getConfigItem('ytdl_generate_nfo_files')) {
exports.generateNFOFile(output_json, `${filepath_no_extension}.nfo`);
}
if (options.cropFileSettings) {
await utils.cropFile(full_file_path, options.cropFileSettings.cropFileStart, options.cropFileSettings.cropFileEnd, ext);
}
// registers file in DB
const file_obj = await db_api.registerFileDB(full_file_path, type, download['user_uid'], category, download['sub_id'] ? download['sub_id'] : null, options.cropFileSettings);
file_objs.push(file_obj);
}
if (config_api.getConfigItem('ytdl_generate_nfo_files')) {
exports.generateNFOFile(output_json, `${filepath_no_extension}.nfo`);
if (options.merged_string !== null && options.merged_string !== undefined) {
const archive_folder = getArchiveFolder(fileFolderPath, options, download['user_uid']);
const current_merged_archive = fs.readFileSync(path.join(archive_folder, `merged_${type}.txt`), 'utf8');
const diff = current_merged_archive.replace(options.merged_string, '');
const archive_path = path.join(archive_folder, `archive_${type}.txt`);
fs.appendFileSync(archive_path, diff);
}
if (options.cropFileSettings) {
await utils.cropFile(full_file_path, options.cropFileSettings.cropFileStart, options.cropFileSettings.cropFileEnd, ext);
let container = null;
if (file_objs.length > 1) {
// create playlist
const playlist_name = file_objs.map(file_obj => file_obj.title).join(', ');
container = await db_api.createPlaylist(playlist_name, file_objs.map(file_obj => file_obj.uid), type, download['user_uid']);
} else if (file_objs.length === 1) {
container = file_objs[0];
} else {
const error_message = 'Downloaded file failed to result in metadata object.';
logger.error(error_message);
await handleDownloadError(download_uid, error_message);
}
// registers file in DB
const file_obj = await files_api.registerFileDB(full_file_path, type, download['user_uid'], category, download['sub_id'] ? download['sub_id'] : null, options.cropFileSettings);
await archive_api.addToArchive(output_json['extractor'], output_json['id'], type, output_json['title'], download['user_uid'], download['sub_id']);
notifications_api.sendDownloadNotification(file_obj, download['user_uid']);
file_objs.push(file_obj);
const file_uids = file_objs.map(file_obj => file_obj.uid);
await db_api.updateRecord('download_queue', {uid: download_uid}, {finished_step: true, finished: true, running: false, step_index: 3, percent_complete: 100, file_uids: file_uids, container: container});
resolve();
}
let container = null;
if (file_objs.length > 1) {
// create playlist
container = await files_api.createPlaylist(download['title'], file_objs.map(file_obj => file_obj.uid), download['user_uid']);
} else if (file_objs.length === 1) {
container = file_objs[0];
} else {
const error_message = 'Downloaded file failed to result in metadata object.';
logger.error(error_message);
await handleDownloadError(download_uid, error_message, 'no_metadata');
}
const file_uids = file_objs.map(file_obj => file_obj.uid);
await db_api.updateRecord('download_queue', {uid: download_uid}, {finished_step: true, finished: true, running: false, step_index: 3, percent_complete: 100, file_uids: file_uids, container: container});
resolve(file_uids);
}
});
});
}
// helper functions
exports.generateArgs = async (url, type, options, user_uid = null, simulated = false) => {
const default_downloader = config_api.getConfigItem('ytdl_default_downloader');
if (!simulated && (default_downloader === 'youtube-dl' || default_downloader === 'youtube-dlc')) {
logger.warn('It is recommended you use yt-dlp! To prevent failed downloads, change the downloader in your settings menu to yt-dlp and restart your instance.')
}
const audioFolderPath = config_api.getConfigItem('ytdl_audio_folder_path');
const videoFolderPath = config_api.getConfigItem('ytdl_video_folder_path');
const usersFolderPath = config_api.getConfigItem('ytdl_users_base_path');
const videopath = config_api.getConfigItem('ytdl_default_file_output') ? config_api.getConfigItem('ytdl_default_file_output') : '%(title)s';
const globalArgs = config_api.getConfigItem('ytdl_custom_args');
const useCookies = config_api.getConfigItem('ytdl_use_cookies');
const is_audio = type === 'audio';
let fileFolderPath = type === 'audio' ? audioFolderPath : videoFolderPath; // TODO: fix
if (options.customFileFolderPath) {
fileFolderPath = options.customFileFolderPath;
} else if (user_uid) {
fileFolderPath = path.join(usersFolderPath, user_uid, fileFolderPath);
}
let fileFolderPath = is_audio ? audioFolderPath : videoFolderPath;
if (options.customFileFolderPath) fileFolderPath = options.customFileFolderPath;
@@ -456,8 +388,6 @@ exports.generateArgs = async (url, type, options, user_uid = null, simulated = f
// video-specific args
const selectedHeight = options.selectedHeight;
const maxHeight = options.maxHeight;
const heightParam = selectedHeight || maxHeight;
// audio-specific args
const maxBitrate = options.maxBitrate;
@@ -471,6 +401,8 @@ exports.generateArgs = async (url, type, options, user_uid = null, simulated = f
if (!is_audio && !is_youtube) {
// tiktok videos fail when using the default format
qualityPath = null;
} else if (!is_audio && !is_youtube && (url.includes('reddit') || url.includes('pornhub'))) {
qualityPath = ['-f', 'bestvideo+bestaudio']
}
if (customArgs) {
@@ -478,9 +410,8 @@ exports.generateArgs = async (url, type, options, user_uid = null, simulated = f
} else {
if (customQualityConfiguration) {
qualityPath = ['-f', customQualityConfiguration, '--merge-output-format', 'mp4'];
} else if (heightParam && heightParam !== '' && !is_audio) {
const heightFilter = (maxHeight && default_downloader === 'yt-dlp') ? ['-S', `res:${heightParam}`] : ['-f', `best[height${maxHeight ? '<' : ''}=${heightParam}]+bestaudio`]
qualityPath = [...heightFilter, '--merge-output-format', 'mp4'];
} else if (selectedHeight && selectedHeight !== '' && !is_audio) {
qualityPath = ['-f', `'(mp4)[height=${selectedHeight}'`];
} else if (is_audio) {
qualityPath = ['--audio-quality', maxBitrate ? maxBitrate : '0']
}
@@ -517,6 +448,28 @@ exports.generateArgs = async (url, type, options, user_uid = null, simulated = f
downloadConfig.splice(0, 0, '--external-downloader', customDownloadingAgent);
}
let useYoutubeDLArchive = config_api.getConfigItem('ytdl_use_youtubedl_archive');
if (useYoutubeDLArchive) {
const archive_folder = getArchiveFolder(fileFolderPath, options, user_uid);
const archive_path = path.join(archive_folder, `archive_${type}.txt`);
await fs.ensureDir(archive_folder);
await fs.ensureFile(archive_path);
const blacklist_path = path.join(archive_folder, `blacklist_${type}.txt`);
await fs.ensureFile(blacklist_path);
const merged_path = path.join(archive_folder, `merged_${type}.txt`);
await fs.ensureFile(merged_path);
// merges blacklist and regular archive
let inputPathList = [archive_path, blacklist_path];
await mergeFiles(inputPathList, merged_path);
options.merged_string = await fs.readFile(merged_path, "utf8");
downloadConfig.push('--download-archive', merged_path);
}
if (config_api.getConfigItem('ytdl_include_thumbnail')) {
downloadConfig.push('--write-thumbnail');
}
@@ -532,7 +485,7 @@ exports.generateArgs = async (url, type, options, user_uid = null, simulated = f
}
if (options.additionalArgs && options.additionalArgs !== '') {
downloadConfig = utils.injectArgs(downloadConfig, options.additionalArgs.split(',,'));
downloadConfig = downloadConfig.concat(options.additionalArgs.split(',,'));
}
const rate_limit = config_api.getConfigItem('ytdl_download_rate_limit');
@@ -540,11 +493,9 @@ exports.generateArgs = async (url, type, options, user_uid = null, simulated = f
downloadConfig.push('-r', rate_limit);
}
const default_downloader = utils.getCurrentDownloader() || config_api.getConfigItem('ytdl_default_downloader');
if (default_downloader === 'yt-dlp') {
downloadConfig = utils.filterArgs(downloadConfig, ['--print-json']);
// in yt-dlp -j --no-simulate is preferable
downloadConfig.push('--no-clean-info-json', '-j', '--no-simulate');
downloadConfig.push('--no-clean-infojson');
}
}
@@ -552,41 +503,67 @@ exports.generateArgs = async (url, type, options, user_uid = null, simulated = f
// filter out incompatible args
downloadConfig = filterArgs(downloadConfig, is_audio);
if (!simulated) logger.verbose(`${default_downloader} args being used: ${downloadConfig.join(',')}`);
if (!simulated) logger.verbose(`youtube-dl args being used: ${downloadConfig.join(',')}`);
return downloadConfig;
}
exports.getVideoInfoByURL = async (url, args = [], download_uid = null) => {
// remove bad args
const temp_args = utils.filterArgs(args, ['--no-simulate']);
const new_args = [...temp_args];
async function getVideoInfoByURL(url, args = [], download_uid = null) {
return new Promise(resolve => {
// remove bad args
const new_args = [...args];
const archiveArgIndex = new_args.indexOf('--download-archive');
if (archiveArgIndex !== -1) {
new_args.splice(archiveArgIndex, 2);
}
new_args.push('--dump-json');
let {callback} = await youtubedl_api.runYoutubeDL(url, new_args);
const {parsed_output, err} = await callback;
if (!parsed_output || parsed_output.length === 0) {
let error_message = `Error while retrieving info on video with URL ${url} with the following message: ${err}`;
if (err.stderr) error_message += `\n\n${err.stderr}`;
logger.error(error_message);
if (download_uid) {
await handleDownloadError(download_uid, error_message, 'info_retrieve_failed');
const archiveArgIndex = new_args.indexOf('--download-archive');
if (archiveArgIndex !== -1) {
new_args.splice(archiveArgIndex, 2);
}
return null;
}
return parsed_output;
new_args.push('--dump-json');
youtubedl.exec(url, new_args, {maxBuffer: Infinity}, async (err, output) => {
if (output) {
let outputs = [];
try {
for (let i = 0; i < output.length; i++) {
let output_json = null;
try {
output_json = JSON.parse(output[i]);
} catch(e) {
output_json = null;
}
if (!output_json) {
continue;
}
outputs.push(output_json);
}
resolve(outputs.length === 1 ? outputs[0] : outputs);
} catch(e) {
const error = `Error while retrieving info on video with URL ${url} with the following message: output JSON could not be parsed. Output JSON: ${output}`;
logger.error(error);
if (download_uid) {
await handleDownloadError(download_uid, error);
}
resolve(null);
}
} else {
let error_message = `Error while retrieving info on video with URL ${url} with the following message: ${err}`;
if (err.stderr) error_message += `\n\n${err.stderr}`;
logger.error(error_message);
if (download_uid) {
await handleDownloadError(download_uid, error_message);
}
resolve(null);
}
});
});
}
function filterArgs(args, isAudio) {
const video_only_args = ['--add-metadata', '--embed-subs', '--xattrs'];
const audio_only_args = ['-x', '--extract-audio', '--embed-thumbnail'];
return utils.filterArgs(args, isAudio ? video_only_args : audio_only_args);
const args_to_remove = isAudio ? video_only_args : audio_only_args;
return args.filter(x => !args_to_remove.includes(x));
}
async function checkDownloadPercent(download_uid) {
@@ -599,7 +576,6 @@ async function checkDownloadPercent(download_uid) {
*/
const download = await db_api.getRecord('download_queue', {uid: download_uid});
if (!download) return;
const files_to_check_for_progress = download['files_to_check_for_progress'];
const resulting_file_size = download['expected_file_size'];
@@ -642,3 +618,13 @@ exports.generateNFOFile = (info, output_path) => {
const xml = doc.end({ prettyPrint: true });
fs.writeFileSync(output_path, xml);
}
function getArchiveFolder(fileFolderPath, options, user_uid) {
if (options.customArchivePath) {
return path.join(options.customArchivePath);
} else if (user_uid) {
return path.join(fileFolderPath, 'archives');
} else {
return path.join(archivePath);
}
}

View File

@@ -1,7 +1,17 @@
#!/bin/bash
#!/bin/sh
set -eu
CMD="pm2-runtime pm2.config.js"
# if the first arg starts with "-" pass it to program
if [ "${1#-}" != "$1" ]; then
set -- "$CMD" "$@"
fi
# chown current working directory to current user
echo "[entrypoint] setup permission, this may take a while"
find . \! -user "$UID" -exec chown "$UID:$GID" '{}' + || echo "WARNING! Could not change directory ownership. If you manage permissions externally this is fine, otherwise you may experience issues when downloading or deleting videos."
exec gosu "$UID:$GID" "$@"
if [ "$*" = "$CMD" ] && [ "$(id -u)" = "0" ]; then
find . \! -user "$UID" -exec chown "$UID:$GID" -R '{}' + || echo "WARNING! Could not change directory ownership. If you manage permissions externally this is fine, otherwise you may experience issues when downloading or deleting videos."
exec su-exec "$UID:$GID" "$0" "$@"
fi
exec "$@"

View File

@@ -1,350 +0,0 @@
const fs = require('fs-extra')
const path = require('path')
const { v4: uuid } = require('uuid');
const config_api = require('./config');
const db_api = require('./db');
const archive_api = require('./archive');
const utils = require('./utils')
const logger = require('./logger');
exports.registerFileDB = async (file_path, type, user_uid = null, category = null, sub_id = null, cropFileSettings = null, file_object = null) => {
if (!file_object) file_object = generateFileObject(file_path, type);
if (!file_object) {
logger.error(`Could not find associated JSON file for ${type} file ${file_path}`);
return false;
}
utils.fixVideoMetadataPerms(file_path, type);
// add thumbnail path
file_object['thumbnailPath'] = utils.getDownloadedThumbnail(file_path);
// if category exists, only include essential info
if (category) file_object['category'] = {name: category['name'], uid: category['uid']};
// modify duration
if (cropFileSettings) {
file_object['duration'] = (cropFileSettings.cropFileEnd || file_object.duration) - cropFileSettings.cropFileStart;
}
if (user_uid) file_object['user_uid'] = user_uid;
if (sub_id) file_object['sub_id'] = sub_id;
const file_obj = await registerFileDBManual(file_object);
// remove metadata JSON if needed
if (!config_api.getConfigItem('ytdl_include_metadata')) {
utils.deleteJSONFile(file_path, type)
}
return file_obj;
}
async function registerFileDBManual(file_object) {
// add additional info
file_object['uid'] = uuid();
file_object['registered'] = Date.now();
const path_object = path.parse(file_object['path']);
file_object['path'] = path.format(path_object);
await db_api.insertRecordIntoTable('files', file_object, {path: file_object['path']})
return file_object;
}
function generateFileObject(file_path, type) {
const jsonobj = utils.getJSON(file_path, type);
if (!jsonobj) {
return null;
} else if (!jsonobj['_filename']) {
logger.error(`Failed to get filename from info JSON! File ${jsonobj['title']} could not be added.`);
return null;
}
const true_file_path = utils.getTrueFileName(jsonobj['_filename'], type);
// console.
const stats = fs.statSync(true_file_path);
const file_id = utils.removeFileExtension(path.basename(file_path));
const title = jsonobj.title;
const url = jsonobj.webpage_url;
const uploader = jsonobj.uploader;
const upload_date = utils.formatDateString(jsonobj.upload_date);
const size = stats.size;
const thumbnail = jsonobj.thumbnail;
const duration = jsonobj.duration;
const isaudio = type === 'audio';
const description = jsonobj.description;
const file_obj = new utils.File(file_id, title, thumbnail, isaudio, duration, url, uploader, size, true_file_path, upload_date, description, jsonobj.view_count, jsonobj.height, jsonobj.abr);
return file_obj;
}
exports.importUnregisteredFiles = async () => {
const imported_files = [];
const dirs_to_check = await db_api.getFileDirectoriesAndDBs();
// run through check list and check each file to see if it's missing from the db
for (let i = 0; i < dirs_to_check.length; i++) {
const dir_to_check = dirs_to_check[i];
// recursively get all files in dir's path
const files = await utils.getDownloadedFilesByType(dir_to_check.basePath, dir_to_check.type);
for (let j = 0; j < files.length; j++) {
const file = files[j];
// check if file exists in db, if not add it
const files_with_same_url = await db_api.getRecords('files', {url: file.url, sub_id: dir_to_check.sub_id});
const file_is_registered = !!(files_with_same_url.find(file_with_same_url => path.resolve(file_with_same_url.path) === path.resolve(file.path)));
if (!file_is_registered) {
// add additional info
const file_obj = await exports.registerFileDB(file['path'], dir_to_check.type, dir_to_check.user_uid, null, dir_to_check.sub_id, null);
if (file_obj) {
imported_files.push(file_obj['uid']);
logger.verbose(`Added discovered file to the database: ${file.id}`);
} else {
logger.error(`Failed to import ${file['path']} automatically.`);
}
}
}
}
return imported_files;
}
exports.addMetadataPropertyToDB = async (property_key) => {
try {
const dirs_to_check = await db_api.getFileDirectoriesAndDBs();
const update_obj = {};
for (let i = 0; i < dirs_to_check.length; i++) {
const dir_to_check = dirs_to_check[i];
// recursively get all files in dir's path
const files = await utils.getDownloadedFilesByType(dir_to_check.basePath, dir_to_check.type, true);
for (let j = 0; j < files.length; j++) {
const file = files[j];
if (file[property_key]) {
update_obj[file.uid] = {[property_key]: file[property_key]};
}
}
}
return await db_api.bulkUpdateRecordsByKey('files', 'uid', update_obj);
} catch(err) {
logger.error(err);
return false;
}
}
exports.createPlaylist = async (playlist_name, uids, user_uid = null) => {
const first_video = await exports.getVideo(uids[0]);
const thumbnailToUse = first_video['thumbnailURL'];
let new_playlist = {
name: playlist_name,
uids: uids,
id: uuid(),
thumbnailURL: thumbnailToUse,
registered: Date.now(),
randomize_order: false
};
new_playlist.user_uid = user_uid ? user_uid : undefined;
await db_api.insertRecordIntoTable('playlists', new_playlist);
const duration = await exports.calculatePlaylistDuration(new_playlist);
await db_api.updateRecord('playlists', {id: new_playlist.id}, {duration: duration});
return new_playlist;
}
exports.getPlaylist = async (playlist_id, user_uid = null, require_sharing = false) => {
let playlist = await db_api.getRecord('playlists', {id: playlist_id});
if (!playlist) {
playlist = await db_api.getRecord('categories', {uid: playlist_id});
if (playlist) {
const uids = (await db_api.getRecords('files', {'category.uid': playlist_id})).map(file => file.uid);
playlist['uids'] = uids;
playlist['auto'] = true;
}
}
// converts playlists to new UID-based schema
if (playlist && playlist['fileNames'] && !playlist['uids']) {
playlist['uids'] = [];
logger.verbose(`Converting playlist ${playlist['name']} to new UID-based schema.`);
for (let i = 0; i < playlist['fileNames'].length; i++) {
const fileName = playlist['fileNames'][i];
const uid = await exports.getVideoUIDByID(fileName, user_uid);
if (uid) playlist['uids'].push(uid);
else logger.warn(`Failed to convert file with name ${fileName} to its UID while converting playlist ${playlist['name']} to the new UID-based schema. The original file is likely missing/deleted and it will be skipped.`);
}
exports.updatePlaylist(playlist, user_uid);
}
// prevent unauthorized users from accessing the file info
if (require_sharing && !playlist['sharingEnabled']) return null;
return playlist;
}
exports.updatePlaylist = async (playlist) => {
let playlistID = playlist.id;
const duration = await exports.calculatePlaylistDuration(playlist);
playlist.duration = duration;
return await db_api.updateRecord('playlists', {id: playlistID}, playlist);
}
exports.setPlaylistProperty = async (playlist_id, assignment_obj, user_uid = null) => {
let success = await db_api.updateRecord('playlists', {id: playlist_id}, assignment_obj);
if (!success) {
success = await db_api.updateRecord('categories', {uid: playlist_id}, assignment_obj);
}
if (!success) {
logger.error(`Could not find playlist or category with ID ${playlist_id}`);
}
return success;
}
exports.calculatePlaylistDuration = async (playlist, playlist_file_objs = null) => {
if (!playlist_file_objs) {
playlist_file_objs = [];
for (let i = 0; i < playlist['uids'].length; i++) {
const uid = playlist['uids'][i];
const file_obj = await exports.getVideo(uid);
if (file_obj) playlist_file_objs.push(file_obj);
}
}
return playlist_file_objs.reduce((a, b) => a + utils.durationStringToNumber(b.duration), 0);
}
exports.deleteFile = async (uid, blacklistMode = false) => {
const file_obj = await exports.getVideo(uid);
const type = file_obj.isAudio ? 'audio' : 'video';
const folderPath = path.dirname(file_obj.path);
const name = file_obj.id;
const filePathNoExtension = utils.removeFileExtension(file_obj.path);
var jsonPath = `${file_obj.path}.info.json`;
var altJSONPath = `${filePathNoExtension}.info.json`;
var thumbnailPath = `${filePathNoExtension}.webp`;
var altThumbnailPath = `${filePathNoExtension}.jpg`;
jsonPath = path.join(__dirname, jsonPath);
altJSONPath = path.join(__dirname, altJSONPath);
let jsonExists = await fs.pathExists(jsonPath);
let thumbnailExists = await fs.pathExists(thumbnailPath);
if (!jsonExists) {
if (await fs.pathExists(altJSONPath)) {
jsonExists = true;
jsonPath = altJSONPath;
}
}
if (!thumbnailExists) {
if (await fs.pathExists(altThumbnailPath)) {
thumbnailExists = true;
thumbnailPath = altThumbnailPath;
}
}
let fileExists = await fs.pathExists(file_obj.path);
if (config_api.descriptors[uid]) {
try {
for (let i = 0; i < config_api.descriptors[uid].length; i++) {
config_api.descriptors[uid][i].destroy();
}
} catch(e) {
}
}
let useYoutubeDLArchive = config_api.getConfigItem('ytdl_use_youtubedl_archive');
if (useYoutubeDLArchive || file_obj.sub_id) {
// get id/extractor from JSON
const info_json = await (type === 'audio' ? utils.getJSONMp3(name, folderPath) : utils.getJSONMp4(name, folderPath));
let retrievedID = null;
let retrievedExtractor = null;
if (info_json) {
retrievedID = info_json['id'];
retrievedExtractor = info_json['extractor'];
}
// Remove file ID from the archive file, and write it to the blacklist (if enabled)
if (!blacklistMode) {
await archive_api.removeFromArchive(retrievedExtractor, retrievedID, type, file_obj.user_uid, file_obj.sub_id)
} else {
const exists_in_archive = await archive_api.existsInArchive(retrievedExtractor, retrievedID, type, file_obj.user_uid, file_obj.sub_id);
if (!exists_in_archive) {
await archive_api.addToArchive(retrievedExtractor, retrievedID, type, file_obj.title, file_obj.user_uid, file_obj.sub_id);
}
}
}
if (jsonExists) await fs.unlink(jsonPath);
if (thumbnailExists) await fs.unlink(thumbnailPath);
await db_api.removeRecord('files', {uid: uid});
if (fileExists) {
await fs.unlink(file_obj.path);
if (await fs.pathExists(jsonPath) || await fs.pathExists(file_obj.path)) {
return false;
} else {
return true;
}
} else {
// TODO: tell user that the file didn't exist
return true;
}
}
// Video ID is basically just the file name without the base path and file extension - this method helps us get away from that
exports.getVideoUIDByID = async (file_id, uuid = null) => {
const file_obj = await db_api.getRecord('files', {id: file_id});
return file_obj ? file_obj['uid'] : null;
}
exports.getVideo = async (file_uid) => {
return await db_api.getRecord('files', {uid: file_uid});
}
exports.getAllFiles = async (sort, range, text_search, file_type_filter, favorite_filter, sub_id, uuid) => {
const filter_obj = {user_uid: uuid};
const regex = true;
if (text_search) {
if (regex) {
filter_obj['title'] = {$regex: `.*${text_search}.*`, $options: 'i'};
} else {
filter_obj['$text'] = { $search: utils.createEdgeNGrams(text_search) };
}
}
if (favorite_filter) {
filter_obj['favorite'] = true;
}
if (sub_id) {
filter_obj['sub_id'] = sub_id;
}
if (file_type_filter === 'audio_only') filter_obj['isAudio'] = true;
else if (file_type_filter === 'video_only') filter_obj['isAudio'] = false;
const files = JSON.parse(JSON.stringify(await db_api.getRecords('files', filter_obj, false, sort, range, text_search)));
const file_count = await db_api.getRecords('files', filter_obj, true);
return {files, file_count};
}

View File

@@ -1,57 +0,0 @@
#!/bin/bash
# INTERACTIVE PERMISSIONS FIX SCRIPT FOR YTDL-M
# Date: 2022-05-03
# If you want to run this script on a bare-metal installation instead of within Docker
# make sure that the paths configured below match your paths! (it's wise to use the full paths)
# USAGE: within your container's bash shell:
# ./fix-scripts/<name of fix-script>
# User defines / Docker env defaults
PATH_SUBS=/app/subscriptions
PATH_AUDIO=/app/audio
PATH_VIDS=/app/video
clear -x
echo "\n"
printf '%*s\n' "${COLUMNS:-$(tput cols)}" '' | tr ' ' - # horizontal line
echo "Welcome to the INTERACTIVE PERMISSIONS FIX SCRIPT FOR YTDL-M."
echo "This script will set YTDL-M's download paths' owner to ${USER} (${UID}:${GID})"
echo "and permissions to the default of 644."
printf '%*s\n' "${COLUMNS:-$(tput cols)}" '' | tr ' ' - # horizontal line
echo "\n"
# check whether dirs exist
i=0
[ -d $PATH_SUBS ] && i=$((i+1)) && echo "✔ (${i}/3) Found Subscriptions directory at ${PATH_SUBS}"
[ -d $PATH_AUDIO ] && i=$((i+1)) && echo "✔ (${i}/3) Found Audio directory at ${PATH_AUDIO}"
[ -d $PATH_VIDS ] && i=$((i+1)) && echo "✔ (${i}/3) Found Video directory at ${PATH_VIDS}"
# Ask to proceed or cancel, exit on missing paths
case $i in
0)
echo "\nCouldn't find any download path to fix permissions for! \nPlease edit this script to configure!"
exit 2;;
3)
echo "\nFound all download paths to fix permissions for. \nProceed? (Y/N)";;
*)
echo "\nOnly found ${i} out of 3 download paths! Something about this script's config must be wrong. \nProceed anyways? (Y/N)";;
esac
old_stty_cfg=$(stty -g)
stty raw -echo ; answer=$(head -c 1) ; stty $old_stty_cfg # Careful playing with stty
if echo "$answer" | grep -iq "^y" ;then
echo "\n Running jobs now... (this may take a while)\n"
[ -d $PATH_SUBS ] && chown "$UID:$GID" -R $PATH_SUBS && echo "✔ Set owner of ${PATH_SUBS} to ${USER}."
[ -d $PATH_SUBS ] && chmod 644 -R $PATH_SUBS && echo "✔ Set permissions of ${PATH_SUBS} to 644."
[ -d $PATH_AUDIO ] && chown "$UID:$GID" -R $PATH_AUDIO && echo "✔ Set owner of ${PATH_AUDIO} to ${USER}."
[ -d $PATH_AUDIO ] && chmod 644 -R $PATH_AUDIO && echo "✔ Set permissions of ${PATH_AUDIO} to 644."
[ -d $PATH_VIDS ] && chown "$UID:$GID" -R $PATH_VIDS && echo "✔ Set owner of ${PATH_VIDS} to ${USER}."
[ -d $PATH_VIDS ] && chmod 644 -R $PATH_VIDS && echo "✔ Set permissions of ${PATH_VIDS} to 644."
echo "\n✔ Done."
echo "\n If you noticed file access errors those MAY be due to currently running downloads."
echo " Feel free to re-run this script, however download parts should have correct file permissions anyhow. :)"
exit
else
echo "\nOkay, bye."
fi

View File

@@ -1,142 +0,0 @@
#!/bin/bash
# INTERACTIVE ARCHIVE-DUPE-ENTRY FIX SCRIPT FOR YTDL-M
# Date: 2022-05-09
# If you want to run this script on a bare-metal installation instead of within Docker
# make sure that the paths configured below match your paths! (it's wise to use the full paths)
# USAGE: within your container's bash shell:
# ./fix-scripts/<name of fix-script>
# User defines (NO TRAILING SLASHES) / Docker env defaults
PATH_SUBSARCHIVE=/app/subscriptions/archives
PATH_ONEOFFARCHIVE=/app/appdata/archives
# Backup paths (substitute with your personal preference if you like)
PATH_SUBSARCHIVEBKP=$PATH_SUBSARCHIVE-BKP-$(date +%Y%m%d%H%M%S)
PATH_ONEOFFARCHIVEBKP=$PATH_ONEOFFARCHIVE-BKP-$(date +%Y%m%d%H%M%S)
# Define Colors for TUI
yellow=$(tput setaf 3)
normal=$(tput sgr0)
tput civis # hide the cursor
clear -x
printf "\n"
printf '%*s\n' "${COLUMNS:-$(tput cols)}" '' | tr ' ' - # horizontal line
printf "Welcome to the INTERACTIVE ARCHIVE-DUPE-ENTRY FIX SCRIPT FOR YTDL-M."
printf "\nThis script will cycle through the archive files in the folders mentioned"
printf "\nbelow and remove within each archive the dupe entries. (compact them)"
printf "\nDuring some older builds of YTDL-M the archives could receive dupe"
printf "\nentries and blow up in size, sometimes causing conflicts with download management."
printf '\n%*s' "${COLUMNS:-$(tput cols)}" '' | tr ' ' - # horizontal line
printf "\n"
# check whether dirs exist
i=0
[ -d $PATH_SUBSARCHIVE ] && i=$((i+1)) && printf "\n✔ (${i}/2) Found Subscriptions archive directory at ${PATH_SUBSARCHIVE}"
[ -d $PATH_ONEOFFARCHIVE ] && i=$((i+1)) && printf "\n✔ (${i}/2) Found one-off archive directory at ${PATH_ONEOFFARCHIVE}"
# Ask to proceed or cancel, exit on missing paths
case $i in
0)
printf "\n\n Couldn't find any archive location path! \n\nPlease edit this script to configure!"
tput cnorm
exit 2;;
2)
printf "\n\n Found all archive locations. \n\nProceed? (Y/N)";;
*)
printf "\n\n Only found ${i} out of 2 archive locations! Something about this script's config must be wrong. \n\nProceed anyways? (Y/N)";;
esac
old_stty_cfg=$(stty -g)
stty raw -echo ; answer=$(head -c 1) ; stty $old_stty_cfg # Careful playing with stty
if echo "$answer" | grep -iq "^y" ;then
printf "\n\nRunning jobs now... (this may take a while)\n"
printf "\nBacking up directories...\n"
chars="⣾⣽⣻⢿⡿⣟⣯⣷"
cp -R $PATH_SUBSARCHIVE $PATH_SUBSARCHIVEBKP &
PID=$!
i=1
echo -n ' '
while [ -d /proc/$PID ]
do
printf "${yellow}\b${chars:i++%${#chars}:1}${normal}"
sleep 0.15
done
[ -d $PATH_SUBSARCHIVEBKP ] && printf "\r✔ Backed up ${PATH_SUBSARCHIVE} to ${PATH_SUBSARCHIVEBKP} ($(du -sh $PATH_SUBSARCHIVEBKP | cut -f1))\n"
cp -R $PATH_ONEOFFARCHIVE $PATH_ONEOFFARCHIVEBKP &
PID2=$!
i=1
echo -n ' '
while [ -d /proc/$PID2 ]
do
printf "${yellow}\b${chars:i++%${#chars}:1}${normal}"
sleep 0.1
done
[ -d $PATH_ONEOFFARCHIVEBKP ] && printf "\r✔ Backed up ${PATH_ONEOFFARCHIVE} to ${PATH_ONEOFFARCHIVEBKP} ($(du -sh $PATH_ONEOFFARCHIVEBKP | cut -f1))\n"
printf "\nCompacting files...\n"
tmpfile=$(mktemp) &&
[ -d $PATH_SUBSARCHIVE ] &&
find $PATH_SUBSARCHIVE -name '*.txt' -print0 | while read -d $'\0' file # Set delimiter to null because we want to catch all possible filenames (WE CANNOT CHANGE IFS HERE) - https://stackoverflow.com/a/15931055
do
cp "$file" "$tmpfile"
{ awk '!x[$0]++' "$tmpfile" > "$file"; } & # https://unix.stackexchange.com/questions/159695/how-does-awk-a0-work
PID3=$!
i=1
echo -n ''
while [ -d /proc/$PID3 ]
do
printf "${yellow}\b${chars:i++%${#chars}:1}${normal}"
sleep 0.1
done
BEFORE=$(wc -l < $tmpfile)
AFTER=$(wc -l < $file)
if [[ "$AFTER" -ne "$BEFORE" ]]; then
printf "\b✔ Compacted down to ${AFTER} lines from ${BEFORE}: ${file}\n"
else
printf "\b No action needed for file: ${file}\n"
fi
done
[ -d $PATH_ONEOFFARCHIVE ] &&
find $PATH_ONEOFFARCHIVE -name '*.txt' -print0 | while read -d $'\0' file
do
cp "$file" "$tmpfile" &
awk '!x[$0]++' "$tmpfile" > "$file" &
PID4=$!
i=1
echo -n ''
while [ -d /proc/$PID4 ]
do
printf "${yellow}\b${chars:i++%${#chars}:1}${normal}"
sleep 0.1
done
BEFORE=$(wc -l < $tmpfile)
AFTER=$(wc -l < $file)
if [ "$BEFORE" -ne "$AFTER" ]; then
printf "\b✔ Compacted down to ${AFTER} lines from ${BEFORE}: ${file}\n"
else
printf "\b No action ran for file: ${file}\n"
fi
done
tput cnorm # show the cursor
rm "$tmpfile"
printf "\n\n✔ Done."
printf "\n Please keep in mind that you may still want to"
printf "\n run corruption checks against your archives!\n\n"
exit
else
tput cnorm
printf "\nOkay, bye.\n\n"
exit
fi

View File

@@ -1,293 +0,0 @@
const db_api = require('./db');
const config_api = require('./config');
const logger = require('./logger');
const utils = require('./utils');
const consts = require('./consts');
const { v4: uuid } = require('uuid');
const fetch = require('node-fetch');
const { gotify } = require("gotify");
const TelegramBotAPI = require('node-telegram-bot-api');
let telegram_bot = null;
const REST = require('@discordjs/rest').REST;
const API = require('@discordjs/core').API;
const EmbedBuilder = require('@discordjs/builders').EmbedBuilder;
const NOTIFICATION_TYPE_TO_TITLE = {
task_finished: 'Task finished',
download_complete: 'Download complete',
download_error: 'Download error'
}
const NOTIFICATION_TYPE_TO_BODY = {
task_finished: (notification) => notification['data']['task_title'],
download_complete: (notification) => {return `${notification['data']['file_title']}\nOriginal URL: ${notification['data']['original_url']}`},
download_error: (notification) => {return `Error: ${notification['data']['download_error_message']}\nError code: ${notification['data']['download_error_type']}\n\nOriginal URL: ${notification['data']['download_url']}`}
}
const NOTIFICATION_TYPE_TO_URL = {
task_finished: () => {return `${utils.getBaseURL()}/#/tasks`},
download_complete: (notification) => {return `${utils.getBaseURL()}/#/player;uid=${notification['data']['file_uid']}`},
download_error: () => {return `${utils.getBaseURL()}/#/downloads`},
}
const NOTIFICATION_TYPE_TO_THUMBNAIL = {
task_finished: () => null,
download_complete: (notification) => notification['data']['file_thumbnail'],
download_error: () => null
}
exports.sendNotification = async (notification) => {
// info necessary if we are using 3rd party APIs
const type = notification['type'];
const data = {
title: NOTIFICATION_TYPE_TO_TITLE[type],
body: NOTIFICATION_TYPE_TO_BODY[type](notification),
type: type,
url: NOTIFICATION_TYPE_TO_URL[type](notification),
thumbnail: NOTIFICATION_TYPE_TO_THUMBNAIL[type](notification)
}
if (config_api.getConfigItem('ytdl_use_ntfy_API') && config_api.getConfigItem('ytdl_ntfy_topic_url')) {
sendNtfyNotification(data);
}
if (config_api.getConfigItem('ytdl_use_gotify_API') && config_api.getConfigItem('ytdl_gotify_server_url') && config_api.getConfigItem('ytdl_gotify_app_token')) {
sendGotifyNotification(data);
}
if (config_api.getConfigItem('ytdl_use_telegram_API') && config_api.getConfigItem('ytdl_telegram_bot_token') && config_api.getConfigItem('ytdl_telegram_chat_id')) {
exports.sendTelegramNotification(data);
}
if (config_api.getConfigItem('ytdl_webhook_url')) {
sendGenericNotification(data);
}
if (config_api.getConfigItem('ytdl_discord_webhook_url')) {
sendDiscordNotification(data);
}
if (config_api.getConfigItem('ytdl_slack_webhook_url')) {
sendSlackNotification(data);
}
await db_api.insertRecordIntoTable('notifications', notification);
return notification;
}
exports.sendTaskNotification = async (task_obj, confirmed) => {
if (!notificationEnabled('task_finished')) return;
// workaround for tasks which are user_uid agnostic
const user_uid = config_api.getConfigItem('ytdl_multi_user_mode') ? 'admin' : null;
await db_api.removeAllRecords('notifications', {"data.task_key": task_obj.key});
const data = {task_key: task_obj.key, task_title: task_obj.title, confirmed: confirmed};
const notification = exports.createNotification('task_finished', ['view_tasks'], data, user_uid);
return await exports.sendNotification(notification);
}
exports.sendDownloadNotification = async (file, user_uid) => {
if (!notificationEnabled('download_complete')) return;
const data = {file_uid: file.uid, file_title: file.title, file_thumbnail: file.thumbnailURL, original_url: file.url};
const notification = exports.createNotification('download_complete', ['play'], data, user_uid);
return await exports.sendNotification(notification);
}
exports.sendDownloadErrorNotification = async (download, user_uid, error_message, error_type = null) => {
if (!notificationEnabled('download_error')) return;
const data = {download_uid: download.uid, download_url: download.url, download_error_message: error_message, download_error_type: error_type};
const notification = exports.createNotification('download_error', ['view_download_error', 'retry_download'], data, user_uid);
return await exports.sendNotification(notification);
}
exports.createNotification = (type, actions, data, user_uid) => {
const notification = {
type: type,
actions: actions,
data: data,
user_uid: user_uid,
uid: uuid(),
read: false,
timestamp: Date.now()/1000
}
return notification;
}
function notificationEnabled(type) {
return config_api.getConfigItem('ytdl_enable_notifications') && (config_api.getConfigItem('ytdl_enable_all_notifications') || config_api.getConfigItem('ytdl_allowed_notification_types').includes(type));
}
// ntfy
function sendNtfyNotification({body, title, type, url, thumbnail}) {
logger.verbose('Sending notification to ntfy');
fetch(config_api.getConfigItem('ytdl_ntfy_topic_url'), {
method: 'POST',
body: body,
headers: {
'Title': title,
'Tags': type,
'Click': url,
'Attach': thumbnail
}
});
}
// Gotify
async function sendGotifyNotification({body, title, type, url, thumbnail}) {
logger.verbose('Sending notification to gotify');
await gotify({
server: config_api.getConfigItem('ytdl_gotify_server_url'),
app: config_api.getConfigItem('ytdl_gotify_app_token'),
title: title,
message: body,
tag: type,
priority: 5, // Keeping default from docs, may want to change this,
extras: {
"client::notification": {
click: { url: url },
bigImageUrl: thumbnail
}
}
});
}
// Telegram
setupTelegramBot();
config_api.config_updated.subscribe(change => {
const use_telegram_api = config_api.getConfigItem('ytdl_use_telegram_API');
const bot_token = config_api.getConfigItem('ytdl_telegram_bot_token');
if (!use_telegram_api || !bot_token) return;
if (!change) return;
if (change['key'] === 'ytdl_use_telegram_API' || change['key'] === 'ytdl_telegram_bot_token' || change['key'] === 'ytdl_telegram_webhook_proxy') {
logger.debug('Telegram bot setting up');
setupTelegramBot();
}
});
async function setupTelegramBot() {
const use_telegram_api = config_api.getConfigItem('ytdl_use_telegram_API');
const bot_token = config_api.getConfigItem('ytdl_telegram_bot_token');
if (!use_telegram_api || !bot_token) return;
telegram_bot = new TelegramBotAPI(bot_token);
const webhook_proxy = config_api.getConfigItem('ytdl_telegram_webhook_proxy');
const webhook_url = webhook_proxy ? webhook_proxy : `${utils.getBaseURL()}/api/telegramRequest`;
telegram_bot.setWebHook(webhook_url);
}
exports.sendTelegramNotification = async ({body, title, type, url, thumbnail}) => {
if (!telegram_bot){
logger.error('Telegram bot not found!');
return;
}
const chat_id = config_api.getConfigItem('ytdl_telegram_chat_id');
if (!chat_id){
logger.error('Telegram chat ID required!');
return;
}
logger.verbose('Sending notification to Telegram');
if (thumbnail) await telegram_bot.sendPhoto(chat_id, thumbnail);
telegram_bot.sendMessage(chat_id, `<b>${title}</b>\n\n${body}\n<a href="${url}">${url}</a>`, {parse_mode: 'HTML'});
}
// Discord
async function sendDiscordNotification({body, title, type, url, thumbnail}) {
const discord_webhook_url = config_api.getConfigItem('ytdl_discord_webhook_url');
const url_split = discord_webhook_url.split('webhooks/');
const [webhook_id, webhook_token] = url_split[1].split('/');
const rest = new REST({ version: '10' });
const api = new API(rest);
const embed = new EmbedBuilder()
.setTitle(title)
.setColor(0x00FFFF)
.setURL(url)
.setDescription(`ID: ${type}`);
if (thumbnail) embed.setThumbnail(thumbnail);
if (type === 'download_error') embed.setColor(0xFC2003);
const result = await api.webhooks.execute(webhook_id, webhook_token, {
content: body,
username: 'YoutubeDL-Material',
avatar_url: consts.ICON_URL,
embeds: [embed],
});
return result;
}
// Slack
function sendSlackNotification({body, title, type, url, thumbnail}) {
const slack_webhook_url = config_api.getConfigItem('ytdl_slack_webhook_url');
logger.verbose(`Sending slack notification to ${slack_webhook_url}`);
const data = {
blocks: [
{
type: "section",
text: {
type: "mrkdwn",
text: `*${title}*`
}
},
{
type: "section",
text: {
type: "plain_text",
text: body
}
}
]
}
// add thumbnail if exists
if (thumbnail) {
data['blocks'].push({
type: "image",
image_url: thumbnail,
alt_text: "notification_thumbnail"
});
}
data['blocks'].push(
{
type: "section",
text: {
type: "mrkdwn",
text: `<${url}|${url}>`
}
},
{
type: "context",
elements: [
{
type: "mrkdwn",
text: `*ID:* ${type}`
}
]
}
);
fetch(slack_webhook_url, {
method: 'POST',
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(data),
});
}
// Generic
function sendGenericNotification(data) {
const webhook_url = config_api.getConfigItem('ytdl_webhook_url');
logger.verbose(`Sending generic notification to ${webhook_url}`);
fetch(webhook_url, {
method: 'POST',
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(data),
});
}

5353
backend/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -4,10 +4,21 @@
"description": "backend for YoutubeDL-Material",
"main": "index.js",
"scripts": {
"test": "mocha test --exit -s 1000",
"start": "pm2-runtime --raw pm2.config.js",
"test": "echo \"Error: no test specified\" && exit 1",
"start": "nodemon app.js",
"debug": "set YTDL_MODE=debug && node app.js"
},
"nodemonConfig": {
"ignore": [
"*.js",
"appdata/*",
"public/*"
],
"watch": [
"restart_update.json",
"restart_general.json"
]
},
"repository": {
"type": "git",
"url": ""
@@ -17,44 +28,33 @@
"bugs": {
"url": ""
},
"engines": {
"node": "^16",
"npm": "6.14.4"
},
"homepage": "",
"dependencies": {
"@discordjs/builders": "^1.6.1",
"@discordjs/core": "^0.5.2",
"archiver": "^5.3.1",
"async": "^3.2.3",
"async-mutex": "^0.4.0",
"async-mutex": "^0.3.1",
"axios": "^0.21.2",
"bcryptjs": "^2.4.0",
"command-exists": "^1.2.9",
"compression": "^1.7.4",
"config": "^3.2.3",
"execa": "^5.1.1",
"express": "^4.18.2",
"express-session": "^1.17.3",
"feed": "^4.2.2",
"express": "^4.17.3",
"fluent-ffmpeg": "^2.1.2",
"fs-extra": "^9.0.0",
"gotify": "^1.1.0",
"jsonwebtoken": "^9.0.0",
"lodash": "^4.17.21",
"jsonwebtoken": "^8.5.1",
"lowdb": "^1.0.0",
"md5": "^2.2.1",
"merge-files": "^0.1.2",
"mocha": "^9.2.2",
"moment": "^2.29.4",
"moment": "^2.29.2",
"mongodb": "^3.6.9",
"multer": "1.4.5-lts.1",
"multer": "^1.4.2",
"node-fetch": "^2.6.7",
"node-id3": "^0.2.6",
"node-id3": "^0.1.14",
"node-schedule": "^2.1.0",
"node-telegram-bot-api": "^0.61.0",
"passport": "^0.6.0",
"nodemon": "^2.0.7",
"passport": "^0.4.1",
"passport-http": "^0.3.0",
"passport-jwt": "^4.0.1",
"passport-jwt": "^4.0.0",
"passport-ldapauth": "^3.0.1",
"passport-local": "^1.0.0",
"progress": "^2.0.3",
@@ -62,10 +62,10 @@
"read-last-lines": "^1.7.2",
"rxjs": "^7.3.0",
"shortid": "^2.2.15",
"tree-kill": "^1.2.2",
"unzipper": "^0.10.10",
"uuid": "^9.0.1",
"uuidv4": "^6.0.6",
"winston": "^3.7.2",
"xmlbuilder2": "^3.0.2"
"xmlbuilder2": "^3.0.2",
"youtube-dl": "^3.0.2"
}
}

View File

@@ -6,4 +6,4 @@ module.exports = {
out_file: "/dev/null",
error_file: "/dev/null"
}]
}
}

View File

@@ -1,26 +1,24 @@
const fs = require('fs-extra');
const path = require('path');
const youtubedl = require('youtube-dl');
const youtubedl_api = require('./youtube-dl');
const config_api = require('./config');
const archive_api = require('./archive');
const utils = require('./utils');
const logger = require('./logger');
const CONSTS = require('./consts');
const debugMode = process.env.YTDL_MODE === 'debug';
const db_api = require('./db');
const downloader_api = require('./downloader');
exports.subscribe = async (sub, user_uid = null, skip_get_info = false) => {
async function subscribe(sub, user_uid = null) {
const result_obj = {
success: false,
error: ''
};
return new Promise(async resolve => {
// sub should just have url and name. here we will get isPlaylist and path
sub.isPlaylist = sub.isPlaylist || sub.url.includes('playlist');
sub.isPlaylist = sub.url.includes('playlist');
sub.videos = [];
let url_exists = !!(await db_api.getRecord('subscriptions', {url: sub.url, user_uid: user_uid}));
@@ -33,13 +31,12 @@ exports.subscribe = async (sub, user_uid = null, skip_get_info = false) => {
}
sub['user_uid'] = user_uid ? user_uid : undefined;
await db_api.insertRecordIntoTable('subscriptions', JSON.parse(JSON.stringify(sub)));
await db_api.insertRecordIntoTable('subscriptions', sub);
let success = skip_get_info ? true : await getSubscriptionInfo(sub);
exports.writeSubscriptionMetadata(sub);
let success = await getSubscriptionInfo(sub);
if (success) {
if (!sub.paused) exports.getVideosForSub(sub.id);
getVideosForSub(sub, user_uid);
} else {
logger.error('Subscribe: Failed to get subscription info. Subscribe failed.')
}
@@ -63,41 +60,52 @@ async function getSubscriptionInfo(sub) {
}
}
let {callback} = await youtubedl_api.runYoutubeDL(sub.url, downloadConfig);
const {parsed_output, err} = await callback;
if (err) {
logger.error(err.stderr);
return false;
}
logger.verbose('Subscribe: got info for subscription ' + sub.id);
for (const output_json of parsed_output) {
if (!output_json) {
continue;
}
if (!sub.name) {
if (sub.isPlaylist) {
sub.name = output_json.playlist_title ? output_json.playlist_title : output_json.playlist;
} else {
sub.name = output_json.uploader;
return new Promise(async resolve => {
youtubedl.exec(sub.url, downloadConfig, {maxBuffer: Infinity}, async (err, output) => {
if (debugMode) {
logger.info('Subscribe: got info for subscription ' + sub.id);
}
// if it's now valid, update
if (sub.name) {
let sub_name = sub.name;
const sub_name_exists = await db_api.getRecord('subscriptions', {name: sub.name, isPlaylist: sub.isPlaylist, user_uid: sub.user_uid});
if (sub_name_exists) sub_name += ` - ${sub.id}`;
await db_api.updateRecord('subscriptions', {id: sub.id}, {name: sub_name});
if (err) {
logger.error(err.stderr);
resolve(false);
} else if (output) {
if (output.length === 0 || (output.length === 1 && output[0] === '')) {
logger.verbose('Could not get info for ' + sub.id);
resolve(false);
}
for (let i = 0; i < output.length; i++) {
let output_json = null;
try {
output_json = JSON.parse(output[i]);
} catch(e) {
output_json = null;
}
if (!output_json) {
continue;
}
if (!sub.name) {
if (sub.isPlaylist) {
sub.name = output_json.playlist_title ? output_json.playlist_title : output_json.playlist;
} else {
sub.name = output_json.uploader;
}
// if it's now valid, update
if (sub.name) {
await db_api.updateRecord('subscriptions', {id: sub.id}, {name: sub.name});
}
}
// TODO: get even more info
resolve(true);
}
resolve(false);
}
}
return true;
}
return false;
});
});
}
exports.unsubscribe = async (sub_id, deleteMode, user_uid = null) => {
const sub = await exports.getSubscription(sub_id);
async function unsubscribe(sub, deleteMode, user_uid = null) {
let basePath = null;
if (user_uid)
basePath = path.join(config_api.getConfigItem('ytdl_users_base_path'), user_uid, 'subscriptions');
@@ -120,7 +128,6 @@ exports.unsubscribe = async (sub_id, deleteMode, user_uid = null) => {
}
}
await killSubDownloads(sub_id, true);
await db_api.removeRecord('subscriptions', {id: id});
await db_api.removeAllRecords('files', {sub_id: id});
@@ -131,25 +138,27 @@ exports.unsubscribe = async (sub_id, deleteMode, user_uid = null) => {
const appendedBasePath = getAppendedBasePath(sub, basePath);
if (deleteMode && (await fs.pathExists(appendedBasePath))) {
if (sub.archive && (await fs.pathExists(sub.archive))) {
const archive_file_path = path.join(sub.archive, 'archive.txt');
// deletes archive if it exists
if (await fs.pathExists(archive_file_path)) {
await fs.unlink(archive_file_path);
}
await fs.rmdir(sub.archive);
}
await fs.remove(appendedBasePath);
}
await db_api.removeAllRecords('archives', {sub_id: sub.id});
}
exports.deleteSubscriptionFile = async (sub, file, deleteForever, file_uid = null, user_uid = null) => {
if (typeof sub === 'string') {
// TODO: fix bad workaround where sub is a sub_id
sub = await db_api.getRecord('subscriptions', {sub_id: sub});
}
async function deleteSubscriptionFile(sub, file, deleteForever, file_uid = null, user_uid = null) {
// TODO: combine this with deletefile
let basePath = null;
basePath = user_uid ? path.join(config_api.getConfigItem('ytdl_users_base_path'), user_uid, 'subscriptions')
: config_api.getConfigItem('ytdl_subscriptions_base_path');
const useArchive = config_api.getConfigItem('ytdl_use_youtubedl_archive');
const appendedBasePath = getAppendedBasePath(sub, basePath);
const name = file;
let retrievedID = null;
let retrievedExtractor = null;
await db_api.removeRecord('files', {uid: file_uid});
@@ -168,9 +177,7 @@ exports.deleteSubscriptionFile = async (sub, file, deleteForever, file_uid = nul
]);
if (jsonExists) {
const info_json = fs.readJSONSync(jsonPath);
retrievedID = info_json['id'];
retrievedExtractor = info_json['extractor'];
retrievedID = JSON.parse(await fs.readFile(jsonPath, 'utf8'))['id'];
await fs.unlink(jsonPath);
}
@@ -188,14 +195,12 @@ exports.deleteSubscriptionFile = async (sub, file, deleteForever, file_uid = nul
return false;
} else {
// check if the user wants the video to be redownloaded (deleteForever === false)
if (deleteForever) {
// ensure video is in the archives
const exists_in_archive = await archive_api.existsInArchive(retrievedExtractor, retrievedID, sub.type, user_uid, sub.id);
if (!exists_in_archive) {
await archive_api.addToArchive(retrievedExtractor, retrievedID, sub.type, file.title, user_uid, sub.id);
if (!deleteForever && useArchive && sub.archive && retrievedID) {
const archive_path = path.join(sub.archive, 'archive.txt')
// if archive exists, remove line with video ID
if (await fs.pathExists(archive_path)) {
utils.removeIDFromArchive(archive_path, retrievedID);
}
} else {
await archive_api.removeFromArchive(retrievedExtractor, retrievedID, sub.type, user_uid, sub.id);
}
return true;
}
@@ -205,76 +210,12 @@ exports.deleteSubscriptionFile = async (sub, file, deleteForever, file_uid = nul
}
}
let current_sub_index = 0; // To keep track of the current subscription
exports.watchSubscriptionsInterval = async () => {
const subscriptions_check_interval = config_api.getConfigItem('ytdl_subscriptions_check_interval');
let parent_interval = setInterval(() => watchSubscriptions(), subscriptions_check_interval*1000);
watchSubscriptions();
config_api.config_updated.subscribe(change => {
if (!change) return;
if (change['key'] === 'ytdl_subscriptions_check_interval' || change['key'] === 'ytdl_multi_user_mode') {
current_sub_index = 0; // TODO: start after the last sub check
logger.verbose('Resetting sub check schedule due to config change');
clearInterval(parent_interval);
const new_interval = config_api.getConfigItem('ytdl_subscriptions_check_interval');
parent_interval = setInterval(() => watchSubscriptions(), new_interval*1000);
watchSubscriptions();
}
});
}
async function watchSubscriptions() {
const subscription_ids = await getValidSubscriptionsToCheck();
if (subscription_ids.length === 0) {
logger.info('Skipping subscription check as no valid subscriptions exist.');
return;
}
checkSubscription(subscription_ids[current_sub_index]);
current_sub_index = (current_sub_index + 1) % subscription_ids.length;
}
async function checkSubscription(sub_id) {
let sub = await exports.getSubscription(sub_id);
// don't check the sub if the last check for the same subscription has not completed
if (sub.downloading) {
logger.verbose(`Subscription: skipped checking ${sub.name} as it's downloading videos.`);
return;
}
if (!sub.name) {
logger.verbose(`Subscription: skipped check for subscription with uid ${sub.id} as name has not been retrieved yet.`);
return;
}
await exports.getVideosForSub(sub.id);
}
async function getValidSubscriptionsToCheck() {
const subscriptions = await exports.getAllSubscriptions();
if (!subscriptions) return;
// auto pause deprecated streamingOnly mode
const streaming_only_subs = subscriptions.filter(sub => sub.streamingOnly);
exports.updateSubscriptionPropertyMultiple(streaming_only_subs, {paused: true});
const valid_subscription_ids = subscriptions.filter(sub => !sub.paused && !sub.streamingOnly).map(sub => sub.id);
return valid_subscription_ids;
}
exports.getVideosForSub = async (sub_id) => {
const sub = await exports.getSubscription(sub_id);
if (!sub || sub['downloading']) {
async function getVideosForSub(sub, user_uid = null) {
const latest_sub_obj = await getSubscription(sub.id);
if (!latest_sub_obj || latest_sub_obj['downloading']) {
return false;
}
_getVideosForSub(sub);
return true;
}
async function _getVideosForSub(sub) {
const user_uid = sub['user_uid'];
updateSubscriptionProperty(sub, {downloading: true}, user_uid);
// get basePath
@@ -290,54 +231,84 @@ async function _getVideosForSub(sub) {
const downloadConfig = await generateArgsForSubscription(sub, user_uid);
// get videos
logger.verbose(`Subscription: getting list of videos to download for ${sub.name} with args: ${downloadConfig.join(',')}`);
logger.verbose(`Subscription: getting videos for subscription ${sub.name} with args: ${downloadConfig.join(',')}`);
let {child_process, callback} = await youtubedl_api.runYoutubeDL(sub.url, downloadConfig);
updateSubscriptionProperty(sub, {child_process: child_process}, user_uid);
const {parsed_output, err} = await callback;
updateSubscriptionProperty(sub, {downloading: false, child_process: null}, user_uid);
if (!parsed_output) {
logger.error('Subscription check failed!');
if (err) logger.error(err);
return null;
}
return new Promise(async resolve => {
youtubedl.exec(sub.url, downloadConfig, {maxBuffer: Infinity}, async function(err, output) {
// cleanup
updateSubscriptionProperty(sub, {downloading: false}, user_uid);
// remove temporary archive file if it exists
const archive_path = path.join(appendedBasePath, 'archive.txt');
const archive_exists = await fs.pathExists(archive_path);
if (archive_exists) {
await fs.unlink(archive_path);
}
logger.verbose('Subscription: finished check for ' + sub.name);
if (err && !output) {
logger.error(err.stderr ? err.stderr : err.message);
if (err.stderr.includes('This video is unavailable')) {
logger.info('An error was encountered with at least one video, backup method will be used.')
try {
// TODO: reimplement
logger.verbose('Subscription: finished check for ' + sub.name);
const files_to_download = await handleOutputJSON(parsed_output, sub, user_uid);
return files_to_download;
// const outputs = err.stdout.split(/\r\n|\r|\n/);
// for (let i = 0; i < outputs.length; i++) {
// const output = JSON.parse(outputs[i]);
// await handleOutputJSON(sub, output, i === 0, multiUserMode)
// if (err.stderr.includes(output['id']) && archive_path) {
// // we found a video that errored! add it to the archive to prevent future errors
// if (sub.archive) {
// archive_dir = sub.archive;
// archive_path = path.join(archive_dir, 'archive.txt')
// fs.appendFileSync(archive_path, output['id']);
// }
// }
// }
} catch(e) {
logger.error('Backup method failed. See error below:');
logger.error(e);
}
}
resolve(false);
} else if (output) {
if (config_api.getConfigItem('ytdl_subscriptions_redownload_fresh_uploads')) {
await setFreshUploads(sub, user_uid);
checkVideosForFreshUploads(sub, user_uid);
}
if (output.length === 0 || (output.length === 1 && output[0] === '')) {
logger.verbose('No additional videos to download for ' + sub.name);
resolve(true);
return;
}
const output_jsons = [];
for (let i = 0; i < output.length; i++) {
let output_json = null;
try {
output_json = JSON.parse(output[i]);
output_jsons.push(output_json);
} catch(e) {
output_json = null;
}
if (!output_json) {
continue;
}
}
const files_to_download = await getFilesToDownload(sub, output_jsons);
const base_download_options = generateOptionsForSubscriptionDownload(sub, user_uid);
for (let j = 0; j < files_to_download.length; j++) {
const file_to_download = files_to_download[j];
await downloader_api.createDownload(file_to_download['webpage_url'], sub.type || 'video', base_download_options, user_uid, sub.id, sub.name);
}
resolve(files_to_download);
}
});
}, err => {
logger.error(err);
updateSubscriptionProperty(sub, {downloading: false}, user_uid);
});
}
async function handleOutputJSON(output_jsons, sub, user_uid) {
if (config_api.getConfigItem('ytdl_subscriptions_redownload_fresh_uploads')) {
await setFreshUploads(sub, user_uid);
checkVideosForFreshUploads(sub, user_uid);
}
if (output_jsons.length === 0 || (output_jsons.length === 1 && output_jsons[0] === '')) {
logger.verbose('No additional videos to download for ' + sub.name);
return [];
}
const files_to_download = await getFilesToDownload(sub, output_jsons);
const base_download_options = exports.generateOptionsForSubscriptionDownload(sub, user_uid);
for (let j = 0; j < files_to_download.length; j++) {
const file_to_download = files_to_download[j];
file_to_download['formats'] = utils.stripPropertiesFromObject(file_to_download['formats'], ['format_id', 'filesize', 'filesize_approx']); // prevent download object from blowing up in size
await downloader_api.createDownload(file_to_download['webpage_url'], sub.type || 'video', base_download_options, user_uid, sub.id, sub.name, [file_to_download]);
}
return files_to_download;
}
exports.generateOptionsForSubscriptionDownload = (sub, user_uid) => {
function generateOptionsForSubscriptionDownload(sub, user_uid) {
let basePath = null;
if (user_uid)
basePath = path.join(config_api.getConfigItem('ytdl_users_base_path'), user_uid, 'subscriptions');
@@ -347,10 +318,10 @@ exports.generateOptionsForSubscriptionDownload = (sub, user_uid) => {
let default_output = config_api.getConfigItem('ytdl_default_file_output') ? config_api.getConfigItem('ytdl_default_file_output') : '%(title)s';
const base_download_options = {
maxHeight: sub.maxQuality && sub.maxQuality !== 'best' ? sub.maxQuality : null,
selectedHeight: sub.maxQuality && sub.maxQuality !== 'best' ? sub.maxQuality : null,
customFileFolderPath: getAppendedBasePath(sub, basePath),
customOutput: sub.custom_output ? `${sub.custom_output}` : `${default_output}`,
customArchivePath: path.join(basePath, 'archives', sub.name),
customArchivePath: path.join(__dirname, basePath, 'archives', sub.name),
additionalArgs: sub.custom_args
}
@@ -365,6 +336,8 @@ async function generateArgsForSubscription(sub, user_uid, redownload = false, de
else
basePath = config_api.getConfigItem('ytdl_subscriptions_base_path');
const useArchive = config_api.getConfigItem('ytdl_use_youtubedl_archive');
let appendedBasePath = getAppendedBasePath(sub, basePath);
const file_output = config_api.getConfigItem('ytdl_default_file_output') ? config_api.getConfigItem('ytdl_default_file_output') : '%(title)s';
@@ -390,16 +363,6 @@ async function generateArgsForSubscription(sub, user_uid, redownload = false, de
downloadConfig.push(...qualityPath)
// skip videos that are in the archive. otherwise sub download can be permanently slow (vs. just the first time)
const archive_text = await archive_api.generateArchive(sub.type, sub.user_uid, sub.id);
const archive_count = archive_text.split('\n').length - 1;
if (archive_count > 0) {
logger.verbose(`Generating temporary archive file for subscription ${sub.name} with ${archive_count} entries.`)
const archive_path = path.join(appendedBasePath, 'archive.txt');
await fs.writeFile(archive_path, archive_text);
downloadConfig.push('--download-archive', archive_path);
}
if (sub.custom_args) {
const customArgsArray = sub.custom_args.split(',,');
if (customArgsArray.indexOf('-f') !== -1) {
@@ -410,6 +373,22 @@ async function generateArgsForSubscription(sub, user_uid, redownload = false, de
downloadConfig.push(...customArgsArray);
}
let archive_dir = null;
let archive_path = null;
if (useArchive && !redownload) {
if (sub.archive) {
archive_dir = sub.archive;
archive_path = path.join(archive_dir, 'archive.txt')
}
downloadConfig.push('--download-archive', archive_path);
}
// if streaming only mode, just get the list of videos
if (sub.streamingOnly) {
downloadConfig = ['-f', 'best', '--dump-json'];
}
if (sub.timerange && !redownload) {
downloadConfig.push('--dateafter', sub.timerange);
}
@@ -432,13 +411,11 @@ async function generateArgsForSubscription(sub, user_uid, redownload = false, de
downloadConfig.push('-r', rate_limit);
}
const default_downloader = config_api.getConfigItem('ytdl_default_downloader');
const default_downloader = utils.getCurrentDownloader() || config_api.getConfigItem('ytdl_default_downloader');
if (default_downloader === 'yt-dlp') {
downloadConfig.push('--no-clean-info-json');
downloadConfig.push('--no-clean-infojson');
}
downloadConfig = utils.filterArgs(downloadConfig, ['--write-comments']);
return downloadConfig;
}
@@ -452,79 +429,40 @@ async function getFilesToDownload(sub, output_jsons) {
if (file_with_path_exists) {
// or maybe just overwrite???
logger.info(`Skipping adding file ${output_json['_filename']} for subscription ${sub.name} as a file with that path already exists.`)
continue;
}
const exists_in_archive = await archive_api.existsInArchive(output_json['extractor'], output_json['id'], sub.type, sub.user_uid, sub.id);
if (exists_in_archive) continue;
files_to_download.push(output_json);
}
}
return files_to_download;
}
exports.cancelCheckSubscription = async (sub_id) => {
const sub = await exports.getSubscription(sub_id);
if (!sub['downloading'] && !sub['child_process']) {
logger.error('Failed to cancel subscription check, verify that it is still running!');
return false;
}
// if check is ongoing
if (sub['child_process']) {
const child_process = sub['child_process'];
youtubedl_api.killYoutubeDLProcess(child_process);
}
// cancel activate video downloads
await killSubDownloads(sub_id);
return true;
}
async function killSubDownloads(sub_id, remove_downloads = false) {
const sub_downloads = await db_api.getRecords('download_queue', {sub_id: sub_id});
for (const sub_download of sub_downloads) {
if (sub_download['running'])
await downloader_api.cancelDownload(sub_download['uid']);
if (remove_downloads)
await db_api.removeRecord('download_queue', {uid: sub_download['uid']});
}
}
exports.getSubscriptions = async (user_uid = null) => {
// TODO: fix issue where the downloading property may not match getSubscription()
async function getSubscriptions(user_uid = null) {
return await db_api.getRecords('subscriptions', {user_uid: user_uid});
}
exports.getAllSubscriptions = async () => {
async function getAllSubscriptions() {
const all_subs = await db_api.getRecords('subscriptions');
const multiUserMode = config_api.getConfigItem('ytdl_multi_user_mode');
return all_subs.filter(sub => !!(sub.user_uid) === !!multiUserMode);
}
exports.getSubscription = async (subID) => {
// stringify and parse because we may override the 'downloading' property
const sub = JSON.parse(JSON.stringify(await db_api.getRecord('subscriptions', {id: subID})));
// now with the download_queue, we may need to override 'downloading'
const current_downloads = await db_api.getRecords('download_queue', {running: true, sub_id: subID}, true);
if (!sub['downloading']) sub['downloading'] = current_downloads > 0;
return sub;
async function getSubscription(subID) {
return await db_api.getRecord('subscriptions', {id: subID});
}
exports.getSubscriptionByName = async (subName, user_uid = null) => {
async function getSubscriptionByName(subName, user_uid = null) {
return await db_api.getRecord('subscriptions', {name: subName, user_uid: user_uid});
}
exports.updateSubscription = async (sub) => {
async function updateSubscription(sub) {
await db_api.updateRecord('subscriptions', {id: sub.id}, sub);
exports.writeSubscriptionMetadata(sub);
return true;
}
exports.updateSubscriptionPropertyMultiple = async (subs, assignment_obj) => {
async function updateSubscriptionPropertyMultiple(subs, assignment_obj) {
subs.forEach(async sub => {
await updateSubscriptionProperty(sub, assignment_obj);
await updateSubscriptionProperty(sub, assignment_obj, sub.user_uid);
});
}
@@ -534,19 +472,8 @@ async function updateSubscriptionProperty(sub, assignment_obj) {
return true;
}
exports.writeSubscriptionMetadata = (sub) => {
let basePath = sub.user_uid ? path.join(config_api.getConfigItem('ytdl_users_base_path'), sub.user_uid, 'subscriptions')
: config_api.getConfigItem('ytdl_subscriptions_base_path');
const appendedBasePath = getAppendedBasePath(sub, basePath);
const metadata_path = path.join(appendedBasePath, CONSTS.SUBSCRIPTION_BACKUP_PATH);
fs.ensureDirSync(appendedBasePath);
fs.writeJSONSync(metadata_path, sub);
}
async function setFreshUploads(sub) {
const sub_files = await db_api.getRecords('files', {sub_id: sub.id});
if (!sub_files) return;
const current_date = new Date().toISOString().split('T')[0].replace(/-/g, '');
sub_files.forEach(async file => {
if (current_date === file['upload_date'].replace(/-/g, '')) {
@@ -572,22 +499,24 @@ async function checkVideoIfBetterExists(file_obj, sub, user_uid) {
const downloadConfig = await generateArgsForSubscription(sub, user_uid, true, new_path);
logger.verbose(`Checking if a better version of the fresh upload ${file_obj['id']} exists.`);
// simulate a download to verify that a better version exists
const info = await downloader_api.getVideoInfoByURL(file_obj['url'], downloadConfig);
if (info && info.length === 1) {
const metric_to_compare = sub.type === 'audio' ? 'abr' : 'height';
if (info[metric_to_compare] > file_obj[metric_to_compare]) {
// download new video as the simulated one is better
let {callback} = await youtubedl_api.runYoutubeDL(sub.url, downloadConfig);
const {parsed_output, err} = await callback;
if (err) {
logger.verbose(`Failed to download better version of video ${file_obj['id']}`);
} else if (parsed_output) {
logger.verbose(`Successfully upgraded video ${file_obj['id']}'s ${metric_to_compare} from ${file_obj[metric_to_compare]} to ${info[metric_to_compare]}`);
await db_api.setVideoProperty(file_obj['uid'], {[metric_to_compare]: info[metric_to_compare]});
}
}
}
youtubedl.getInfo(file_obj['url'], downloadConfig, async (err, output) => {
if (err) {
// video is not available anymore for whatever reason
} else if (output) {
const metric_to_compare = sub.type === 'audio' ? 'abr' : 'height';
if (output[metric_to_compare] > file_obj[metric_to_compare]) {
// download new video as the simulated one is better
youtubedl.exec(file_obj['url'], downloadConfig, {maxBuffer: Infinity}, async (err, output) => {
if (err) {
logger.verbose(`Failed to download better version of video ${file_obj['id']}`);
} else if (output) {
logger.verbose(`Successfully upgraded video ${file_obj['id']}'s ${metric_to_compare} from ${file_obj[metric_to_compare]} to ${output[metric_to_compare]}`);
await db_api.setVideoProperty(file_obj['uid'], {[metric_to_compare]: output[metric_to_compare]});
}
});
}
}
});
await db_api.setVideoProperty(file_obj['uid'], {'fresh_upload': false});
}
@@ -596,3 +525,17 @@ async function checkVideoIfBetterExists(file_obj, sub, user_uid) {
function getAppendedBasePath(sub, base_path) {
return path.join(base_path, (sub.isPlaylist ? 'playlists/' : 'channels/'), sub.name);
}
module.exports = {
getSubscription : getSubscription,
getSubscriptionByName : getSubscriptionByName,
getSubscriptions : getSubscriptions,
getAllSubscriptions : getAllSubscriptions,
updateSubscription : updateSubscription,
subscribe : subscribe,
unsubscribe : unsubscribe,
deleteSubscriptionFile : deleteSubscriptionFile,
getVideosForSub : getVideosForSub,
updateSubscriptionPropertyMultiple : updateSubscriptionPropertyMultiple,
generateOptionsForSubscriptionDownload: generateOptionsForSubscriptionDownload
}

View File

@@ -1,17 +1,8 @@
const db_api = require('./db');
const notifications_api = require('./notifications');
const youtubedl_api = require('./youtube-dl');
const archive_api = require('./archive');
const files_api = require('./files');
const subscriptions_api = require('./subscriptions');
const config_api = require('./config');
const auth_api = require('./authentication/auth');
const utils = require('./utils');
const logger = require('./logger');
const CONSTS = require('./consts');
const fs = require('fs-extra');
const path = require('path');
const logger = require('./logger');
const scheduler = require('node-schedule');
const TASKS = {
@@ -27,7 +18,7 @@ const TASKS = {
job: null
},
missing_db_records: {
run: files_api.importUnregisteredFiles,
run: db_api.importUnregisteredFiles,
title: 'Import missing DB records',
job: null
},
@@ -42,33 +33,6 @@ const TASKS = {
confirm: youtubedl_api.updateYoutubeDL,
title: 'Update youtube-dl',
job: null
},
delete_old_files: {
run: checkForAutoDeleteFiles,
confirm: autoDeleteFiles,
title: 'Delete old files',
job: null
},
import_legacy_archives: {
run: archive_api.importArchives,
title: 'Import legacy archives',
job: null
},
rebuild_database: {
run: rebuildDB,
title: 'Rebuild database',
job: null
}
}
const defaultOptions = {
all: {
auto_confirm: false
},
delete_old_files: {
blacklist_files: false,
blacklist_subscription_files: false,
threshold_days: ''
}
}
@@ -81,7 +45,7 @@ function scheduleJob(task_key, schedule) {
const dayOfWeek = schedule['data']['dayOfWeek'] != null ? schedule['data']['dayOfWeek'] : null;
const hour = schedule['data']['hour'] != null ? schedule['data']['hour'] : null;
const minute = schedule['data']['minute'] != null ? schedule['data']['minute'] : null;
converted_schedule = new scheduler.RecurrenceRule(null, null, null, dayOfWeek, hour, minute, undefined, schedule['data']['tz'] ? schedule['data']['tz'] : undefined);
converted_schedule = new scheduler.RecurrenceRule(null, null, null, dayOfWeek, hour, minute);
} else {
logger.error(`Failed to schedule job '${task_key}' as the type '${schedule['type']}' is invalid.`)
return null;
@@ -93,7 +57,7 @@ function scheduleJob(task_key, schedule) {
logger.verbose(`Skipping running task ${task_state['key']} as it is already in progress.`);
return;
}
// remove schedule if it's a one-time task
if (task_state['schedule']['type'] !== 'recurring') await db_api.updateRecord('tasks', {key: task_key}, {schedule: null});
// we're just "running" the task, any confirmation should be user-initiated
@@ -113,10 +77,9 @@ exports.setupTasks = async () => {
const tasks_keys = Object.keys(TASKS);
for (let i = 0; i < tasks_keys.length; i++) {
const task_key = tasks_keys[i];
const mergedDefaultOptions = Object.assign({}, defaultOptions['all'], defaultOptions[task_key] || {});
const task_in_db = await db_api.getRecord('tasks', {key: task_key});
if (!task_in_db) {
// insert task metadata into table if missing, eventually move title to UI
// insert task metadata into table if missing
await db_api.insertRecordIntoTable('tasks', {
key: task_key,
title: TASKS[task_key]['title'],
@@ -127,19 +90,9 @@ exports.setupTasks = async () => {
data: null,
error: null,
schedule: null,
options: Object.assign({}, defaultOptions['all'], defaultOptions[task_key] || {})
options: {}
});
} else {
// verify all options exist in task
for (const key of Object.keys(mergedDefaultOptions)) {
const option_key = `options.${key}`;
// Remove any potential mangled option keys (#861)
await db_api.removePropertyFromRecord('tasks', {key: task_key}, {[option_key]: true});
if (!(task_in_db.options && task_in_db.options.hasOwnProperty(key))) {
await db_api.updateRecord('tasks', {key: task_key}, {[option_key]: mergedDefaultOptions[key]}, true);
}
}
// reset task if necessary
await db_api.updateRecord('tasks', {key: task_key}, {running: false, confirming: false});
@@ -170,23 +123,15 @@ exports.executeTask = async (task_key) => {
exports.executeRun = async (task_key) => {
logger.verbose(`Running task ${task_key}`);
await db_api.updateRecord('tasks', {key: task_key}, {error: null})
// don't set running to true when backup up DB as it will be stick "running" if restored
if (task_key !== 'backup_local_db') await db_api.updateRecord('tasks', {key: task_key}, {running: true});
const data = await TASKS[task_key].run();
await db_api.updateRecord('tasks', {key: task_key}, {data: TASKS[task_key]['confirm'] ? data : null, last_ran: Date.now()/1000, running: false});
logger.verbose(`Finished running task ${task_key}`);
const task_obj = await db_api.getRecord('tasks', {key: task_key});
await notifications_api.sendTaskNotification(task_obj, false);
if (task_obj['options'] && task_obj['options']['auto_confirm']) {
exports.executeConfirm(task_key);
}
}
exports.executeConfirm = async (task_key) => {
logger.verbose(`Confirming task ${task_key}`);
await db_api.updateRecord('tasks', {key: task_key}, {error: null})
if (!TASKS[task_key]['confirm']) {
return null;
}
@@ -196,7 +141,6 @@ exports.executeConfirm = async (task_key) => {
await TASKS[task_key].confirm(data);
await db_api.updateRecord('tasks', {key: task_key}, {confirming: false, last_confirmed: Date.now()/1000, data: null});
logger.verbose(`Finished confirming task ${task_key}`);
await notifications_api.sendTaskNotification(task_obj, false);
}
exports.updateTaskSchedule = async (task_key, schedule) => {
@@ -204,7 +148,6 @@ exports.updateTaskSchedule = async (task_key, schedule) => {
await db_api.updateRecord('tasks', {key: task_key}, {schedule: schedule});
if (TASKS[task_key]['job']) {
TASKS[task_key]['job'].cancel();
TASKS[task_key]['job'] = null;
}
if (schedule) {
TASKS[task_key]['job'] = scheduleJob(task_key, schedule);
@@ -249,95 +192,4 @@ async function removeDuplicates(data) {
}
}
// auto delete files
async function checkForAutoDeleteFiles() {
const task_obj = await db_api.getRecord('tasks', {key: 'delete_old_files'});
if (!task_obj['options'] || !task_obj['options']['threshold_days']) {
const error_message = 'Failed to do delete check because no limit was set!';
logger.error(error_message);
await db_api.updateRecord('tasks', {key: 'delete_old_files'}, {error: error_message})
return null;
}
const delete_older_than_timestamp = Date.now() - task_obj['options']['threshold_days']*86400*1000;
const files = (await db_api.getRecords('files', {registered: {$lt: delete_older_than_timestamp}}))
const files_to_remove = files.map(file => {return {uid: file.uid, sub_id: file.sub_id}});
return {files_to_remove: files_to_remove};
}
async function autoDeleteFiles(data) {
const task_obj = await db_api.getRecord('tasks', {key: 'delete_old_files'});
if (data['files_to_remove']) {
logger.info(`Removing ${data['files_to_remove'].length} old files!`);
for (let i = 0; i < data['files_to_remove'].length; i++) {
const file_to_remove = data['files_to_remove'][i];
await files_api.deleteFile(file_to_remove['uid'], task_obj['options']['blacklist_files'] || (file_to_remove['sub_id'] && file_to_remove['blacklist_subscription_files']));
}
}
}
async function rebuildDB() {
await db_api.backupDB();
let subs_to_add = await guessSubscriptions(false);
subs_to_add = subs_to_add.concat(await guessSubscriptions(true));
const users_to_add = await guessUsers();
for (const user_to_add of users_to_add) {
const usersFileFolder = config_api.getConfigItem('ytdl_users_base_path');
const user_exists = await db_api.getRecord('users', {uid: user_to_add});
if (!user_exists) {
await auth_api.registerUser(user_to_add, user_to_add, 'password');
logger.info(`Regenerated user ${user_to_add}`);
}
const user_channel_subs = await guessSubscriptions(false, path.join(usersFileFolder, user_to_add), user_to_add);
const user_playlist_subs = await guessSubscriptions(true, path.join(usersFileFolder, user_to_add), user_to_add);
subs_to_add = subs_to_add.concat(user_channel_subs, user_playlist_subs);
}
for (const sub_to_add of subs_to_add) {
const sub_exists = !!(await subscriptions_api.getSubscriptionByName(sub_to_add['name'], sub_to_add['user_uid']));
// TODO: we shouldn't be creating this here
const new_sub = Object.assign({}, sub_to_add, {paused: true});
if (!sub_exists) {
await subscriptions_api.subscribe(new_sub, sub_to_add['user_uid'], true);
logger.info(`Regenerated subscription ${sub_to_add['name']}`);
}
}
logger.info(`Importing unregistered files`);
await files_api.importUnregisteredFiles();
}
const guessUsers = async () => {
const usersFileFolder = config_api.getConfigItem('ytdl_users_base_path');
const userPaths = await utils.getDirectoriesInDirectory(usersFileFolder);
return userPaths.map(userPath => path.basename(userPath));
}
const guessSubscriptions = async (isPlaylist, basePath = null) => {
const guessed_subs = [];
const subscriptionsFileFolder = config_api.getConfigItem('ytdl_subscriptions_base_path');
const subsSubPath = basePath ? path.join(basePath, 'subscriptions') : subscriptionsFileFolder;
const subsPath = path.join(subsSubPath, isPlaylist ? 'playlists' : 'channels');
const subs = await utils.getDirectoriesInDirectory(subsPath);
for (const subPath of subs) {
const sub_backup_path = path.join(subPath, CONSTS.SUBSCRIPTION_BACKUP_PATH);
if (!fs.existsSync(sub_backup_path)) continue;
try {
const sub_backup = fs.readJSONSync(sub_backup_path)
delete sub_backup['_id'];
guessed_subs.push(sub_backup);
} catch(err) {
logger.warn(`Failed to reimport subscription in path ${subPath}`)
logger.warn(err);
}
}
return guessed_subs;
}
exports.TASKS = TASKS;

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

View File

@@ -1,74 +1,90 @@
var moment = require('moment');
var Axios = require('axios');
var fs = require('fs-extra')
var path = require('path');
const config_api = require('./config');
const logger = require('./logger');
const moment = require('moment');
const fs = require('fs-extra')
const path = require('path');
const { promisify } = require('util');
const child_process = require('child_process');
const commandExistsSync = require('command-exists').sync;
async function getCommentsForVOD(clientID, vodId) {
let url = `https://api.twitch.tv/v5/videos/${vodId}/comments?content_offset_seconds=0`,
batch,
cursor;
async function getCommentsForVOD(vodId) {
const exec = promisify(child_process.exec);
// Reject invalid params to prevent command injection attack
if (!vodId.match(/^[0-9a-z]+$/)) {
logger.error('VOD ID must be purely alphanumeric. Twitch chat download failed!');
return null;
let comments = null;
try {
do {
batch = (await Axios.get(url, {
headers: {
'Client-ID': clientID,
Accept: 'application/vnd.twitchtv.v5+json; charset=UTF-8',
'Content-Type': 'application/json; charset=UTF-8',
}
})).data;
const str = batch.comments.map(c => {
let {
created_at: msgCreated,
content_offset_seconds: timestamp,
commenter: {
name,
_id,
created_at: acctCreated
},
message: {
body: msg,
user_color: user_color
}
} = c;
const timestamp_str = moment.duration(timestamp, 'seconds')
.toISOString()
.replace(/P.*?T(?:(\d+?)H)?(?:(\d+?)M)?(?:(\d+).*?S)?/,
(_, ...ms) => {
const seg = v => v ? v.padStart(2, '0') : '00';
return `${seg(ms[0])}:${seg(ms[1])}:${seg(ms[2])}`;
});
acctCreated = moment(acctCreated).utc();
msgCreated = moment(msgCreated).utc();
if (!comments) comments = [];
comments.push({
timestamp: timestamp,
timestamp_str: timestamp_str,
name: name,
message: msg,
user_color: user_color
});
// let line = `${timestamp},${msgCreated.format(tsFormat)},${name},${_id},"${msg.replace(/"/g, '""')}",${acctCreated.format(tsFormat)}`;
// return line;
}).join('\n');
cursor = batch._next;
url = `https://api.twitch.tv/v5/videos/${vodId}/comments?cursor=${cursor}`;
await new Promise(res => setTimeout(res, 300));
} while (cursor);
} catch (err) {
console.error(err);
}
const is_windows = process.platform === 'win32';
const cliExt = is_windows ? '.exe' : ''
const cliPath = `TwitchDownloaderCLI${cliExt}`
if (!commandExistsSync(cliPath)) {
logger.error(`${cliPath} does not exist. Twitch chat download failed! Get it here: https://github.com/lay295/TwitchDownloader`);
return null;
}
const result = await exec(`${cliPath} chatdownload -u ${vodId} -o appdata/${vodId}.json`, {stdio:[0,1,2]});
if (result['stderr']) {
logger.error(`Failed to download twitch comments for ${vodId}`);
logger.error(result['stderr']);
return null;
}
const temp_chat_path = path.join('appdata', `${vodId}.json`);
const raw_json = fs.readJSONSync(temp_chat_path);
const new_json = raw_json.comments.map(comment_obj => {
return {
timestamp: comment_obj.content_offset_seconds,
timestamp_str: convertTimestamp(comment_obj.content_offset_seconds),
name: comment_obj.commenter.name,
message: comment_obj.message.body,
user_color: comment_obj.message.user_color
}
});
fs.unlinkSync(temp_chat_path);
return new_json;
return comments;
}
async function getTwitchChatByFileID(id, type, user_uid, uuid, sub) {
const usersFileFolder = config_api.getConfigItem('ytdl_users_base_path');
const subscriptionsFileFolder = config_api.getConfigItem('ytdl_subscriptions_base_path');
let file_path = null;
if (user_uid) {
if (sub) {
file_path = path.join(usersFileFolder, user_uid, 'subscriptions', sub.isPlaylist ? 'playlists' : 'channels', sub.name, `${id}.twitch_chat.json`);
file_path = path.join('users', user_uid, 'subscriptions', sub.isPlaylist ? 'playlists' : 'channels', sub.name, id + '.twitch_chat.json');
} else {
file_path = path.join(usersFileFolder, user_uid, type, `${id}.twitch_chat.json`);
file_path = path.join('users', user_uid, type, id + '.twitch_chat.json');
}
} else {
if (sub) {
file_path = path.join(subscriptionsFileFolder, sub.isPlaylist ? 'playlists' : 'channels', sub.name, `${id}.twitch_chat.json`);
file_path = path.join('subscriptions', sub.isPlaylist ? 'playlists' : 'channels', sub.name, id + '.twitch_chat.json');
} else {
const typeFolder = config_api.getConfigItem(`ytdl_${type}_folder_path`);
file_path = path.join(typeFolder, `${id}.twitch_chat.json`);
file_path = path.join(type, id + '.twitch_chat.json');
}
}
@@ -80,26 +96,23 @@ async function getTwitchChatByFileID(id, type, user_uid, uuid, sub) {
return chat_file;
}
async function downloadTwitchChatByVODID(vodId, id, type, user_uid, sub, customFileFolderPath = null) {
const usersFileFolder = config_api.getConfigItem('ytdl_users_base_path');
const subscriptionsFileFolder = config_api.getConfigItem('ytdl_subscriptions_base_path');
const chat = await getCommentsForVOD(vodId);
async function downloadTwitchChatByVODID(vodId, id, type, user_uid, sub) {
const twitch_api_key = config_api.getConfigItem('ytdl_twitch_api_key');
const chat = await getCommentsForVOD(twitch_api_key, vodId);
// save file if needed params are included
let file_path = null;
if (customFileFolderPath) {
file_path = path.join(customFileFolderPath, `${id}.twitch_chat.json`)
} else if (user_uid) {
if (user_uid) {
if (sub) {
file_path = path.join(usersFileFolder, user_uid, 'subscriptions', sub.isPlaylist ? 'playlists' : 'channels', sub.name, `${id}.twitch_chat.json`);
file_path = path.join('users', user_uid, 'subscriptions', sub.isPlaylist ? 'playlists' : 'channels', sub.name, id + '.twitch_chat.json');
} else {
file_path = path.join(usersFileFolder, user_uid, type, `${id}.twitch_chat.json`);
file_path = path.join('users', user_uid, type, id + '.twitch_chat.json');
}
} else {
if (sub) {
file_path = path.join(subscriptionsFileFolder, sub.isPlaylist ? 'playlists' : 'channels', sub.name, `${id}.twitch_chat.json`);
file_path = path.join('subscriptions', sub.isPlaylist ? 'playlists' : 'channels', sub.name, id + '.twitch_chat.json');
} else {
file_path = path.join(type, `${id}.twitch_chat.json`);
file_path = path.join(type, id + '.twitch_chat.json');
}
}
@@ -108,14 +121,6 @@ async function downloadTwitchChatByVODID(vodId, id, type, user_uid, sub, customF
return chat;
}
const convertTimestamp = (timestamp) => moment.duration(timestamp, 'seconds')
.toISOString()
.replace(/P.*?T(?:(\d+?)H)?(?:(\d+?)M)?(?:(\d+).*?S)?/,
(_, ...ms) => {
const seg = v => v ? v.padStart(2, '0') : '00';
return `${seg(ms[0])}:${seg(ms[1])}:${seg(ms[2])}`;
});
module.exports = {
getCommentsForVOD: getCommentsForVOD,
getTwitchChatByFileID: getTwitchChatByFileID,

View File

@@ -4,7 +4,6 @@ const ffmpeg = require('fluent-ffmpeg');
const archiver = require('archiver');
const fetch = require('node-fetch');
const ProgressBar = require('progress');
const winston = require('winston');
const config_api = require('./config');
const logger = require('./logger');
@@ -13,7 +12,7 @@ const CONSTS = require('./consts');
const is_windows = process.platform === 'win32';
// replaces .webm with appropriate extension
exports.getTrueFileName = (unfixed_path, type, force_ext = null) => {
function getTrueFileName(unfixed_path, type) {
let fixed_path = unfixed_path;
const new_ext = (type === 'audio' ? 'mp3' : 'mp4');
@@ -22,19 +21,19 @@ exports.getTrueFileName = (unfixed_path, type, force_ext = null) => {
if (old_ext !== new_ext) {
unfixed_parts[unfixed_parts.length-1] = force_ext || new_ext;
unfixed_parts[unfixed_parts.length-1] = new_ext;
fixed_path = unfixed_parts.join('.');
}
return fixed_path;
}
exports.getDownloadedFilesByType = async (basePath, type, full_metadata = false) => {
async function getDownloadedFilesByType(basePath, type, full_metadata = false) {
// return empty array if the path doesn't exist
if (!(await fs.pathExists(basePath))) return [];
let files = [];
const ext = type === 'audio' ? 'mp3' : 'mp4';
var located_files = await exports.recFindByExt(basePath, ext);
var located_files = await recFindByExt(basePath, ext);
for (let i = 0; i < located_files.length; i++) {
let file = located_files[i];
var file_path = file.substring(basePath.includes('\\') ? basePath.length+1 : basePath.length, file.length);
@@ -42,33 +41,33 @@ exports.getDownloadedFilesByType = async (basePath, type, full_metadata = false)
var stats = await fs.stat(file);
var id = file_path.substring(0, file_path.length-4);
var jsonobj = await exports.getJSONByType(type, id, basePath);
var jsonobj = await getJSONByType(type, id, basePath);
if (!jsonobj) continue;
if (full_metadata) {
jsonobj['id'] = id;
files.push(jsonobj);
continue;
}
var upload_date = exports.formatDateString(jsonobj.upload_date);
var upload_date = formatDateString(jsonobj.upload_date);
var isaudio = type === 'audio';
var file_obj = new exports.File(id, jsonobj.title, jsonobj.thumbnail, isaudio, jsonobj.duration, jsonobj.webpage_url, jsonobj.uploader,
var file_obj = new File(id, jsonobj.title, jsonobj.thumbnail, isaudio, jsonobj.duration, jsonobj.webpage_url, jsonobj.uploader,
stats.size, file, upload_date, jsonobj.description, jsonobj.view_count, jsonobj.height, jsonobj.abr);
files.push(file_obj);
}
return files;
}
exports.createContainerZipFile = async (file_name, container_file_objs) => {
async function createContainerZipFile(file_name, container_file_objs) {
const container_files_to_download = [];
for (let i = 0; i < container_file_objs.length; i++) {
const container_file_obj = container_file_objs[i];
container_files_to_download.push(container_file_obj.path);
}
return await exports.createZipFile(path.join('appdata', file_name + '.zip'), container_files_to_download);
return await createZipFile(path.join('appdata', file_name + '.zip'), container_files_to_download);
}
exports.createZipFile = async (zip_file_path, file_paths) => {
async function createZipFile(zip_file_path, file_paths) {
let output = fs.createWriteStream(zip_file_path);
var archive = archiver('zip', {
@@ -92,11 +91,11 @@ exports.createZipFile = async (zip_file_path, file_paths) => {
await archive.finalize();
// wait a tiny bit for the zip to reload in fs
await exports.wait(100);
await wait(100);
return zip_file_path;
}
exports.getJSONMp4 = (name, customPath, openReadPerms = false) => {
function getJSONMp4(name, customPath, openReadPerms = false) {
var obj = null; // output
if (!customPath) customPath = config_api.getConfigItem('ytdl_video_folder_path');
var jsonPath = path.join(customPath, name + ".info.json");
@@ -111,7 +110,7 @@ exports.getJSONMp4 = (name, customPath, openReadPerms = false) => {
return obj;
}
exports.getJSONMp3 = (name, customPath, openReadPerms = false) => {
function getJSONMp3(name, customPath, openReadPerms = false) {
var obj = null;
if (!customPath) customPath = config_api.getConfigItem('ytdl_audio_folder_path');
var jsonPath = path.join(customPath, name + ".info.json");
@@ -128,11 +127,11 @@ exports.getJSONMp3 = (name, customPath, openReadPerms = false) => {
return obj;
}
exports.getJSON = (file_path, type) => {
function getJSON(file_path, type) {
const ext = type === 'audio' ? '.mp3' : '.mp4';
let obj = null;
var jsonPath = exports.removeFileExtension(file_path) + '.info.json';
var alternateJsonPath = exports.removeFileExtension(file_path) + `${ext}.info.json`;
var jsonPath = removeFileExtension(file_path) + '.info.json';
var alternateJsonPath = removeFileExtension(file_path) + `${ext}.info.json`;
if (fs.existsSync(jsonPath))
{
obj = JSON.parse(fs.readFileSync(jsonPath, 'utf8'));
@@ -143,12 +142,12 @@ exports.getJSON = (file_path, type) => {
return obj;
}
exports.getJSONByType = (type, name, customPath, openReadPerms = false) => {
return type === 'audio' ? exports.getJSONMp3(name, customPath, openReadPerms) : exports.getJSONMp4(name, customPath, openReadPerms)
function getJSONByType(type, name, customPath, openReadPerms = false) {
return type === 'audio' ? getJSONMp3(name, customPath, openReadPerms) : getJSONMp4(name, customPath, openReadPerms)
}
exports.getDownloadedThumbnail = (file_path) => {
const file_path_no_extension = exports.removeFileExtension(file_path);
function getDownloadedThumbnail(file_path) {
const file_path_no_extension = removeFileExtension(file_path);
let jpgPath = file_path_no_extension + '.jpg';
let webpPath = file_path_no_extension + '.webp';
@@ -164,7 +163,7 @@ exports.getDownloadedThumbnail = (file_path) => {
return null;
}
exports.getExpectedFileSize = (input_info_jsons) => {
function getExpectedFileSize(input_info_jsons) {
// treat single videos as arrays to have the file sizes checked/added to. makes the code cleaner
const info_jsons = Array.isArray(input_info_jsons) ? input_info_jsons : [input_info_jsons];
@@ -173,13 +172,11 @@ exports.getExpectedFileSize = (input_info_jsons) => {
const formats = info_json['format_id'].split('+');
let individual_expected_filesize = 0;
formats.forEach(format_id => {
if (info_json.formats !== undefined) {
info_json.formats.forEach(available_format => {
if (available_format.format_id === format_id && (available_format.filesize || available_format.filesize_approx)) {
individual_expected_filesize += (available_format.filesize ? available_format.filesize : available_format.filesize_approx);
}
});
}
info_json.formats.forEach(available_format => {
if (available_format.format_id === format_id && available_format.filesize) {
individual_expected_filesize += available_format.filesize;
}
});
});
expected_filesize += individual_expected_filesize;
});
@@ -187,12 +184,12 @@ exports.getExpectedFileSize = (input_info_jsons) => {
return expected_filesize;
}
exports.fixVideoMetadataPerms = (file_path, type) => {
function fixVideoMetadataPerms(file_path, type) {
if (is_windows) return;
const ext = type === 'audio' ? '.mp3' : '.mp4';
const file_path_no_extension = exports.removeFileExtension(file_path);
const file_path_no_extension = removeFileExtension(file_path);
const files_to_fix = [
// JSONs
@@ -209,11 +206,11 @@ exports.fixVideoMetadataPerms = (file_path, type) => {
}
}
exports.deleteJSONFile = (file_path, type) => {
function deleteJSONFile(file_path, type) {
const ext = type === 'audio' ? '.mp3' : '.mp4';
const file_path_no_extension = exports.removeFileExtension(file_path);
const file_path_no_extension = removeFileExtension(file_path);
let json_path = file_path_no_extension + '.info.json';
let alternate_json_path = file_path_no_extension + ext + '.info.json';
@@ -221,7 +218,33 @@ exports.deleteJSONFile = (file_path, type) => {
if (fs.existsSync(alternate_json_path)) fs.unlinkSync(alternate_json_path);
}
exports.durationStringToNumber = (dur_str) => {
async function removeIDFromArchive(archive_path, id) {
let data = await fs.readFile(archive_path, {encoding: 'utf-8'});
if (!data) {
logger.error('Archive could not be found.');
return;
}
let dataArray = data.split('\n'); // convert file data in an array
const searchKeyword = id; // we are looking for a line, contains, key word id in the file
let lastIndex = -1; // let say, we have not found the keyword
for (let index=0; index<dataArray.length; index++) {
if (dataArray[index].includes(searchKeyword)) { // check if a line contains the id keyword
lastIndex = index; // found a line includes a id keyword
break;
}
}
const line = dataArray.splice(lastIndex, 1); // remove the keyword id from the data Array
// UPDATE FILE WITH NEW DATA
const updatedData = dataArray.join('\n');
await fs.writeFile(archive_path, updatedData);
if (line) return line;
}
function durationStringToNumber(dur_str) {
if (typeof dur_str === 'number') return dur_str;
let num_sum = 0;
const dur_str_parts = dur_str.split(':');
@@ -231,17 +254,23 @@ exports.durationStringToNumber = (dur_str) => {
return num_sum;
}
exports.getMatchingCategoryFiles = (category, files) => {
function getMatchingCategoryFiles(category, files) {
return files && files.filter(file => file.category && file.category.uid === category.uid);
}
exports.addUIDsToCategory = (category, files) => {
const files_that_match = exports.getMatchingCategoryFiles(category, files);
function addUIDsToCategory(category, files) {
const files_that_match = getMatchingCategoryFiles(category, files);
category['uids'] = files_that_match.map(file => file.uid);
return files_that_match;
}
exports.recFindByExt = async (base, ext, files, result, recursive = true) => {
function getCurrentDownloader() {
const details_json = fs.readJSONSync(CONSTS.DETAILS_BIN_PATH);
return details_json['downloader'];
}
async function recFindByExt(base, ext, files, result, recursive = true)
{
files = files || (await fs.readdir(base))
result = result || []
@@ -250,7 +279,7 @@ exports.recFindByExt = async (base, ext, files, result, recursive = true) => {
if ( (await fs.stat(newbase)).isDirectory() )
{
if (!recursive) continue;
result = await exports.recFindByExt(newbase,ext,await fs.readdir(newbase),result)
result = await recFindByExt(newbase,ext,await fs.readdir(newbase),result)
}
else
{
@@ -263,23 +292,23 @@ exports.recFindByExt = async (base, ext, files, result, recursive = true) => {
return result
}
exports.removeFileExtension = (filename) => {
function removeFileExtension(filename) {
const filename_parts = filename.split('.');
filename_parts.splice(filename_parts.length - 1);
return filename_parts.join('.');
}
exports.formatDateString = (date_string) => {
function formatDateString(date_string) {
return date_string ? `${date_string.substring(0, 4)}-${date_string.substring(4, 6)}-${date_string.substring(6, 8)}` : 'N/A';
}
exports.createEdgeNGrams = (str) => {
function createEdgeNGrams(str) {
if (str && str.length > 3) {
const minGram = 3
const maxGram = str.length
return str.split(" ").reduce((ngrams, token) => {
if (token.length > minGram) {
if (token.length > minGram) {
for (let i = minGram; i <= maxGram && i <= token.length; ++i) {
ngrams = [...ngrams, token.substr(0, i)]
}
@@ -289,13 +318,13 @@ exports.createEdgeNGrams = (str) => {
return ngrams
}, []).join(" ")
}
return str
}
// ffmpeg helper functions
exports.cropFile = async (file_path, start, end, ext) => {
async function cropFile(file_path, start, end, ext) {
return new Promise(resolve => {
const temp_file_path = `${file_path}.cropped${ext}`;
let base_ffmpeg_call = ffmpeg(file_path);
@@ -324,13 +353,13 @@ exports.cropFile = async (file_path, start, end, ext) => {
* setTimeout, but its a promise.
* @param {number} ms
*/
exports.wait = async (ms) => {
async function wait(ms) {
await new Promise(resolve => {
setTimeout(resolve, ms);
});
}
exports.checkExistsWithTimeout = async (filePath, timeout) => {
async function checkExistsWithTimeout(filePath, timeout) {
return new Promise(function (resolve, reject) {
var timer = setTimeout(function () {
@@ -342,7 +371,7 @@ exports.checkExistsWithTimeout = async (filePath, timeout) => {
if (!err) {
clearTimeout(timer);
if (watcher) watcher.close();
resolve(true);
resolve();
}
});
@@ -352,14 +381,14 @@ exports.checkExistsWithTimeout = async (filePath, timeout) => {
if (eventType === 'rename' && filename === basename) {
clearTimeout(timer);
if (watcher) watcher.close();
resolve(true);
resolve();
}
});
});
}
// helper function to download file using fetch
exports.fetchFile = async (url, path, file_label) => {
async function fetchFile(url, path, file_label) {
var len = null;
const res = await fetch(url);
@@ -386,179 +415,6 @@ exports.fetchFile = async (url, path, file_label) => {
});
}
exports.restartServer = async (is_update = false) => {
logger.info(`${is_update ? 'Update complete! ' : ''}Restarting server...`);
// the following line restarts the server through pm2
fs.writeFileSync(`restart${is_update ? '_update' : '_general'}.json`, 'internal use only');
process.exit(1);
}
// adds or replaces args according to the following rules:
// - if it already exists and has value, then replace both arg and value
// - if already exists and doesn't have value, ignore
// - if it doesn't exist and has value, add both arg and value
// - if it doesn't exist and doesn't have value, add arg
exports.injectArgs = (original_args, new_args) => {
const updated_args = original_args.slice();
try {
for (let i = 0; i < new_args.length; i++) {
const new_arg = new_args[i];
if (!new_arg.startsWith('-') && !new_arg.startsWith('--') && i > 0 && original_args.includes(new_args[i - 1])) continue;
if (CONSTS.YTDL_ARGS_WITH_VALUES.has(new_arg)) {
if (original_args.includes(new_arg)) {
const original_index = original_args.indexOf(new_arg);
updated_args.splice(original_index, 2);
}
updated_args.push(new_arg, new_args[i + 1]);
i++; // we need to skip the arg value on the next loop
} else {
if (!original_args.includes(new_arg)) {
updated_args.push(new_arg);
}
}
}
} catch (err) {
logger.warn(err);
logger.warn(`Failed to inject args (${new_args}) into (${original_args})`);
}
return updated_args;
}
exports.filterArgs = (args, args_to_remove) => {
return args.filter(x => !args_to_remove.includes(x));
}
exports.searchObjectByString = (o, s) => {
s = s.replace(/\[(\w+)\]/g, '.$1'); // convert indexes to properties
s = s.replace(/^\./, ''); // strip a leading dot
var a = s.split('.');
for (var i = 0, n = a.length; i < n; ++i) {
var k = a[i];
if (k in o) {
o = o[k];
} else {
return;
}
}
return o;
}
exports.stripPropertiesFromObject = (obj, properties, whitelist = false) => {
if (!whitelist) {
const new_obj = JSON.parse(JSON.stringify(obj));
for (let field of properties) {
delete new_obj[field];
}
return new_obj;
}
const new_obj = {};
for (let field of properties) {
new_obj[field] = obj[field];
}
return new_obj;
}
exports.getArchiveFolder = (type, user_uid = null, sub = null) => {
const usersFolderPath = config_api.getConfigItem('ytdl_users_base_path');
const subsFolderPath = config_api.getConfigItem('ytdl_subscriptions_base_path');
if (user_uid) {
if (sub) {
return path.join(usersFolderPath, user_uid, 'subscriptions', 'archives', sub.name);
} else {
return path.join(usersFolderPath, user_uid, type, 'archives');
}
} else {
if (sub) {
return path.join(subsFolderPath, 'archives', sub.name);
} else {
return path.join('appdata', 'archives');
}
}
}
exports.getBaseURL = () => {
return `${config_api.getConfigItem('ytdl_url')}:${config_api.getConfigItem('ytdl_port')}`
}
exports.updateLoggerLevel = (new_logger_level) => {
const possible_levels = ['error', 'warn', 'info', 'verbose', 'debug'];
if (!possible_levels.includes(new_logger_level)) {
logger.error(`${new_logger_level} is not a valid logger level! Choose one of the following: ${possible_levels.join(', ')}.`)
new_logger_level = 'info';
}
logger.level = new_logger_level;
winston.loggers.get('console').level = new_logger_level;
logger.transports[2].level = new_logger_level;
}
exports.convertFlatObjectToNestedObject = (obj) => {
const result = {};
for (const key in obj) {
const nestedKeys = key.split('.');
let currentObj = result;
for (let i = 0; i < nestedKeys.length; i++) {
if (i === nestedKeys.length - 1) {
currentObj[nestedKeys[i]] = obj[key];
} else {
currentObj[nestedKeys[i]] = currentObj[nestedKeys[i]] || {};
currentObj = currentObj[nestedKeys[i]];
}
}
}
return result;
}
exports.getDirectoriesInDirectory = async (basePath) => {
try {
const files = await fs.readdir(basePath, { withFileTypes: true });
return files
.filter((file) => file.isDirectory())
.map((file) => path.join(basePath, file.name));
} catch (err) {
return [];
}
}
exports.parseOutputJSON = (output, err) => {
let split_output = [];
// const output_jsons = [];
if (err && !output) {
if (!err.stderr.includes('This video is unavailable') && !err.stderr.includes('Private video')) {
return null;
}
logger.info('An error was encountered with at least one video, backup method will be used.')
try {
split_output = err.stdout.split(/\r\n|\r|\n/);
} catch (e) {
logger.error('Backup method failed. See error below:');
logger.error(e);
return null;
}
} else if (output.length === 0 || (output.length === 1 && output[0].length === 0)) {
// output is '' or ['']
return [];
} else {
for (const output_item of output) {
// we have to do this because sometimes there will be leading characters before the actual json
const start_idx = output_item.indexOf('{"');
const clean_output = output_item.slice(start_idx, output_item.length);
split_output.push(clean_output);
}
}
try {
return split_output.map(split_output_str => JSON.parse(split_output_str));
} catch(e) {
return null;
}
}
// objects
function File(id, title, thumbnailURL, isAudio, duration, url, uploader, size, path, upload_date, description, view_count, height, abr) {
@@ -576,7 +432,31 @@ function File(id, title, thumbnailURL, isAudio, duration, url, uploader, size, p
this.view_count = view_count;
this.height = height;
this.abr = abr;
this.favorite = false;
}
exports.File = File;
}
module.exports = {
getJSONMp3: getJSONMp3,
getJSONMp4: getJSONMp4,
getJSON: getJSON,
getTrueFileName: getTrueFileName,
getDownloadedThumbnail: getDownloadedThumbnail,
getExpectedFileSize: getExpectedFileSize,
fixVideoMetadataPerms: fixVideoMetadataPerms,
deleteJSONFile: deleteJSONFile,
removeIDFromArchive: removeIDFromArchive,
getDownloadedFilesByType: getDownloadedFilesByType,
createContainerZipFile: createContainerZipFile,
durationStringToNumber: durationStringToNumber,
getMatchingCategoryFiles: getMatchingCategoryFiles,
addUIDsToCategory: addUIDsToCategory,
getCurrentDownloader: getCurrentDownloader,
recFindByExt: recFindByExt,
removeFileExtension: removeFileExtension,
formatDateString: formatDateString,
cropFile: cropFile,
createEdgeNGrams: createEdgeNGrams,
wait: wait,
checkExistsWithTimeout: checkExistsWithTimeout,
fetchFile: fetchFile,
File: File
}

View File

@@ -1,8 +1,5 @@
const fs = require('fs-extra');
const fetch = require('node-fetch');
const path = require('path');
const execa = require('execa');
const kill = require('tree-kill');
const logger = require('./logger');
const utils = require('./utils');
@@ -11,155 +8,120 @@ const config_api = require('./config.js');
const is_windows = process.platform === 'win32';
exports.youtubedl_forks = {
const download_sources = {
'youtube-dl': {
'download_url': 'https://github.com/ytdl-org/youtube-dl/releases/latest/download/youtube-dl',
'tags_url': 'https://api.github.com/repos/ytdl-org/youtube-dl/tags'
'tags_url': 'https://api.github.com/repos/ytdl-org/youtube-dl/tags',
'func': downloadLatestYoutubeDLBinary
},
'youtube-dlc': {
'download_url': 'https://github.com/blackjack4494/yt-dlc/releases/latest/download/youtube-dlc',
'tags_url': 'https://api.github.com/repos/blackjack4494/yt-dlc/tags'
'tags_url': 'https://api.github.com/repos/blackjack4494/yt-dlc/tags',
'func': downloadLatestYoutubeDLCBinary
},
'yt-dlp': {
'download_url': 'https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp',
'tags_url': 'https://api.github.com/repos/yt-dlp/yt-dlp/tags'
'tags_url': 'https://api.github.com/repos/yt-dlp/yt-dlp/tags',
'func': downloadLatestYoutubeDLPBinary
}
}
exports.runYoutubeDL = async (url, args, customDownloadHandler = null) => {
const output_file_path = getYoutubeDLPath();
if (!fs.existsSync(output_file_path)) await exports.checkForYoutubeDLUpdate();
let callback = null;
let child_process = null;
if (customDownloadHandler) {
callback = runYoutubeDLCustom(url, args, customDownloadHandler);
} else {
({callback, child_process} = await runYoutubeDLProcess(url, args));
}
return {child_process, callback};
}
// Run youtube-dl directly (not cancellable)
const runYoutubeDLCustom = async (url, args, customDownloadHandler) => {
const downloadHandler = customDownloadHandler;
return new Promise(resolve => {
downloadHandler(url, args, {maxBuffer: Infinity}, async function(err, output) {
const parsed_output = utils.parseOutputJSON(output, err);
resolve({parsed_output, err});
});
});
}
// Run youtube-dl in a subprocess (cancellable)
const runYoutubeDLProcess = async (url, args, youtubedl_fork = config_api.getConfigItem('ytdl_default_downloader')) => {
const youtubedl_path = getYoutubeDLPath(youtubedl_fork);
const binary_exists = fs.existsSync(youtubedl_path);
if (!binary_exists) {
const err = `Could not find path for ${youtubedl_fork} at ${youtubedl_path}`;
logger.error(err);
return;
}
const child_process = execa(getYoutubeDLPath(youtubedl_fork), [url, ...args], {maxBuffer: Infinity});
const callback = new Promise(async resolve => {
try {
const {stdout, stderr} = await child_process;
const parsed_output = utils.parseOutputJSON(stdout.trim().split(/\r?\n/), stderr);
resolve({parsed_output, err: stderr});
} catch (e) {
resolve({parsed_output: null, err: e})
}
});
return {child_process, callback}
}
function getYoutubeDLPath(youtubedl_fork = config_api.getConfigItem('ytdl_default_downloader')) {
const binary_file_name = youtubedl_fork + (is_windows ? '.exe' : '');
const binary_path = path.join('appdata', 'bin', binary_file_name);
return binary_path;
}
exports.killYoutubeDLProcess = async (child_process) => {
kill(child_process.pid, 'SIGKILL');
}
exports.checkForYoutubeDLUpdate = async () => {
const selected_fork = config_api.getConfigItem('ytdl_default_downloader');
const output_file_path = getYoutubeDLPath();
// get current version
let current_app_details_exists = fs.existsSync(CONSTS.DETAILS_BIN_PATH);
if (!current_app_details_exists[selected_fork]) {
logger.warn(`Failed to get youtube-dl binary details at location '${CONSTS.DETAILS_BIN_PATH}'. Generating file...`);
updateDetailsJSON(CONSTS.OUTDATED_YOUTUBEDL_VERSION, selected_fork, output_file_path);
}
const current_app_details = JSON.parse(fs.readFileSync(CONSTS.DETAILS_BIN_PATH));
const current_version = current_app_details[selected_fork]['version'];
const current_fork = current_app_details[selected_fork]['downloader'];
return new Promise(async resolve => {
const default_downloader = config_api.getConfigItem('ytdl_default_downloader');
const tags_url = download_sources[default_downloader]['tags_url'];
// get current version
let current_app_details_exists = fs.existsSync(CONSTS.DETAILS_BIN_PATH);
if (!current_app_details_exists) {
logger.warn(`Failed to get youtube-dl binary details at location '${CONSTS.DETAILS_BIN_PATH}'. Generating file...`);
fs.writeJSONSync(CONSTS.DETAILS_BIN_PATH, {"version":"2020.00.00", "downloader": default_downloader});
}
let current_app_details = JSON.parse(fs.readFileSync(CONSTS.DETAILS_BIN_PATH));
let current_version = current_app_details['version'];
let current_downloader = current_app_details['downloader'];
let stored_binary_path = current_app_details['path'];
if (!stored_binary_path || typeof stored_binary_path !== 'string') {
// logger.info(`INFO: Failed to get youtube-dl binary path at location: ${CONSTS.DETAILS_BIN_PATH}, attempting to guess actual path...`);
const guessed_base_path = 'node_modules/youtube-dl/bin/';
const guessed_file_path = guessed_base_path + 'youtube-dl' + (is_windows ? '.exe' : '');
if (fs.existsSync(guessed_file_path)) {
stored_binary_path = guessed_file_path;
// logger.info('INFO: Guess successful! Update process continuing...')
} else {
logger.error(`Guess '${guessed_file_path}' is not correct. Cancelling update check. Verify that your youtube-dl binaries exist by running npm install.`);
resolve(null);
return;
}
}
const latest_version = await exports.getLatestUpdateVersion(selected_fork);
// if the binary does not exist, or default_downloader doesn't match existing fork, or if the fork has been updated, redownload
// TODO: don't redownload if fork already exists
if (!fs.existsSync(output_file_path) || current_fork !== selected_fork || !current_version || current_version !== latest_version) {
logger.warn(`Updating ${selected_fork} binary to '${output_file_path}', downloading...`);
await exports.updateYoutubeDL(latest_version);
}
}
// got version, now let's check the latest version from the youtube-dl API
exports.updateYoutubeDL = async (latest_update_version, custom_output_path = null) => {
await fs.ensureDir(path.join('appdata', 'bin'));
const default_downloader = config_api.getConfigItem('ytdl_default_downloader');
await downloadLatestYoutubeDLBinaryGeneric(default_downloader, latest_update_version, custom_output_path);
}
async function downloadLatestYoutubeDLBinaryGeneric(youtubedl_fork, new_version, custom_output_path = null) {
const file_ext = is_windows ? '.exe' : '';
// build the URL
const download_url = `${exports.youtubedl_forks[youtubedl_fork]['download_url']}${file_ext}`;
const output_path = custom_output_path || getYoutubeDLPath(youtubedl_fork);
try {
await utils.fetchFile(download_url, output_path, `${youtubedl_fork} ${new_version}`);
fs.chmod(output_path, 0o777);
updateDetailsJSON(new_version, youtubedl_fork, output_path);
} catch (e) {
logger.error(`Failed to download new ${youtubedl_fork} version: ${new_version}`);
logger.error(e);
return;
}
}
exports.getLatestUpdateVersion = async (youtubedl_fork) => {
const tags_url = exports.youtubedl_forks[youtubedl_fork]['tags_url'];
return new Promise(resolve => {
fetch(tags_url, {method: 'Get'})
.then(async res => res.json())
.then(async (json) => {
// check if the versions are different
if (!json || !json[0]) {
logger.error(`Failed to check ${youtubedl_fork} version for an update.`)
logger.error(`Failed to check ${default_downloader} version for an update.`)
resolve(null);
return;
}
const latest_update_version = json[0]['name'];
resolve(latest_update_version);
if (current_version !== latest_update_version || default_downloader !== current_downloader) {
// versions different or different downloader is being used, download new update
resolve(latest_update_version);
} else {
resolve(null);
}
return;
})
.catch(err => {
logger.error(`Failed to check ${youtubedl_fork} version for an update.`)
logger.error(`Failed to check ${default_downloader} version for an update.`)
logger.error(err);
resolve(null);
return;
});
});
}
function updateDetailsJSON(new_version, fork, output_path) {
exports.updateYoutubeDL = async (latest_update_version) => {
const default_downloader = config_api.getConfigItem('ytdl_default_downloader');
await download_sources[default_downloader]['func'](latest_update_version);
}
async function downloadLatestYoutubeDLBinary(new_version) {
const file_ext = is_windows ? '.exe' : '';
const details_json = fs.existsSync(CONSTS.DETAILS_BIN_PATH) ? fs.readJSONSync(CONSTS.DETAILS_BIN_PATH) : {};
if (!details_json[fork]) details_json[fork] = {};
const fork_json = details_json[fork];
fork_json['version'] = new_version;
fork_json['downloader'] = fork;
fork_json['path'] = output_path; // unused
fork_json['exec'] = fork + file_ext; // unused
const download_url = `https://github.com/ytdl-org/youtube-dl/releases/latest/download/youtube-dl${file_ext}`;
const output_path = `node_modules/youtube-dl/bin/youtube-dl${file_ext}`;
await utils.fetchFile(download_url, output_path, `youtube-dl ${new_version}`);
updateDetailsJSON(new_version, 'youtube-dl');
}
async function downloadLatestYoutubeDLCBinary(new_version) {
const file_ext = is_windows ? '.exe' : '';
const download_url = `https://github.com/blackjack4494/yt-dlc/releases/latest/download/youtube-dlc${file_ext}`;
const output_path = `node_modules/youtube-dl/bin/youtube-dl${file_ext}`;
await utils.fetchFile(download_url, output_path, `youtube-dlc ${new_version}`);
updateDetailsJSON(new_version, 'youtube-dlc');
}
async function downloadLatestYoutubeDLPBinary(new_version) {
const file_ext = is_windows ? '.exe' : '';
const download_url = `https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp${file_ext}`;
const output_path = `node_modules/youtube-dl/bin/youtube-dl${file_ext}`;
await utils.fetchFile(download_url, output_path, `yt-dlp ${new_version}`);
updateDetailsJSON(new_version, 'yt-dlp');
}
function updateDetailsJSON(new_version, downloader) {
const details_json = fs.readJSONSync(CONSTS.DETAILS_BIN_PATH);
if (new_version) details_json['version'] = new_version;
details_json['downloader'] = downloader;
fs.writeJSONSync(CONSTS.DETAILS_BIN_PATH, details_json);
}

View File

@@ -1,6 +1,6 @@
apiVersion: v2
name: youtubedl-material
description: A Helm chart for https://github.com/Tzahi12345/YoutubeDL-Material
description: A Helm chart for Kubernetes
# A chart can be either an 'application' or a 'library' chart.
#
@@ -15,10 +15,10 @@ type: application
# This is the chart version. This version number should be incremented each time you make changes
# to the chart and its templates, including the app version.
# Versions are expected to follow Semantic Versioning (https://semver.org/)
version: 0.2.0
version: 0.1.0
# This is the version number of the application being deployed. This version number should be
# incremented each time you make changes to the application. Versions are not expected to
# follow Semantic Versioning. They should reflect the version the application is using.
# It is recommended to use it with quotes.
appVersion: "4.3.2"
appVersion: "4.2"

View File

@@ -1,14 +1,7 @@
{{- if .Values.ingress.enabled -}}
{{- $fullName := include "youtubedl-material.fullname" . -}}
{{- $svcPort := .Values.service.port -}}
{{- if and .Values.ingress.className (not (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion)) }}
{{- if not (hasKey .Values.ingress.annotations "kubernetes.io/ingress.class") }}
{{- $_ := set .Values.ingress.annotations "kubernetes.io/ingress.class" .Values.ingress.className}}
{{- end }}
{{- end }}
{{- if semverCompare ">=1.19-0" .Capabilities.KubeVersion.GitVersion -}}
apiVersion: networking.k8s.io/v1
{{- else if semverCompare ">=1.14-0" .Capabilities.KubeVersion.GitVersion -}}
{{- if semverCompare ">=1.14-0" .Capabilities.KubeVersion.GitVersion -}}
apiVersion: networking.k8s.io/v1beta1
{{- else -}}
apiVersion: extensions/v1beta1
@@ -23,9 +16,6 @@ metadata:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
{{- if and .Values.ingress.className (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion) }}
ingressClassName: {{ .Values.ingress.className }}
{{- end }}
{{- if .Values.ingress.tls }}
tls:
{{- range .Values.ingress.tls }}
@@ -43,19 +33,9 @@ spec:
paths:
{{- range .paths }}
- path: {{ .path }}
{{- if and .pathType (semverCompare ">=1.18-0" $.Capabilities.KubeVersion.GitVersion) }}
pathType: {{ .pathType }}
{{- end }}
backend:
{{- if semverCompare ">=1.19-0" $.Capabilities.KubeVersion.GitVersion }}
service:
name: {{ $fullName }}
port:
number: {{ $svcPort }}
{{- else }}
serviceName: {{ $fullName }}
servicePort: {{ $svcPort }}
{{- end }}
{{- end }}
{{- end }}
{{- end }}
{{- end }}

28
chrome-extension.pem Normal file
View File

@@ -0,0 +1,28 @@
-----BEGIN PRIVATE KEY-----
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDMX9Wk5SM5cIfY
6ReKX3ybY1rsbNbOzG8ceN7yyeXB0mor8pVsX1MOna2HewOyBuaaYNJRO4tJBxic
7a8zQErfgHL/i/QrVvVCpfJ7xKvq6zij5NYoqd/FBUwawqjeH5/voIcAp9z5Vmsr
kL0sxJUKy6b4IWNp3noU7Nvq2RwxnXQbKDhz8FrX6oQAnDC6gsG5a2OSPsaE4oqw
6nmonORJypmpP5hqyHY8ffXBT2lAxjHT7OnYbaCBe2TQP8+rH6rDBOhjVNtUJ089
ocTQL6LtQEPkcF4yKJmtcOwHl8OPGZs5l9i8xb4j9RuSPkm2lbzZX8sOsdGGoqJZ
q68nYhsHAgMBAAECggEAXmtKEzfPObq88B/kAcgSk+FngMHZzcmR7bgD3GwdSxnQ
dkRI9zvk7eQ35tcUwntAr4Lat6/ILjFqlBmVLxrdXHuF5Xz9jcZLYgKzz61xdYM9
dC6FKF0u5eGIIvbauGAo7jaeGFX1F3Zu5b4lP9kEOGwU1B7sxF0FzsQM5+dtCJgv
We/hWQeF+9gtoVnkCSS/Mq2p0UomXXHW0Bz4+HuHlTR9aiYbviYnotABiLUhZyzt
v5yUaktb9qniBfdLpRlq8cp06xYlTEA9gJpa4Pnok8OWUsbAiW6EiXUSaZ/cchVa
AnO8WWYvVOnnt6WHI3+QdFTnqVjE5TBX4N/7bVhHGQKBgQD0dtbFqp7vZK/jVYvE
z0WPdySOg2ZDmoSfk5ZlR1+Y9zWToHv0qu8zqoOjL8Ubxrh9fGlOow+cCVdkEuaa
jWC2AWetuRvW0Z5A3XMXr0/N/1fgOkTqtp3WNrUPjVJahEg3lN+90opgFoT8swSi
s1oxW0oLcVIlrjhGBXAPCfsAuQKBgQDWBLRhHsRAvGcK5wGuVnxVApTIyBOermsW
3bJt+7+UI+4sYrBAwkWdQG93IG0cQtn48TEPBgmR2fjRF5IFT9M4/u+QOeeByT7I
we7nVtHgSY5ByC9N0mjWbcmSg8fktz/LonjldNC4kWdOFb75fxGf8kOGS5rUaMA4
zHucfB6ZvwKBgQCPHJrysMXGY21MaqIeHzEboaX3ABl37hdBzAa5V6UxSVdGCydF
vmO2HVZey/JaJmWOoKyNaowSzq0oWqBBTg6VvhDR9JHFmoVId9uOvAS+FYN+Mt5x
gWK5KuGoLxVNBC+6yh6JY526TrSfsrU+Aj0Es+qO9FIg2PL8muZVB4S3kQKBgH/5
CDMaxpc/EQ5/2413wZjDllwI51J3USm3Hz6Mzp2ybnSz/lh60k2Zfg1polTH1Lb6
4i7tmUNRZ2sAARyUAuWN64n+VeRRhe1dqZFDZPQMh7fmEAMk0fOGaoXlrt2ghdEq
Mchi9Xun1nHmpu9hgBR4NNBU3RwuFuLfwvprbZDZAoGAWa62QJChE86xQGP1MrL2
SbIzw3cfeP5xdQ3MKldJiy5IkbMR7Z13WZ7FwvPTy0g/onLHD1rqlm1kUMsGRHpD
5vH06PNpKXQ6x8BYaRGtE6P39jLycO/X+WK/lYTrWo1bR+mGCebDh4B5XrwT3gI6
x4Gvz134pZCTyQCf5JCwbQs=
-----END PRIVATE KEY-----

File diff suppressed because one or more lines are too long

36
docker-build.sh Normal file
View File

@@ -0,0 +1,36 @@
#!/bin/sh
# THANK YOU TALULAH (https://github.com/nottalulah) for your help in figuring this out
# and also optimizing some code with this commit.
# xoxo :D
# set -xeuo pipefail
case $(uname -m) in
x86_64)
ARCH=amd64;;
aarch64)
ARCH=arm64;;
armhf)
ARCH=armhf;;
armv7)
ARCH=armel;;
armv7l)
ARCH=armel;;
*)
echo "Unsupported architecture: $(uname -m)"
exit 1
esac
echo "Architecture: $ARCH"
curl --connect-timeout 5 \
--max-time 10 \
--retry 5 \
--retry-delay 0 \
--retry-max-time 40 \
"https://johnvansickle.com/ffmpeg/builds/ffmpeg-git-${ARCH}-static.tar.xz" -o ffmpeg.txz
mkdir /tmp/ffmpeg
tar xf ffmpeg.txz -C /tmp/ffmpeg
cp /tmp/ffmpeg/*/ffmpeg /usr/local/bin/ffmpeg
cp /tmp/ffmpeg/*/ffprobe /usr/local/bin/ffprobe
rm -rf /tmp/ffmpeg ffmpeg.txz

View File

@@ -2,6 +2,7 @@ version: "2"
services:
ytdl_material:
environment:
ALLOW_CONFIG_MUTATIONS: 'true'
ytdl_mongodb_connection_string: 'mongodb://ytdl-mongo-db:27017'
ytdl_use_local_db: 'false'
write_ytdl_config: 'true'
@@ -16,13 +17,14 @@ services:
- ./users:/app/users
ports:
- "8998:17442"
image: tzahi12345/youtubedl-material:latest
image: tzahi12345/youtubedl-material:nightly
ytdl-mongo-db:
# If you are using a Raspberry Pi, use mongo:4.4.18
image: mongo:4
image: mongo
ports:
- "27017:27017"
logging:
driver: "none"
container_name: mongo-db
restart: always
volumes:
- ./db/:/data/db
- ./db/:/data/db

View File

@@ -1,69 +0,0 @@
import platform
import requests
import shutil
import os
import re
import sys
from collections import OrderedDict
from github import Github
machine = platform.machine()
# https://stackoverflow.com/questions/45125516/possible-values-for-uname-m
MACHINES_TO_ZIP = OrderedDict([
("x86_64", "Linux-x64"),
("aarch64", "LinuxArm64"),
("armv8", "LinuxArm64"),
("arm", "LinuxArm"),
("AMD64", "Windows-x64")
])
def getZipName():
for possibleMachine, possibleZipName in MACHINES_TO_ZIP.items():
if possibleMachine in machine:
return possibleZipName
def getLatestFileInRepo(repo, search_string):
# Create an unauthenticated instance of the Github object
g = Github(os.environ.get('GH_TOKEN'))
# Replace with the repository owner and name
repo = g.get_repo(repo)
# Get all releases of the repository
releases = repo.get_releases()
# Loop through the releases in reverse order (from latest to oldest)
for release in list(releases):
# Get the release assets (files attached to the release)
assets = release.get_assets()
# Loop through the assets
for asset in assets:
if re.search(search_string, asset.name):
print(f'Downloading: {asset.name}')
response = requests.get(asset.browser_download_url)
with open(asset.name, 'wb') as f:
f.write(response.content)
print(f'Download complete: {asset.name}. Unzipping...')
shutil.unpack_archive(asset.name, './')
print(f'Unzipping complete!')
os.remove(asset.name)
break
else:
continue
break
else:
# If no matching release is found, print a message
print(f'No release found with {search_string}')
def getLatestCLIRelease():
zipName = getZipName()
if not zipName:
print(f"GetTwitchDownloader.py could not get valid path for '{machine}'. Exiting...")
sys.exit(1)
searchString = r'.*CLI.*' + zipName
getLatestFileInRepo("lay295/TwitchDownloader", searchString)
getLatestCLIRelease()

View File

@@ -1,39 +0,0 @@
#!/bin/sh
# THANK YOU TALULAH (https://github.com/nottalulah) for your help in figuring this out
# and also optimizing some code with this commit.
# xoxo :D
case $(uname -m) in
x86_64)
ARCH=Linux-x64;;
aarch64)
ARCH=LinuxArm64;;
armhf)
ARCH=LinuxArm;;
armv7)
ARCH=LinuxArm;;
armv7l)
ARCH=LinuxArm;;
*)
echo "Unsupported architecture: $(uname -m)"
exit 1
esac
echo "(INFO) Architecture detected: $ARCH"
echo "(1/5) READY - Install unzip"
apt-get update && apt-get -y install unzip curl jq libicu70
VERSION=$(curl --silent "https://api.github.com/repos/lay295/TwitchDownloader/releases" | jq -r --arg arch "$ARCH" '[.[] | select(.assets | length > 0) | select(.assets[].name | contains("CLI") and contains($arch))] | max_by(.published_at) | .tag_name')
echo "(2/5) DOWNLOAD - Acquire twitchdownloader"
curl -o twitchdownloader.zip \
--connect-timeout 5 \
--max-time 120 \
--retry 5 \
--retry-delay 0 \
--retry-max-time 40 \
-L "https://github.com/lay295/TwitchDownloader/releases/download/$VERSION/TwitchDownloaderCLI-$VERSION-$ARCH.zip"
unzip twitchdownloader.zip
chmod +x TwitchDownloaderCLI
echo "(3/5) Smoke test"
./TwitchDownloaderCLI --help
cp ./TwitchDownloaderCLI /usr/local/bin/TwitchDownloaderCLI

View File

@@ -1,43 +0,0 @@
#!/bin/sh
# THANK YOU TALULAH (https://github.com/nottalulah) for your help in figuring this out
# and also optimizing some code with this commit.
# xoxo :D
case $(uname -m) in
x86_64)
ARCH=amd64;;
aarch64)
ARCH=arm64;;
armhf)
ARCH=armhf;;
armv7)
ARCH=armel;;
armv7l)
ARCH=armel;;
*)
echo "Unsupported architecture: $(uname -m)"
exit 1
esac
echo "(INFO) Architecture detected: $ARCH"
echo "(1/5) READY - Acquire temp dependencies in ffmpeg obtain layer"
apt-get update && apt-get -y install curl xz-utils
echo "(2/5) DOWNLOAD - Acquire latest ffmpeg and ffprobe from John van Sickle's master-sourced builds in ffmpeg obtain layer"
curl -o ffmpeg.txz \
--connect-timeout 5 \
--max-time 120 \
--retry 5 \
--retry-delay 0 \
--retry-max-time 40 \
"https://johnvansickle.com/ffmpeg/old-releases/ffmpeg-5.1.1-${ARCH}-static.tar.xz"
mkdir /tmp/ffmpeg
tar xf ffmpeg.txz -C /tmp/ffmpeg
echo "(3/5) CLEANUP - Remove temp dependencies from ffmpeg obtain layer"
apt-get -y remove curl xz-utils
apt-get -y autoremove
echo "(4/5) PROVISION - Provide ffmpeg and ffprobe from ffmpeg obtain layer"
cp /tmp/ffmpeg/*/ffmpeg /usr/local/bin/ffmpeg
cp /tmp/ffmpeg/*/ffprobe /usr/local/bin/ffprobe
echo "(5/5) CLEANUP - Remove temporary downloads from ffmpeg obtain layer"
rm -rf /tmp/ffmpeg ffmpeg.txz

View File

@@ -1,3 +0,0 @@
build:
docker:
web: Dockerfile.heroku

20509
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,11 +1,10 @@
{
"name": "youtube-dl-material",
"version": "4.3.2",
"version": "4.2.0",
"license": "MIT",
"scripts": {
"ng": "ng",
"start": "ng serve",
"codespaces": "ng serve --configuration=codespaces",
"build": "ng build --configuration production",
"prebuild": "node src/postbuild.mjs",
"heroku-postbuild": "npm install --prefix backend",
@@ -13,8 +12,7 @@
"lint": "ng lint",
"e2e": "ng e2e",
"electron": "ng build --base-href ./ && electron .",
"generate": "openapi --input ./\"Public API v1.yaml\" --output ./src/api-types --exportCore false --exportServices false --exportModels true",
"i18n-source": "ng extract-i18n --output-path=src/assets/i18n --out-file=messages.en.xlf"
"generate": "openapi --input ./\"Public API v1.yaml\" --output ./src/api-types --exportCore false --exportServices false --exportModels true"
},
"engines": {
"node": "12.3.1",
@@ -22,68 +20,64 @@
},
"private": true,
"dependencies": {
"@angular-devkit/core": "^15.0.1",
"@angular/animations": "^15.0.1",
"@angular/cdk": "^15.0.0",
"@angular/common": "^15.0.1",
"@angular/compiler": "^15.0.1",
"@angular/core": "^15.0.1",
"@angular/forms": "^15.0.1",
"@angular/localize": "^15.0.1",
"@angular/material": "^15.0.0",
"@angular/platform-browser": "^15.0.1",
"@angular/platform-browser-dynamic": "^15.0.1",
"@angular/router": "^15.0.1",
"@angular-devkit/core": "^13.3.3",
"@angular/animations": "^13.3.4",
"@angular/cdk": "^13.3.4",
"@angular/common": "^13.3.4",
"@angular/compiler": "^13.3.4",
"@angular/core": "^13.3.4",
"@angular/forms": "^13.3.4",
"@angular/localize": "^13.3.4",
"@angular/material": "^13.3.4",
"@angular/platform-browser": "^13.3.4",
"@angular/platform-browser-dynamic": "^13.3.4",
"@angular/router": "^13.3.4",
"@fontsource/material-icons": "^4.5.4",
"@ngneat/content-loader": "^7.0.0",
"@videogular/ngx-videogular": "^6.0.0",
"@ngneat/content-loader": "^5.0.0",
"@videogular/ngx-videogular": "^5.0.1",
"core-js": "^2.4.1",
"crypto-js": "^4.1.1",
"file-saver": "^2.0.2",
"filesize": "^10.0.7",
"filesize": "^6.1.0",
"fingerprintjs2": "^2.1.0",
"fs-extra": "^10.0.0",
"material-icons": "^1.10.8",
"nan": "^2.14.1",
"ngx-avatars": "^1.4.1",
"ngx-file-drop": "^15.0.0",
"ng-lazyload-image": "^7.0.1",
"ngx-avatars": "^1.3.1",
"ngx-file-drop": "^13.0.0",
"rxjs": "^6.6.3",
"rxjs-compat": "^6.6.7",
"rxjs-compat": "^6.0.0-rc.0",
"tslib": "^2.0.0",
"typescript": "~4.8.4",
"typescript": "~4.6.3",
"xliff-to-json": "^1.0.4",
"zone.js": "~0.11.4"
},
"devDependencies": {
"@angular-devkit/build-angular": "^15.0.1",
"@angular/cli": "^15.0.1",
"@angular/compiler-cli": "^15.0.1",
"@angular/language-service": "^15.0.1",
"@angular-devkit/build-angular": "^13.3.3",
"@angular/cli": "^13.3.3",
"@angular/compiler-cli": "^13.3.4",
"@angular/language-service": "^13.3.4",
"@types/core-js": "^2.5.2",
"@types/file-saver": "^2.0.1",
"@types/jasmine": "^4.3.1",
"@types/jasmine": "~3.6.0",
"@types/node": "^12.11.1",
"@typescript-eslint/eslint-plugin": "^4.29.0",
"@typescript-eslint/parser": "^4.29.0",
"ajv": "^7.2.4",
"codelyzer": "^6.0.0",
"electron": "^13.6.6",
"eslint": "^7.32.0",
"jasmine-core": "~3.6.0",
"jasmine-spec-reporter": "~5.0.0",
"karma": "~6.4.2",
"karma": "~6.3.16",
"karma-chrome-launcher": "~3.1.0",
"karma-cli": "~1.0.1",
"karma-coverage-istanbul-reporter": "~3.0.2",
"karma-jasmine": "~5.1.0",
"karma-jasmine": "~4.0.0",
"karma-jasmine-html-reporter": "^1.5.0",
"openapi-typescript-codegen": "^0.23.0",
"openapi-typescript-codegen": "^0.21.0",
"protractor": "~7.0.0",
"ts-node": "~3.0.4",
"tslint": "~6.1.0"
},
"overrides": {
"ngx-avatars": {
"@angular/common": "15.0.1",
"@angular/core": "15.0.1"
}
}
}
}

Binary file not shown.

View File

@@ -3,9 +3,7 @@
/* eslint-disable */
export type { AddFileToPlaylistRequest } from './models/AddFileToPlaylistRequest';
export type { Archive } from './models/Archive';
export type { BaseChangePermissionsRequest } from './models/BaseChangePermissionsRequest';
export type { binary } from './models/binary';
export type { body_19 } from './models/body_19';
export type { body_20 } from './models/body_20';
export type { Category } from './models/Category';
@@ -14,8 +12,6 @@ export type { ChangeRolePermissionsRequest } from './models/ChangeRolePermission
export type { ChangeUserPermissionsRequest } from './models/ChangeUserPermissionsRequest';
export type { CheckConcurrentStreamRequest } from './models/CheckConcurrentStreamRequest';
export type { CheckConcurrentStreamResponse } from './models/CheckConcurrentStreamResponse';
export type { CheckSubscriptionRequest } from './models/CheckSubscriptionRequest';
export type { ClearDownloadsRequest } from './models/ClearDownloadsRequest';
export type { ConcurrentStream } from './models/ConcurrentStream';
export type { Config } from './models/Config';
export type { ConfigResponse } from './models/ConfigResponse';
@@ -27,11 +23,8 @@ export type { CropFileSettings } from './models/CropFileSettings';
export type { DatabaseFile } from './models/DatabaseFile';
export { DBBackup } from './models/DBBackup';
export type { DBInfoResponse } from './models/DBInfoResponse';
export type { DeleteAllFilesResponse } from './models/DeleteAllFilesResponse';
export type { DeleteArchiveItemsRequest } from './models/DeleteArchiveItemsRequest';
export type { DeleteCategoryRequest } from './models/DeleteCategoryRequest';
export type { DeleteMp3Mp4Request } from './models/DeleteMp3Mp4Request';
export type { DeleteNotificationRequest } from './models/DeleteNotificationRequest';
export type { DeletePlaylistRequest } from './models/DeletePlaylistRequest';
export type { DeleteSubscriptionFileRequest } from './models/DeleteSubscriptionFileRequest';
export type { DeleteUserRequest } from './models/DeleteUserRequest';
@@ -43,19 +36,16 @@ export type { DownloadResponse } from './models/DownloadResponse';
export type { DownloadTwitchChatByVODIDRequest } from './models/DownloadTwitchChatByVODIDRequest';
export type { DownloadTwitchChatByVODIDResponse } from './models/DownloadTwitchChatByVODIDResponse';
export type { DownloadVideosForSubscriptionRequest } from './models/DownloadVideosForSubscriptionRequest';
export type { File } from './models/File';
export { FileType } from './models/FileType';
export { FileTypeFilter } from './models/FileTypeFilter';
export type { GenerateArgsResponse } from './models/GenerateArgsResponse';
export type { GenerateNewApiKeyResponse } from './models/GenerateNewApiKeyResponse';
export type { GetAllCategoriesResponse } from './models/GetAllCategoriesResponse';
export type { GetAllDownloadsRequest } from './models/GetAllDownloadsRequest';
export type { GetAllDownloadsResponse } from './models/GetAllDownloadsResponse';
export type { GetAllFilesRequest } from './models/GetAllFilesRequest';
export type { GetAllFilesResponse } from './models/GetAllFilesResponse';
export type { GetAllSubscriptionsResponse } from './models/GetAllSubscriptionsResponse';
export type { GetAllTasksResponse } from './models/GetAllTasksResponse';
export type { GetArchivesRequest } from './models/GetArchivesRequest';
export type { GetArchivesResponse } from './models/GetArchivesResponse';
export type { GetDBBackupsResponse } from './models/GetDBBackupsResponse';
export type { GetDownloadRequest } from './models/GetDownloadRequest';
export type { GetDownloadResponse } from './models/GetDownloadResponse';
@@ -69,7 +59,6 @@ export type { GetLogsRequest } from './models/GetLogsRequest';
export type { GetLogsResponse } from './models/GetLogsResponse';
export type { GetMp3sResponse } from './models/GetMp3sResponse';
export type { GetMp4sResponse } from './models/GetMp4sResponse';
export type { GetNotificationsResponse } from './models/GetNotificationsResponse';
export type { GetPlaylistRequest } from './models/GetPlaylistRequest';
export type { GetPlaylistResponse } from './models/GetPlaylistResponse';
export type { GetPlaylistsRequest } from './models/GetPlaylistsRequest';
@@ -80,24 +69,17 @@ export type { GetSubscriptionResponse } from './models/GetSubscriptionResponse';
export type { GetTaskRequest } from './models/GetTaskRequest';
export type { GetTaskResponse } from './models/GetTaskResponse';
export type { GetUsersResponse } from './models/GetUsersResponse';
export type { ImportArchiveRequest } from './models/ImportArchiveRequest';
export type { IncrementViewCountRequest } from './models/IncrementViewCountRequest';
export type { inline_response_200_15 } from './models/inline_response_200_15';
export type { LoginRequest } from './models/LoginRequest';
export type { LoginResponse } from './models/LoginResponse';
export type { Notification } from './models/Notification';
export { NotificationAction } from './models/NotificationAction';
export { NotificationType } from './models/NotificationType';
export type { Playlist } from './models/Playlist';
export type { RegisterRequest } from './models/RegisterRequest';
export type { RegisterResponse } from './models/RegisterResponse';
export type { RestartDownloadResponse } from './models/RestartDownloadResponse';
export type { RestoreDBBackupRequest } from './models/RestoreDBBackupRequest';
export { Schedule } from './models/Schedule';
export type { SetConfigRequest } from './models/SetConfigRequest';
export type { SetNotificationsToReadRequest } from './models/SetNotificationsToReadRequest';
export type { SharingToggle } from './models/SharingToggle';
export type { Sort } from './models/Sort';
export type { SubscribeRequest } from './models/SubscribeRequest';
export type { SubscribeResponse } from './models/SubscribeResponse';
export type { Subscription } from './models/Subscription';
@@ -105,7 +87,6 @@ export type { SubscriptionRequestData } from './models/SubscriptionRequestData';
export type { SuccessObject } from './models/SuccessObject';
export type { TableInfo } from './models/TableInfo';
export type { Task } from './models/Task';
export { TaskType } from './models/TaskType';
export type { TestConnectionStringRequest } from './models/TestConnectionStringRequest';
export type { TestConnectionStringResponse } from './models/TestConnectionStringResponse';
export type { TransferDBRequest } from './models/TransferDBRequest';
@@ -117,15 +98,12 @@ export type { UpdateCategoriesRequest } from './models/UpdateCategoriesRequest';
export type { UpdateCategoryRequest } from './models/UpdateCategoryRequest';
export type { UpdateConcurrentStreamRequest } from './models/UpdateConcurrentStreamRequest';
export type { UpdateConcurrentStreamResponse } from './models/UpdateConcurrentStreamResponse';
export type { UpdateFileRequest } from './models/UpdateFileRequest';
export type { UpdatePlaylistRequest } from './models/UpdatePlaylistRequest';
export type { UpdaterStatus } from './models/UpdaterStatus';
export type { UpdateServerRequest } from './models/UpdateServerRequest';
export type { UpdateTaskDataRequest } from './models/UpdateTaskDataRequest';
export type { UpdateTaskOptionsRequest } from './models/UpdateTaskOptionsRequest';
export type { UpdateTaskScheduleRequest } from './models/UpdateTaskScheduleRequest';
export type { UpdateUserRequest } from './models/UpdateUserRequest';
export type { UploadCookiesRequest } from './models/UploadCookiesRequest';
export type { User } from './models/User';
export { UserPermission } from './models/UserPermission';
export type { Version } from './models/Version';

View File

@@ -2,7 +2,8 @@
/* tslint:disable */
/* eslint-disable */
export type AddFileToPlaylistRequest = {
export interface AddFileToPlaylistRequest {
file_uid: string;
playlist_id: string;
};
}

View File

@@ -1,16 +0,0 @@
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { FileType } from './FileType';
export type Archive = {
extractor: string;
id: string;
type: FileType;
title: string;
user_uid?: string;
sub_id?: string;
timestamp: number;
uid: string;
};

View File

@@ -2,10 +2,10 @@
/* tslint:disable */
/* eslint-disable */
import type { UserPermission } from './UserPermission';
import type { YesNo } from './YesNo';
import { UserPermission } from './UserPermission';
import { YesNo } from './YesNo';
export type BaseChangePermissionsRequest = {
export interface BaseChangePermissionsRequest {
permission: UserPermission;
new_value: YesNo;
};
}

View File

@@ -2,9 +2,9 @@
/* tslint:disable */
/* eslint-disable */
import type { CategoryRule } from './CategoryRule';
import { CategoryRule } from './CategoryRule';
export type Category = {
export interface Category {
name?: string;
uid?: string;
rules?: Array<CategoryRule>;
@@ -12,4 +12,4 @@ export type Category = {
* Overrides file output for downloaded files in category
*/
custom_output?: string;
};
}

View File

@@ -2,10 +2,11 @@
/* tslint:disable */
/* eslint-disable */
export type CategoryRule = {
export interface CategoryRule {
preceding_operator?: CategoryRule.preceding_operator;
comparator?: CategoryRule.comparator;
};
}
export namespace CategoryRule {
@@ -22,4 +23,4 @@ export namespace CategoryRule {
}
}
}

View File

@@ -2,8 +2,8 @@
/* tslint:disable */
/* eslint-disable */
import type { BaseChangePermissionsRequest } from './BaseChangePermissionsRequest';
import { BaseChangePermissionsRequest } from './BaseChangePermissionsRequest';
export type ChangeRolePermissionsRequest = (BaseChangePermissionsRequest & {
role: string;
});
export interface ChangeRolePermissionsRequest extends BaseChangePermissionsRequest {
role: string;
}

View File

@@ -2,8 +2,8 @@
/* tslint:disable */
/* eslint-disable */
import type { BaseChangePermissionsRequest } from './BaseChangePermissionsRequest';
import { BaseChangePermissionsRequest } from './BaseChangePermissionsRequest';
export type ChangeUserPermissionsRequest = (BaseChangePermissionsRequest & {
user_uid: string;
});
export interface ChangeUserPermissionsRequest extends BaseChangePermissionsRequest {
user_uid: string;
}

View File

@@ -2,9 +2,10 @@
/* tslint:disable */
/* eslint-disable */
export type CheckConcurrentStreamRequest = {
export interface CheckConcurrentStreamRequest {
/**
* UID of the concurrent stream
*/
uid: string;
};
}

View File

@@ -2,8 +2,8 @@
/* tslint:disable */
/* eslint-disable */
import type { ConcurrentStream } from './ConcurrentStream';
import { ConcurrentStream } from './ConcurrentStream';
export type CheckConcurrentStreamResponse = {
export interface CheckConcurrentStreamResponse {
stream: ConcurrentStream;
};
}

View File

@@ -1,7 +0,0 @@
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type CheckSubscriptionRequest = {
sub_id: string;
};

View File

@@ -1,9 +0,0 @@
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type ClearDownloadsRequest = {
clear_finished?: boolean;
clear_paused?: boolean;
clear_errors?: boolean;
};

View File

@@ -2,8 +2,9 @@
/* tslint:disable */
/* eslint-disable */
export type ConcurrentStream = {
export interface ConcurrentStream {
playback_timestamp?: number;
unix_timestamp?: number;
playing?: boolean;
};
}

View File

@@ -2,6 +2,7 @@
/* tslint:disable */
/* eslint-disable */
export type Config = {
export interface Config {
YoutubeDLMaterial: any;
};
}

View File

@@ -2,9 +2,9 @@
/* tslint:disable */
/* eslint-disable */
import type { Config } from './Config';
import { Config } from './Config';
export type ConfigResponse = {
export interface ConfigResponse {
config_file: Config;
success: boolean;
};
}

View File

@@ -2,6 +2,7 @@
/* tslint:disable */
/* eslint-disable */
export type CreateCategoryRequest = {
export interface CreateCategoryRequest {
name: string;
};
}

View File

@@ -2,9 +2,9 @@
/* tslint:disable */
/* eslint-disable */
import type { Category } from './Category';
import { Category } from './Category';
export type CreateCategoryResponse = {
export interface CreateCategoryResponse {
new_category?: Category;
success?: boolean;
};
}

View File

@@ -2,8 +2,11 @@
/* tslint:disable */
/* eslint-disable */
export type CreatePlaylistRequest = {
import { FileType } from './FileType';
export interface CreatePlaylistRequest {
playlistName: string;
uids: Array<string>;
type: FileType;
thumbnailURL: string;
};
}

View File

@@ -2,9 +2,9 @@
/* tslint:disable */
/* eslint-disable */
import type { Playlist } from './Playlist';
import { Playlist } from './Playlist';
export type CreatePlaylistResponse = {
export interface CreatePlaylistResponse {
new_playlist: Playlist;
success: boolean;
};
}

View File

@@ -2,7 +2,8 @@
/* tslint:disable */
/* eslint-disable */
export type CropFileSettings = {
export interface CropFileSettings {
cropFileStart: number;
cropFileEnd: number;
};
}

View File

@@ -2,12 +2,13 @@
/* tslint:disable */
/* eslint-disable */
export type DBBackup = {
export interface DBBackup {
name: string;
timestamp: number;
size: number;
source: DBBackup.source;
};
}
export namespace DBBackup {
@@ -17,4 +18,4 @@ export namespace DBBackup {
}
}
}

View File

@@ -2,18 +2,17 @@
/* tslint:disable */
/* eslint-disable */
import type { TableInfo } from './TableInfo';
import { TableInfo } from './TableInfo';
export type DBInfoResponse = {
export interface DBInfoResponse {
using_local_db?: boolean;
stats_by_table?: {
files?: TableInfo;
playlists?: TableInfo;
categories?: TableInfo;
subscriptions?: TableInfo;
users?: TableInfo;
roles?: TableInfo;
download_queue?: TableInfo;
archives?: TableInfo;
};
files?: TableInfo,
playlists?: TableInfo,
categories?: TableInfo,
subscriptions?: TableInfo,
users?: TableInfo,
roles?: TableInfo,
download_queue?: TableInfo,
};
}

View File

@@ -2,16 +2,11 @@
/* tslint:disable */
/* eslint-disable */
import type { Category } from './Category';
export type DatabaseFile = {
export interface DatabaseFile {
id: string;
title: string;
/**
* Backup if thumbnailPath is not defined
*/
thumbnailURL: string;
thumbnailPath?: string;
isAudio: boolean;
/**
* In seconds
@@ -19,27 +14,9 @@ export type DatabaseFile = {
duration: number;
url: string;
uploader: string;
/**
* In bytes
*/
size: number;
path: string;
upload_date: string;
uid: string;
user_uid?: string;
sharingEnabled?: boolean;
category?: Category;
view_count?: number;
local_view_count?: number;
sub_id?: string;
registered?: number;
/**
* In pixels, only for videos
*/
height?: number;
/**
* In Kbps
*/
abr?: number;
favorite: boolean;
};
}

View File

@@ -1,14 +0,0 @@
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type DeleteAllFilesResponse = {
/**
* Number of files found matching search parameters
*/
file_count?: number;
/**
* Number of files removed
*/
delete_count?: number;
};

View File

@@ -1,9 +0,0 @@
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { Archive } from './Archive';
export type DeleteArchiveItemsRequest = {
archives: Array<Archive>;
};

View File

@@ -2,6 +2,7 @@
/* tslint:disable */
/* eslint-disable */
export type DeleteCategoryRequest = {
export interface DeleteCategoryRequest {
category_uid: string;
};
}

View File

@@ -2,7 +2,8 @@
/* tslint:disable */
/* eslint-disable */
export type DeleteMp3Mp4Request = {
export interface DeleteMp3Mp4Request {
uid: string;
blacklistMode?: boolean;
};
}

View File

@@ -1,7 +0,0 @@
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type DeleteNotificationRequest = {
uid: string;
};

View File

@@ -2,6 +2,9 @@
/* tslint:disable */
/* eslint-disable */
export type DeletePlaylistRequest = {
import { FileType } from './FileType';
export interface DeletePlaylistRequest {
playlist_id: string;
};
type: FileType;
}

View File

@@ -2,10 +2,14 @@
/* tslint:disable */
/* eslint-disable */
export type DeleteSubscriptionFileRequest = {
file_uid: string;
import { SubscriptionRequestData } from './SubscriptionRequestData';
export interface DeleteSubscriptionFileRequest {
file: string;
file_uid?: string;
sub: SubscriptionRequestData;
/**
* If true, does not remove id from archive. Only valid if youtube-dl archive is enabled in settings.
*/
deleteForever?: boolean;
};
}

View File

@@ -2,6 +2,7 @@
/* tslint:disable */
/* eslint-disable */
export type DeleteUserRequest = {
export interface DeleteUserRequest {
uid: string;
};
}

View File

@@ -2,6 +2,6 @@
/* tslint:disable */
/* eslint-disable */
export type UploadCookiesRequest = {
cookies: Blob;
};
export type Dictionary<T> = {
[key: string]: T;
}

View File

@@ -2,13 +2,13 @@
/* tslint:disable */
/* eslint-disable */
export type Download = {
export interface Download {
uid: string;
ui_uid?: string;
running: boolean;
finished: boolean;
paused: boolean;
cancelled?: boolean;
finished_step: boolean;
url: string;
type: string;
@@ -20,12 +20,7 @@ export type Download = {
* Error text, set if download fails.
*/
error?: string | null;
/**
* Error type, may or may not be set in case of an error
*/
error_type?: string | null;
user_uid?: string;
sub_id?: string;
sub_name?: string;
prefetched_info?: any;
};
}

View File

@@ -2,9 +2,9 @@
/* tslint:disable */
/* eslint-disable */
import type { FileType } from './FileType';
export type DownloadArchiveRequest = {
type?: FileType;
sub_id?: string;
export interface DownloadArchiveRequest {
sub: {
archive_dir: string,
};
}

View File

@@ -2,13 +2,13 @@
/* tslint:disable */
/* eslint-disable */
import type { FileType } from './FileType';
import { FileType } from './FileType';
export type DownloadFileRequest = {
export interface DownloadFileRequest {
uid?: string;
uuid?: string;
sub_id?: string;
playlist_id?: string;
url?: string;
type?: FileType;
};
}

View File

@@ -2,10 +2,10 @@
/* tslint:disable */
/* eslint-disable */
import type { CropFileSettings } from './CropFileSettings';
import type { FileType } from './FileType';
import { CropFileSettings } from './CropFileSettings';
import { FileType } from './FileType';
export type DownloadRequest = {
export interface DownloadRequest {
url: string;
/**
* Video format code. Overrides other quality options.
@@ -35,18 +35,10 @@ export type DownloadRequest = {
* Height of the video, if known
*/
selectedHeight?: string;
/**
* Max height that should be used, useful for playlists. selectedHeight will override this.
*/
maxHeight?: string;
/**
* Specify ffmpeg/avconv audio quality
*/
maxBitrate?: string;
type?: FileType;
cropFileSettings?: CropFileSettings;
/**
* If using youtube-dl archive, download will ignore it
*/
ignoreArchive?: boolean;
};
}

View File

@@ -2,8 +2,8 @@
/* tslint:disable */
/* eslint-disable */
import type { Download } from './Download';
import { Download } from './Download';
export type DownloadResponse = {
export interface DownloadResponse {
download?: Download;
};
}

View File

@@ -2,10 +2,10 @@
/* tslint:disable */
/* eslint-disable */
import type { FileType } from './FileType';
import type { Subscription } from './Subscription';
import { FileType } from './FileType';
import { Subscription } from './Subscription';
export type DownloadTwitchChatByVODIDRequest = {
export interface DownloadTwitchChatByVODIDRequest {
/**
* File ID
*/
@@ -20,4 +20,4 @@ export type DownloadTwitchChatByVODIDRequest = {
*/
uuid?: string;
sub?: Subscription;
};
}

Some files were not shown because too many files have changed in this diff Show More