Compare commits

..

3 Commits

Author SHA1 Message Date
Isaac Abadi
c789ba9553 Server autocloses on crash
Thumbnails are now retrieved using file UID
2021-07-31 15:51:16 -06:00
Isaac Abadi
b8e1117ff6 Removed all __dirname references in backend to allow for electron to boot 2021-07-28 21:14:32 -06:00
Isaac Abadi
b64a001ae1 Electron almost boots, but errors presumably due to a filesystem issue (missing folder?) 2021-07-28 19:17:08 -06:00
299 changed files with 20509 additions and 45067 deletions

View File

@@ -1,7 +0,0 @@
.git
db
appdata
audio
video
subscriptions
users

View File

@@ -1,20 +0,0 @@
{
"env": {
"browser": true,
"es2021": true
},
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended"
],
"parser": "@typescript-eslint/parser",
"parserOptions": {
"ecmaVersion": 12,
"sourceType": "module"
},
"plugins": [
"@typescript-eslint"
],
"rules": {
}
}

View File

@@ -27,12 +27,5 @@ If applicable, add screenshots to help explain your problem.
- YoutubeDL-Material version - YoutubeDL-Material version
- Docker tag: <tag> (optional) - Docker tag: <tag> (optional)
Ideally you'd copy the info as presented on the "About" dialogue
in YoutubeDL-Material.
(for that, click on the three dots on the top right and then
check "installation details". On later versions of YoutubeDL-
Material you will find pretty much all the crucial information
here that we need in most cases!)
**Additional context** **Additional context**
Add any other context about the problem here. For example, a YouTube link. Add any other context about the problem here.

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

@@ -25,21 +25,8 @@ jobs:
cd backend cd backend
npm install npm install
sudo npm install -g @angular/cli sudo npm install -g @angular/cli
- 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@1.1.2
with:
name: "version.json"
json: '{"type": "autobuild", "tag": "N/A", "commit": "${{ steps.vars.outputs.sha_short }}", "date": "${{ steps.date.outputs.date }}"}'
dir: 'backend/'
- name: build - name: build
run: npm run build run: ng build --prod
- name: prepare artifact upload - name: prepare artifact upload
shell: pwsh shell: pwsh
run: | run: |
@@ -51,7 +38,7 @@ jobs:
Copy-Item -Path ./backend/public -Recurse -Destination ./build/youtubedl-material Copy-Item -Path ./backend/public -Recurse -Destination ./build/youtubedl-material
Copy-Item -Path ./backend/subscriptions -Recurse -Destination ./build/youtubedl-material Copy-Item -Path ./backend/subscriptions -Recurse -Destination ./build/youtubedl-material
Copy-Item -Path ./backend/video -Recurse -Destination ./build/youtubedl-material Copy-Item -Path ./backend/video -Recurse -Destination ./build/youtubedl-material
New-Item -Path ./build/youtubedl-material -Name users -ItemType Directory New-Item -Path ./build/youtubedl-material -Name users
Copy-Item -Path ./backend/*.js -Destination ./build/youtubedl-material Copy-Item -Path ./backend/*.js -Destination ./build/youtubedl-material
Copy-Item -Path ./backend/*.json -Destination ./build/youtubedl-material Copy-Item -Path ./backend/*.json -Destination ./build/youtubedl-material
- name: upload build artifact - name: upload build artifact

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

@@ -1,27 +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@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@1.1.2
with:
name: "version.json"
json: '{"type": "docker", "tag": "nightly", "commit": "${{ steps.vars.outputs.sha_short }}", "date": "${{ steps.date.outputs.date }}"}'
dir: 'backend/'
- name: Build docker images
run: docker build . -t tzahi12345/youtubedl-material:nightly-pr

View File

@@ -6,75 +6,22 @@ on:
tags: tags:
description: 'Docker tags' description: 'Docker tags'
required: true required: true
release:
types: [published]
jobs: jobs:
build-and-push: build-and-push:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: checkout code - name: checkout code
uses: actions/checkout@v2 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@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.action }} == "workflow_dispatch" ]; 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@v4
with:
images: |
${{ secrets.DOCKERHUB_USERNAME }}/${{ secrets.DOCKERHUB_REPO }}
ghcr.io/${{ github.repository_owner }}/${{ secrets.DOCKERHUB_REPO }}
tags: |
raw=${{ steps.tags.outputs.tags }}
raw=latest
- name: setup platform emulator - name: setup platform emulator
uses: docker/setup-qemu-action@v1 uses: docker/setup-qemu-action@v1
- name: setup multi-arch docker build - name: setup multi-arch docker build
uses: docker/setup-buildx-action@v1 uses: docker/setup-buildx-action@v1
- name: Login to DockerHub - name: Login to DockerHub
uses: docker/login-action@v1 uses: docker/login-action@v1
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GitHub Container Registry
uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: build & push images - name: build & push images
uses: docker/build-push-action@v2 uses: docker/build-push-action@v2
with: with:
@@ -82,5 +29,4 @@ jobs:
file: ./Dockerfile file: ./Dockerfile
platforms: linux/amd64,linux/arm,linux/arm64/v8 platforms: linux/amd64,linux/arm,linux/arm64/v8
push: true push: true
tags: ${{ steps.docker-meta.outputs.tags }} tags: ${{ github.event.inputs.tags }}
labels: ${{ steps.docker-meta.outputs.labels }}

View File

@@ -3,19 +3,6 @@ name: docker
on: on:
push: push:
branches: [master] branches: [master]
paths-ignore:
- '.github/**'
- '.vscode/**'
- 'chrome-extension/**'
- 'releases/**'
- '**/**.md'
- '**.crx'
- '**.pem'
- '.dockerignore'
- '.gitignore'
schedule:
- cron: '34 4 * * 2'
workflow_dispatch:
jobs: jobs:
build-and-push: build-and-push:
@@ -23,58 +10,15 @@ jobs:
steps: steps:
- name: checkout code - name: checkout code
uses: actions/checkout@v2 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@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 }}"}'
dir: 'backend/'
- name: setup platform emulator - name: setup platform emulator
uses: docker/setup-qemu-action@v1 uses: docker/setup-qemu-action@v1
- name: setup multi-arch docker build - name: setup multi-arch docker build
uses: docker/setup-buildx-action@v1 uses: docker/setup-buildx-action@v1
- name: Generate Docker image metadata
id: docker-meta
uses: docker/metadata-action@v4
# 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
- name: Login to DockerHub - name: Login to DockerHub
uses: docker/login-action@v1 uses: docker/login-action@v1
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GitHub Container Registry
uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: build & push images - name: build & push images
uses: docker/build-push-action@v2 uses: docker/build-push-action@v2
with: with:
@@ -82,5 +26,4 @@ jobs:
file: ./Dockerfile file: ./Dockerfile
platforms: linux/amd64,linux/arm,linux/arm64/v8 platforms: linux/amd64,linux/arm,linux/arm64/v8
push: true push: true
tags: ${{ steps.docker-meta.outputs.tags }} tags: tzahi12345/youtubedl-material:nightly
labels: ${{ steps.docker-meta.outputs.labels }}

10
.gitignore vendored
View File

@@ -25,7 +25,6 @@
!.vscode/extensions.json !.vscode/extensions.json
# misc # misc
/.angular/cache
/.sass-cache /.sass-cache
/connect.lock /connect.lock
/coverage /coverage
@@ -67,12 +66,3 @@ backend/appdata/users.json
backend/users/* backend/users/*
backend/appdata/cookies.txt backend/appdata/cookies.txt
backend/public backend/public
src/assets/i18n/*.json
# User Files
db/
appdata/
audio/
video/
subscriptions/
users/

View File

@@ -1,67 +1,48 @@
# Fetching our ffmpeg FROM alpine:3.12 as frontend
FROM ubuntu:22.04 AS ffmpeg
ENV DEBIAN_FRONTEND=noninteractive
# Use script due local build compability
COPY ffmpeg-fetch.sh .
RUN sh ./ffmpeg-fetch.sh
RUN apk add --no-cache \
npm
# Create our Ubuntu 22.04 with node 16
# Go to 20.04
FROM ubuntu:20.04 AS base
ENV DEBIAN_FRONTEND=noninteractive
ENV UID=1000
ENV GID=1000
ENV USER=youtube
ENV NO_UPDATE_NOTIFIER=true
ENV PM2_HOME=/app/pm2
RUN groupadd -g $GID $USER && useradd --system -m -g $USER --uid $UID $USER && \
apt update && \
apt install -y --no-install-recommends curl ca-certificates && \
curl -fsSL https://deb.nodesource.com/setup_16.x | bash - && \
apt install -y --no-install-recommends nodejs && \
npm -g install npm && \
apt clean && \
rm -rf /var/lib/apt/lists/*
# Build frontend
FROM base as frontend
RUN npm install -g @angular/cli RUN npm install -g @angular/cli
WORKDIR /build 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/" ] COPY [ "src/", "/build/src/" ]
RUN npm install && \ RUN ng build --prod
npm run build && \
ls -al /build/backend/public
#--------------#
FROM alpine:3.12
ENV UID=1000 \
GID=1000 \
USER=youtube
ENV NO_UPDATE_NOTIFIER=true
ENV FOREVER_ROOT=/app/.forever
RUN addgroup -S $USER -g $GID && adduser -D -S $USER -G $USER -u $UID
RUN apk add --no-cache \
ffmpeg \
npm \
python2 \
python3 \
su-exec \
&& apk add --no-cache --repository http://dl-cdn.alpinelinux.org/alpine/edge/testing/ \
atomicparsley
# Install backend deps
FROM base as backend
WORKDIR /app WORKDIR /app
COPY [ "backend/","/app/" ] COPY --chown=$UID:$GID [ "backend/package.json", "backend/package-lock.json", "/app/" ]
RUN npm config set strict-ssl false && \ RUN npm install forever -g
npm install --prod && \ RUN npm install && chown -R $UID:$GID ./
ls -al
# Final image
FROM base
RUN apt update && \
apt install -y --no-install-recommends gosu python3-minimal python-is-python3 python3-pip atomicparsley && \
apt clean && \
rm -rf /var/lib/apt/lists/*
RUN pip install tcd
WORKDIR /app
# User 1000 already exist from base image
COPY --chown=$UID:$GID --from=ffmpeg [ "/usr/local/bin/ffmpeg", "/usr/local/bin/ffmpeg" ]
COPY --chown=$UID:$GID --from=ffmpeg [ "/usr/local/bin/ffprobe", "/usr/local/bin/ffprobe" ]
COPY --chown=$UID:$GID --from=backend ["/app/","/app/"]
COPY --chown=$UID:$GID --from=frontend [ "/build/backend/public/", "/app/public/" ] COPY --chown=$UID:$GID --from=frontend [ "/build/backend/public/", "/app/public/" ]
RUN chmod +x /app/fix-scripts/*.sh COPY --chown=$UID:$GID [ "/backend/", "/app/" ]
# Add some persistence data
#VOLUME ["/app/appdata"]
EXPOSE 17442 EXPOSE 17442
ENTRYPOINT [ "/app/entrypoint.sh" ] ENTRYPOINT [ "/app/entrypoint.sh" ]
CMD [ "npm","start" ] CMD [ "forever", "app.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

File diff suppressed because it is too large Load Diff

View File

@@ -6,12 +6,10 @@
[![GitHub issues badge](https://img.shields.io/github/issues/Tzahi12345/YoutubeDL-Material)](https://github.com/Tzahi12345/YoutubeDL-Material/issues) [![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) [![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 13](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! Now with [Docker](#Docker) support!
<hr>
## Getting Started ## Getting Started
Check out the prerequisites, and go to the installation section. Easy as pie! Check out the prerequisites, and go to the installation section. Easy as pie!
@@ -48,7 +46,6 @@ sudo yum install nodejs youtube-dl ffmpeg ffmpeg-devel
Optional dependencies: Optional dependencies:
* AtomicParsley (for embedding thumbnails, package name `atomicparsley`) * AtomicParsley (for embedding thumbnails, package name `atomicparsley`)
* [tcd](https://github.com/PetterKraabol/Twitch-Chat-Downloader) (for downloading Twitch VOD chats)
### Installing ### Installing
@@ -70,7 +67,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. 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 build`. This will build the app, and put the output files in the `youtubedl-material/backend/public` folder. 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 `ng build --prod`. 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`. The frontend is now complete. The backend is much easier. Just go into the `backend` folder, and type `npm start`.
@@ -80,10 +77,6 @@ Alternatively, you can port forward the port specified in the config (defaults t
## Docker ## Docker
### 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)
### Setup ### Setup
If you are looking to setup YoutubeDL-Material with Docker, this section is for you. And you're in luck! Docker setup is quite simple. If you are looking to setup YoutubeDL-Material with Docker, this section is for you. And you're in luck! Docker setup is quite simple.
@@ -93,6 +86,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**. 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! 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 ### 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: 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:
@@ -103,12 +98,6 @@ environment:
GID: YOUR_GID 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
[API Docs](https://youtubedl-material.stoplight.io/docs/youtubedl-material/Public%20API%20v1.yaml) [API Docs](https://youtubedl-material.stoplight.io/docs/youtubedl-material/Public%20API%20v1.yaml)
@@ -117,12 +106,6 @@ To get started, go to the settings menu and enable the public API from the *Extr
Once you have enabled the API and have the key, you can start sending requests by adding the query param `apiKey=API_KEY`. Replace `API_KEY` with your actual API key, and you should be good to go! Nearly all of the backend should be at your disposal. View available endpoints in the link above. Once you have enabled the API and have the key, you can start sending requests by adding the query param `apiKey=API_KEY`. Replace `API_KEY` with your actual API key, and you should be good to go! Nearly all of the backend should be at your disposal. View available endpoints in the link above.
## iOS Shortcut
If you are using iOS, try YoutubeDL-Material more conveniently with a Shortcut. With this Shorcut, you can easily start downloading YouTube video with just two taps! (Or maybe three?)
You can download Shortcut [here.](https://routinehub.co/shortcut/10283/)
## Contributing ## Contributing
If you're interested in contributing, first: awesome! Second, please refer to the guidelines/setup information located in the [Contributing](https://github.com/Tzahi12345/YoutubeDL-Material/wiki/Contributing) wiki page, it's a helpful way to get you on your feet and coding away. If you're interested in contributing, first: awesome! Second, please refer to the guidelines/setup information located in the [Contributing](https://github.com/Tzahi12345/YoutubeDL-Material/wiki/Contributing) wiki page, it's a helpful way to get you on your feet and coding away.
@@ -141,16 +124,12 @@ Official translators:
* German - UnlimitedCookies * German - UnlimitedCookies
* Chinese - TyRoyal * Chinese - TyRoyal
See also the list of [contributors](https://github.com/Tzahi12345/YoutubeDL-Material/graphs/contributors) who participated in this project. See also the list of [contributors](https://github.com/your/project/contributors) who participated in this project.
## License ## License
This project is licensed under the MIT License - see the [LICENSE.md](LICENSE.md) file for details This project is licensed under the MIT License - see the [LICENSE.md](LICENSE.md) file for details
## Legal Disclaimer
This project is in no way affiliated with Google LLC, Alphabet Inc. or YouTube (or their subsidiaries) nor endorsed by them.
## Acknowledgments ## Acknowledgments
* youtube-dl * youtube-dl

View File

@@ -1,21 +0,0 @@
# Security Policy
## Supported Versions
If you would like to see the latest updates, use the `nightly` tag on Docker.
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: |
## Reporting a Vulnerability
Please file an issue in our GitHub's repo, because this app
isn't meant to be safe to run as public instance yet, but rather as a LAN facing app.
We welcome PRs and help in general in making YTDL-M more secure, but it's not a priority as of now.

View File

@@ -17,6 +17,7 @@
"build": { "build": {
"builder": "@angular-devkit/build-angular:browser", "builder": "@angular-devkit/build-angular:browser",
"options": { "options": {
"aot": true,
"outputPath": "backend/public", "outputPath": "backend/public",
"index": "src/index.html", "index": "src/index.html",
"main": "src/main.ts", "main": "src/main.ts",
@@ -30,20 +31,9 @@
"src/backend" "src/backend"
], ],
"styles": [ "styles": [
"src/styles.scss", "src/styles.scss"
"src/bootstrap.min.css"
], ],
"scripts": [], "scripts": []
"vendorChunk": true,
"extractLicenses": false,
"buildOptimizer": false,
"sourceMap": true,
"optimization": false,
"namedChunks": true,
"allowedCommonJsDependencies": [
"rxjs",
"crypto-js"
]
}, },
"configurations": { "configurations": {
"production": { "production": {
@@ -56,6 +46,7 @@
"optimization": true, "optimization": true,
"outputHashing": "all", "outputHashing": "all",
"namedChunks": false, "namedChunks": false,
"aot": true,
"extractLicenses": true, "extractLicenses": true,
"vendorChunk": false, "vendorChunk": false,
"buildOptimizer": true, "buildOptimizer": true,
@@ -69,8 +60,7 @@
"es": { "es": {
"localize": ["es"] "localize": ["es"]
} }
}, }
"defaultConfiguration": ""
}, },
"serve": { "serve": {
"builder": "@angular-devkit/build-angular:dev-server", "builder": "@angular-devkit/build-angular:dev-server",
@@ -119,8 +109,7 @@
"src/backend" "src/backend"
], ],
"styles": [ "styles": [
"src/styles.scss", "src/styles.scss"
"src/bootstrap.min.css"
], ],
"scripts": [] "scripts": []
}, },
@@ -153,8 +142,7 @@
"tsConfig": "src/tsconfig.spec.json", "tsConfig": "src/tsconfig.spec.json",
"scripts": [], "scripts": [],
"styles": [ "styles": [
"src/styles.scss", "src/styles.scss"
"src/bootstrap.min.css"
], ],
"assets": [ "assets": [
"src/assets", "src/assets",
@@ -164,6 +152,16 @@
"src/backend" "src/backend"
] ]
} }
},
"lint": {
"builder": "@angular-devkit/build-angular:tslint",
"options": {
"tsConfig": [
"src/tsconfig.app.json",
"src/tsconfig.spec.json"
],
"exclude": []
}
} }
} }
}, },
@@ -178,6 +176,15 @@
"protractorConfig": "./protractor.conf.js", "protractorConfig": "./protractor.conf.js",
"devServerTarget": "youtube-dl-material:serve" "devServerTarget": "youtube-dl-material:serve"
} }
},
"lint": {
"builder": "@angular-devkit/build-angular:tslint",
"options": {
"tsConfig": [
"e2e/tsconfig.e2e.json"
],
"exclude": []
}
} }
} }
} }

View File

@@ -2,7 +2,6 @@
"name": "YoutubeDL-Material", "name": "YoutubeDL-Material",
"description": "An open-source and self-hosted YouTube downloader based on Google's Material Design specifications.", "description": "An open-source and self-hosted YouTube downloader based on Google's Material Design specifications.",
"repository": "https://github.com/Tzahi12345/YoutubeDL-Material", "repository": "https://github.com/Tzahi12345/YoutubeDL-Material",
"stack": "container",
"logo": "https://i.imgur.com/GPzvPiU.png", "logo": "https://i.imgur.com/GPzvPiU.png",
"keywords": ["youtube-dl", "youtubedl-material", "nodejs"] "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,18 +0,0 @@
{
"env": {
"node": true,
"es2021": true
},
"extends": [
"eslint:recommended"
],
"parser": "esprima",
"parserOptions": {
"ecmaVersion": 12,
"sourceType": "module"
},
"plugins": [],
"rules": {
},
"root": true
}

File diff suppressed because it is too large Load Diff

View File

@@ -12,16 +12,14 @@
"custom_args": "", "custom_args": "",
"safe_download_override": false, "safe_download_override": false,
"include_thumbnail": true, "include_thumbnail": true,
"include_metadata": true, "include_metadata": true
"max_concurrent_downloads": 5,
"download_rate_limit": ""
}, },
"Extra": { "Extra": {
"title_top": "YoutubeDL-Material", "title_top": "YoutubeDL-Material",
"file_manager_enabled": true, "file_manager_enabled": true,
"allow_quality_select": true, "allow_quality_select": true,
"download_only_mode": false, "download_only_mode": false,
"allow_autoplay": true, "allow_multi_download_mode": true,
"enable_downloads_manager": true, "enable_downloads_manager": true,
"allow_playlist_categorization": true "allow_playlist_categorization": true
}, },
@@ -31,11 +29,8 @@
"use_youtube_API": false, "use_youtube_API": false,
"youtube_API_key": "", "youtube_API_key": "",
"use_twitch_API": false, "use_twitch_API": false,
"twitch_client_ID": "", "twitch_API_key": "",
"twitch_client_secret": "", "twitch_auto_download_chat": false
"twitch_auto_download_chat": false,
"use_sponsorblock_API": false,
"generate_NFO_files": false
}, },
"Themes": { "Themes": {
"default_theme": "default", "default_theme": "default",
@@ -60,11 +55,11 @@
} }
}, },
"Database": { "Database": {
"use_local_db": true, "use_local_db": false,
"mongodb_connection_string": "mongodb://127.0.0.1:27017/?compressors=zlib" "mongodb_connection_string": "mongodb://127.0.0.1:27017/?compressors=zlib"
}, },
"Advanced": { "Advanced": {
"default_downloader": "yt-dlp", "default_downloader": "youtube-dl",
"use_default_downloading_agent": true, "use_default_downloading_agent": true,
"custom_downloading_agent": "", "custom_downloading_agent": "",
"multi_user_mode": false, "multi_user_mode": false,

View File

@@ -1,8 +1,7 @@
const path = require('path');
const config_api = require('../config'); const config_api = require('../config');
const consts = require('../consts'); const consts = require('../consts');
const logger = require('../logger'); const fs = require('fs-extra');
const db_api = require('../db');
const jwt = require('jsonwebtoken'); const jwt = require('jsonwebtoken');
const { uuid } = require('uuidv4'); const { uuid } = require('uuidv4');
const bcrypt = require('bcryptjs'); const bcrypt = require('bcryptjs');
@@ -13,24 +12,20 @@ var JwtStrategy = require('passport-jwt').Strategy,
ExtractJwt = require('passport-jwt').ExtractJwt; ExtractJwt = require('passport-jwt').ExtractJwt;
// other required vars // other required vars
let logger = null;
let db_api = null;
let SERVER_SECRET = null; let SERVER_SECRET = null;
let JWT_EXPIRATION = null; let JWT_EXPIRATION = null;
let opts = null; let opts = null;
let saltRounds = null; let saltRounds = null;
exports.initialize = function () { exports.initialize = function(db_api, input_logger) {
setLogger(input_logger)
setDB(db_api);
/************************* /*************************
* Authentication module * Authentication module
************************/ ************************/
if (db_api.database_initialized) {
setupRoles();
} else {
db_api.database_initialized_bs.subscribe(init => {
if (init) setupRoles();
});
}
saltRounds = 10; saltRounds = 10;
JWT_EXPIRATION = config_api.getConfigItem('ytdl_jwt_expiration'); JWT_EXPIRATION = config_api.getConfigItem('ytdl_jwt_expiration');
@@ -58,39 +53,12 @@ exports.initialize = function () {
})); }));
} }
const setupRoles = async () => { function setLogger(input_logger) {
const required_roles = { logger = input_logger;
admin: { }
permissions: [
'filemanager',
'settings',
'subscriptions',
'sharing',
'advanced_download',
'downloads_manager'
]
},
user: {
permissions: [
'filemanager',
'subscriptions',
'sharing'
]
}
}
const role_keys = Object.keys(required_roles); function setDB(input_db_api) {
for (let i = 0; i < role_keys.length; i++) { db_api = input_db_api;
const role_key = role_keys[i];
const role_in_db = await db_api.getRecord('roles', {key: role_key});
if (!role_in_db) {
// insert task metadata into table if missing
await db_api.insertRecordIntoTable('roles', {
key: role_key,
permissions: required_roles[role_key]['permissions']
});
}
}
} }
exports.passport = require('passport'); exports.passport = require('passport');
@@ -171,12 +139,8 @@ exports.registerUser = async function(req, res) {
exports.login = async (username, password) => { 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}); const user = await db_api.getRecord('users', {name: username});
if (!user) { if (!user) { logger.error(`User ${username} not found`); false }
if (config_api.getConfigItem('ytdl_auth_method') === 'internal') logger.error(`User ${username} not found`);
return false;
}
if (user.auth_method && user.auth_method !== 'internal') { return false } if (user.auth_method && user.auth_method !== 'internal') { return false }
return await bcrypt.compare(password, user.passhash) ? user : false; return await bcrypt.compare(password, user.passhash) ? user : false;
} }
@@ -327,12 +291,17 @@ exports.getUserVideo = async function(user_uid, file_uid, requireSharing = false
return file; return file;
} }
exports.updatePlaylistFiles = function(user_uid, playlistID, new_filenames) {
users_db.get('users').find({uid: user_uid}).get(`playlists`).find({id: playlistID}).assign({fileNames: new_filenames});
return true;
}
exports.removePlaylist = async function(user_uid, playlistID) { exports.removePlaylist = async function(user_uid, playlistID) {
await db_api.removeRecord('playlist', {playlistID: playlistID}); await db_api.removeRecord('playlist', {playlistID: playlistID});
return true; return true;
} }
exports.getUserPlaylists = async function(user_uid) { exports.getUserPlaylists = async function(user_uid, user_files = null) {
return await db_api.getRecords('playlists', {user_uid: user_uid}); return await db_api.getRecords('playlists', {user_uid: user_uid});
} }

View File

@@ -1,6 +1,19 @@
const config_api = require('./config');
const utils = require('./utils'); const utils = require('./utils');
const logger = require('./logger');
const db_api = require('./db'); var logger = null;
var db = null;
var users_db = null;
var db_api = null;
function setDB(input_db, input_users_db, input_db_api) { db = input_db; users_db = input_users_db; db_api = input_db_api }
function setLogger(input_logger) { logger = input_logger; }
function initialize(input_db, input_users_db, input_logger, input_db_api) {
setDB(input_db, input_users_db, input_db_api);
setLogger(input_logger);
}
/* /*
Categories: Categories:
@@ -55,18 +68,17 @@ async function getCategories() {
return categories ? categories : null; return categories ? categories : null;
} }
async function getCategoriesAsPlaylists() { async function getCategoriesAsPlaylists(files = null) {
const categories_as_playlists = []; const categories_as_playlists = [];
const available_categories = await getCategories(); const available_categories = await getCategories();
if (available_categories) { if (available_categories && files) {
for (let category of available_categories) { for (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) { if (files_that_match && files_that_match.length > 0) {
category['thumbnailURL'] = files_that_match[0].thumbnailURL; category['thumbnailURL'] = files_that_match[0].thumbnailURL;
category['thumbnailPath'] = files_that_match[0].thumbnailPath; category['thumbnailPath'] = files_that_match[0].thumbnailPath;
category['duration'] = files_that_match.reduce((a, b) => a + utils.durationStringToNumber(b.duration), 0); category['duration'] = files_that_match.reduce((a, b) => a + utils.durationStringToNumber(b.duration), 0);
category['id'] = category['uid']; category['id'] = category['uid'];
category['auto'] = true;
categories_as_playlists.push(category); categories_as_playlists.push(category);
} }
} }
@@ -113,23 +125,24 @@ function applyCategoryRules(file_json, rules, category_name) {
return rules_apply; return rules_apply;
} }
// async function addTagToVideo(tag, video, user_uid) { async function addTagToVideo(tag, video, user_uid) {
// // TODO: Implement // TODO: Implement
// } }
// async function removeTagFromVideo(tag, video, user_uid) { async function removeTagFromVideo(tag, video, user_uid) {
// // TODO: Implement // TODO: Implement
// } }
// // adds tag to list of existing tags (used for tag suggestions) // adds tag to list of existing tags (used for tag suggestions)
// async function addTagToExistingTags(tag) { async function addTagToExistingTags(tag) {
// const existing_tags = db.get('tags').value(); const existing_tags = db.get('tags').value();
// if (!existing_tags.includes(tag)) { if (!existing_tags.includes(tag)) {
// db.get('tags').push(tag).write(); db.get('tags').push(tag).write();
// } }
// } }
module.exports = { module.exports = {
initialize: initialize,
categorize: categorize, categorize: categorize,
getCategories: getCategories, getCategories: getCategories,
getCategoriesAsPlaylists: getCategoriesAsPlaylists getCategoriesAsPlaylists: getCategoriesAsPlaylists

View File

@@ -1,5 +1,3 @@
const logger = require('./logger');
const fs = require('fs'); const fs = require('fs');
let CONFIG_ITEMS = require('./consts.js')['CONFIG_ITEMS']; let CONFIG_ITEMS = require('./consts.js')['CONFIG_ITEMS'];
@@ -7,7 +5,11 @@ const debugMode = process.env.YTDL_MODE === 'debug';
let configPath = debugMode ? '../src/assets/default.json' : 'appdata/default.json'; let configPath = debugMode ? '../src/assets/default.json' : 'appdata/default.json';
function initialize() { var logger = null;
function setLogger(input_logger) { logger = input_logger; }
function initialize(input_logger) {
setLogger(input_logger);
ensureConfigFileExists(); ensureConfigFileExists();
ensureConfigItemsExist(); ensureConfigItemsExist();
} }
@@ -95,13 +97,13 @@ function getConfigItem(key) {
} }
let path = CONFIG_ITEMS[key]['path']; let path = CONFIG_ITEMS[key]['path'];
const val = Object.byString(config_json, path); const val = Object.byString(config_json, path);
if (val === undefined && Object.byString(DEFAULT_CONFIG, path) !== undefined) { if (val === undefined && Object.byString(DEFAULT_CONFIG, path)) {
logger.warn(`Cannot find config with key '${key}'. Creating one with the default value...`); logger.warn(`Cannot find config with key '${key}'. Creating one with the default value...`);
setConfigItem(key, Object.byString(DEFAULT_CONFIG, path)); setConfigItem(key, Object.byString(DEFAULT_CONFIG, path));
return Object.byString(DEFAULT_CONFIG, path); return Object.byString(DEFAULT_CONFIG, path);
} }
return Object.byString(config_json, path); return Object.byString(config_json, path);
} };
function setConfigItem(key, value) { function setConfigItem(key, value) {
let success = false; let success = false;
@@ -127,7 +129,7 @@ function setConfigItem(key, value) {
success = setConfigFile(config_json); success = setConfigFile(config_json);
return success; return success;
} };
function setConfigItems(items) { function setConfigItems(items) {
let success = false; let success = false;
@@ -173,7 +175,7 @@ module.exports = {
globalArgsRequiresSafeDownload: globalArgsRequiresSafeDownload globalArgsRequiresSafeDownload: globalArgsRequiresSafeDownload
} }
const DEFAULT_CONFIG = { DEFAULT_CONFIG = {
"YoutubeDLMaterial": { "YoutubeDLMaterial": {
"Host": { "Host": {
"url": "http://example.com", "url": "http://example.com",
@@ -187,16 +189,14 @@ const DEFAULT_CONFIG = {
"custom_args": "", "custom_args": "",
"safe_download_override": false, "safe_download_override": false,
"include_thumbnail": true, "include_thumbnail": true,
"include_metadata": true, "include_metadata": true
"max_concurrent_downloads": 5,
"download_rate_limit": ""
}, },
"Extra": { "Extra": {
"title_top": "YoutubeDL-Material", "title_top": "YoutubeDL-Material",
"file_manager_enabled": true, "file_manager_enabled": true,
"allow_quality_select": true, "allow_quality_select": true,
"download_only_mode": false, "download_only_mode": false,
"allow_autoplay": true, "allow_multi_download_mode": true,
"enable_downloads_manager": true, "enable_downloads_manager": true,
"allow_playlist_categorization": true "allow_playlist_categorization": true
}, },
@@ -206,11 +206,8 @@ const DEFAULT_CONFIG = {
"use_youtube_API": false, "use_youtube_API": false,
"youtube_API_key": "", "youtube_API_key": "",
"use_twitch_API": false, "use_twitch_API": false,
"twitch_client_ID": "", "twitch_API_key": "",
"twitch_client_secret": "", "twitch_auto_download_chat": false
"twitch_auto_download_chat": false,
"use_sponsorblock_API": false,
"generate_NFO_files": false
}, },
"Themes": { "Themes": {
"default_theme": "default", "default_theme": "default",
@@ -219,7 +216,7 @@ const DEFAULT_CONFIG = {
"Subscriptions": { "Subscriptions": {
"allow_subscriptions": true, "allow_subscriptions": true,
"subscriptions_base_path": "subscriptions/", "subscriptions_base_path": "subscriptions/",
"subscriptions_check_interval": "86400", "subscriptions_check_interval": "300",
"redownload_fresh_uploads": false "redownload_fresh_uploads": false
}, },
"Users": { "Users": {
@@ -235,11 +232,11 @@ const DEFAULT_CONFIG = {
} }
}, },
"Database": { "Database": {
"use_local_db": true, "use_local_db": false,
"mongodb_connection_string": "mongodb://127.0.0.1:27017/?compressors=zlib" "mongodb_connection_string": "mongodb://127.0.0.1:27017/?compressors=zlib"
}, },
"Advanced": { "Advanced": {
"default_downloader": "yt-dlp", "default_downloader": "youtube-dl",
"use_default_downloading_agent": true, "use_default_downloading_agent": true,
"custom_downloading_agent": "", "custom_downloading_agent": "",
"multi_user_mode": false, "multi_user_mode": false,

View File

@@ -1,4 +1,4 @@
exports.CONFIG_ITEMS = { let CONFIG_ITEMS = {
// Host // Host
'ytdl_url': { 'ytdl_url': {
'key': 'ytdl_url', 'key': 'ytdl_url',
@@ -42,14 +42,6 @@ exports.CONFIG_ITEMS = {
'key': 'ytdl_include_metadata', 'key': 'ytdl_include_metadata',
'path': 'YoutubeDLMaterial.Downloader.include_metadata' 'path': 'YoutubeDLMaterial.Downloader.include_metadata'
}, },
'ytdl_max_concurrent_downloads': {
'key': 'ytdl_max_concurrent_downloads',
'path': 'YoutubeDLMaterial.Downloader.max_concurrent_downloads'
},
'ytdl_download_rate_limit': {
'key': 'ytdl_download_rate_limit',
'path': 'YoutubeDLMaterial.Downloader.download_rate_limit'
},
// Extra // Extra
'ytdl_title_top': { 'ytdl_title_top': {
@@ -68,9 +60,9 @@ exports.CONFIG_ITEMS = {
'key': 'ytdl_download_only_mode', 'key': 'ytdl_download_only_mode',
'path': 'YoutubeDLMaterial.Extra.download_only_mode' 'path': 'YoutubeDLMaterial.Extra.download_only_mode'
}, },
'ytdl_allow_autoplay': { 'ytdl_allow_multi_download_mode': {
'key': 'ytdl_allow_autoplay', 'key': 'ytdl_allow_multi_download_mode',
'path': 'YoutubeDLMaterial.Extra.allow_autoplay' 'path': 'YoutubeDLMaterial.Extra.allow_multi_download_mode'
}, },
'ytdl_enable_downloads_manager': { 'ytdl_enable_downloads_manager': {
'key': 'ytdl_enable_downloads_manager', 'key': 'ytdl_enable_downloads_manager',
@@ -102,27 +94,14 @@ exports.CONFIG_ITEMS = {
'key': 'ytdl_use_twitch_api', 'key': 'ytdl_use_twitch_api',
'path': 'YoutubeDLMaterial.API.use_twitch_API' 'path': 'YoutubeDLMaterial.API.use_twitch_API'
}, },
'ytdl_twitch_client_id': { 'ytdl_twitch_api_key': {
'key': 'ytdl_twitch_client_id', 'key': 'ytdl_twitch_api_key',
'path': 'YoutubeDLMaterial.API.twitch_client_ID' 'path': 'YoutubeDLMaterial.API.twitch_API_key'
},
'ytdl_twitch_client_secret': {
'key': 'ytdl_twitch_client_secret',
'path': 'YoutubeDLMaterial.API.twitch_client_secret'
}, },
'ytdl_twitch_auto_download_chat': { 'ytdl_twitch_auto_download_chat': {
'key': 'ytdl_twitch_auto_download_chat', 'key': 'ytdl_twitch_auto_download_chat',
'path': 'YoutubeDLMaterial.API.twitch_auto_download_chat' 'path': 'YoutubeDLMaterial.API.twitch_auto_download_chat'
}, },
'ytdl_use_sponsorblock_api': {
'key': 'ytdl_use_sponsorblock_api',
'path': 'YoutubeDLMaterial.API.use_sponsorblock_API'
},
'ytdl_generate_nfo_files': {
'key': 'ytdl_generate_nfo_files',
'path': 'YoutubeDLMaterial.API.generate_NFO_files'
},
// Themes // Themes
'ytdl_default_theme': { 'ytdl_default_theme': {
@@ -147,6 +126,10 @@ exports.CONFIG_ITEMS = {
'key': 'ytdl_subscriptions_check_interval', 'key': 'ytdl_subscriptions_check_interval',
'path': 'YoutubeDLMaterial.Subscriptions.subscriptions_check_interval' 'path': 'YoutubeDLMaterial.Subscriptions.subscriptions_check_interval'
}, },
'ytdl_subscriptions_check_interval': {
'key': 'ytdl_subscriptions_check_interval',
'path': 'YoutubeDLMaterial.Subscriptions.subscriptions_check_interval'
},
'ytdl_subscriptions_redownload_fresh_uploads': { 'ytdl_subscriptions_redownload_fresh_uploads': {
'key': 'ytdl_subscriptions_redownload_fresh_uploads', 'key': 'ytdl_subscriptions_redownload_fresh_uploads',
'path': 'YoutubeDLMaterial.Subscriptions.redownload_fresh_uploads' 'path': 'YoutubeDLMaterial.Subscriptions.redownload_fresh_uploads'
@@ -215,7 +198,7 @@ exports.CONFIG_ITEMS = {
} }
}; };
exports.AVAILABLE_PERMISSIONS = [ AVAILABLE_PERMISSIONS = [
'filemanager', 'filemanager',
'settings', 'settings',
'subscriptions', 'subscriptions',
@@ -224,85 +207,8 @@ exports.AVAILABLE_PERMISSIONS = [
'downloads_manager' 'downloads_manager'
]; ];
exports.DETAILS_BIN_PATH = 'node_modules/youtube-dl/bin/details' module.exports = {
CONFIG_ITEMS: CONFIG_ITEMS,
// args that have a value after it (e.g. -o <output> or -f <format>) AVAILABLE_PERMISSIONS: AVAILABLE_PERMISSIONS,
const YTDL_ARGS_WITH_VALUES = [ CURRENT_VERSION: 'v4.2'
'--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'
];
// we're using a Set here for performance
exports.YTDL_ARGS_WITH_VALUES = new Set(YTDL_ARGS_WITH_VALUES);
exports.CURRENT_VERSION = 'v4.3';

View File

@@ -1,31 +1,24 @@
var fs = require('fs-extra') var fs = require('fs-extra')
var path = require('path') var path = require('path')
const { MongoClient } = require("mongodb");
const { uuid } = require('uuidv4');
const config_api = require('./config');
var utils = require('./utils') var utils = require('./utils')
const logger = require('./logger'); const { uuid } = require('uuidv4');
const config_api = require('./config');
const { MongoClient } = require("mongodb");
const low = require('lowdb') const low = require('lowdb')
const FileSync = require('lowdb/adapters/FileSync'); const FileSync = require('lowdb/adapters/FileSync');
const { BehaviorSubject } = require('rxjs');
const local_adapter = new FileSync('./appdata/local_db.json'); const local_adapter = new FileSync('./appdata/local_db.json');
const local_db = low(local_adapter); const local_db = low(local_adapter);
let database = null; var logger = null;
exports.database_initialized = false; var db = null;
exports.database_initialized_bs = new BehaviorSubject(false); var users_db = null;
var database = null;
const tables = { const tables = {
files: { files: {
name: 'files', name: 'files',
primary_key: 'uid', primary_key: 'uid'
text_search: {
title: 'text',
uploader: 'text',
uid: 'text'
}
}, },
playlists: { playlists: {
name: 'playlists', name: 'playlists',
@@ -50,14 +43,6 @@ const tables = {
name: 'roles', name: 'roles',
primary_key: 'key' primary_key: 'key'
}, },
download_queue: {
name: 'download_queue',
primary_key: 'uid'
},
tasks: {
name: 'tasks',
primary_key: 'key'
},
test: { test: {
name: 'test' name: 'test'
} }
@@ -77,14 +62,20 @@ function setDB(input_db, input_users_db) {
exports.users_db = input_users_db exports.users_db = input_users_db
} }
exports.initialize = (input_db, input_users_db) => { function setLogger(input_logger) {
logger = input_logger;
}
exports.initialize = (input_db, input_users_db, input_logger) => {
setDB(input_db, input_users_db); setDB(input_db, input_users_db);
setLogger(input_logger);
// must be done here to prevent getConfigItem from being called before init // must be done here to prevent getConfigItem from being called before init
using_local_db = config_api.getConfigItem('ytdl_use_local_db'); using_local_db = config_api.getConfigItem('ytdl_use_local_db');
} }
exports.connectToDB = async (retries = 5, no_fallback = false, custom_connection_string = null) => { exports.connectToDB = async (retries = 5, no_fallback = false, custom_connection_string = null) => {
if (using_local_db) return;
const success = await exports._connectToDB(custom_connection_string); const success = await exports._connectToDB(custom_connection_string);
if (success) return true; if (success) return true;
@@ -140,13 +131,8 @@ exports._connectToDB = async (custom_connection_string = null) => {
tables_list.forEach(async table => { tables_list.forEach(async table => {
const primary_key = tables[table]['primary_key']; const primary_key = tables[table]['primary_key'];
if (primary_key) { if (!primary_key) return;
await database.collection(table).createIndex({[primary_key]: 1}, { unique: true }); await database.collection(table).createIndex({[primary_key]: 1}, { unique: true });
}
const text_search = tables[table]['text_search'];
if (text_search) {
await database.collection(table).createIndex(text_search);
}
}); });
return true; return true;
} catch(err) { } catch(err) {
@@ -158,17 +144,51 @@ exports._connectToDB = async (custom_connection_string = null) => {
} }
} }
exports.registerFileDB = async (file_path, type, user_uid = null, category = null, sub_id = null, cropFileSettings = null, file_object = null) => { exports.registerFileDB = async (file_path, type, multiUserMode = null, sub = null, customPath = null, category = null, cropFileSettings = null, file_object = null) => {
if (!file_object) file_object = generateFileObject(file_path, type); let db_path = null;
const file_id = utils.removeFileExtension(file_path);
if (!file_object) file_object = generateFileObject(file_id, type, customPath || multiUserMode && multiUserMode.file_path, sub);
if (!file_object) {
logger.error(`Could not find associated JSON file for ${type} file ${file_id}`);
return false;
}
utils.fixVideoMetadataPerms(file_id, type, multiUserMode && multiUserMode.file_path);
// add thumbnail path
file_object['thumbnailPath'] = utils.getDownloadedThumbnail(file_id, type, customPath || multiUserMode && multiUserMode.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 (multiUserMode) file_object['user_uid'] = multiUserMode.user;
const file_obj = await registerFileDBManual(file_object);
// remove metadata JSON if needed
if (!config_api.getConfigItem('ytdl_include_metadata')) {
utils.deleteJSONFile(file_id, type, multiUserMode && multiUserMode.file_path)
}
return file_obj;
}
exports.registerFileDB2 = async (file_path, type, user_uid = null, category = null, sub_id = null, cropFileSettings = null, file_object = null) => {
if (!file_object) file_object = generateFileObject2(file_path, type);
if (!file_object) { if (!file_object) {
logger.error(`Could not find associated JSON file for ${type} file ${file_path}`); logger.error(`Could not find associated JSON file for ${type} file ${file_path}`);
return false; return false;
} }
utils.fixVideoMetadataPerms(file_path, type); utils.fixVideoMetadataPerms2(file_path, type);
// add thumbnail path // add thumbnail path
file_object['thumbnailPath'] = utils.getDownloadedThumbnail(file_path); file_object['thumbnailPath'] = utils.getDownloadedThumbnail2(file_path, type);
// if category exists, only include essential info // if category exists, only include essential info
if (category) file_object['category'] = {name: category['name'], uid: category['uid']}; if (category) file_object['category'] = {name: category['name'], uid: category['uid']};
@@ -185,7 +205,7 @@ exports.registerFileDB = async (file_path, type, user_uid = null, category = nul
// remove metadata JSON if needed // remove metadata JSON if needed
if (!config_api.getConfigItem('ytdl_include_metadata')) { if (!config_api.getConfigItem('ytdl_include_metadata')) {
utils.deleteJSONFile(file_path, type) utils.deleteJSONFile2(file_path, type)
} }
return file_obj; return file_obj;
@@ -203,12 +223,38 @@ async function registerFileDBManual(file_object) {
return file_object; return file_object;
} }
function generateFileObject(file_path, type) { function generateFileObject(id, type, customPath = null, sub = null) {
var jsonobj = utils.getJSON(file_path, type); if (!customPath && sub) {
customPath = getAppendedBasePathSub(sub, config_api.getConfigItem('ytdl_subscriptions_base_path'));
}
var jsonobj = (type === 'audio') ? utils.getJSONMp3(id, customPath, true) : utils.getJSONMp4(id, customPath, true);
if (!jsonobj) { if (!jsonobj) {
return null; return null;
} else if (!jsonobj['_filename']) { }
logger.error(`Failed to get filename from info JSON! File ${jsonobj['title']} could not be added.`); const ext = (type === 'audio') ? '.mp3' : '.mp4'
const file_path = utils.getTrueFileName(jsonobj['_filename'], type); // path.join(type === 'audio' ? audioFolderPath : videoFolderPath, id + ext);
// console.
var stats = fs.statSync(path.join(file_path));
var title = jsonobj.title;
var url = jsonobj.webpage_url;
var uploader = jsonobj.uploader;
var upload_date = jsonobj.upload_date;
upload_date = upload_date ? `${upload_date.substring(0, 4)}-${upload_date.substring(4, 6)}-${upload_date.substring(6, 8)}` : 'N/A';
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(id, title, thumbnail, isaudio, duration, url, uploader, size, file_path, upload_date, description, jsonobj.view_count, jsonobj.height, jsonobj.abr);
return file_obj;
}
function generateFileObject2(file_path, type) {
var jsonobj = utils.getJSON(file_path, type);
if (!jsonobj) {
return null; return null;
} }
const ext = (type === 'audio') ? '.mp3' : '.mp4' const ext = (type === 'audio') ? '.mp3' : '.mp4'
@@ -220,7 +266,8 @@ function generateFileObject(file_path, type) {
var title = jsonobj.title; var title = jsonobj.title;
var url = jsonobj.webpage_url; var url = jsonobj.webpage_url;
var uploader = jsonobj.uploader; var uploader = jsonobj.uploader;
var upload_date = utils.formatDateString(jsonobj.upload_date); var upload_date = jsonobj.upload_date;
upload_date = upload_date ? `${upload_date.substring(0, 4)}-${upload_date.substring(4, 6)}-${upload_date.substring(6, 8)}` : 'N/A';
var size = stats.size; var size = stats.size;
@@ -303,7 +350,6 @@ exports.getFileDirectoriesAndDBs = async () => {
} }
exports.importUnregisteredFiles = async () => { exports.importUnregisteredFiles = async () => {
const imported_files = [];
const dirs_to_check = await exports.getFileDirectoriesAndDBs(); const dirs_to_check = await exports.getFileDirectoriesAndDBs();
// run through check list and check each file to see if it's missing from the db // run through check list and check each file to see if it's missing from the db
@@ -316,21 +362,33 @@ exports.importUnregisteredFiles = async () => {
const file = files[j]; const file = files[j];
// check if file exists in db, if not add it // 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 = !!(await exports.getRecord('files', {id: file.id, 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) { if (!file_is_registered) {
// add additional info // 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); await exports.registerFileDB2(file['path'], dir_to_check.type, dir_to_check.user_uid, null, dir_to_check.sub_id, null);
if (file_obj) { logger.verbose(`Added discovered file to the database: ${file.id}`);
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.preimportUnregisteredSubscriptionFile = async (sub, appendedBasePath) => {
const preimported_file_paths = [];
const files = await utils.getDownloadedFilesByType(appendedBasePath, sub.type);
for (let i = 0; i < files.length; i++) {
const file = files[i];
// check if file exists in db, if not add it
const file_is_registered = await exports.getRecord('files', {id: file.id, sub_id: sub.id});
if (!file_is_registered) {
// add additional info
await exports.registerFileDB2(file['path'], sub.type, sub.user_uid, null, sub.id, null, file);
preimported_file_paths.push(file['path']);
logger.verbose(`Preemptively added subscription file to the database: ${file.id}`);
}
}
return preimported_file_paths;
} }
exports.addMetadataPropertyToDB = async (property_key) => { exports.addMetadataPropertyToDB = async (property_key) => {
@@ -357,7 +415,7 @@ exports.addMetadataPropertyToDB = async (property_key) => {
} }
} }
exports.createPlaylist = async (playlist_name, uids, user_uid = null) => { exports.createPlaylist = async (playlist_name, uids, type, user_uid = null) => {
const first_video = await exports.getVideo(uids[0]); const first_video = await exports.getVideo(uids[0]);
const thumbnailToUse = first_video['thumbnailURL']; const thumbnailToUse = first_video['thumbnailURL'];
@@ -366,6 +424,7 @@ exports.createPlaylist = async (playlist_name, uids, user_uid = null) => {
uids: uids, uids: uids,
id: uuid(), id: uuid(),
thumbnailURL: thumbnailToUse, thumbnailURL: thumbnailToUse,
type: type,
registered: Date.now(), registered: Date.now(),
randomize_order: false randomize_order: false
}; };
@@ -386,9 +445,9 @@ exports.getPlaylist = async (playlist_id, user_uid = null, require_sharing = fal
if (!playlist) { if (!playlist) {
playlist = await exports.getRecord('categories', {uid: playlist_id}); playlist = await exports.getRecord('categories', {uid: playlist_id});
if (playlist) { if (playlist) {
const uids = (await exports.getRecords('files', {'category.uid': playlist_id})).map(file => file.uid); // category found
playlist['uids'] = uids; const files = await exports.getFiles(user_uid);
playlist['auto'] = true; utils.addUIDsToCategory(playlist, files);
} }
} }
@@ -460,8 +519,8 @@ exports.deleteFile = async (uid, uuid = null, blacklistMode = false) => {
var thumbnailPath = `${filePathNoExtension}.webp`; var thumbnailPath = `${filePathNoExtension}.webp`;
var altThumbnailPath = `${filePathNoExtension}.jpg`; var altThumbnailPath = `${filePathNoExtension}.jpg`;
jsonPath = path.join(__dirname, jsonPath); jsonPath = path.join(jsonPath);
altJSONPath = path.join(__dirname, altJSONPath); altJSONPath = path.join(altJSONPath);
let jsonExists = await fs.pathExists(jsonPath); let jsonExists = await fs.pathExists(jsonPath);
let thumbnailExists = await fs.pathExists(thumbnailPath); let thumbnailExists = await fs.pathExists(thumbnailPath);
@@ -494,7 +553,7 @@ exports.deleteFile = async (uid, uuid = null, blacklistMode = false) => {
let useYoutubeDLArchive = config_api.getConfigItem('ytdl_use_youtubedl_archive'); let useYoutubeDLArchive = config_api.getConfigItem('ytdl_use_youtubedl_archive');
if (useYoutubeDLArchive) { if (useYoutubeDLArchive) {
const archive_path = utils.getArchiveFolder(type, uuid); const archive_path = uuid ? path.join(usersFileFolder, uuid, 'archives', `archive_${type}.txt`) : path.join('appdata', 'archives', `archive_${type}.txt`);
// get ID from JSON // get ID from JSON
@@ -502,8 +561,14 @@ exports.deleteFile = async (uid, uuid = null, blacklistMode = false) => {
let id = null; let id = null;
if (jsonobj) id = jsonobj.id; if (jsonobj) id = jsonobj.id;
// Remove file ID from the archive file, and write it to the blacklist (if enabled) // use subscriptions API to remove video from the archive file, and write it to the blacklist
await utils.deleteFileFromArchive(uid, type, archive_path, id, blacklistMode); 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 (jsonExists) await fs.unlink(jsonPath);
@@ -555,22 +620,7 @@ exports.insertRecordIntoTable = async (table, doc, replaceFilter = null) => {
return true; return true;
} }
if (replaceFilter) { if (replaceFilter) await database.collection(table).deleteMany(replaceFilter);
const output = await database.collection(table).bulkWrite([
{
deleteMany: {
filter: replaceFilter
}
},
{
insertOne: {
document: doc
}
}
]);
logger.debug(`Inserted doc into ${table} with filter: ${JSON.stringify(replaceFilter)}`);
return !!(output['result']['ok']);
}
const output = await database.collection(table).insertOne(doc); const output = await database.collection(table).insertOne(doc);
logger.debug(`Inserted doc into ${table}`); logger.debug(`Inserted doc into ${table}`);
@@ -621,34 +671,19 @@ exports.bulkInsertRecordsIntoTable = async (table, docs) => {
exports.getRecord = async (table, filter_obj) => { exports.getRecord = async (table, filter_obj) => {
// local db override // local db override
if (using_local_db) { 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); return await database.collection(table).findOne(filter_obj);
} }
exports.getRecords = async (table, filter_obj = null, return_count = false, sort = null, range = null) => { exports.getRecords = async (table, filter_obj = null) => {
// local db override // local db override
if (using_local_db) { if (using_local_db) {
let cursor = filter_obj ? exports.applyFilterLocalDB(local_db.get(table), filter_obj, 'filter').value() : local_db.get(table).value(); return 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));
}
if (range) {
cursor = cursor.slice(range[0], range[1]);
}
return !return_count ? cursor : cursor.length;
} }
const cursor = filter_obj ? database.collection(table).find(filter_obj) : database.collection(table).find(); return filter_obj ? await database.collection(table).find(filter_obj).toArray() : await database.collection(table).find().toArray();
if (sort) {
cursor.sort({[sort['by']]: sort['order']});
}
if (range) {
cursor.skip(range[0]).limit(range[1] - range[0]);
}
return !return_count ? await cursor.toArray() : await cursor.count();
} }
// Update // Update
@@ -656,7 +691,7 @@ exports.getRecords = async (table, filter_obj = null, return_count = false, sort
exports.updateRecord = async (table, filter_obj, update_obj) => { exports.updateRecord = async (table, filter_obj, update_obj) => {
// local db override // local db override
if (using_local_db) { if (using_local_db) {
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; return true;
} }
@@ -669,7 +704,7 @@ exports.updateRecord = async (table, filter_obj, update_obj) => {
exports.updateRecords = async (table, filter_obj, update_obj) => { exports.updateRecords = async (table, filter_obj, update_obj) => {
// local db override // local db override
if (using_local_db) { if (using_local_db) {
exports.applyFilterLocalDB(local_db.get(table), filter_obj, 'filter').assign(update_obj).write(); applyFilterLocalDB(local_db.get(table), filter_obj, 'filter').assign(update_obj).write();
return true; return true;
} }
@@ -714,7 +749,7 @@ exports.bulkUpdateRecords = async (table, key_label, update_obj) => {
exports.pushToRecordsArray = async (table, filter_obj, key, value) => { exports.pushToRecordsArray = async (table, filter_obj, key, value) => {
// local db override // local db override
if (using_local_db) { 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; return true;
} }
@@ -725,7 +760,7 @@ exports.pushToRecordsArray = async (table, filter_obj, key, value) => {
exports.pullFromRecordsArray = async (table, filter_obj, key, value) => { exports.pullFromRecordsArray = async (table, filter_obj, key, value) => {
// local db override // local db override
if (using_local_db) { 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; return true;
} }
@@ -738,7 +773,7 @@ exports.pullFromRecordsArray = async (table, filter_obj, key, value) => {
exports.removeRecord = async (table, filter_obj) => { exports.removeRecord = async (table, filter_obj) => {
// local db override // local db override
if (using_local_db) { 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; return true;
} }
@@ -746,86 +781,26 @@ exports.removeRecord = async (table, filter_obj) => {
return !!(output['result']['ok']); return !!(output['result']['ok']);
} }
// exports.removeRecordsByUIDBulk = async (table, uids) => { exports.removeAllRecords = async (table = null) => {
// // local db override
// if (using_local_db) {
// exports.applyFilterLocalDB(local_db.get(table), filter_obj, 'remove').write();
// return true;
// }
// const table_collection = database.collection(table);
// let bulk = table_collection.initializeOrderedBulkOp(); // Initialize the Ordered Batch
// const item_ids_to_remove =
// for (let i = 0; i < item_ids_to_update.length; i++) {
// const item_id_to_update = item_ids_to_update[i];
// bulk.find({[key_label]: item_id_to_update }).updateOne({
// "$set": update_obj[item_id_to_update]
// });
// }
// const output = await bulk.execute();
// return !!(output['result']['ok']);
// }
exports.findDuplicatesByKey = async (table, key) => {
let duplicates = [];
if (using_local_db) {
// this can probably be optimized
const all_records = await exports.getRecords(table);
const existing_records = {};
for (let i = 0; i < all_records.length; i++) {
const record = all_records[i];
const value = record[key];
if (existing_records[value]) {
duplicates.push(record);
}
existing_records[value] = true;
}
return duplicates;
}
const duplicated_values = await database.collection(table).aggregate([
{"$group" : { "_id": `$${key}`, "count": { "$sum": 1 } } },
{"$match": {"_id" :{ "$ne" : null } , "count" : {"$gt": 1} } },
{"$project": {[key] : "$_id", "_id" : 0} }
]).toArray();
for (let i = 0; i < duplicated_values.length; i++) {
const duplicated_value = duplicated_values[i];
const duplicated_records = await exports.getRecords(table, duplicated_value, false);
if (duplicated_records.length > 1) {
duplicates = duplicates.concat(duplicated_records.slice(1, duplicated_records.length));
}
}
return duplicates;
}
exports.removeAllRecords = async (table = null, filter_obj = null) => {
// local db override // local db override
const tables_to_remove = table ? [table] : tables_list; const tables_to_remove = table ? [table] : tables_list;
logger.debug(`Removing all records from: ${tables_to_remove} with filter: ${JSON.stringify(filter_obj)}`)
if (using_local_db) { if (using_local_db) {
logger.debug(`Removing all records from: ${tables_to_remove}`)
for (let i = 0; i < tables_to_remove.length; i++) { for (let i = 0; i < tables_to_remove.length; i++) {
const table_to_remove = tables_to_remove[i]; const table_to_remove = tables_to_remove[i];
if (filter_obj) exports.applyFilterLocalDB(local_db.get(table), filter_obj, 'remove').write(); local_db.assign({[table_to_remove]: []}).write();
else local_db.assign({[table_to_remove]: []}).write(); logger.debug(`Removed all records from ${table_to_remove}`);
logger.debug(`Successfully removed records from ${table_to_remove}`);
} }
return true; return true;
} }
let success = true; let success = true;
logger.debug(`Removing all records from: ${tables_to_remove}`)
for (let i = 0; i < tables_to_remove.length; i++) { for (let i = 0; i < tables_to_remove.length; i++) {
const table_to_remove = tables_to_remove[i]; const table_to_remove = tables_to_remove[i];
const output = await database.collection(table_to_remove).deleteMany(filter_obj ? filter_obj : {}); const output = await database.collection(table_to_remove).deleteMany({});
logger.debug(`Successfully removed records from ${table_to_remove}`); logger.debug(`Removed all records from ${table_to_remove}`);
success &= !!(output['result']['ok']); success &= !!(output['result']['ok']);
} }
return success; return success;
@@ -979,52 +954,6 @@ const createDownloadsRecords = (downloads) => {
return new_downloads; return new_downloads;
} }
exports.backupDB = async () => {
const backup_dir = path.join('appdata', 'db_backup');
fs.ensureDirSync(backup_dir);
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.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++) {
const table = tables_list[i];
table_to_records[table] = await exports.getRecords(table);
}
fs.writeJsonSync(path_to_backups, table_to_records);
return backup_file_name;
}
exports.restoreDB = async (file_name) => {
const path_to_backup = path.join('appdata', 'db_backup', file_name);
logger.debug('Reading database backup file.');
const table_to_records = fs.readJSONSync(path_to_backup);
if (!table_to_records) {
logger.error(`Failed to restore DB! Backup file '${path_to_backup}' could not be read.`);
return false;
}
logger.debug('Clearing database.');
await exports.removeAllRecords();
logger.debug('Database cleared! Beginning restore.');
let success = true;
for (let i = 0; i < tables_list.length; i++) {
const table = tables_list[i];
if (!table_to_records[table] || table_to_records[table].length === 0) continue;
success &= await exports.bulkInsertRecordsIntoTable(table, table_to_records[table]);
}
logger.debug('Restore finished!');
return success;
}
exports.transferDB = async (local_to_remote) => { exports.transferDB = async (local_to_remote) => {
const table_to_records = {}; const table_to_records = {};
for (let i = 0; i < tables_list.length; i++) { for (let i = 0; i < tables_list.length; i++) {
@@ -1034,8 +963,9 @@ exports.transferDB = async (local_to_remote) => {
using_local_db = !local_to_remote; using_local_db = !local_to_remote;
if (local_to_remote) { if (local_to_remote) {
logger.debug('Backup up DB...'); // backup local DB
await exports.backupDB(); logger.debug('Backup up Local DB...');
await fs.copyFile('appdata/local_db.json', `appdata/local_db.json.${Date.now()/1000}.bak`);
const db_connected = await exports.connectToDB(5, true); const db_connected = await exports.connectToDB(5, true);
if (!db_connected) { if (!db_connected) {
logger.error('Failed to transfer database - could not connect to MongoDB. Verify that your connection URL is valid.'); logger.error('Failed to transfer database - could not connect to MongoDB. Verify that your connection URL is valid.');
@@ -1058,8 +988,6 @@ exports.transferDB = async (local_to_remote) => {
config_api.setConfigItem('ytdl_use_local_db', using_local_db); config_api.setConfigItem('ytdl_use_local_db', using_local_db);
logger.debug('Transfer finished!');
return success; return success;
} }
@@ -1067,13 +995,8 @@ exports.transferDB = async (local_to_remote) => {
This function is necessary to emulate mongodb's ability to search for null or missing values. 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 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 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 filter_props = Object.keys(filter_obj);
const return_val = db_path[operation](record => { const return_val = db_path[operation](record => {
if (!filter_props) return true; if (!filter_props) return true;
@@ -1082,21 +1005,9 @@ exports.applyFilterLocalDB = (db_path, filter_obj, operation) => {
const filter_prop = filter_props[i]; const filter_prop = filter_props[i];
const filter_prop_value = filter_obj[filter_prop]; const filter_prop_value = filter_obj[filter_prop];
if (filter_prop_value === undefined || filter_prop_value === null) { 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 { } else {
if (typeof filter_prop_value === 'object') { filtered &= record[filter_prop] === filter_prop_value;
if ('$regex' in filter_prop_value) {
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 {
// 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;
}
} }
} }
return filtered; return filtered;

View File

@@ -1,640 +0,0 @@
const fs = require('fs-extra');
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 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 mutex = new Mutex();
let should_check_downloads = true;
if (db_api.database_initialized) {
setupDownloads();
} else {
db_api.database_initialized_bs.subscribe(init => {
if (init) setupDownloads();
});
}
exports.createDownload = async (url, type, options, user_uid = null, sub_id = null, sub_name = null, prefetched_info = null) => {
return await mutex.runExclusive(async () => {
const download = {
url: url,
type: type,
title: '',
user_uid: user_uid,
sub_id: sub_id,
sub_name: sub_name,
prefetched_info: prefetched_info,
options: options,
uid: uuid(),
step_index: 0,
paused: false,
running: false,
finished_step: true,
error: null,
percent_complete: null,
finished: false,
timestamp_start: Date.now()
};
await db_api.insertRecordIntoTable('download_queue', download);
should_check_downloads = true;
return download;
});
}
exports.pauseDownload = async (download_uid) => {
const download = await db_api.getRecord('download_queue', {uid: download_uid});
if (download['paused']) {
logger.warn(`Download ${download_uid} is already paused!`);
return false;
} else if (download['finished']) {
logger.info(`Download ${download_uid} could not be paused before completing.`);
return false;
}
return await db_api.updateRecord('download_queue', {uid: download_uid}, {paused: true, running: false});
}
exports.resumeDownload = async (download_uid) => {
return await mutex.runExclusive(async () => {
const download = await db_api.getRecord('download_queue', {uid: download_uid});
if (!download['paused']) {
logger.warn(`Download ${download_uid} is not paused!`);
return false;
}
const success = db_api.updateRecord('download_queue', {uid: download_uid}, {paused: false});
should_check_downloads = true;
return success;
})
}
exports.restartDownload = async (download_uid) => {
const download = await db_api.getRecord('download_queue', {uid: download_uid});
await exports.clearDownload(download_uid);
const success = !!(await exports.createDownload(download['url'], download['type'], download['options'], download['user_uid']));
should_check_downloads = true;
return success;
}
exports.cancelDownload = async (download_uid) => {
const download = await db_api.getRecord('download_queue', {uid: download_uid});
if (download['cancelled']) {
logger.warn(`Download ${download_uid} is already cancelled!`);
return false;
} else if (download['finished']) {
logger.info(`Download ${download_uid} could not be cancelled before completing.`);
return false;
}
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) {
if (!download_uid) return;
await db_api.updateRecord('download_queue', {uid: download_uid}, {error: error_message, finished: true, running: false});
}
async function setupDownloads() {
await fixDownloadState();
setInterval(checkDownloads, 1000);
}
async function fixDownloadState() {
const downloads = await db_api.getRecords('download_queue');
downloads.sort((download1, download2) => download1.timestamp_start - download2.timestamp_start);
const running_downloads = downloads.filter(download => !download['finished'] && !download['error']);
for (let i = 0; i < running_downloads.length; i++) {
const running_download = running_downloads[i];
const update_obj = {finished_step: true, paused: true, running: false};
if (running_download['step_index'] > 0) {
update_obj['step_index'] = running_download['step_index'] - 1;
}
await db_api.updateRecord('download_queue', {uid: running_download['uid']}, update_obj);
}
}
async function checkDownloads() {
if (!should_check_downloads) return;
const downloads = await db_api.getRecords('download_queue');
downloads.sort((download1, download2) => download1.timestamp_start - download2.timestamp_start);
await mutex.runExclusive(async () => {
// avoid checking downloads unnecessarily, but double check that should_check_downloads is still true
const running_downloads = downloads.filter(download => !download['paused'] && !download['finished']);
if (running_downloads.length === 0) {
should_check_downloads = false;
logger.verbose('Disabling checking downloads as none are available.');
}
return;
});
let running_downloads_count = downloads.filter(download => download['running']).length;
const waiting_downloads = downloads.filter(download => !download['paused'] && download['finished_step'] && !download['finished']);
for (let i = 0; i < waiting_downloads.length; i++) {
const waiting_download = waiting_downloads[i];
const max_concurrent_downloads = config_api.getConfigItem('ytdl_max_concurrent_downloads');
if (max_concurrent_downloads < 0 || running_downloads_count >= max_concurrent_downloads) break;
if (waiting_download['finished_step'] && !waiting_download['finished']) {
// move to next step
running_downloads_count++;
if (waiting_download['step_index'] === 0) {
collectInfo(waiting_download['uid']);
} else if (waiting_download['step_index'] === 1) {
downloadQueuedFile(waiting_download['uid']);
}
}
}
}
async function collectInfo(download_uid) {
const download = await db_api.getRecord('download_queue', {uid: download_uid});
if (download['paused']) {
return;
}
logger.verbose(`Collecting info for download ${download_uid}`);
await db_api.updateRecord('download_queue', {uid: download_uid}, {step_index: 1, finished_step: false, running: true});
const url = download['url'];
const type = download['type'];
const options = download['options'];
if (download['user_uid'] && !options.customFileFolderPath) {
let usersFileFolder = config_api.getConfigItem('ytdl_users_base_path');
const user_path = path.join(usersFileFolder, download['user_uid'], type);
options.customFileFolderPath = user_path + path.sep;
}
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);
if (!info) {
// info failed, error presumably already recorded
return;
}
let category = null;
// check if it fits into a category. If so, then get info again using new args
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);
}
download['category'] = category;
// setup info required to calculate download progress
const expected_file_size = utils.getExpectedFileSize(info);
const files_to_check_for_progress = [];
// store info in download for future use
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 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: playlist_title ? playlist_title : info['title'],
prefetched_info: null
});
}
async function downloadQueuedFile(download_uid) {
const download = await db_api.getRecord('download_queue', {uid: download_uid});
if (download['paused']) {
return;
}
logger.verbose(`Downloading ${download_uid}`);
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'];
const type = download['type'];
const options = download['options'];
const args = download['args'];
const category = download['category'];
let fileFolderPath = type === 'audio' ? audioFolderPath : videoFolderPath;
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);
// download file
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;
} 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;
}
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;
}
// 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 (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);
}
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);
}
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();
}
});
});
}
// helper functions
exports.generateArgs = async (url, type, options, user_uid = null, simulated = false) => {
const default_downloader = utils.getCurrentDownloader() || config_api.getConfigItem('ytdl_default_downloader');
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);
}
if (options.customFileFolderPath) fileFolderPath = options.customFileFolderPath;
const customArgs = options.customArgs;
let customOutput = options.customOutput;
const customQualityConfiguration = options.customQualityConfiguration;
// video-specific args
const selectedHeight = options.selectedHeight;
// audio-specific args
const maxBitrate = options.maxBitrate;
const youtubeUsername = options.youtubeUsername;
const youtubePassword = options.youtubePassword;
let downloadConfig = null;
let qualityPath = (is_audio && !options.skip_audio_args) ? ['-f', 'bestaudio'] : ['-f', 'bestvideo+bestaudio', '--merge-output-format', 'mp4'];
const is_youtube = url.includes('youtu');
if (!is_audio && !is_youtube) {
// tiktok videos fail when using the default format
qualityPath = null;
}
if (customArgs) {
downloadConfig = customArgs.split(',,');
} else {
if (customQualityConfiguration) {
qualityPath = ['-f', customQualityConfiguration, '--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']
}
if (customOutput) {
customOutput = options.noRelativePath ? customOutput : path.join(fileFolderPath, customOutput);
downloadConfig = ['-o', `${customOutput}.%(ext)s`, '--write-info-json', '--print-json'];
} else {
downloadConfig = ['-o', path.join(fileFolderPath, videopath + (is_audio ? '.%(ext)s' : '.mp4')), '--write-info-json', '--print-json'];
}
if (qualityPath) downloadConfig.push(...qualityPath);
if (is_audio && !options.skip_audio_args) {
downloadConfig.push('-x');
downloadConfig.push('--audio-format', 'mp3');
}
if (youtubeUsername && youtubePassword) {
downloadConfig.push('--username', youtubeUsername, '--password', youtubePassword);
}
if (useCookies) {
if (await fs.pathExists(path.join(__dirname, 'appdata', 'cookies.txt'))) {
downloadConfig.push('--cookies', path.join('appdata', 'cookies.txt'));
} else {
logger.warn('Cookies file could not be found. You can either upload one, or disable \'use cookies\' in the Advanced tab in the settings.');
}
}
const useDefaultDownloadingAgent = config_api.getConfigItem('ytdl_use_default_downloading_agent');
const customDownloadingAgent = config_api.getConfigItem('ytdl_custom_downloading_agent');
if (!useDefaultDownloadingAgent && customDownloadingAgent) {
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');
}
if (globalArgs && globalArgs !== '') {
// adds global args
if (downloadConfig.indexOf('-o') !== -1 && globalArgs.split(',,').indexOf('-o') !== -1) {
// if global args has an output, replce the original output with that of global args
const original_output_index = downloadConfig.indexOf('-o');
downloadConfig.splice(original_output_index, 2);
}
downloadConfig = downloadConfig.concat(globalArgs.split(',,'));
}
if (options.additionalArgs && options.additionalArgs !== '') {
downloadConfig = utils.injectArgs(downloadConfig, options.additionalArgs.split(',,'));
}
const rate_limit = config_api.getConfigItem('ytdl_download_rate_limit');
if (rate_limit && downloadConfig.indexOf('-r') === -1 && downloadConfig.indexOf('--limit-rate') === -1) {
downloadConfig.push('-r', rate_limit);
}
if (default_downloader === 'yt-dlp') {
downloadConfig.push('--no-clean-infojson');
}
}
// filter out incompatible args
downloadConfig = filterArgs(downloadConfig, is_audio);
if (!simulated) logger.verbose(`${default_downloader} args being used: ${downloadConfig.join(',')}`);
return downloadConfig;
}
exports.getVideoInfoByURL = async (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');
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);
}
async function checkDownloadPercent(download_uid) {
/*
This is more of an art than a science, we're just selecting files that start with the file name,
thus capturing the parts being downloaded in files named like so: '<video title>.<format>.<ext>.part'.
Any file that starts with <video title> will be counted as part of the "bytes downloaded", which will
be divided by the "total expected bytes."
*/
const download = await db_api.getRecord('download_queue', {uid: download_uid});
const files_to_check_for_progress = download['files_to_check_for_progress'];
const resulting_file_size = download['expected_file_size'];
if (!resulting_file_size) return;
let sum_size = 0;
for (let i = 0; i < files_to_check_for_progress.length; i++) {
const file_to_check_for_progress = files_to_check_for_progress[i];
const dir = path.dirname(file_to_check_for_progress);
if (!fs.existsSync(dir)) continue;
fs.readdir(dir, async (err, files) => {
for (let j = 0; j < files.length; j++) {
const file = files[j];
if (!file.includes(path.basename(file_to_check_for_progress))) continue;
try {
const file_stats = fs.statSync(path.join(dir, file));
if (file_stats && file_stats.size) {
sum_size += file_stats.size;
}
} catch (e) {}
}
const percent_complete = (sum_size/resulting_file_size * 100).toFixed(2);
await db_api.updateRecord('download_queue', {uid: download_uid}, {percent_complete: percent_complete});
});
}
}
exports.generateNFOFile = (info, output_path) => {
const nfo_obj = {
episodedetails: {
title: info['fulltitle'],
episode: info['playlist_index'] ? info['playlist_index'] : undefined,
premiered: utils.formatDateString(info['upload_date']),
plot: `${info['uploader_url']}\n${info['description']}\n${info['playlist_title'] ? info['playlist_title'] : ''}`,
director: info['artist'] ? info['artist'] : info['uploader']
}
};
const doc = create(nfo_obj);
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('appdata', 'archives');
}
}

View File

@@ -1,8 +0,0 @@
module.exports = {
apps : [{
name : "YoutubeDL-Material",
script : "./app.js",
watch : "placeholder",
watch_delay: 5000
}]
}

View File

@@ -1,7 +1,7 @@
#!/bin/sh #!/bin/sh
set -eu set -eu
CMD="npm start" CMD="forever app.js"
# if the first arg starts with "-" pass it to program # if the first arg starts with "-" pass it to program
if [ "${1#-}" != "$1" ]; then if [ "${1#-}" != "$1" ]; then
@@ -11,7 +11,7 @@ fi
# chown current working directory to current user # chown current working directory to current user
if [ "$*" = "$CMD" ] && [ "$(id -u)" = "0" ]; then 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." 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 gosu "$UID:$GID" "$0" "$@" exec su-exec "$UID:$GID" "$0" "$@"
fi fi
exec "$@" exec "$@"

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,23 +0,0 @@
const winston = require('winston');
let debugMode = process.env.YTDL_MODE === 'debug';
const defaultFormat = winston.format.printf(({ level, message, label, timestamp }) => {
return `${timestamp} ${level.toUpperCase()}: ${message}`;
});
const logger = winston.createLogger({
level: 'info',
format: winston.format.combine(winston.format.timestamp(), defaultFormat),
defaultMeta: {},
transports: [
//
// - Write to all logs with level `info` and below to `combined.log`
// - Write all logs error (and below) to `error.log`.
//
new winston.transports.File({ filename: 'appdata/logs/error.log', level: 'error' }),
new winston.transports.File({ filename: 'appdata/logs/combined.log' }),
new winston.transports.Console({level: !debugMode ? 'info' : 'debug', name: 'console'})
]
});
module.exports = logger;

View File

@@ -1,6 +1,7 @@
const { app, BrowserWindow } = require('electron'); const { app, BrowserWindow } = require('electron');
const path = require('path'); const path = require('path');
const url = require('url'); const url = require('url');
const server = require('./app');
let win; let win;
@@ -8,13 +9,7 @@ function createWindow() {
win = new BrowserWindow({ width: 800, height: 600 }); win = new BrowserWindow({ width: 800, height: 600 });
// load the dist folder from Angular // load the dist folder from Angular
win.loadURL( win.loadURL('http://localhost:17442') //ADD THIS
url.format({
pathname: path.join(__dirname, `/dist/index.html`),
protocol: 'file:',
slashes: true
})
);
// The following is optional and will open the DevTools: // The following is optional and will open the DevTools:
// win.webContents.openDevTools() // win.webContents.openDevTools()

4411
backend/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -2,11 +2,32 @@
"name": "backend", "name": "backend",
"version": "1.0.0", "version": "1.0.0",
"description": "backend for YoutubeDL-Material", "description": "backend for YoutubeDL-Material",
"main": "index.js", "main": "main.js",
"scripts": { "scripts": {
"test": "echo \"Error: no test specified\" && exit 1", "test": "echo \"Error: no test specified\" && exit 1",
"start": "pm2-runtime --raw pm2.config.js", "start": "nodemon app.js",
"debug": "set YTDL_MODE=debug && node app.js" "debug": "set YTDL_MODE=debug && node app.js",
"electron": "electron main.js",
"pack": "electron-builder --dir",
"dist": "electron-builder"
},
"nodemonConfig": {
"ignore": [
"*.js",
"appdata/*",
"public/*"
],
"watch": [
"restart_update.json",
"restart_general.json"
]
},
"build": {
"appId": "youtubedl.material",
"mac": {
"category": "public.app-category.utilities"
},
"files": ["!audio/*", "!video/*", "!users/*", "!subscriptions/*", "!appdata/*"]
}, },
"repository": { "repository": {
"type": "git", "type": "git",
@@ -19,42 +40,44 @@
}, },
"homepage": "", "homepage": "",
"dependencies": { "dependencies": {
"archiver": "^5.3.1", "archiver": "^3.1.1",
"async": "^3.2.3", "async": "^3.1.0",
"async-mutex": "^0.3.1", "axios": "^0.21.1",
"axios": "^0.21.2",
"bcryptjs": "^2.4.0", "bcryptjs": "^2.4.0",
"compression": "^1.7.4", "compression": "^1.7.4",
"config": "^3.2.3", "config": "^3.2.3",
"express": "^4.17.3", "exe": "^1.0.2",
"express": "^4.17.1",
"fluent-ffmpeg": "^2.1.2", "fluent-ffmpeg": "^2.1.2",
"fs-extra": "^9.0.0", "fs-extra": "^9.0.0",
"glob": "^7.1.6",
"jsonwebtoken": "^8.5.1", "jsonwebtoken": "^8.5.1",
"lowdb": "^1.0.0", "lowdb": "^1.0.0",
"md5": "^2.2.1", "md5": "^2.2.1",
"merge-files": "^0.1.2", "merge-files": "^0.1.2",
"mocha": "^9.2.2", "mocha": "^8.4.0",
"moment": "^2.29.2", "moment": "^2.29.1",
"mongodb": "^3.6.9", "mongodb": "^3.6.9",
"multer": "1.4.5-lts.1", "multer": "^1.4.2",
"node-fetch": "^2.6.7", "node-fetch": "^2.6.1",
"node-id3": "^0.1.14", "node-id3": "^0.1.14",
"node-schedule": "^2.1.0", "nodemon": "^2.0.7",
"passport": "^0.4.1", "passport": "^0.4.1",
"passport-http": "^0.3.0", "passport-http": "^0.3.0",
"passport-jwt": "^4.0.0", "passport-jwt": "^4.0.0",
"passport-ldapauth": "^3.0.1", "passport-ldapauth": "^2.1.4",
"passport-local": "^1.0.0", "passport-local": "^1.0.0",
"pm2": "^5.2.0",
"progress": "^2.0.3", "progress": "^2.0.3",
"ps-node": "^0.1.6", "ps-node": "^0.1.6",
"read-last-lines": "^1.7.2", "read-last-lines": "^1.7.2",
"rxjs": "^7.3.0",
"shortid": "^2.2.15", "shortid": "^2.2.15",
"unzipper": "^0.10.10", "unzipper": "^0.10.10",
"uuidv4": "^6.0.6", "uuidv4": "^6.0.6",
"winston": "^3.7.2", "winston": "^3.2.1",
"xmlbuilder2": "^3.0.2",
"youtube-dl": "^3.0.2" "youtube-dl": "^3.0.2"
},
"devDependencies": {
"electron": "^13.1.7",
"electron-builder": "^22.11.7"
} }
} }

View File

@@ -1,9 +0,0 @@
module.exports = {
apps : [{
name : "YoutubeDL-Material",
script : "./app.js",
watch : "placeholder",
out_file: "/dev/null",
error_file: "/dev/null"
}]
}

View File

@@ -1,15 +1,28 @@
const fs = require('fs-extra'); const FileSync = require('lowdb/adapters/FileSync')
const path = require('path');
const youtubedl = require('youtube-dl');
var fs = require('fs-extra');
const { uuid } = require('uuidv4');
var path = require('path');
var youtubedl = require('youtube-dl');
const config_api = require('./config'); const config_api = require('./config');
const utils = require('./utils'); const twitch_api = require('./twitch');
const logger = require('./logger'); var utils = require('./utils');
const debugMode = process.env.YTDL_MODE === 'debug'; const debugMode = process.env.YTDL_MODE === 'debug';
const db_api = require('./db'); var logger = null;
const downloader_api = require('./downloader'); var db = null;
var users_db = null;
let db_api = null;
function setDB(input_db_api) { db_api = input_db_api }
function setLogger(input_logger) { logger = input_logger; }
function initialize(input_db_api, input_logger) {
setDB(input_db_api);
setLogger(input_logger);
}
async function subscribe(sub, user_uid = null) { async function subscribe(sub, user_uid = null) {
const result_obj = { const result_obj = {
@@ -33,13 +46,13 @@ async function subscribe(sub, user_uid = null) {
sub['user_uid'] = user_uid ? user_uid : undefined; sub['user_uid'] = user_uid ? user_uid : undefined;
await db_api.insertRecordIntoTable('subscriptions', sub); await db_api.insertRecordIntoTable('subscriptions', sub);
let success = await getSubscriptionInfo(sub); let success = await getSubscriptionInfo(sub, user_uid);
if (success) { if (success) {
getVideosForSub(sub, user_uid); getVideosForSub(sub, user_uid);
} else { } else {
logger.error('Subscribe: Failed to get subscription info. Subscribe failed.') logger.error('Subscribe: Failed to get subscription info. Subscribe failed.')
} };
result_obj.success = success; result_obj.success = success;
result_obj.sub = sub; result_obj.sub = sub;
@@ -48,12 +61,18 @@ async function subscribe(sub, user_uid = null) {
} }
async function getSubscriptionInfo(sub) { async function getSubscriptionInfo(sub, user_uid = null) {
let basePath = null;
if (user_uid)
basePath = path.join(config_api.getConfigItem('ytdl_users_base_path'), user_uid, 'subscriptions');
else
basePath = config_api.getConfigItem('ytdl_subscriptions_base_path');
// get videos // get videos
let downloadConfig = ['--dump-json', '--playlist-end', '1']; let downloadConfig = ['--dump-json', '--playlist-end', '1'];
let useCookies = config_api.getConfigItem('ytdl_use_cookies'); let useCookies = config_api.getConfigItem('ytdl_use_cookies');
if (useCookies) { if (useCookies) {
if (await fs.pathExists(path.join(__dirname, 'appdata', 'cookies.txt'))) { if (await fs.pathExists(path.join('appdata', 'cookies.txt'))) {
downloadConfig.push('--cookies', path.join('appdata', 'cookies.txt')); downloadConfig.push('--cookies', path.join('appdata', 'cookies.txt'));
} else { } else {
logger.warn('Cookies file could not be found. You can either upload one, or disable \'use cookies\' in the Advanced tab in the settings.'); logger.warn('Cookies file could not be found. You can either upload one, or disable \'use cookies\' in the Advanced tab in the settings.');
@@ -95,6 +114,22 @@ async function getSubscriptionInfo(sub) {
} }
} }
const useArchive = config_api.getConfigItem('ytdl_use_youtubedl_archive');
if (useArchive && !sub.archive) {
// must create the archive
const archive_dir = path.join(basePath, 'archives', sub.name);
const archive_path = path.join(archive_dir, 'archive.txt');
// creates archive directory and text file if it doesn't exist
fs.ensureDirSync(archive_dir);
fs.ensureFileSync(archive_path);
// updates subscription
sub.archive = archive_dir;
await db_api.updateRecord('subscriptions', {id: sub.id}, {archive: archive_dir});
}
// TODO: get even more info // TODO: get even more info
resolve(true); resolve(true);
@@ -111,23 +146,9 @@ async function unsubscribe(sub, deleteMode, user_uid = null) {
basePath = path.join(config_api.getConfigItem('ytdl_users_base_path'), user_uid, 'subscriptions'); basePath = path.join(config_api.getConfigItem('ytdl_users_base_path'), user_uid, 'subscriptions');
else else
basePath = config_api.getConfigItem('ytdl_subscriptions_base_path'); basePath = config_api.getConfigItem('ytdl_subscriptions_base_path');
let result_obj = { success: false, error: '' };
let id = sub.id; let id = sub.id;
const sub_files = await db_api.getRecords('files', {sub_id: id});
for (let i = 0; i < sub_files.length; i++) {
const sub_file = sub_files[i];
if (config_api.descriptors[sub_file['uid']]) {
try {
for (let i = 0; i < config_api.descriptors[sub_file['uid']].length; i++) {
config_api.descriptors[sub_file['uid']][i].destroy();
}
} catch(e) {
continue;
}
}
}
await db_api.removeRecord('subscriptions', {id: id}); await db_api.removeRecord('subscriptions', {id: id});
await db_api.removeAllRecords('files', {sub_id: id}); await db_api.removeAllRecords('files', {sub_id: id});
@@ -141,7 +162,6 @@ async function unsubscribe(sub, deleteMode, user_uid = null) {
if (sub.archive && (await fs.pathExists(sub.archive))) { if (sub.archive && (await fs.pathExists(sub.archive))) {
const archive_file_path = path.join(sub.archive, 'archive.txt'); const archive_file_path = path.join(sub.archive, 'archive.txt');
// deletes archive if it exists // deletes archive if it exists
// TODO: Keep entries in blacklist_video.txt by moving them to a global blacklist
if (await fs.pathExists(archive_file_path)) { if (await fs.pathExists(archive_file_path)) {
await fs.unlink(archive_file_path); await fs.unlink(archive_file_path);
} }
@@ -165,10 +185,10 @@ async function deleteSubscriptionFile(sub, file, deleteForever, file_uid = null,
let filePath = appendedBasePath; let filePath = appendedBasePath;
const ext = (sub.type && sub.type === 'audio') ? '.mp3' : '.mp4' const ext = (sub.type && sub.type === 'audio') ? '.mp3' : '.mp4'
var jsonPath = path.join(__dirname,filePath,name+'.info.json'); var jsonPath = path.join(filePath,name+'.info.json');
var videoFilePath = path.join(__dirname,filePath,name+ext); var videoFilePath = path.join(filePath,name+ext);
var imageFilePath = path.join(__dirname,filePath,name+'.jpg'); var imageFilePath = path.join(filePath,name+'.jpg');
var altImageFilePath = path.join(__dirname,filePath,name+'.webp'); var altImageFilePath = path.join(filePath,name+'.webp');
const [jsonExists, videoFileExists, imageFileExists, altImageFileExists] = await Promise.all([ const [jsonExists, videoFileExists, imageFileExists, altImageFileExists] = await Promise.all([
fs.pathExists(jsonPath), fs.pathExists(jsonPath),
@@ -178,7 +198,7 @@ async function deleteSubscriptionFile(sub, file, deleteForever, file_uid = null,
]); ]);
if (jsonExists) { if (jsonExists) {
retrievedID = fs.readJSONSync(jsonPath)['id']; retrievedID = JSON.parse(await fs.readFile(jsonPath, 'utf8'))['id'];
await fs.unlink(jsonPath); await fs.unlink(jsonPath);
} }
@@ -196,11 +216,12 @@ async function deleteSubscriptionFile(sub, file, deleteForever, file_uid = null,
return false; return false;
} else { } else {
// check if the user wants the video to be redownloaded (deleteForever === false) // check if the user wants the video to be redownloaded (deleteForever === false)
if (useArchive && retrievedID) { if (!deleteForever && useArchive && sub.archive && retrievedID) {
const archive_path = utils.getArchiveFolder(sub.type, user_uid, sub); const archive_path = path.join(sub.archive, 'archive.txt')
// if archive exists, remove line with video ID
// Remove file ID from the archive file, and write it to the blacklist (if enabled) if (await fs.pathExists(archive_path)) {
await utils.deleteFileFromArchive(file_uid, sub.type, archive_path, retrievedID, deleteForever); utils.removeIDFromArchive(archive_path, retrievedID);
}
} }
return true; return true;
} }
@@ -228,101 +249,92 @@ async function getVideosForSub(sub, user_uid = null) {
let appendedBasePath = getAppendedBasePath(sub, basePath); let appendedBasePath = getAppendedBasePath(sub, basePath);
fs.ensureDirSync(appendedBasePath); fs.ensureDirSync(appendedBasePath);
let multiUserMode = null;
if (user_uid) {
multiUserMode = {
user: user_uid,
file_path: appendedBasePath
}
}
const downloadConfig = await generateArgsForSubscription(sub, user_uid); const downloadConfig = await generateArgsForSubscription(sub, user_uid);
// get videos // get videos
logger.verbose(`Subscription: getting videos for subscription ${sub.name} with args: ${downloadConfig.join(',')}`); logger.verbose('Subscription: getting videos for subscription ' + sub.name);
return new Promise(async resolve => { return new Promise(async resolve => {
const preimported_file_paths = [];
const PREIMPORT_INTERVAL = 5000;
const preregister_check = setInterval(async () => {
if (sub.streamingOnly) return;
await db_api.preimportUnregisteredSubscriptionFile(sub, appendedBasePath);
}, PREIMPORT_INTERVAL);
youtubedl.exec(sub.url, downloadConfig, {maxBuffer: Infinity}, async function(err, output) { youtubedl.exec(sub.url, downloadConfig, {maxBuffer: Infinity}, async function(err, output) {
// cleanup // cleanup
updateSubscriptionProperty(sub, {downloading: false}, user_uid); updateSubscriptionProperty(sub, {downloading: false}, user_uid);
clearInterval(preregister_check);
logger.verbose('Subscription: finished check for ' + sub.name); logger.verbose('Subscription: finished check for ' + sub.name);
if (err && !output) { if (err && !output) {
logger.error(err.stderr ? err.stderr : err.message); logger.error(err.stderr ? err.stderr : err.message);
if (err.stderr.includes('This video is unavailable') || err.stderr.includes('Private video')) { if (err.stderr.includes('This video is unavailable')) {
logger.info('An error was encountered with at least one video, backup method will be used.') logger.info('An error was encountered with at least one video, backup method will be used.')
try { try {
const outputs = err.stdout.split(/\r\n|\r|\n/); const outputs = err.stdout.split(/\r\n|\r|\n/);
const files_to_download = await handleOutputJSON(outputs, sub, user_uid); for (let i = 0; i < outputs.length; i++) {
resolve(files_to_download); 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) { } catch(e) {
logger.error('Backup method failed. See error below:'); logger.error('Backup method failed. See error below:');
logger.error(e); logger.error(e);
} }
} else {
logger.error('Subscription check failed!');
} }
resolve(false); resolve(false);
} else if (output) { } else if (output) {
const files_to_download = await handleOutputJSON(output, sub, user_uid); if (output.length === 0 || (output.length === 1 && output[0] === '')) {
resolve(files_to_download); logger.verbose('No additional videos to download for ' + sub.name);
} resolve(true);
return;
}
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;
}
const reset_videos = i === 0;
await handleOutputJSON(sub, output_json, multiUserMode, preimported_file_paths, reset_videos);
}
if (config_api.getConfigItem('ytdl_subscriptions_redownload_fresh_uploads')) {
await setFreshUploads(sub, user_uid);
checkVideosForFreshUploads(sub, user_uid);
}
resolve(true);
}
}); });
}, err => { }, err => {
logger.error(err); logger.error(err);
updateSubscriptionProperty(sub, {downloading: false}, user_uid); updateSubscriptionProperty(sub, {downloading: false}, user_uid);
clearInterval(preregister_check);
}); });
} }
async function handleOutputJSON(output, sub, user_uid) {
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);
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];
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;
}
function generateOptionsForSubscriptionDownload(sub, user_uid) {
let basePath = null;
if (user_uid)
basePath = path.join(config_api.getConfigItem('ytdl_users_base_path'), user_uid, 'subscriptions');
else
basePath = config_api.getConfigItem('ytdl_subscriptions_base_path');
let default_output = config_api.getConfigItem('ytdl_default_file_output') ? config_api.getConfigItem('ytdl_default_file_output') : '%(title)s';
const base_download_options = {
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),
additionalArgs: sub.custom_args
}
return base_download_options;
}
async function generateArgsForSubscription(sub, user_uid, redownload = false, desired_path = null) { async function generateArgsForSubscription(sub, user_uid, redownload = false, desired_path = null) {
// get basePath // get basePath
let basePath = null; let basePath = null;
@@ -337,14 +349,14 @@ async function generateArgsForSubscription(sub, user_uid, redownload = false, de
const file_output = config_api.getConfigItem('ytdl_default_file_output') ? config_api.getConfigItem('ytdl_default_file_output') : '%(title)s'; const file_output = config_api.getConfigItem('ytdl_default_file_output') ? config_api.getConfigItem('ytdl_default_file_output') : '%(title)s';
let fullOutput = `"${appendedBasePath}/${file_output}.%(ext)s"`; let fullOutput = `${appendedBasePath}/${file_output}.%(ext)s`;
if (desired_path) { if (desired_path) {
fullOutput = `"${desired_path}.%(ext)s"`; fullOutput = `${desired_path}.%(ext)s`;
} else if (sub.custom_output) { } else if (sub.custom_output) {
fullOutput = `"${appendedBasePath}/${sub.custom_output}.%(ext)s"`; fullOutput = `${appendedBasePath}/${sub.custom_output}.%(ext)s`;
} }
let downloadConfig = ['--dump-json', '-o', fullOutput, !redownload ? '-ciw' : '-ci', '--write-info-json', '--print-json']; let downloadConfig = ['-o', fullOutput, !redownload ? '-ciw' : '-ci', '--write-info-json', '--print-json'];
let qualityPath = null; let qualityPath = null;
if (sub.type && sub.type === 'audio') { if (sub.type && sub.type === 'audio') {
@@ -359,7 +371,7 @@ async function generateArgsForSubscription(sub, user_uid, redownload = false, de
downloadConfig.push(...qualityPath) downloadConfig.push(...qualityPath)
if (sub.custom_args) { if (sub.custom_args) {
const customArgsArray = sub.custom_args.split(',,'); customArgsArray = sub.custom_args.split(',,');
if (customArgsArray.indexOf('-f') !== -1) { if (customArgsArray.indexOf('-f') !== -1) {
// if custom args has a custom quality, replce the original quality with that of custom args // if custom args has a custom quality, replce the original quality with that of custom args
const original_output_index = downloadConfig.indexOf('-f'); const original_output_index = downloadConfig.indexOf('-f');
@@ -374,22 +386,23 @@ async function generateArgsForSubscription(sub, user_uid, redownload = false, de
if (useArchive && !redownload) { if (useArchive && !redownload) {
if (sub.archive) { if (sub.archive) {
archive_dir = sub.archive; archive_dir = sub.archive;
if (sub.type && sub.type === 'audio') { archive_path = path.join(archive_dir, 'archive.txt')
archive_path = path.join(archive_dir, 'merged_audio.txt');
} else {
archive_path = path.join(archive_dir, 'merged_video.txt');
}
} }
downloadConfig.push('--download-archive', archive_path); 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) { if (sub.timerange && !redownload) {
downloadConfig.push('--dateafter', sub.timerange); downloadConfig.push('--dateafter', sub.timerange);
} }
let useCookies = config_api.getConfigItem('ytdl_use_cookies'); let useCookies = config_api.getConfigItem('ytdl_use_cookies');
if (useCookies) { if (useCookies) {
if (await fs.pathExists(path.join(__dirname, 'appdata', 'cookies.txt'))) { if (await fs.pathExists(path.join('appdata', 'cookies.txt'))) {
downloadConfig.push('--cookies', path.join('appdata', 'cookies.txt')); downloadConfig.push('--cookies', path.join('appdata', 'cookies.txt'));
} else { } else {
logger.warn('Cookies file could not be found. You can either upload one, or disable \'use cookies\' in the Advanced tab in the settings.'); logger.warn('Cookies file could not be found. You can either upload one, or disable \'use cookies\' in the Advanced tab in the settings.');
@@ -400,39 +413,46 @@ async function generateArgsForSubscription(sub, user_uid, redownload = false, de
downloadConfig.push('--write-thumbnail'); downloadConfig.push('--write-thumbnail');
} }
const rate_limit = config_api.getConfigItem('ytdl_download_rate_limit');
if (rate_limit && downloadConfig.indexOf('-r') === -1 && downloadConfig.indexOf('--limit-rate') === -1) {
downloadConfig.push('-r', rate_limit);
}
const default_downloader = utils.getCurrentDownloader() || config_api.getConfigItem('ytdl_default_downloader');
if (default_downloader === 'yt-dlp') {
downloadConfig.push('--no-clean-infojson');
}
downloadConfig = utils.filterArgs(downloadConfig, ['--write-comments']);
return downloadConfig; return downloadConfig;
} }
async function getFilesToDownload(sub, output_jsons) { async function handleOutputJSON(sub, output_json, multiUserMode = null, reset_videos = false) {
const files_to_download = []; // TODO: remove streaming only mode
for (let i = 0; i < output_jsons.length; i++) { if (false && sub.streamingOnly) {
const output_json = output_jsons[i]; if (reset_videos) {
const file_missing = !(await db_api.getRecord('files', {sub_id: sub.id, url: output_json['webpage_url']})) && !(await db_api.getRecord('download_queue', {sub_id: sub.id, url: output_json['webpage_url'], error: null, finished: false})); sub_db.assign({videos: []}).write();
if (file_missing) { }
const file_with_path_exists = await db_api.getRecord('files', {sub_id: sub.id, path: output_json['_filename']});
if (file_with_path_exists) { // remove unnecessary info
// or maybe just overwrite??? output_json.formats = null;
logger.info(`Skipping adding file ${output_json['_filename']} for subscription ${sub.name} as a file with that path already exists.`)
} // add to db
files_to_download.push(output_json); sub_db.get('videos').push(output_json).write();
} else {
path_object = path.parse(output_json['_filename']);
const path_string = path.format(path_object);
const file_exists = await db_api.getRecord('files', {path: path_string, sub_id: sub.id});
if (file_exists) {
// TODO: fix issue where files of different paths due to custom path get downloaded multiple times
// file already exists in DB, return early to avoid reseting the download date
return;
}
await db_api.registerFileDB2(output_json['_filename'], sub.type, sub.user_uid, null, sub.id);
const url = output_json['webpage_url'];
if (sub.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')) {
const file_name = path.basename(output_json['_filename']);
const id = file_name.substring(0, file_name.length-4);
let vodId = url.split('twitch.tv/videos/')[1];
vodId = vodId.split('?')[0];
twitch_api.downloadTwitchChatByVODID(vodId, id, sub.type, multiUserMode.user, sub);
} }
} }
return files_to_download;
} }
async function getSubscriptions(user_uid = null) { async function getSubscriptions(user_uid = null) {
return await db_api.getRecords('subscriptions', {user_uid: user_uid}); return await db_api.getRecords('subscriptions', {user_uid: user_uid});
} }
@@ -440,7 +460,7 @@ async function getSubscriptions(user_uid = null) {
async function getAllSubscriptions() { async function getAllSubscriptions() {
const all_subs = await db_api.getRecords('subscriptions'); const all_subs = await db_api.getRecords('subscriptions');
const multiUserMode = config_api.getConfigItem('ytdl_multi_user_mode'); const multiUserMode = config_api.getConfigItem('ytdl_multi_user_mode');
return all_subs.filter(sub => !!(sub.user_uid) === !!multiUserMode); return all_subs.filter(sub => !!(sub.user_uid) === multiUserMode);
} }
async function getSubscription(subID) { async function getSubscription(subID) {
@@ -451,7 +471,7 @@ async function getSubscriptionByName(subName, user_uid = null) {
return await db_api.getRecord('subscriptions', {name: subName, user_uid: user_uid}); return await db_api.getRecord('subscriptions', {name: subName, user_uid: user_uid});
} }
async function updateSubscription(sub) { async function updateSubscription(sub, user_uid = null) {
await db_api.updateRecord('subscriptions', {id: sub.id}, sub); await db_api.updateRecord('subscriptions', {id: sub.id}, sub);
return true; return true;
} }
@@ -462,31 +482,28 @@ async function updateSubscriptionPropertyMultiple(subs, assignment_obj) {
}); });
} }
async function updateSubscriptionProperty(sub, assignment_obj) { async function updateSubscriptionProperty(sub, assignment_obj, user_uid = null) {
// TODO: combine with updateSubscription // TODO: combine with updateSubscription
await db_api.updateRecord('subscriptions', {id: sub.id}, assignment_obj); await db_api.updateRecord('subscriptions', {id: sub.id}, assignment_obj);
return true; return true;
} }
async function setFreshUploads(sub) { async function setFreshUploads(sub, user_uid) {
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, ''); const current_date = new Date().toISOString().split('T')[0].replace(/-/g, '');
sub_files.forEach(async file => { sub.videos.forEach(async video => {
if (current_date === file['upload_date'].replace(/-/g, '')) { if (current_date === video['upload_date'].replace(/-/g, '')) {
// set upload as fresh // set upload as fresh
const file_uid = file['uid']; const video_uid = video['uid'];
await db_api.setVideoProperty(file_uid, {'fresh_upload': true}); await db_api.setVideoProperty(video_uid, {'fresh_upload': true}, user_uid, sub['id']);
} }
}); });
} }
async function checkVideosForFreshUploads(sub, user_uid) { async function checkVideosForFreshUploads(sub, user_uid) {
const sub_files = await db_api.getRecords('files', {sub_id: sub.id});
const current_date = new Date().toISOString().split('T')[0].replace(/-/g, ''); const current_date = new Date().toISOString().split('T')[0].replace(/-/g, '');
sub_files.forEach(async file => { sub.videos.forEach(async video => {
if (file['fresh_upload'] && current_date > file['upload_date'].replace(/-/g, '')) { if (video['fresh_upload'] && current_date > video['upload_date'].replace(/-/g, '')) {
await checkVideoIfBetterExists(file, sub, user_uid) await checkVideoIfBetterExists(video, sub, user_uid)
} }
}); });
} }
@@ -508,18 +525,19 @@ async function checkVideoIfBetterExists(file_obj, sub, user_uid) {
logger.verbose(`Failed to download better version of video ${file_obj['id']}`); logger.verbose(`Failed to download better version of video ${file_obj['id']}`);
} else if (output) { } 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]}`); 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'], {[metric_to_compare]: output[metric_to_compare]}, user_uid, sub['id']);
} }
}); });
} }
} }
}); });
await db_api.setVideoProperty(file_obj['uid'], {'fresh_upload': false}); await db_api.setVideoProperty(file_obj['uid'], {'fresh_upload': false}, user_uid, sub['id']);
} }
// helper functions // helper functions
function getAppendedBasePath(sub, base_path) { function getAppendedBasePath(sub, base_path) {
return path.join(base_path, (sub.isPlaylist ? 'playlists/' : 'channels/'), sub.name); return path.join(base_path, (sub.isPlaylist ? 'playlists/' : 'channels/'), sub.name);
} }
@@ -533,6 +551,7 @@ module.exports = {
unsubscribe : unsubscribe, unsubscribe : unsubscribe,
deleteSubscriptionFile : deleteSubscriptionFile, deleteSubscriptionFile : deleteSubscriptionFile,
getVideosForSub : getVideosForSub, getVideosForSub : getVideosForSub,
updateSubscriptionPropertyMultiple : updateSubscriptionPropertyMultiple, setLogger : setLogger,
generateOptionsForSubscriptionDownload: generateOptionsForSubscriptionDownload initialize : initialize,
updateSubscriptionPropertyMultiple : updateSubscriptionPropertyMultiple
} }

View File

@@ -1,196 +0,0 @@
const db_api = require('./db');
const youtubedl_api = require('./youtube-dl');
const fs = require('fs-extra');
const logger = require('./logger');
const scheduler = require('node-schedule');
const TASKS = {
backup_local_db: {
run: db_api.backupDB,
title: 'Backup DB',
job: null
},
missing_files_check: {
run: checkForMissingFiles,
confirm: deleteMissingFiles,
title: 'Missing files check',
job: null
},
missing_db_records: {
run: db_api.importUnregisteredFiles,
title: 'Import missing DB records',
job: null
},
duplicate_files_check: {
run: checkForDuplicateFiles,
confirm: removeDuplicates,
title: 'Find duplicate files in DB',
job: null
},
youtubedl_update_check: {
run: youtubedl_api.checkForYoutubeDLUpdate,
confirm: youtubedl_api.updateYoutubeDL,
title: 'Update youtube-dl',
job: null
}
}
function scheduleJob(task_key, schedule) {
// schedule has to be converted from our format to one node-schedule can consume
let converted_schedule = null;
if (schedule['type'] === 'timestamp') {
converted_schedule = new Date(schedule['data']['timestamp']);
} else if (schedule['type'] === 'recurring') {
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);
} else {
logger.error(`Failed to schedule job '${task_key}' as the type '${schedule['type']}' is invalid.`)
return null;
}
return scheduler.scheduleJob(converted_schedule, async () => {
const task_state = await db_api.getRecord('tasks', {key: task_key});
if (task_state['running'] || task_state['confirming']) {
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
exports.executeRun(task_key);
});
}
if (db_api.database_initialized) {
exports.setupTasks();
} else {
db_api.database_initialized_bs.subscribe(init => {
if (init) exports.setupTasks();
});
}
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 task_in_db = await db_api.getRecord('tasks', {key: task_key});
if (!task_in_db) {
// insert task metadata into table if missing
await db_api.insertRecordIntoTable('tasks', {
key: task_key,
title: TASKS[task_key]['title'],
last_ran: null,
last_confirmed: null,
running: false,
confirming: false,
data: null,
error: null,
schedule: null,
options: {}
});
} else {
// reset task if necessary
await db_api.updateRecord('tasks', {key: task_key}, {running: false, confirming: false});
// schedule task and save job
if (task_in_db['schedule']) {
// prevent timestamp schedules from being set to the past
if (task_in_db['schedule']['type'] === 'timestamp' && task_in_db['schedule']['data']['timestamp'] < Date.now()) {
await db_api.updateRecord('tasks', {key: task_key}, {schedule: null});
continue;
}
TASKS[task_key]['job'] = scheduleJob(task_key, task_in_db['schedule']);
}
}
}
}
exports.executeTask = async (task_key) => {
if (!TASKS[task_key]) {
logger.error(`Task ${task_key} does not exist!`);
return;
}
logger.verbose(`Executing task ${task_key}`);
await exports.executeRun(task_key);
if (!TASKS[task_key]['confirm']) return;
await exports.executeConfirm(task_key);
logger.verbose(`Finished executing ${task_key}`);
}
exports.executeRun = async (task_key) => {
logger.verbose(`Running task ${task_key}`);
// 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}`);
}
exports.executeConfirm = async (task_key) => {
logger.verbose(`Confirming task ${task_key}`);
if (!TASKS[task_key]['confirm']) {
return null;
}
await db_api.updateRecord('tasks', {key: task_key}, {confirming: true});
const task_obj = await db_api.getRecord('tasks', {key: task_key});
const data = task_obj['data'];
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}`);
}
exports.updateTaskSchedule = async (task_key, schedule) => {
logger.verbose(`Updating schedule for task ${task_key}`);
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);
}
}
// missing files check
async function checkForMissingFiles() {
const missing_files = [];
const all_files = await db_api.getRecords('files');
for (let i = 0; i < all_files.length; i++) {
const file_to_check = all_files[i];
const file_exists = fs.existsSync(file_to_check['path']);
if (!file_exists) missing_files.push(file_to_check['uid']);
}
return {uids: missing_files};
}
async function deleteMissingFiles(data) {
const uids = data['uids'];
for (let i = 0; i < uids.length; i++) {
const uid = uids[i];
await db_api.removeRecord('files', {uid: uid});
}
}
// duplicate files check
async function checkForDuplicateFiles() {
const duplicate_files = await db_api.findDuplicatesByKey('files', 'path');
const duplicate_uids = duplicate_files.map(duplicate_file => duplicate_file['uid']);
if (duplicate_uids && duplicate_uids.length > 0) {
return {uids: duplicate_uids};
}
return {uids: []};
}
async function removeDuplicates(data) {
for (let i = 0; i < data['uids'].length; i++) {
await db_api.removeRecord('files', {uid: data['uids'][i]});
}
}
exports.TASKS = TASKS;

File diff suppressed because one or more lines are too long

View File

@@ -1,7 +1,6 @@
const assert = require('assert'); var assert = require('assert');
const low = require('lowdb') const low = require('lowdb')
const winston = require('winston'); var winston = require('winston');
const path = require('path');
process.chdir('./backend') process.chdir('./backend')
@@ -40,29 +39,9 @@ const utils = require('../utils');
const subscriptions_api = require('../subscriptions'); const subscriptions_api = require('../subscriptions');
const fs = require('fs-extra'); const fs = require('fs-extra');
const { uuid } = require('uuidv4'); const { uuid } = require('uuidv4');
const NodeID3 = require('node-id3');
db_api.initialize(db, users_db); db_api.initialize(db, users_db, logger);
const sample_video_json = {
id: "Sample Video",
title: "Sample Video",
thumbnailURL: "https://sampleurl.jpg",
isAudio: false,
duration: 177.413,
url: "sampleurl.com",
uploader: "Sample Uploader",
size: 2838445,
path: "users\\admin\\video\\Sample Video.mp4",
upload_date: "2017-07-28",
description: null,
view_count: 230,
abr: 128,
thumbnailPath: null,
user_uid: "admin",
uid: "1ada04ab-2773-4dd4-bbdd-3e2d40761c50",
registered: 1628469039377
}
describe('Database', async function() { describe('Database', async function() {
describe('Import', async function() { describe('Import', async function() {
@@ -91,17 +70,6 @@ describe('Database', async function() {
const success = await db_api.getRecord('test', {test: 'test'}); const success = await db_api.getRecord('test', {test: 'test'});
assert(success); assert(success);
}); });
it('Restore db', async function() {
const db_stats = await db_api.getDBStats();
const file_name = await db_api.backupDB();
await db_api.restoreDB(file_name);
const new_db_stats = await db_api.getDBStats();
assert(JSON.stringify(db_stats), JSON.stringify(new_db_stats));
});
}); });
describe('Export', function() { describe('Export', function() {
@@ -115,37 +83,12 @@ describe('Database', async function() {
await db_api.removeAllRecords('test'); await db_api.removeAllRecords('test');
}); });
it('Add and read record', async function() { it('Add and read record', async function() {
this.timeout(120000);
await db_api.insertRecordIntoTable('test', {test_add: 'test', test_undefined: undefined, test_null: undefined}); await db_api.insertRecordIntoTable('test', {test_add: 'test', test_undefined: undefined, test_null: undefined});
const added_record = await db_api.getRecord('test', {test_add: 'test', test_undefined: undefined, test_null: null}); const added_record = await db_api.getRecord('test', {test_add: 'test', test_undefined: undefined, test_null: null});
assert(added_record['test_add'] === 'test'); assert(added_record['test_add'] === 'test');
await db_api.removeRecord('test', {test_add: 'test'}); await db_api.removeRecord('test', {test_add: 'test'});
}); });
it('Find duplicates by key', async function() {
const test_duplicates = [
{
test: 'testing',
key: '1'
},
{
test: 'testing',
key: '2'
},
{
test: 'testing_missing',
key: '3'
},
{
test: 'testing',
key: '4'
}
];
await db_api.insertRecordsIntoTable('test', test_duplicates);
const duplicates = await db_api.findDuplicatesByKey('test', 'test');
console.log(duplicates);
});
it('Update record', async function() { it('Update record', async function() {
await db_api.insertRecordIntoTable('test', {test_update: 'test'}); await db_api.insertRecordIntoTable('test', {test_update: 'test'});
await db_api.updateRecord('test', {test_update: 'test'}, {added_field: true}); await db_api.updateRecord('test', {test_update: 'test'}, {added_field: true});
@@ -179,7 +122,6 @@ describe('Database', async function() {
}); });
it('Bulk add', async function() { it('Bulk add', async function() {
this.timeout(120000);
const NUM_RECORDS_TO_ADD = 2002; // max batch ops is 1000 const NUM_RECORDS_TO_ADD = 2002; // max batch ops is 1000
const test_records = []; const test_records = [];
for (let i = 0; i < NUM_RECORDS_TO_ADD; i++) { for (let i = 0; i < NUM_RECORDS_TO_ADD; i++) {
@@ -235,7 +177,7 @@ describe('Database', async function() {
for (let i = 0; i < NUM_RECORDS_TO_ADD; i++) { for (let i = 0; i < NUM_RECORDS_TO_ADD; i++) {
const uid = uuid(); const uid = uuid();
if (i === NUM_RECORDS_TO_ADD/2) random_uid = uid; if (i === NUM_RECORDS_TO_ADD/2) random_uid = uid;
test_records.push({"id":"RandomTextRandomText","title":"RandomTextRandomTextRandomTextRandomTextRandomTextRandomTextRandomTextRandomText","thumbnailURL":"https://i.ytimg.com/vi/randomurl/maxresdefault.jpg","isAudio":true,"duration":312,"url":"https://www.youtube.com/watch?v=randomvideo","uploader":"randomUploader","size":5060157,"path":"audio\\RandomTextRandomText.mp3","upload_date":"2016-05-11","description":"RandomTextRandomTextRandomTextRandomTextRandomTextRandomTextRandomTextRandomTextRandomTextRandomTextRandomTextRandomText","view_count":118689353,"height":null,"abr":160,"uid": uid,"registered":1626672120632}); test_records.push({"id":"A$AP Mob - Yamborghini High (Official Music Video) ft. Juicy J","title":"A$AP Mob - Yamborghini High (Official Music Video) ft. Juicy J","thumbnailURL":"https://i.ytimg.com/vi/tt7gP_IW-1w/maxresdefault.jpg","isAudio":true,"duration":312,"url":"https://www.youtube.com/watch?v=tt7gP_IW-1w","uploader":"asapmobVEVO","size":5060157,"path":"audio\\A$AP Mob - Yamborghini High (Official Music Video) ft. Juicy J.mp3","upload_date":"2016-05-11","description":"A$AP Mob ft. Juicy J - \"Yamborghini High\" Get it now on:\niTunes: http://smarturl.it/iYAMH?IQid=yt\nListen on Spotify: http://smarturl.it/sYAMH?IQid=yt\nGoogle Play: http://smarturl.it/gYAMH?IQid=yt\nAmazon: http://smarturl.it/aYAMH?IQid=yt\n\nFollow A$AP Mob:\nhttps://www.facebook.com/asapmobofficial\nhttps://twitter.com/ASAPMOB\nhttp://instagram.com/asapmob \nhttp://www.asapmob.com/\n\n#AsapMob #YamborghiniHigh #Vevo #HipHop #OfficialMusicVideo #JuicyJ","view_count":118689353,"height":null,"abr":160,"uid": uid,"registered":1626672120632});
} }
const insert_start = Date.now(); const insert_start = Date.now();
let success = await db_api.bulkInsertRecordsIntoTable('test', test_records); let success = await db_api.bulkInsertRecordsIntoTable('test', test_records);
@@ -256,30 +198,6 @@ describe('Database', async function() {
assert(success); assert(success);
}); });
}); });
describe('Local DB Filters', async function() {
it('Basic', async function() {
const result = db_api.applyFilterLocalDB([{test: 'test'}, {test: 'test1'}], {test: 'test'}, 'find');
assert(result && result['test'] === 'test');
});
it('Regex', async function() {
const filter = {$regex: `\\w+\\d`, $options: 'i'};
const result = db_api.applyFilterLocalDB([{test: 'test'}, {test: 'test1'}], {test: filter}, 'find');
assert(result && result['test'] === 'test1');
});
it('Not equals', async function() {
const filter = {$ne: 'test'};
const result = db_api.applyFilterLocalDB([{test: 'test'}, {test: 'test1'}], {test: filter}, 'find');
assert(result && result['test'] === 'test1');
});
it('Nested', async function() {
const result = db_api.applyFilterLocalDB([{test1: {test2: 'test3'}}, {test4: 'test5'}], {'test1.test2': 'test3'}, 'find');
assert(result && result['test1']['test2'] === 'test3');
});
})
}); });
describe('Multi User', async function() { describe('Multi User', async function() {
@@ -298,12 +216,10 @@ describe('Multi User', async function() {
assert(user); assert(user);
}); });
}); });
describe('Video player - normal', async function() { describe('Video player - normal', function() {
await db_api.removeRecord('files', {uid: sample_video_json['uid']}); const video_to_test = 'ebbcfffb-d6f1-4510-ad25-d1ec82e0477e';
await db_api.insertRecordIntoTable('files', sample_video_json);
const video_to_test = sample_video_json['uid'];
it('Get video', async function() { it('Get video', async function() {
const video_obj = await db_api.getVideo(video_to_test); const video_obj = db_api.getVideo(video_to_test, 'admin');
assert(video_obj); assert(video_obj);
}); });
@@ -372,258 +288,3 @@ describe('Multi User', async function() {
// }); // });
}); });
describe('Downloader', function() {
const downloader_api = require('../downloader');
const url = 'https://www.youtube.com/watch?v=dQw4w9WgXcQ';
const sub_id = 'dc834388-3454-41bf-a618-e11cb8c7de1c';
const options = {
ui_uid: uuid(),
user: 'admin'
}
beforeEach(async function() {
await db_api.connectToDB();
await db_api.removeAllRecords('download_queue');
});
it('Get file info', async function() {
this.timeout(300000);
const info = await downloader_api.getVideoInfoByURL(url);
assert(!!info);
});
it('Download file', async function() {
this.timeout(300000);
const returned_download = await downloader_api.createDownload(url, 'video', options);
console.log(returned_download);
await utils.wait(20000);
});
it('Tag file', async function() {
const audio_path = './test/sample.mp3';
const sample_json = fs.readJSONSync('./test/sample.info.json');
const tags = {
title: sample_json['title'],
artist: sample_json['artist'] ? sample_json['artist'] : sample_json['uploader'],
TRCK: '27'
}
NodeID3.write(tags, audio_path);
const written_tags = NodeID3.read(audio_path);
assert(written_tags['raw']['TRCK'] === '27');
});
it('Queue file', async function() {
this.timeout(300000);
const returned_download = await downloader_api.createDownload(url, 'video', options);
console.log(returned_download);
await utils.wait(20000);
});
it('Pause file', async function() {
const returned_download = await downloader_api.createDownload(url, 'video', options);
await downloader_api.pauseDownload(returned_download['uid']);
const updated_download = await db_api.getRecord('download_queue', {uid: returned_download['uid']});
assert(updated_download['paused'] && !updated_download['running']);
});
it('Generate args', async function() {
const args = await downloader_api.generateArgs(url, 'video', options);
assert(args.length > 0);
});
it('Generate args - subscription', async function() {
const sub = await subscriptions_api.getSubscription(sub_id);
const sub_options = subscriptions_api.generateOptionsForSubscriptionDownload(sub, 'admin');
const args_normal = await downloader_api.generateArgs(url, 'video', options);
const args_sub = await downloader_api.generateArgs(url, 'video', sub_options, 'admin');
console.log(JSON.stringify(args_normal) !== JSON.stringify(args_sub));
});
it('Generate kodi NFO file', async function() {
const nfo_file_path = './test/sample.nfo';
if (fs.existsSync(nfo_file_path)) {
fs.unlinkSync(nfo_file_path);
}
const sample_json = fs.readJSONSync('./test/sample.info.json');
downloader_api.generateNFOFile(sample_json, nfo_file_path);
assert(fs.existsSync(nfo_file_path), true);
fs.unlinkSync(nfo_file_path);
});
it('Inject args', async function() {
const original_args1 = ['--no-resize-buffer', '-o', '%(title)s', '--no-mtime'];
const new_args1 = ['--age-limit', '25', '--yes-playlist', '--abort-on-error', '-o', '%(id)s'];
const updated_args1 = utils.injectArgs(original_args1, new_args1);
const expected_args1 = ['--no-resize-buffer', '--no-mtime', '--age-limit', '25', '--yes-playlist', '--abort-on-error', '-o', '%(id)s'];
assert(JSON.stringify(updated_args1), JSON.stringify(expected_args1));
const original_args2 = ['-o', '%(title)s.%(ext)s', '--write-info-json', '--print-json', '--audio-quality', '0', '-x', '--audio-format', 'mp3'];
const new_args2 = ['--add-metadata', '--embed-thumbnail', '--convert-thumbnails', 'jpg'];
const updated_args2 = utils.injectArgs(original_args2, new_args2);
const expected_args2 = ['-o', '%(title)s.%(ext)s', '--write-info-json', '--print-json', '--audio-quality', '0', '-x', '--audio-format', 'mp3', '--add-metadata', '--embed-thumbnail', '--convert_thumbnails', 'jpg'];
console.log(updated_args2);
assert(JSON.stringify(updated_args2), JSON.stringify(expected_args2));
});
describe('Twitch', async function () {
const twitch_api = require('../twitch');
const example_vod = '1493770675';
it('Download VOD', async function() {
const sample_path = path.join('test', 'sample.twitch_chat.json');
if (fs.existsSync(sample_path)) fs.unlinkSync(sample_path);
this.timeout(300000);
await twitch_api.downloadTwitchChatByVODID(example_vod, 'sample', null, null, null, './test');
assert(fs.existsSync(sample_path));
// cleanup
if (fs.existsSync(sample_path)) fs.unlinkSync(sample_path);
});
});
});
describe('Tasks', function() {
const tasks_api = require('../tasks');
beforeEach(async function() {
await db_api.connectToDB();
await db_api.removeAllRecords('tasks');
const dummy_task = {
run: async () => { await utils.wait(500); return true; },
confirm: async () => { await utils.wait(500); return true; },
title: 'Dummy task',
job: null
};
tasks_api.TASKS['dummy_task'] = dummy_task;
await tasks_api.setupTasks();
});
it('Backup db', async function() {
const backups_original = await utils.recFindByExt('appdata', 'bak');
const original_length = backups_original.length;
await tasks_api.executeTask('backup_local_db');
const backups_new = await utils.recFindByExt('appdata', 'bak');
const new_length = backups_new.length;
assert(original_length, new_length-1);
});
it('Check for missing files', async function() {
this.timeout(300000);
await db_api.removeAllRecords('files', {uid: 'test'});
const test_missing_file = {uid: 'test', path: 'test/missing_file.mp4'};
await db_api.insertRecordIntoTable('files', test_missing_file);
await tasks_api.executeTask('missing_files_check');
const missing_file_db_record = await db_api.getRecord('files', {uid: 'test'});
assert(!missing_file_db_record, true);
});
it('Check for duplicate files', async function() {
this.timeout(300000);
await db_api.removeAllRecords('files', {uid: 'test1'});
await db_api.removeAllRecords('files', {uid: 'test2'});
const test_duplicate_file1 = {uid: 'test1', path: 'test/missing_file.mp4'};
const test_duplicate_file2 = {uid: 'test2', path: 'test/missing_file.mp4'};
const test_duplicate_file3 = {uid: 'test3', path: 'test/missing_file.mp4'};
await db_api.insertRecordIntoTable('files', test_duplicate_file1);
await db_api.insertRecordIntoTable('files', test_duplicate_file2);
await db_api.insertRecordIntoTable('files', test_duplicate_file3);
await tasks_api.executeRun('duplicate_files_check');
const task_obj = await db_api.getRecord('tasks', {key: 'duplicate_files_check'});
assert(task_obj['data'] && task_obj['data']['uids'] && task_obj['data']['uids'].length >= 1, true);
await tasks_api.executeTask('duplicate_files_check');
const duplicated_record_count = await db_api.getRecords('files', {path: 'test/missing_file.mp4'}, true);
assert(duplicated_record_count == 1, true);
});
it('Import unregistered files', async function() {
this.timeout(300000);
// pre-test cleanup
await db_api.removeAllRecords('files', {title: 'Sample File'});
if (fs.existsSync('video/sample.info.json')) fs.unlinkSync('video/sample.info.json');
if (fs.existsSync('video/sample.mp4')) fs.unlinkSync('video/sample.mp4');
// copies in files
fs.copyFileSync('test/sample.info.json', 'video/sample.info.json');
fs.copyFileSync('test/sample.mp4', 'video/sample.mp4');
await tasks_api.executeTask('missing_db_records');
const imported_file = await db_api.getRecord('files', {title: 'Sample File'});
assert(!!imported_file, true);
// post-test cleanup
if (fs.existsSync('video/sample.info.json')) fs.unlinkSync('video/sample.info.json');
if (fs.existsSync('video/sample.mp4')) fs.unlinkSync('video/sample.mp4');
});
it('Schedule and cancel task', async function() {
this.timeout(5000);
const today_one_year = new Date();
today_one_year.setFullYear(today_one_year.getFullYear() + 1);
const schedule_obj = {
type: 'timestamp',
data: { timestamp: today_one_year.getTime() }
}
await tasks_api.updateTaskSchedule('dummy_task', schedule_obj);
const dummy_task = await db_api.getRecord('tasks', {key: 'dummy_task'});
assert(!!tasks_api.TASKS['dummy_task']['job']);
assert(!!dummy_task['schedule']);
await tasks_api.updateTaskSchedule('dummy_task', null);
const dummy_task_updated = await db_api.getRecord('tasks', {key: 'dummy_task'});
assert(!tasks_api.TASKS['dummy_task']['job']);
assert(!dummy_task_updated['schedule']);
});
it('Schedule and run task', async function() {
this.timeout(5000);
const today_1_second = new Date();
today_1_second.setSeconds(today_1_second.getSeconds() + 1);
const schedule_obj = {
type: 'timestamp',
data: { timestamp: today_1_second.getTime() }
}
await tasks_api.updateTaskSchedule('dummy_task', schedule_obj);
assert(!!tasks_api.TASKS['dummy_task']['job']);
await utils.wait(2000);
const dummy_task_obj = await db_api.getRecord('tasks', {key: 'dummy_task'});
assert(dummy_task_obj['data']);
});
});
describe('Archive', async function() {
const archive_path = path.join('test', 'archives');
fs.ensureDirSync(archive_path);
const archive_file_path = path.join(archive_path, 'archive_video.txt');
const blacklist_file_path = path.join(archive_path, 'blacklist_video.txt');
beforeEach(async function() {
if (fs.existsSync(archive_file_path)) fs.unlinkSync(archive_file_path);
fs.writeFileSync(archive_file_path, 'youtube testing1\nyoutube testing2\nyoutube testing3\n');
if (fs.existsSync(blacklist_file_path)) fs.unlinkSync(blacklist_file_path);
fs.writeFileSync(blacklist_file_path, '');
});
it('Delete from archive', async function() {
await utils.deleteFileFromArchive('N/A', 'video', archive_path, 'testing2', false);
const new_archive = fs.readFileSync(archive_file_path);
assert(!new_archive.includes('testing2'));
});
it('Delete from archive - blacklist', async function() {
await utils.deleteFileFromArchive('N/A', 'video', archive_path, 'testing2', true);
const new_archive = fs.readFileSync(archive_file_path);
const new_blacklist = fs.readFileSync(blacklist_file_path);
assert(!new_archive.includes('testing2'));
assert(new_blacklist.includes('testing2'));
});
});
describe('Utils', async function() {
it('Strip properties', async function() {
const test_obj = {test1: 'test1', test2: 'test2', test3: 'test3'};
const stripped_obj = utils.stripPropertiesFromObject(test_obj, ['test1', 'test3']);
assert(!stripped_obj['test1'] && stripped_obj['test2'] && !stripped_obj['test3'])
});
});

View File

@@ -1,64 +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 config_api = require('./config');
const logger = require('./logger');
const moment = require('moment'); async function getCommentsForVOD(clientID, vodId) {
const fs = require('fs-extra') let url = `https://api.twitch.tv/v5/videos/${vodId}/comments?content_offset_seconds=0`,
const path = require('path'); batch,
cursor;
async function getCommentsForVOD(clientID, clientSecret, vodId) { let comments = null;
const { promisify } = require('util');
const child_process = require('child_process');
const exec = promisify(child_process.exec);
// Reject invalid params to prevent command injection attack try {
if (!clientID.match(/^[0-9a-z]+$/) || !clientSecret.match(/^[0-9a-z]+$/) || !vodId.match(/^[0-9a-z]+$/)) { do {
logger.error('Client ID, client secret, and VOD ID must be purely alphanumeric. Twitch chat download failed!'); batch = (await Axios.get(url, {
return null; 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 result = await exec(`tcd --video ${vodId} --client-id ${clientID} --client-secret ${clientSecret} --format json -o appdata`, {stdio:[0,1,2]}); return comments;
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;
} }
async function getTwitchChatByFileID(id, type, user_uid, uuid, sub) { 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; let file_path = null;
if (user_uid) { if (user_uid) {
if (sub) { 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 { } 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 { } else {
if (sub) { 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 { } else {
const typeFolder = config_api.getConfigItem(`ytdl_${type}_folder_path`); file_path = path.join(type, id + '.twitch_chat.json');
file_path = path.join(typeFolder, `${id}.twitch_chat.json`);
} }
} }
@@ -70,28 +96,23 @@ async function getTwitchChatByFileID(id, type, user_uid, uuid, sub) {
return chat_file; return chat_file;
} }
async function downloadTwitchChatByVODID(vodId, id, type, user_uid, sub, customFileFolderPath = null) { async function downloadTwitchChatByVODID(vodId, id, type, user_uid, sub) {
const usersFileFolder = config_api.getConfigItem('ytdl_users_base_path'); const twitch_api_key = config_api.getConfigItem('ytdl_twitch_api_key');
const subscriptionsFileFolder = config_api.getConfigItem('ytdl_subscriptions_base_path'); const chat = await getCommentsForVOD(twitch_api_key, vodId);
const twitch_client_id = config_api.getConfigItem('ytdl_twitch_client_id');
const twitch_client_secret = config_api.getConfigItem('ytdl_twitch_client_secret');
const chat = await getCommentsForVOD(twitch_client_id, twitch_client_secret, vodId);
// save file if needed params are included // save file if needed params are included
let file_path = null; let file_path = null;
if (customFileFolderPath) { if (user_uid) {
file_path = path.join(customFileFolderPath, `${id}.twitch_chat.json`)
} else if (user_uid) {
if (sub) { 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 { } 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 { } else {
if (sub) { 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 { } else {
file_path = path.join(type, `${id}.twitch_chat.json`); file_path = path.join(type, id + '.twitch_chat.json');
} }
} }
@@ -100,14 +121,6 @@ async function downloadTwitchChatByVODID(vodId, id, type, user_uid, sub, customF
return chat; 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 = { module.exports = {
getCommentsForVOD: getCommentsForVOD, getCommentsForVOD: getCommentsForVOD,
getTwitchChatByFileID: getTwitchChatByFileID, getTwitchChatByFileID: getTwitchChatByFileID,

View File

@@ -1,13 +1,7 @@
const fs = require('fs-extra'); const fs = require('fs-extra')
const path = require('path'); const path = require('path')
const ffmpeg = require('fluent-ffmpeg');
const archiver = require('archiver');
const fetch = require('node-fetch');
const ProgressBar = require('progress');
const config_api = require('./config'); const config_api = require('./config');
const logger = require('./logger'); const archiver = require('archiver');
const CONSTS = require('./consts');
const is_windows = process.platform === 'win32'; const is_windows = process.platform === 'win32';
@@ -48,7 +42,8 @@ async function getDownloadedFilesByType(basePath, type, full_metadata = false) {
files.push(jsonobj); files.push(jsonobj);
continue; continue;
} }
var upload_date = formatDateString(jsonobj.upload_date); var upload_date = jsonobj.upload_date;
upload_date = upload_date ? `${upload_date.substring(0, 4)}-${upload_date.substring(4, 6)}-${upload_date.substring(6, 8)}` : null;
var isaudio = type === 'audio'; var isaudio = type === 'audio';
var file_obj = new 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,
@@ -58,13 +53,13 @@ async function getDownloadedFilesByType(basePath, type, full_metadata = false) {
return files; return files;
} }
async function createContainerZipFile(file_name, container_file_objs) { async function createContainerZipFile(container_obj, container_file_objs) {
const container_files_to_download = []; const container_files_to_download = [];
for (let i = 0; i < container_file_objs.length; i++) { for (let i = 0; i < container_file_objs.length; i++) {
const container_file_obj = container_file_objs[i]; const container_file_obj = container_file_objs[i];
container_files_to_download.push(container_file_obj.path); container_files_to_download.push(container_file_obj.path);
} }
return await createZipFile(path.join('appdata', file_name + '.zip'), container_files_to_download); return await createZipFile(path.join('appdata', container_obj.name + '.zip'), container_files_to_download);
} }
async function createZipFile(zip_file_path, file_paths) { async function createZipFile(zip_file_path, file_paths) {
@@ -146,7 +141,24 @@ function getJSONByType(type, name, customPath, openReadPerms = false) {
return type === 'audio' ? getJSONMp3(name, customPath, openReadPerms) : getJSONMp4(name, customPath, openReadPerms) return type === 'audio' ? getJSONMp3(name, customPath, openReadPerms) : getJSONMp4(name, customPath, openReadPerms)
} }
function getDownloadedThumbnail(file_path) { function getDownloadedThumbnail(name, type, customPath = null) {
if (!customPath) customPath = type === 'audio' ? config_api.getConfigItem('ytdl_audio_folder_path') : config_api.getConfigItem('ytdl_video_folder_path');
let jpgPath = path.join(customPath, name + '.jpg');
let webpPath = path.join(customPath, name + '.webp');
let pngPath = path.join(customPath, name + '.png');
if (fs.existsSync(jpgPath))
return jpgPath;
else if (fs.existsSync(webpPath))
return webpPath;
else if (fs.existsSync(pngPath))
return pngPath;
else
return null;
}
function getDownloadedThumbnail2(file_path, type) {
const file_path_no_extension = removeFileExtension(file_path); const file_path_no_extension = removeFileExtension(file_path);
let jpgPath = file_path_no_extension + '.jpg'; let jpgPath = file_path_no_extension + '.jpg';
@@ -169,12 +181,16 @@ function getExpectedFileSize(input_info_jsons) {
let expected_filesize = 0; let expected_filesize = 0;
info_jsons.forEach(info_json => { info_jsons.forEach(info_json => {
if (info_json['filesize']) {
expected_filesize += info_json['filesize'];
return;
}
const formats = info_json['format_id'].split('+'); const formats = info_json['format_id'].split('+');
let individual_expected_filesize = 0; let individual_expected_filesize = 0;
formats.forEach(format_id => { formats.forEach(format_id => {
info_json.formats.forEach(available_format => { info_json.formats.forEach(available_format => {
if (available_format.format_id === format_id && (available_format.filesize || available_format.filesize_approx)) { if (available_format.format_id === format_id && available_format.filesize) {
individual_expected_filesize += (available_format.filesize ? available_format.filesize : available_format.filesize_approx); individual_expected_filesize += available_format.filesize;
} }
}); });
}); });
@@ -184,7 +200,29 @@ function getExpectedFileSize(input_info_jsons) {
return expected_filesize; return expected_filesize;
} }
function fixVideoMetadataPerms(file_path, type) { function fixVideoMetadataPerms(name, type, customPath = null) {
if (is_windows) return;
if (!customPath) customPath = type === 'audio' ? config_api.getConfigItem('ytdl_audio_folder_path')
: config_api.getConfigItem('ytdl_video_folder_path');
const ext = type === 'audio' ? '.mp3' : '.mp4';
const files_to_fix = [
// JSONs
path.join(customPath, name + '.info.json'),
path.join(customPath, name + ext + '.info.json'),
// Thumbnails
path.join(customPath, name + '.webp'),
path.join(customPath, name + '.jpg')
];
for (const file of files_to_fix) {
if (!fs.existsSync(file)) continue;
fs.chmodSync(file, 0o644);
}
}
function fixVideoMetadataPerms2(file_path, type) {
if (is_windows) return; if (is_windows) return;
const ext = type === 'audio' ? '.mp3' : '.mp4'; const ext = type === 'audio' ? '.mp3' : '.mp4';
@@ -206,7 +244,19 @@ function fixVideoMetadataPerms(file_path, type) {
} }
} }
function deleteJSONFile(file_path, type) { function deleteJSONFile(name, type, customPath = null) {
if (!customPath) customPath = type === 'audio' ? config_api.getConfigItem('ytdl_audio_folder_path')
: config_api.getConfigItem('ytdl_video_folder_path');
const ext = type === 'audio' ? '.mp3' : '.mp4';
let json_path = path.join(customPath, name + '.info.json');
let alternate_json_path = path.join(customPath, name + ext + '.info.json');
if (fs.existsSync(json_path)) fs.unlinkSync(json_path);
if (fs.existsSync(alternate_json_path)) fs.unlinkSync(alternate_json_path);
}
function deleteJSONFile2(file_path, type) {
const ext = type === 'audio' ? '.mp3' : '.mp4'; const ext = type === 'audio' ? '.mp3' : '.mp4';
const file_path_no_extension = removeFileExtension(file_path); const file_path_no_extension = removeFileExtension(file_path);
@@ -218,11 +268,8 @@ function deleteJSONFile(file_path, type) {
if (fs.existsSync(alternate_json_path)) fs.unlinkSync(alternate_json_path); if (fs.existsSync(alternate_json_path)) fs.unlinkSync(alternate_json_path);
} }
// archive helper functions async function removeIDFromArchive(archive_path, id) {
let data = await fs.readFile(archive_path, {encoding: 'utf-8'});
async function removeIDFromArchive(archive_path, type, id) {
const archive_file = path.join(archive_path, `archive_${type}.txt`);
const data = await fs.readFile(archive_file, {encoding: 'utf-8'});
if (!data) { if (!data) {
logger.error('Archive could not be found.'); logger.error('Archive could not be found.');
return; return;
@@ -239,34 +286,13 @@ async function removeIDFromArchive(archive_path, type, id) {
} }
} }
if (lastIndex === -1) return null;
const line = dataArray.splice(lastIndex, 1); // remove the keyword id from the data Array const line = dataArray.splice(lastIndex, 1); // remove the keyword id from the data Array
// UPDATE FILE WITH NEW DATA // UPDATE FILE WITH NEW DATA
const updatedData = dataArray.join('\n'); const updatedData = dataArray.join('\n');
await fs.writeFile(archive_file, updatedData); await fs.writeFile(archive_path, updatedData);
if (line) return Array.isArray(line) && line.length === 1 ? line[0] : line; if (line) return line;
} if (err) throw err;
async function writeToBlacklist(archive_folder, type, line) {
let blacklistPath = path.join(archive_folder, (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);
}
async function deleteFileFromArchive(uid, type, archive_path, id, blacklistMode) {
const archive_file = path.join(archive_path, `archive_${type}.txt`);
if (await fs.pathExists(archive_path)) {
const line = id ? await removeIDFromArchive(archive_path, type, id) : null;
if (blacklistMode && line) await writeToBlacklist(archive_path, type, line);
} else {
logger.info(`Could not find archive file for file ${uid}. Creating...`);
await fs.close(await fs.open(archive_file, 'w'));
}
} }
function durationStringToNumber(dur_str) { function durationStringToNumber(dur_str) {
@@ -289,12 +315,7 @@ function addUIDsToCategory(category, files) {
return files_that_match; return files_that_match;
} }
function getCurrentDownloader() { async function recFindByExt(base,ext,files,result)
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)) files = files || (await fs.readdir(base))
result = result || [] result = result || []
@@ -303,7 +324,6 @@ async function recFindByExt(base, ext, files, result, recursive = true)
var newbase = path.join(base,file) var newbase = path.join(base,file)
if ( (await fs.stat(newbase)).isDirectory() ) if ( (await fs.stat(newbase)).isDirectory() )
{ {
if (!recursive) continue;
result = await recFindByExt(newbase,ext,await fs.readdir(newbase),result) result = await recFindByExt(newbase,ext,await fs.readdir(newbase),result)
} }
else else
@@ -323,57 +343,6 @@ function removeFileExtension(filename) {
return filename_parts.join('.'); return filename_parts.join('.');
} }
function formatDateString(date_string) {
return date_string ? `${date_string.substring(0, 4)}-${date_string.substring(4, 6)}-${date_string.substring(6, 8)}` : 'N/A';
}
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) {
for (let i = minGram; i <= maxGram && i <= token.length; ++i) {
ngrams = [...ngrams, token.substr(0, i)]
}
} else {
ngrams = [...ngrams, token]
}
return ngrams
}, []).join(" ")
}
return str
}
// ffmpeg helper functions
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);
if (start) {
base_ffmpeg_call = base_ffmpeg_call.seekOutput(start);
}
if (end) {
base_ffmpeg_call = base_ffmpeg_call.duration(end - start);
}
base_ffmpeg_call
.on('end', () => {
logger.verbose(`Cropping for '${file_path}' complete.`);
fs.unlinkSync(file_path);
fs.moveSync(temp_file_path, file_path);
resolve(true);
})
.on('error', (err) => {
logger.error(`Failed to crop ${file_path}.`);
logger.error(err);
resolve(false);
}).save(temp_file_path);
});
}
/** /**
* setTimeout, but its a promise. * setTimeout, but its a promise.
* @param {number} ms * @param {number} ms
@@ -384,157 +353,6 @@ async function cropFile(file_path, start, end, ext) {
}); });
} }
async function checkExistsWithTimeout(filePath, timeout) {
return new Promise(function (resolve, reject) {
var timer = setTimeout(function () {
if (watcher) watcher.close();
reject(new Error('File did not exists and was not created during the timeout.'));
}, timeout);
fs.access(filePath, fs.constants.R_OK, function (err) {
if (!err) {
clearTimeout(timer);
if (watcher) watcher.close();
resolve();
}
});
var dir = path.dirname(filePath);
var basename = path.basename(filePath);
var watcher = fs.watch(dir, function (eventType, filename) {
if (eventType === 'rename' && filename === basename) {
clearTimeout(timer);
if (watcher) watcher.close();
resolve();
}
});
});
}
// helper function to download file using fetch
async function fetchFile(url, path, file_label) {
var len = null;
const res = await fetch(url);
len = parseInt(res.headers.get("Content-Length"), 10);
var bar = new ProgressBar(` Downloading ${file_label} [:bar] :percent :etas`, {
complete: '=',
incomplete: ' ',
width: 20,
total: len
});
const fileStream = fs.createWriteStream(path);
await new Promise((resolve, reject) => {
res.body.pipe(fileStream);
res.body.on("error", (err) => {
reject(err);
});
res.body.on('data', function (chunk) {
bar.tick(chunk.length);
});
fileStream.on("finish", function() {
resolve();
});
});
}
async function restartServer(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
function 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);
original_args.splice(original_index, 2);
}
updated_args.push(new_arg, new_args[i + 1]);
} 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;
}
function filterArgs(args, args_to_remove) {
return args.filter(x => !args_to_remove.includes(x));
}
const searchObjectByString = function(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;
}
function 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;
}
function 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');
}
}
}
// objects // objects
function File(id, title, thumbnailURL, isAudio, duration, url, uploader, size, path, upload_date, description, view_count, height, abr) { function File(id, title, thumbnailURL, isAudio, duration, url, uploader, size, path, upload_date, description, view_count, height, abr) {
@@ -560,30 +378,20 @@ module.exports = {
getJSON: getJSON, getJSON: getJSON,
getTrueFileName: getTrueFileName, getTrueFileName: getTrueFileName,
getDownloadedThumbnail: getDownloadedThumbnail, getDownloadedThumbnail: getDownloadedThumbnail,
getDownloadedThumbnail2: getDownloadedThumbnail2,
getExpectedFileSize: getExpectedFileSize, getExpectedFileSize: getExpectedFileSize,
fixVideoMetadataPerms: fixVideoMetadataPerms, fixVideoMetadataPerms: fixVideoMetadataPerms,
fixVideoMetadataPerms2: fixVideoMetadataPerms2,
deleteJSONFile: deleteJSONFile, deleteJSONFile: deleteJSONFile,
removeIDFromArchive: removeIDFromArchive, deleteJSONFile2: deleteJSONFile2,
writeToBlacklist: writeToBlacklist, removeIDFromArchive, removeIDFromArchive,
deleteFileFromArchive: deleteFileFromArchive,
getDownloadedFilesByType: getDownloadedFilesByType, getDownloadedFilesByType: getDownloadedFilesByType,
createContainerZipFile: createContainerZipFile, createContainerZipFile: createContainerZipFile,
durationStringToNumber: durationStringToNumber, durationStringToNumber: durationStringToNumber,
getMatchingCategoryFiles: getMatchingCategoryFiles, getMatchingCategoryFiles: getMatchingCategoryFiles,
getCurrentDownloader: getCurrentDownloader, addUIDsToCategory: addUIDsToCategory,
recFindByExt: recFindByExt, recFindByExt: recFindByExt,
removeFileExtension: removeFileExtension, removeFileExtension: removeFileExtension,
formatDateString: formatDateString,
cropFile: cropFile,
createEdgeNGrams: createEdgeNGrams,
wait: wait, wait: wait,
checkExistsWithTimeout: checkExistsWithTimeout,
fetchFile: fetchFile,
restartServer: restartServer,
injectArgs: injectArgs,
filterArgs: filterArgs,
searchObjectByString: searchObjectByString,
stripPropertiesFromObject: stripPropertiesFromObject,
getArchiveFolder: getArchiveFolder,
File: File File: File
} }

View File

@@ -1,141 +0,0 @@
const fs = require('fs-extra');
const fetch = require('node-fetch');
const logger = require('./logger');
const utils = require('./utils');
const CONSTS = require('./consts');
const config_api = require('./config.js');
const OUTDATED_VERSION = "2020.00.00";
const is_windows = process.platform === 'win32';
const download_sources = {
'youtube-dl': {
'tags_url': 'https://api.github.com/repos/ytdl-org/youtube-dl/tags',
'func': downloadLatestYoutubeDLBinary
},
'youtube-dlc': {
'tags_url': 'https://api.github.com/repos/blackjack4494/yt-dlc/tags',
'func': downloadLatestYoutubeDLCBinary
},
'yt-dlp': {
'tags_url': 'https://api.github.com/repos/yt-dlp/yt-dlp/tags',
'func': downloadLatestYoutubeDLPBinary
}
}
exports.checkForYoutubeDLUpdate = async () => {
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": OUTDATED_VERSION, "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;
}
}
// got version, now let's check the latest version from the youtube-dl API
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 ${default_downloader} version for an update.`)
resolve(null);
return;
}
const latest_update_version = json[0]['name'];
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 ${default_downloader} version for an update.`)
logger.error(err);
resolve(null);
return;
});
});
}
exports.updateYoutubeDL = async (latest_update_version) => {
const default_downloader = config_api.getConfigItem('ytdl_default_downloader');
await download_sources[default_downloader]['func'](latest_update_version);
}
exports.verifyBinaryExistsLinux = () => {
const details_json = fs.readJSONSync(CONSTS.DETAILS_BIN_PATH);
if (!is_windows && details_json && (!details_json['path'] || details_json['path'].includes('.exe'))) {
details_json['path'] = 'node_modules/youtube-dl/bin/youtube-dl';
details_json['exec'] = 'youtube-dl';
details_json['version'] = OUTDATED_VERSION;
fs.writeJSONSync(CONSTS.DETAILS_BIN_PATH, details_json);
utils.restartServer();
}
}
async function downloadLatestYoutubeDLBinary(new_version) {
const file_ext = is_windows ? '.exe' : '';
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

@@ -21,4 +21,4 @@ version: 0.1.0
# incremented each time you make changes to the application. Versions are not expected to # 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. # follow Semantic Versioning. They should reflect the version the application is using.
# It is recommended to use it with quotes. # It is recommended to use it with quotes.
appVersion: "4.3" appVersion: "4.2"

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-----

View File

@@ -7,8 +7,6 @@ services:
ytdl_use_local_db: 'false' ytdl_use_local_db: 'false'
write_ytdl_config: 'true' write_ytdl_config: 'true'
restart: always restart: always
depends_on:
- ytdl-mongo-db
volumes: volumes:
- ./appdata:/app/appdata - ./appdata:/app/appdata
- ./audio:/app/audio - ./audio:/app/audio
@@ -25,6 +23,5 @@ services:
logging: logging:
driver: "none" driver: "none"
container_name: mongo-db container_name: mongo-db
restart: always
volumes: volumes:
- ./db/:/data/db - ./db/:/data/db

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 10 \
--retry 5 \
--retry-delay 0 \
--retry-max-time 40 \
"https://johnvansickle.com/ffmpeg/builds/ffmpeg-git-${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

13486
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,19 +1,16 @@
{ {
"name": "youtube-dl-material", "name": "youtube-dl-material",
"version": "4.3.0", "version": "4.2.0",
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {
"ng": "ng", "ng": "ng",
"start": "ng serve", "start": "ng serve",
"build": "ng build --configuration production", "build": "ng build",
"prebuild": "node src/postbuild.mjs",
"heroku-postbuild": "npm install --prefix backend", "heroku-postbuild": "npm install --prefix backend",
"test": "ng test", "test": "ng test",
"lint": "ng lint", "lint": "ng lint",
"e2e": "ng e2e", "e2e": "ng e2e",
"electron": "ng build --base-href ./ && electron .", "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"
}, },
"engines": { "engines": {
"node": "12.3.1", "node": "12.3.1",
@@ -21,62 +18,55 @@
}, },
"private": true, "private": true,
"dependencies": { "dependencies": {
"@angular-devkit/core": "^13.3.3", "@angular-devkit/core": "^11.0.4",
"@angular/animations": "^13.3.4", "@angular/animations": "^11.0.4",
"@angular/cdk": "^13.3.4", "@angular/cdk": "^11.0.2",
"@angular/common": "^13.3.4", "@angular/common": "^11.0.4",
"@angular/compiler": "^13.3.4", "@angular/compiler": "^11.0.4",
"@angular/core": "^13.3.4", "@angular/core": "^11.0.4",
"@angular/forms": "^13.3.4", "@angular/forms": "^11.0.4",
"@angular/localize": "^13.3.4", "@angular/localize": "^11.0.4",
"@angular/material": "^13.3.4", "@angular/material": "^11.0.2",
"@angular/platform-browser": "^13.3.4", "@angular/platform-browser": "^11.0.4",
"@angular/platform-browser-dynamic": "^13.3.4", "@angular/platform-browser-dynamic": "^11.0.4",
"@angular/router": "^13.3.4", "@angular/router": "^11.0.4",
"@fontsource/material-icons": "^4.5.4",
"@ngneat/content-loader": "^5.0.0", "@ngneat/content-loader": "^5.0.0",
"@videogular/ngx-videogular": "^5.0.1", "@videogular/ngx-videogular": "^2.1.0",
"core-js": "^2.4.1", "core-js": "^2.4.1",
"crypto-js": "^4.1.1",
"file-saver": "^2.0.2", "file-saver": "^2.0.2",
"filesize": "^6.1.0", "filesize": "^6.1.0",
"fingerprintjs2": "^2.1.0", "fingerprintjs2": "^2.1.0",
"fs-extra": "^10.0.0", "material-icons": "^0.5.4",
"material-icons": "^1.10.8",
"nan": "^2.14.1", "nan": "^2.14.1",
"ng-lazyload-image": "^7.0.1", "ng-lazyload-image": "^7.0.1",
"ngx-avatars": "^1.3.1", "ngx-avatar": "^4.0.0",
"ngx-file-drop": "^13.0.0", "ngx-file-drop": "^9.0.1",
"rxjs": "^6.6.3", "rxjs": "^6.6.3",
"rxjs-compat": "^6.0.0-rc.0", "rxjs-compat": "^6.0.0-rc.0",
"tslib": "^2.0.0", "tslib": "^2.0.0",
"typescript": "~4.6.3", "typescript": "~4.0.5",
"xliff-to-json": "^1.0.4", "web-animations-js": "^2.3.2",
"zone.js": "~0.11.4" "zone.js": "~0.10.2"
}, },
"devDependencies": { "devDependencies": {
"@angular-devkit/build-angular": "^13.3.3", "@angular-devkit/build-angular": "^0.1100.4",
"@angular/cli": "^13.3.3", "@angular/cli": "^11.0.4",
"@angular/compiler-cli": "^13.3.4", "@angular/compiler-cli": "^11.0.4",
"@angular/language-service": "^13.3.4", "@angular/language-service": "^11.0.4",
"@types/core-js": "^2.5.2", "@types/core-js": "^2.5.2",
"@types/file-saver": "^2.0.1", "@types/file-saver": "^2.0.1",
"@types/jasmine": "~3.6.0", "@types/jasmine": "~3.6.0",
"@types/node": "^12.11.1", "@types/node": "^12.11.1",
"@typescript-eslint/eslint-plugin": "^4.29.0",
"@typescript-eslint/parser": "^4.29.0",
"codelyzer": "^6.0.0", "codelyzer": "^6.0.0",
"electron": "^19.0.6", "electron": "^8.0.1",
"eslint": "^7.32.0",
"jasmine-core": "~3.6.0", "jasmine-core": "~3.6.0",
"jasmine-spec-reporter": "~5.0.0", "jasmine-spec-reporter": "~5.0.0",
"karma": "~6.3.16", "karma": "~5.0.0",
"karma-chrome-launcher": "~3.1.0", "karma-chrome-launcher": "~3.1.0",
"karma-cli": "~1.0.1", "karma-cli": "~1.0.1",
"karma-coverage-istanbul-reporter": "~3.0.2", "karma-coverage-istanbul-reporter": "~3.0.2",
"karma-jasmine": "~4.0.0", "karma-jasmine": "~4.0.0",
"karma-jasmine-html-reporter": "^1.5.0", "karma-jasmine-html-reporter": "^1.5.0",
"openapi-typescript-codegen": "^0.21.0",
"protractor": "~7.0.0", "protractor": "~7.0.0",
"ts-node": "~3.0.4", "ts-node": "~3.0.4",
"tslint": "~6.1.0" "tslint": "~6.1.0"

Binary file not shown.

View File

@@ -1,11 +1,11 @@
/* Coolors Exported Palette - coolors.co/e8aeb7-b8e1ff-a9fff7-94fbab-82aba1 */ /* Coolors Exported Palette - coolors.co/e8aeb7-b8e1ff-a9fff7-94fbab-82aba1 */
/* HSL */ /* HSL */
$color1: hsla(351, 56%, 80%, 1); $color1: hsla(351%, 56%, 80%, 1);
$softblue: hsla(205, 100%, 86%, 1); $softblue: hsla(205%, 100%, 86%, 1);
$color3: hsla(174, 100%, 83%, 1); $color3: hsla(174%, 100%, 83%, 1);
$color4: hsla(133, 93%, 78%, 1); $color4: hsla(133%, 93%, 78%, 1);
$color5: hsla(165, 20%, 59%, 1); $color5: hsla(165%, 20%, 59%, 1);
/* RGB */ /* RGB */
$color1: rgba(232, 174, 183, 1); $color1: rgba(232, 174, 183, 1);

View File

@@ -1,117 +0,0 @@
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type { AddFileToPlaylistRequest } from './models/AddFileToPlaylistRequest';
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';
export { CategoryRule } from './models/CategoryRule';
export type { ChangeRolePermissionsRequest } from './models/ChangeRolePermissionsRequest';
export type { ChangeUserPermissionsRequest } from './models/ChangeUserPermissionsRequest';
export type { CheckConcurrentStreamRequest } from './models/CheckConcurrentStreamRequest';
export type { CheckConcurrentStreamResponse } from './models/CheckConcurrentStreamResponse';
export type { ClearDownloadsRequest } from './models/ClearDownloadsRequest';
export type { ConcurrentStream } from './models/ConcurrentStream';
export type { Config } from './models/Config';
export type { ConfigResponse } from './models/ConfigResponse';
export type { CreateCategoryRequest } from './models/CreateCategoryRequest';
export type { CreateCategoryResponse } from './models/CreateCategoryResponse';
export type { CreatePlaylistRequest } from './models/CreatePlaylistRequest';
export type { CreatePlaylistResponse } from './models/CreatePlaylistResponse';
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 { DeleteCategoryRequest } from './models/DeleteCategoryRequest';
export type { DeleteMp3Mp4Request } from './models/DeleteMp3Mp4Request';
export type { DeletePlaylistRequest } from './models/DeletePlaylistRequest';
export type { DeleteSubscriptionFileRequest } from './models/DeleteSubscriptionFileRequest';
export type { DeleteUserRequest } from './models/DeleteUserRequest';
export type { Download } from './models/Download';
export type { DownloadArchiveRequest } from './models/DownloadArchiveRequest';
export type { DownloadFileRequest } from './models/DownloadFileRequest';
export type { DownloadRequest } from './models/DownloadRequest';
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 { 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 { GetDBBackupsResponse } from './models/GetDBBackupsResponse';
export type { GetDownloadRequest } from './models/GetDownloadRequest';
export type { GetDownloadResponse } from './models/GetDownloadResponse';
export type { GetFileFormatsRequest } from './models/GetFileFormatsRequest';
export type { GetFileFormatsResponse } from './models/GetFileFormatsResponse';
export type { GetFileRequest } from './models/GetFileRequest';
export type { GetFileResponse } from './models/GetFileResponse';
export type { GetFullTwitchChatRequest } from './models/GetFullTwitchChatRequest';
export type { GetFullTwitchChatResponse } from './models/GetFullTwitchChatResponse';
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 { GetPlaylistRequest } from './models/GetPlaylistRequest';
export type { GetPlaylistResponse } from './models/GetPlaylistResponse';
export type { GetPlaylistsRequest } from './models/GetPlaylistsRequest';
export type { GetPlaylistsResponse } from './models/GetPlaylistsResponse';
export type { GetRolesResponse } from './models/GetRolesResponse';
export type { GetSubscriptionRequest } from './models/GetSubscriptionRequest';
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 { 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 { Playlist } from './models/Playlist';
export type { RegisterRequest } from './models/RegisterRequest';
export type { RegisterResponse } from './models/RegisterResponse';
export type { RestoreDBBackupRequest } from './models/RestoreDBBackupRequest';
export { Schedule } from './models/Schedule';
export type { SetConfigRequest } from './models/SetConfigRequest';
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';
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 type { TestConnectionStringRequest } from './models/TestConnectionStringRequest';
export type { TestConnectionStringResponse } from './models/TestConnectionStringResponse';
export type { TransferDBRequest } from './models/TransferDBRequest';
export type { TransferDBResponse } from './models/TransferDBResponse';
export type { TwitchChatMessage } from './models/TwitchChatMessage';
export type { UnsubscribeRequest } from './models/UnsubscribeRequest';
export type { UnsubscribeResponse } from './models/UnsubscribeResponse';
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 { UpdateTaskScheduleRequest } from './models/UpdateTaskScheduleRequest';
export type { UpdateUserRequest } from './models/UpdateUserRequest';
export type { User } from './models/User';
export { UserPermission } from './models/UserPermission';
export type { Version } from './models/Version';
export type { VersionInfoResponse } from './models/VersionInfoResponse';
export { YesNo } from './models/YesNo';

View File

@@ -1,8 +0,0 @@
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type AddFileToPlaylistRequest = {
file_uid: string;
playlist_id: string;
};

View File

@@ -1,11 +0,0 @@
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { UserPermission } from './UserPermission';
import type { YesNo } from './YesNo';
export type BaseChangePermissionsRequest = {
permission: UserPermission;
new_value: YesNo;
};

View File

@@ -1,15 +0,0 @@
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { CategoryRule } from './CategoryRule';
export type Category = {
name?: string;
uid?: string;
rules?: Array<CategoryRule>;
/**
* Overrides file output for downloaded files in category
*/
custom_output?: string;
};

View File

@@ -1,25 +0,0 @@
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type CategoryRule = {
preceding_operator?: CategoryRule.preceding_operator;
comparator?: CategoryRule.comparator;
};
export namespace CategoryRule {
export enum preceding_operator {
OR = 'or',
AND = 'and',
}
export enum comparator {
INCLUDES = 'includes',
NOT_INCLUDES = 'not_includes',
EQUALS = 'equals',
NOT_EQUALS = 'not_equals',
}
}

View File

@@ -1,9 +0,0 @@
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { BaseChangePermissionsRequest } from './BaseChangePermissionsRequest';
export type ChangeRolePermissionsRequest = (BaseChangePermissionsRequest & {
role: string;
});

View File

@@ -1,9 +0,0 @@
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { BaseChangePermissionsRequest } from './BaseChangePermissionsRequest';
export type ChangeUserPermissionsRequest = (BaseChangePermissionsRequest & {
user_uid: string;
});

View File

@@ -1,10 +0,0 @@
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type CheckConcurrentStreamRequest = {
/**
* UID of the concurrent stream
*/
uid: string;
};

View File

@@ -1,9 +0,0 @@
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { ConcurrentStream } from './ConcurrentStream';
export type CheckConcurrentStreamResponse = {
stream: ConcurrentStream;
};

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

@@ -1,9 +0,0 @@
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type ConcurrentStream = {
playback_timestamp?: number;
unix_timestamp?: number;
playing?: boolean;
};

View File

@@ -1,7 +0,0 @@
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type Config = {
YoutubeDLMaterial: any;
};

View File

@@ -1,10 +0,0 @@
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { Config } from './Config';
export type ConfigResponse = {
config_file: Config;
success: boolean;
};

View File

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

View File

@@ -1,10 +0,0 @@
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { Category } from './Category';
export type CreateCategoryResponse = {
new_category?: Category;
success?: boolean;
};

View File

@@ -1,9 +0,0 @@
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type CreatePlaylistRequest = {
playlistName: string;
uids: Array<string>;
thumbnailURL: string;
};

View File

@@ -1,10 +0,0 @@
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { Playlist } from './Playlist';
export type CreatePlaylistResponse = {
new_playlist: Playlist;
success: boolean;
};

View File

@@ -1,8 +0,0 @@
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type CropFileSettings = {
cropFileStart: number;
cropFileEnd: number;
};

View File

@@ -1,20 +0,0 @@
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type DBBackup = {
name: string;
timestamp: number;
size: number;
source: DBBackup.source;
};
export namespace DBBackup {
export enum source {
LOCAL = 'local',
REMOTE = 'remote',
}
}

View File

@@ -1,18 +0,0 @@
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { TableInfo } from './TableInfo';
export type DBInfoResponse = {
using_local_db?: boolean;
stats_by_table?: {
files?: TableInfo;
playlists?: TableInfo;
categories?: TableInfo;
subscriptions?: TableInfo;
users?: TableInfo;
roles?: TableInfo;
download_queue?: TableInfo;
};
};

View File

@@ -1,43 +0,0 @@
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { Category } from './Category';
export type DatabaseFile = {
id: string;
title: string;
/**
* Backup if thumbnailPath is not defined
*/
thumbnailURL: string;
thumbnailPath?: string;
isAudio: boolean;
/**
* In seconds
*/
duration: number;
url: string;
uploader: string;
/**
* In bytes
*/
size: number;
path: string;
upload_date: string;
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;
};

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,7 +0,0 @@
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type DeleteCategoryRequest = {
category_uid: string;
};

View File

@@ -1,8 +0,0 @@
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type DeleteMp3Mp4Request = {
uid: string;
blacklistMode?: boolean;
};

View File

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

View File

@@ -1,15 +0,0 @@
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { SubscriptionRequestData } from './SubscriptionRequestData';
export type 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

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

View File

@@ -1,26 +0,0 @@
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type Download = {
uid: string;
ui_uid?: string;
running: boolean;
finished: boolean;
paused: boolean;
finished_step: boolean;
url: string;
type: string;
title: string;
step_index: number;
percent_complete: number;
timestamp_start: number;
/**
* Error text, set if download fails.
*/
error?: string | null;
user_uid?: string;
sub_id?: string;
sub_name?: string;
prefetched_info?: any;
};

View File

@@ -1,9 +0,0 @@
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type DownloadArchiveRequest = {
sub: {
archive_dir: string;
};
};

View File

@@ -1,14 +0,0 @@
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { FileType } from './FileType';
export type DownloadFileRequest = {
uid?: string;
uuid?: string;
sub_id?: string;
playlist_id?: string;
url?: string;
type?: FileType;
};

View File

@@ -1,44 +0,0 @@
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { CropFileSettings } from './CropFileSettings';
import type { FileType } from './FileType';
export type DownloadRequest = {
url: string;
/**
* Video format code. Overrides other quality options.
*/
customQualityConfiguration?: string;
/**
* Custom command-line arguments for youtube-dl. Overrides all other options, except url.
*/
customArgs?: string;
/**
* Additional command-line arguments for youtube-dl. Added to whatever args would normally be used.
*/
additionalArgs?: string;
/**
* Custom output filename template.
*/
customOutput?: string;
/**
* Login with this account ID
*/
youtubeUsername?: string;
/**
* Account password
*/
youtubePassword?: string;
/**
* Height of the video, if known
*/
selectedHeight?: string;
/**
* Specify ffmpeg/avconv audio quality
*/
maxBitrate?: string;
type?: FileType;
cropFileSettings?: CropFileSettings;
};

View File

@@ -1,9 +0,0 @@
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { Download } from './Download';
export type DownloadResponse = {
download?: Download;
};

View File

@@ -1,23 +0,0 @@
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { FileType } from './FileType';
import type { Subscription } from './Subscription';
export type DownloadTwitchChatByVODIDRequest = {
/**
* File ID
*/
id: string;
/**
* ID of the VOD
*/
vodId: string;
type: FileType;
/**
* User UID
*/
uuid?: string;
sub?: Subscription;
};

View File

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

View File

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

View File

@@ -1,8 +0,0 @@
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export enum FileType {
AUDIO = 'audio',
VIDEO = 'video',
}

View File

@@ -1,9 +0,0 @@
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export enum FileTypeFilter {
AUDIO_ONLY = 'audio_only',
VIDEO_ONLY = 'video_only',
BOTH = 'both',
}

View File

@@ -1,7 +0,0 @@
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type GenerateArgsResponse = {
args?: Array<string>;
};

View File

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

View File

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

View File

@@ -1,10 +0,0 @@
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type GetAllDownloadsRequest = {
/**
* Filters downloads with the array
*/
uids?: Array<string> | null;
};

View File

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

View File

@@ -1,20 +0,0 @@
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { FileTypeFilter } from './FileTypeFilter';
import type { Sort } from './Sort';
export type GetAllFilesRequest = {
sort?: Sort;
range?: Array<number>;
/**
* Filter files by title
*/
text_search?: string;
file_type_filter?: FileTypeFilter;
/**
* Include if you want to filter by subscription
*/
sub_id?: string;
};

View File

@@ -1,14 +0,0 @@
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { DatabaseFile } from './DatabaseFile';
import type { Playlist } from './Playlist';
export type GetAllFilesResponse = {
files: Array<DatabaseFile>;
/**
* All video playlists
*/
playlists: Array<Playlist>;
};

View File

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

View File

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

View File

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

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