mirror of
https://github.com/Tzahi12345/YoutubeDL-Material.git
synced 2026-03-08 04:20:08 +03:00
Compare commits
198 Commits
v4.2
...
forever-to
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
84187b9474 | ||
|
|
dbeeb32d48 | ||
|
|
46087f622e | ||
|
|
5dd48035fb | ||
|
|
db53a12635 | ||
|
|
8cd21bf433 | ||
|
|
c33acfb3de | ||
|
|
562eaa1b9b | ||
|
|
ec7f04552f | ||
|
|
75fc09ed99 | ||
|
|
8aa354ac24 | ||
|
|
58a0dc4afe | ||
|
|
0e37d83740 | ||
|
|
27faff054e | ||
|
|
a71d9f5c7e | ||
|
|
759637c1cf | ||
|
|
33f23c3ca9 | ||
|
|
176c99f813 | ||
|
|
f7e0b3e86b | ||
|
|
bd2443b1e9 | ||
|
|
f689609941 | ||
|
|
1e9eec1b55 | ||
|
|
677af3712b | ||
|
|
5fd9d93007 | ||
|
|
7ee34d21eb | ||
|
|
66c184a2e6 | ||
|
|
13f6f698b7 | ||
|
|
b08325c1e3 | ||
|
|
070d3fed57 | ||
|
|
775a1766d8 | ||
|
|
dbefb66021 | ||
|
|
3241d6aaaf | ||
|
|
d014c6facb | ||
|
|
b25ab70732 | ||
|
|
f9b8e78655 | ||
|
|
acad7cc057 | ||
|
|
c3d91e89a8 | ||
|
|
97c5102eb9 | ||
|
|
865185d277 | ||
|
|
a36794fd4f | ||
|
|
6639305771 | ||
|
|
cca76dd248 | ||
|
|
d899f88164 | ||
|
|
09b3c752d9 | ||
|
|
71bb91b6e6 | ||
|
|
f9b1414460 | ||
|
|
6eb1e2f898 | ||
|
|
30505d0e8b | ||
|
|
48ab1836ca | ||
|
|
20cedb6c29 | ||
|
|
9f5b6122fa | ||
|
|
5321624604 | ||
|
|
8828af4174 | ||
|
|
2bb4860a36 | ||
|
|
ce3d540633 | ||
|
|
f7b152fcf6 | ||
|
|
f892a4a305 | ||
|
|
fc55961822 | ||
|
|
ebfa49240c | ||
|
|
9e60d9fe3e | ||
|
|
ecef8842ae | ||
|
|
8cc653787f | ||
|
|
0360469c5a | ||
|
|
5a90be7703 | ||
|
|
ff403d18d1 | ||
|
|
11284cb1b3 | ||
|
|
8b1a1a56e3 | ||
|
|
32370280ab | ||
|
|
240d6569fa | ||
|
|
2927a4564d | ||
|
|
5c94036625 | ||
|
|
7be90ccd94 | ||
|
|
01b6e22f83 | ||
|
|
b1385f451b | ||
|
|
f40ac49082 | ||
|
|
2756cfae17 | ||
|
|
dac5919ffb | ||
|
|
34245bd339 | ||
|
|
8d6ec819e6 | ||
|
|
b03b4d173b | ||
|
|
c8f219d5b0 | ||
|
|
ec3ab17507 | ||
|
|
5124e3b333 | ||
|
|
d09b244bc2 | ||
|
|
c0a385ce78 | ||
|
|
258d5ff495 | ||
|
|
fb5c13db27 | ||
|
|
92413bd360 | ||
|
|
7174ef5f57 | ||
|
|
73b9cf7893 | ||
|
|
7ff906fd35 | ||
|
|
6e084bd94a | ||
|
|
21b97911e8 | ||
|
|
ccb4819a94 | ||
|
|
ce8f90ca1d | ||
|
|
8469ae10ad | ||
|
|
117255b0b7 | ||
|
|
f0e73c1708 | ||
|
|
aa1e36ae35 | ||
|
|
a1841e84ca | ||
|
|
05909877f4 | ||
|
|
90af895552 | ||
|
|
9f908aa3fc | ||
|
|
b56b371ece | ||
|
|
84e54cb4d5 | ||
|
|
42aaecc13a | ||
|
|
aac11b2105 | ||
|
|
bbf94ef982 | ||
|
|
2876cf55db | ||
|
|
375d3b4f38 | ||
|
|
160cffc737 | ||
|
|
7aad7b7d24 | ||
|
|
380475b33e | ||
|
|
384d365cf9 | ||
|
|
d6a43c76a4 | ||
|
|
407333a314 | ||
|
|
0fb01469c4 | ||
|
|
d10eb4f2eb | ||
|
|
148ed9aa65 | ||
|
|
1125de43d7 | ||
|
|
00b591a9a4 | ||
|
|
06d9793d1a | ||
|
|
0a2529330d | ||
|
|
19317dbddb | ||
|
|
3b74a2b5da | ||
|
|
a810628f15 | ||
|
|
a7d349a71a | ||
|
|
f8c4653ae0 | ||
|
|
bb6503e86d | ||
|
|
dbbfc041a4 | ||
|
|
342dafd52a | ||
|
|
984e990103 | ||
|
|
4ea239170e | ||
|
|
e2c31319cf | ||
|
|
b933af03e2 | ||
|
|
419fe3c3c6 | ||
|
|
07b48a4da1 | ||
|
|
a11445b80d | ||
|
|
297a4a3f34 | ||
|
|
1d2ab0dc41 | ||
|
|
46f8579439 | ||
|
|
b3744e616d | ||
|
|
de154a9c3e | ||
|
|
9e71b1ff12 | ||
|
|
6d318234b6 | ||
|
|
49925848ff | ||
|
|
356a807cad | ||
|
|
4e07440ed2 | ||
|
|
59c9237be5 | ||
|
|
4ba4710741 | ||
|
|
addd54fefd | ||
|
|
aefdde5401 | ||
|
|
4c1f975eae | ||
|
|
4c06bc750c | ||
|
|
4643efbae0 | ||
|
|
d11f77a6c9 | ||
|
|
1f0153b17e | ||
|
|
f32b394715 | ||
|
|
9d09eeffe3 | ||
|
|
c660c28422 | ||
|
|
669c87dd1b | ||
|
|
023df9c29d | ||
|
|
433d08e9df | ||
|
|
e34aa4d9d6 | ||
|
|
3f9314a0c3 | ||
|
|
00a0ab460b | ||
|
|
a1b32e2851 | ||
|
|
b8cab673ae | ||
|
|
6481102e01 | ||
|
|
af58854f0e | ||
|
|
d7d861ef0e | ||
|
|
1d5490c0ff | ||
|
|
28ee77cee0 | ||
|
|
133d848729 | ||
|
|
a78f4e99d0 | ||
|
|
539bc5094a | ||
|
|
f0f2faa398 | ||
|
|
7835185fe0 | ||
|
|
95bb69f16b | ||
|
|
a93aa080b3 | ||
|
|
ed1375d40b | ||
|
|
db78e4ad5e | ||
|
|
6ef0082563 | ||
|
|
b978007472 | ||
|
|
c09dd7a03b | ||
|
|
b6c09324d9 | ||
|
|
1a900399d8 | ||
|
|
ea959547fd | ||
|
|
085849c7ee | ||
|
|
cf1dd43d36 | ||
|
|
250f150587 | ||
|
|
dbf08e1276 | ||
|
|
f74ce4b865 | ||
|
|
8e4e0c7908 | ||
|
|
b0cb09309d | ||
|
|
75c1c9e9b7 | ||
|
|
c19e0bb881 | ||
|
|
a1af5496c7 |
20
.eslintrc.json
Normal file
20
.eslintrc.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"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": {
|
||||
}
|
||||
}
|
||||
31
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
31
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
@@ -0,0 +1,31 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
title: "[BUG]"
|
||||
labels: bug
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Describe the bug**
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**To Reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
|
||||
**Expected behavior**
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
**Screenshots**
|
||||
If applicable, add screenshots to help explain your problem.
|
||||
|
||||
**Environment**
|
||||
- YoutubeDL-Material version
|
||||
- Docker tag: <tag> (optional)
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here. For example, a YouTube link.
|
||||
17
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
17
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
@@ -0,0 +1,17 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea for this project
|
||||
title: "[FEATURE]"
|
||||
labels: enhancement
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Is your feature request related to a problem? Please describe.**
|
||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||
|
||||
**Describe the solution you'd like**
|
||||
A clear and concise description of what you want to happen.
|
||||
|
||||
**Additional context**
|
||||
Add any other context or screenshots about the feature request here.
|
||||
35
.github/workflows/build.yml
vendored
35
.github/workflows/build.yml
vendored
@@ -15,13 +15,33 @@ jobs:
|
||||
- name: checkout code
|
||||
uses: actions/checkout@v2
|
||||
- name: setup node
|
||||
uses: actions/setup-node@v1
|
||||
uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: '12'
|
||||
cache: 'npm'
|
||||
- name: install dependencies
|
||||
run: |
|
||||
npm install
|
||||
cd backend
|
||||
npm install
|
||||
sudo npm install -g @angular/cli
|
||||
- name: prepare localization
|
||||
run: |
|
||||
sudo npm install -g xliff-to-json
|
||||
xliff-to-json ./src/assets/i18n
|
||||
- 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
|
||||
run: ng build --prod
|
||||
- name: prepare artifact upload
|
||||
@@ -35,7 +55,7 @@ jobs:
|
||||
Copy-Item -Path ./backend/public -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
|
||||
New-Item -Path ./build/youtubedl-material -Name users
|
||||
New-Item -Path ./build/youtubedl-material -Name users -ItemType Directory
|
||||
Copy-Item -Path ./backend/*.js -Destination ./build/youtubedl-material
|
||||
Copy-Item -Path ./backend/*.json -Destination ./build/youtubedl-material
|
||||
- name: upload build artifact
|
||||
@@ -69,17 +89,20 @@ jobs:
|
||||
with:
|
||||
name: youtubedl-material
|
||||
path: ${{runner.temp}}/youtubedl-material
|
||||
- name: extract tag name
|
||||
id: tag_name
|
||||
run: echo ::set-output name=tag_name::${GITHUB_REF#refs/tags/}
|
||||
- name: prepare release asset
|
||||
shell: pwsh
|
||||
run: Compress-Archive -Path ${{runner.temp}}/youtubedl-material -DestinationPath youtubedl-material-${{ github.ref }}.zip
|
||||
- name: upload build asset
|
||||
run: Compress-Archive -Path ${{runner.temp}}/youtubedl-material -DestinationPath youtubedl-material-${{ steps.tag_name.outputs.tag_name }}.zip
|
||||
- name: upload release asset
|
||||
uses: actions/upload-release-asset@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
||||
asset_path: ./youtubedl-material-${{ github.ref }}.zip
|
||||
asset_name: youtubedl-material-${{ github.ref }}.zip
|
||||
asset_path: ./youtubedl-material-${{ steps.tag_name.outputs.tag_name }}.zip
|
||||
asset_name: youtubedl-material-${{ steps.tag_name.outputs.tag_name }}.zip
|
||||
asset_content_type: application/zip
|
||||
- name: upload docker-compose asset
|
||||
uses: actions/upload-release-asset@v1
|
||||
|
||||
49
.github/workflows/docker-release.yml
vendored
Normal file
49
.github/workflows/docker-release.yml
vendored
Normal file
@@ -0,0 +1,49 @@
|
||||
name: docker-release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tags:
|
||||
description: 'Docker tags'
|
||||
required: true
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: checkout code
|
||||
uses: actions/checkout@v2
|
||||
- name: prepare localization
|
||||
run: |
|
||||
sudo npm install -g xliff-to-json
|
||||
xliff-to-json ./src/assets/i18n
|
||||
- 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: setup platform emulator
|
||||
uses: docker/setup-qemu-action@v1
|
||||
- name: setup multi-arch docker build
|
||||
uses: docker/setup-buildx-action@v1
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v1
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- name: build & push images
|
||||
uses: docker/build-push-action@v2
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
platforms: linux/amd64,linux/arm,linux/arm64/v8
|
||||
push: true
|
||||
tags: ${{ github.event.inputs.tags }}
|
||||
17
.github/workflows/docker.yml
vendored
17
.github/workflows/docker.yml
vendored
@@ -10,6 +10,23 @@ jobs:
|
||||
steps:
|
||||
- name: checkout code
|
||||
uses: actions/checkout@v2
|
||||
- name: prepare localization
|
||||
run: |
|
||||
sudo npm install -g xliff-to-json
|
||||
xliff-to-json ./src/assets/i18n
|
||||
- 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: setup platform emulator
|
||||
uses: docker/setup-qemu-action@v1
|
||||
- name: setup multi-arch docker build
|
||||
|
||||
25
.vscode/tasks.json
vendored
Normal file
25
.vscode/tasks.json
vendored
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{
|
||||
"type": "npm",
|
||||
"script": "start",
|
||||
"problemMatcher": [],
|
||||
"label": "Dev: start frontend",
|
||||
"detail": "ng serve"
|
||||
},
|
||||
{
|
||||
"label": "Dev: start backend",
|
||||
"type": "shell",
|
||||
"command": "set YTDL_MODE=debug && node app.js",
|
||||
"options": {
|
||||
"cwd": "./backend"
|
||||
},
|
||||
"presentation": {
|
||||
"reveal": "always",
|
||||
"panel": "new"
|
||||
},
|
||||
"problemMatcher": []
|
||||
}
|
||||
]
|
||||
}
|
||||
10
Dockerfile
10
Dockerfile
@@ -1,4 +1,4 @@
|
||||
FROM alpine:3.12 as frontend
|
||||
FROM alpine:latest as frontend
|
||||
|
||||
RUN apk add --no-cache \
|
||||
npm
|
||||
@@ -15,24 +15,28 @@ RUN ng build --prod
|
||||
|
||||
#--------------#
|
||||
|
||||
FROM alpine:3.12
|
||||
FROM alpine:latest
|
||||
|
||||
ENV UID=1000 \
|
||||
GID=1000 \
|
||||
USER=youtube
|
||||
|
||||
ENV NO_UPDATE_NOTIFIER=true
|
||||
|
||||
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
|
||||
|
||||
WORKDIR /app
|
||||
COPY --chown=$UID:$GID [ "backend/package.json", "backend/package-lock.json", "/app/" ]
|
||||
RUN npm install pm2 -g
|
||||
RUN npm install && chown -R $UID:$GID ./
|
||||
|
||||
COPY --chown=$UID:$GID --from=frontend [ "/build/backend/public/", "/app/public/" ]
|
||||
@@ -40,4 +44,4 @@ COPY --chown=$UID:$GID [ "/backend/", "/app/" ]
|
||||
|
||||
EXPOSE 17442
|
||||
ENTRYPOINT [ "/app/entrypoint.sh" ]
|
||||
CMD [ "node", "app.js" ]
|
||||
CMD [ "pm2-runtime", "pm2.config.js" ]
|
||||
|
||||
16
README.md
16
README.md
@@ -16,15 +16,11 @@ Check out the prerequisites, and go to the installation section. Easy as pie!
|
||||
|
||||
Here's an image of what it'll look like once you're done:
|
||||
|
||||

|
||||
|
||||
With optional file management enabled (default):
|
||||
|
||||

|
||||
<img src="https://i.imgur.com/C6vFGbL.png" width="800">
|
||||
|
||||
Dark mode:
|
||||
|
||||

|
||||
<img src="https://i.imgur.com/vOtvH5w.png" width="800">
|
||||
|
||||
### Prerequisites
|
||||
|
||||
@@ -33,7 +29,7 @@ NOTE: If you would like to use Docker, you can skip down to the [Docker](#Docker
|
||||
Debian/Ubuntu:
|
||||
|
||||
```bash
|
||||
sudo apt-get install nodejs youtube-dl ffmpeg
|
||||
sudo apt-get install nodejs youtube-dl ffmpeg unzip python npm
|
||||
```
|
||||
|
||||
CentOS 7:
|
||||
@@ -81,6 +77,10 @@ Alternatively, you can port forward the port specified in the config (defaults t
|
||||
|
||||
## Docker
|
||||
|
||||
### Host-specific instructions
|
||||
|
||||
If you're on a Synology NAS, unRAID or any other possible special case you can check if there's known issues or instructions both in the issue tracker and in the [Wiki!](https://github.com/Tzahi12345/YoutubeDL-Material/wiki#environment-specific-guideshelp)
|
||||
|
||||
### Setup
|
||||
|
||||
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.
|
||||
@@ -128,7 +128,7 @@ Official translators:
|
||||
* German - UnlimitedCookies
|
||||
* Chinese - TyRoyal
|
||||
|
||||
See also the list of [contributors](https://github.com/your/project/contributors) who participated in this project.
|
||||
See also the list of [contributors](https://github.com/Tzahi12345/YoutubeDL-Material/graphs/contributors) who participated in this project.
|
||||
|
||||
## License
|
||||
|
||||
|
||||
18
backend/.eslintrc.json
Normal file
18
backend/.eslintrc.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"env": {
|
||||
"node": true,
|
||||
"es2021": true
|
||||
},
|
||||
"extends": [
|
||||
"eslint:recommended"
|
||||
],
|
||||
"parser": "esprima",
|
||||
"parserOptions": {
|
||||
"ecmaVersion": 12,
|
||||
"sourceType": "module"
|
||||
},
|
||||
"plugins": [],
|
||||
"rules": {
|
||||
},
|
||||
"root": true
|
||||
}
|
||||
2064
backend/app.js
2064
backend/app.js
File diff suppressed because it is too large
Load Diff
@@ -12,15 +12,18 @@
|
||||
"custom_args": "",
|
||||
"safe_download_override": false,
|
||||
"include_thumbnail": true,
|
||||
"include_metadata": true
|
||||
"include_metadata": true,
|
||||
"max_concurrent_downloads": 5,
|
||||
"download_rate_limit": ""
|
||||
},
|
||||
"Extra": {
|
||||
"title_top": "YoutubeDL-Material",
|
||||
"file_manager_enabled": true,
|
||||
"allow_quality_select": true,
|
||||
"download_only_mode": false,
|
||||
"allow_multi_download_mode": true,
|
||||
"enable_downloads_manager": true
|
||||
"allow_autoplay": true,
|
||||
"enable_downloads_manager": true,
|
||||
"allow_playlist_categorization": true
|
||||
},
|
||||
"API": {
|
||||
"use_API_key": false,
|
||||
@@ -29,7 +32,9 @@
|
||||
"youtube_API_key": "",
|
||||
"use_twitch_API": false,
|
||||
"twitch_API_key": "",
|
||||
"twitch_auto_download_chat": false
|
||||
"twitch_auto_download_chat": false,
|
||||
"use_sponsorblock_API": false,
|
||||
"generate_NFO_files": false
|
||||
},
|
||||
"Themes": {
|
||||
"default_theme": "default",
|
||||
@@ -53,6 +58,10 @@
|
||||
"searchFilter": "(uid={{username}})"
|
||||
}
|
||||
},
|
||||
"Database": {
|
||||
"use_local_db": true,
|
||||
"mongodb_connection_string": "mongodb://127.0.0.1:27017/?compressors=zlib"
|
||||
},
|
||||
"Advanced": {
|
||||
"default_downloader": "youtube-dl",
|
||||
"use_default_downloading_agent": true,
|
||||
@@ -60,8 +69,8 @@
|
||||
"multi_user_mode": false,
|
||||
"allow_advanced_download": false,
|
||||
"use_cookies": false,
|
||||
"jwt_expiration": 86400,
|
||||
"jwt_expiration": 86400,
|
||||
"logger_level": "info"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,10 @@
|
||||
const path = require('path');
|
||||
const config_api = require('../config');
|
||||
const consts = require('../consts');
|
||||
var subscriptions_api = require('../subscriptions')
|
||||
const fs = require('fs-extra');
|
||||
var jwt = require('jsonwebtoken');
|
||||
const { uuid } = require('uuidv4');
|
||||
var bcrypt = require('bcryptjs');
|
||||
const logger = require('../logger');
|
||||
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { uuid } = require('uuidv4');
|
||||
const bcrypt = require('bcryptjs');
|
||||
|
||||
var LocalStrategy = require('passport-local').Strategy;
|
||||
var LdapStrategy = require('passport-ldapauth');
|
||||
@@ -14,17 +12,14 @@ var JwtStrategy = require('passport-jwt').Strategy,
|
||||
ExtractJwt = require('passport-jwt').ExtractJwt;
|
||||
|
||||
// other required vars
|
||||
let logger = null;
|
||||
let db = null;
|
||||
let users_db = null;
|
||||
let db_api = null;
|
||||
let SERVER_SECRET = null;
|
||||
let JWT_EXPIRATION = null;
|
||||
let opts = null;
|
||||
let saltRounds = null;
|
||||
|
||||
exports.initialize = function(input_db, input_users_db, input_logger) {
|
||||
setLogger(input_logger)
|
||||
setDB(input_db, input_users_db);
|
||||
exports.initialize = function(db_api) {
|
||||
setDB(db_api);
|
||||
|
||||
/*************************
|
||||
* Authentication module
|
||||
@@ -34,21 +29,19 @@ exports.initialize = function(input_db, input_users_db, input_logger) {
|
||||
JWT_EXPIRATION = config_api.getConfigItem('ytdl_jwt_expiration');
|
||||
|
||||
SERVER_SECRET = null;
|
||||
if (users_db.get('jwt_secret').value()) {
|
||||
SERVER_SECRET = users_db.get('jwt_secret').value();
|
||||
if (db_api.users_db.get('jwt_secret').value()) {
|
||||
SERVER_SECRET = db_api.users_db.get('jwt_secret').value();
|
||||
} else {
|
||||
SERVER_SECRET = uuid();
|
||||
users_db.set('jwt_secret', SERVER_SECRET).write();
|
||||
db_api.users_db.set('jwt_secret', SERVER_SECRET).write();
|
||||
}
|
||||
|
||||
opts = {}
|
||||
opts.jwtFromRequest = ExtractJwt.fromUrlQueryParameter('jwt');
|
||||
opts.secretOrKey = SERVER_SECRET;
|
||||
/*opts.issuer = 'example.com';
|
||||
opts.audience = 'example.com';*/
|
||||
|
||||
exports.passport.use(new JwtStrategy(opts, function(jwt_payload, done) {
|
||||
const user = users_db.get('users').find({uid: jwt_payload.user}).value();
|
||||
exports.passport.use(new JwtStrategy(opts, async function(jwt_payload, done) {
|
||||
const user = await db_api.getRecord('users', {uid: jwt_payload.user});
|
||||
if (user) {
|
||||
return done(null, user);
|
||||
} else {
|
||||
@@ -58,13 +51,8 @@ exports.initialize = function(input_db, input_users_db, input_logger) {
|
||||
}));
|
||||
}
|
||||
|
||||
function setLogger(input_logger) {
|
||||
logger = input_logger;
|
||||
}
|
||||
|
||||
function setDB(input_db, input_users_db) {
|
||||
db = input_db;
|
||||
users_db = input_users_db;
|
||||
function setDB(input_db_api) {
|
||||
db_api = input_db_api;
|
||||
}
|
||||
|
||||
exports.passport = require('passport');
|
||||
@@ -80,7 +68,7 @@ exports.passport.deserializeUser(function(user, done) {
|
||||
/***************************************
|
||||
* Register user with hashed password
|
||||
**************************************/
|
||||
exports.registerUser = function(req, res) {
|
||||
exports.registerUser = async function(req, res) {
|
||||
var userid = req.body.userid;
|
||||
var username = req.body.username;
|
||||
var plaintextPassword = req.body.password;
|
||||
@@ -98,20 +86,20 @@ exports.registerUser = function(req, res) {
|
||||
}
|
||||
|
||||
bcrypt.hash(plaintextPassword, saltRounds)
|
||||
.then(function(hash) {
|
||||
.then(async function(hash) {
|
||||
let new_user = generateUserObject(userid, username, hash);
|
||||
// check if user exists
|
||||
if (users_db.get('users').find({uid: userid}).value()) {
|
||||
if (await db_api.getRecord('users', {uid: userid})) {
|
||||
// user id is taken!
|
||||
logger.error('Registration failed: UID is already taken!');
|
||||
res.status(409).send('UID is already taken!');
|
||||
} else if (users_db.get('users').find({name: username}).value()) {
|
||||
} else if (await db_api.getRecord('users', {name: username})) {
|
||||
// user name is taken!
|
||||
logger.error('Registration failed: User name is already taken!');
|
||||
res.status(409).send('User name is already taken!');
|
||||
} else {
|
||||
// add to db
|
||||
users_db.get('users').push(new_user).write();
|
||||
await db_api.insertRecordIntoTable('users', new_user);
|
||||
logger.verbose(`New user created: ${new_user.name}`);
|
||||
res.send({
|
||||
user: new_user
|
||||
@@ -144,16 +132,18 @@ exports.registerUser = function(req, res) {
|
||||
************************************************/
|
||||
|
||||
|
||||
exports.login = async (username, password) => {
|
||||
const user = await db_api.getRecord('users', {name: username});
|
||||
if (!user) { logger.error(`User ${username} not found`); return false }
|
||||
if (user.auth_method && user.auth_method !== 'internal') { return false }
|
||||
return await bcrypt.compare(password, user.passhash) ? user : false;
|
||||
}
|
||||
|
||||
exports.passport.use(new LocalStrategy({
|
||||
usernameField: 'username',
|
||||
passwordField: 'password'},
|
||||
async function(username, password, done) {
|
||||
const user = users_db.get('users').find({name: username}).value();
|
||||
if (!user) { logger.error(`User ${username} not found`); return done(null, false); }
|
||||
if (user.auth_method && user.auth_method !== 'internal') { return done(null, false); }
|
||||
if (user) {
|
||||
return done(null, (await bcrypt.compare(password, user.passhash)) ? user : false);
|
||||
}
|
||||
return done(null, await exports.login(username, password));
|
||||
}
|
||||
));
|
||||
|
||||
@@ -164,17 +154,17 @@ var getLDAPConfiguration = function(req, callback) {
|
||||
};
|
||||
|
||||
exports.passport.use(new LdapStrategy(getLDAPConfiguration,
|
||||
function(user, done) {
|
||||
async function(user, done) {
|
||||
// check if ldap auth is enabled
|
||||
const ldap_enabled = config_api.getConfigItem('ytdl_auth_method') === 'ldap';
|
||||
if (!ldap_enabled) return done(null, false);
|
||||
|
||||
const user_uid = user.uid;
|
||||
let db_user = users_db.get('users').find({uid: user_uid}).value();
|
||||
let db_user = await db_api.getRecord('users', {uid: user_uid});
|
||||
if (!db_user) {
|
||||
// generate DB user
|
||||
let new_user = generateUserObject(user_uid, user_uid, null, 'ldap');
|
||||
users_db.get('users').push(new_user).write();
|
||||
await db_api.insertRecordIntoTable('users', new_user);
|
||||
db_user = new_user;
|
||||
logger.verbose(`Generated new user ${user_uid} using LDAP`);
|
||||
}
|
||||
@@ -198,11 +188,11 @@ exports.generateJWT = function(req, res, next) {
|
||||
next();
|
||||
}
|
||||
|
||||
exports.returnAuthResponse = function(req, res) {
|
||||
exports.returnAuthResponse = async function(req, res) {
|
||||
res.status(200).json({
|
||||
user: req.user,
|
||||
token: req.token,
|
||||
permissions: exports.userPermissions(req.user.uid),
|
||||
permissions: await exports.userPermissions(req.user.uid),
|
||||
available_permissions: consts['AVAILABLE_PERMISSIONS']
|
||||
});
|
||||
}
|
||||
@@ -215,7 +205,7 @@ exports.returnAuthResponse = function(req, res) {
|
||||
* It also passes the user object to the next
|
||||
* middleware through res.locals
|
||||
**************************************/
|
||||
exports.ensureAuthenticatedElseError = function(req, res, next) {
|
||||
exports.ensureAuthenticatedElseError = (req, res, next) => {
|
||||
var token = getToken(req.query);
|
||||
if( token ) {
|
||||
try {
|
||||
@@ -233,10 +223,10 @@ exports.ensureAuthenticatedElseError = function(req, res, next) {
|
||||
}
|
||||
|
||||
// change password
|
||||
exports.changeUserPassword = async function(user_uid, new_pass) {
|
||||
exports.changeUserPassword = async (user_uid, new_pass) => {
|
||||
try {
|
||||
const hash = await bcrypt.hash(new_pass, saltRounds);
|
||||
users_db.get('users').find({uid: user_uid}).assign({passhash: hash}).write();
|
||||
await db_api.updateRecord('users', {uid: user_uid}, {passhash: hash});
|
||||
return true;
|
||||
} catch (err) {
|
||||
return false;
|
||||
@@ -244,16 +234,15 @@ exports.changeUserPassword = async function(user_uid, new_pass) {
|
||||
}
|
||||
|
||||
// change user permissions
|
||||
exports.changeUserPermissions = function(user_uid, permission, new_value) {
|
||||
exports.changeUserPermissions = async (user_uid, permission, new_value) => {
|
||||
try {
|
||||
const user_db_obj = users_db.get('users').find({uid: user_uid});
|
||||
user_db_obj.get('permissions').pull(permission).write();
|
||||
user_db_obj.get('permission_overrides').pull(permission).write();
|
||||
await db_api.pullFromRecordsArray('users', {uid: user_uid}, 'permissions', permission);
|
||||
await db_api.pullFromRecordsArray('users', {uid: user_uid}, 'permission_overrides', permission);
|
||||
if (new_value === 'yes') {
|
||||
user_db_obj.get('permissions').push(permission).write();
|
||||
user_db_obj.get('permission_overrides').push(permission).write();
|
||||
await db_api.pushToRecordsArray('users', {uid: user_uid}, 'permissions', permission);
|
||||
await db_api.pushToRecordsArray('users', {uid: user_uid}, 'permission_overrides', permission);
|
||||
} else if (new_value === 'no') {
|
||||
user_db_obj.get('permission_overrides').push(permission).write();
|
||||
await db_api.pushToRecordsArray('users', {uid: user_uid}, 'permission_overrides', permission);
|
||||
}
|
||||
return true;
|
||||
} catch (err) {
|
||||
@@ -263,12 +252,11 @@ exports.changeUserPermissions = function(user_uid, permission, new_value) {
|
||||
}
|
||||
|
||||
// change role permissions
|
||||
exports.changeRolePermissions = function(role, permission, new_value) {
|
||||
exports.changeRolePermissions = async (role, permission, new_value) => {
|
||||
try {
|
||||
const role_db_obj = users_db.get('roles').get(role);
|
||||
role_db_obj.get('permissions').pull(permission).write();
|
||||
await db_api.pullFromRecordsArray('roles', {key: role}, 'permissions', permission);
|
||||
if (new_value === 'yes') {
|
||||
role_db_obj.get('permissions').push(permission).write();
|
||||
await db_api.pushToRecordsArray('roles', {key: role}, 'permissions', permission);
|
||||
}
|
||||
return true;
|
||||
} catch (err) {
|
||||
@@ -277,19 +265,19 @@ exports.changeRolePermissions = function(role, permission, new_value) {
|
||||
}
|
||||
}
|
||||
|
||||
exports.adminExists = function() {
|
||||
return !!users_db.get('users').find({uid: 'admin'}).value();
|
||||
exports.adminExists = async function() {
|
||||
return !!(await db_api.getRecord('users', {uid: 'admin'}));
|
||||
}
|
||||
|
||||
// video stuff
|
||||
|
||||
exports.getUserVideos = function(user_uid, type) {
|
||||
const user = users_db.get('users').find({uid: user_uid}).value();
|
||||
return type ? user['files'].filter(file => file.isAudio = (type === 'audio')) : user['files'];
|
||||
exports.getUserVideos = async function(user_uid, type) {
|
||||
const files = await db_api.getRecords('files', {user_uid: user_uid});
|
||||
return type ? files.filter(file => file.isAudio === (type === 'audio')) : files;
|
||||
}
|
||||
|
||||
exports.getUserVideo = function(user_uid, file_uid, requireSharing = false) {
|
||||
let file = users_db.get('users').find({uid: user_uid}).get(`files`).find({uid: file_uid}).value();
|
||||
exports.getUserVideo = async function(user_uid, file_uid, requireSharing = false) {
|
||||
let file = await db_api.getRecord('files', {file_uid: file_uid});
|
||||
|
||||
// prevent unauthorized users from accessing the file info
|
||||
if (file && !file['sharingEnabled'] && requireSharing) file = null;
|
||||
@@ -297,58 +285,17 @@ exports.getUserVideo = function(user_uid, file_uid, requireSharing = false) {
|
||||
return file;
|
||||
}
|
||||
|
||||
exports.addPlaylist = function(user_uid, new_playlist) {
|
||||
users_db.get('users').find({uid: user_uid}).get(`playlists`).push(new_playlist).write();
|
||||
exports.removePlaylist = async function(user_uid, playlistID) {
|
||||
await db_api.removeRecord('playlist', {playlistID: playlistID});
|
||||
return true;
|
||||
}
|
||||
|
||||
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.getUserPlaylists = async function(user_uid) {
|
||||
return await db_api.getRecords('playlists', {user_uid: user_uid});
|
||||
}
|
||||
|
||||
exports.removePlaylist = function(user_uid, playlistID) {
|
||||
users_db.get('users').find({uid: user_uid}).get(`playlists`).remove({id: playlistID}).write();
|
||||
return true;
|
||||
}
|
||||
|
||||
exports.getUserPlaylists = function(user_uid, user_files = null) {
|
||||
const user = users_db.get('users').find({uid: user_uid}).value();
|
||||
const playlists = JSON.parse(JSON.stringify(user['playlists']));
|
||||
const categories = db.get('categories').value();
|
||||
if (categories && user_files) {
|
||||
categories.forEach(category => {
|
||||
const audio_files = user_files && user_files.filter(file => file.category && file.category.uid === category.uid && file.isAudio);
|
||||
const video_files = user_files && user_files.filter(file => file.category && file.category.uid === category.uid && !file.isAudio);
|
||||
if (audio_files && audio_files.length > 0) {
|
||||
playlists.push({
|
||||
name: category['name'],
|
||||
thumbnailURL: audio_files[0].thumbnailURL,
|
||||
thumbnailPath: audio_files[0].thumbnailPath,
|
||||
fileNames: audio_files.map(file => file.id),
|
||||
type: 'audio',
|
||||
uid: user_uid,
|
||||
auto: true
|
||||
});
|
||||
}
|
||||
if (video_files && video_files.length > 0) {
|
||||
playlists.push({
|
||||
name: category['name'],
|
||||
thumbnailURL: video_files[0].thumbnailURL,
|
||||
thumbnailPath: video_files[0].thumbnailPath,
|
||||
fileNames: video_files.map(file => file.id),
|
||||
type: 'video',
|
||||
uid: user_uid,
|
||||
auto: true
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
return playlists;
|
||||
}
|
||||
|
||||
exports.getUserPlaylist = function(user_uid, playlistID, requireSharing = false) {
|
||||
let playlist = users_db.get('users').find({uid: user_uid}).get(`playlists`).find({id: playlistID}).value();
|
||||
exports.getUserPlaylist = async function(user_uid, playlistID, requireSharing = false) {
|
||||
let playlist = await db_api.getRecord('playlists', {id: playlistID});
|
||||
|
||||
// prevent unauthorized users from accessing the file info
|
||||
if (requireSharing && !playlist['sharingEnabled']) playlist = null;
|
||||
@@ -356,109 +303,23 @@ exports.getUserPlaylist = function(user_uid, playlistID, requireSharing = false)
|
||||
return playlist;
|
||||
}
|
||||
|
||||
exports.registerUserFile = function(user_uid, file_object) {
|
||||
users_db.get('users').find({uid: user_uid}).get(`files`)
|
||||
.remove({
|
||||
path: file_object['path']
|
||||
}).write();
|
||||
|
||||
users_db.get('users').find({uid: user_uid}).get(`files`)
|
||||
.push(file_object)
|
||||
.write();
|
||||
}
|
||||
|
||||
exports.deleteUserFile = async function(user_uid, file_uid, blacklistMode = false) {
|
||||
exports.changeSharingMode = async function(user_uid, file_uid, is_playlist, enabled) {
|
||||
let success = false;
|
||||
const file_obj = users_db.get('users').find({uid: user_uid}).get(`files`).find({uid: file_uid}).value();
|
||||
if (file_obj) {
|
||||
const type = file_obj.isAudio ? 'audio' : 'video';
|
||||
const usersFileFolder = config_api.getConfigItem('ytdl_users_base_path');
|
||||
const ext = type === 'audio' ? '.mp3' : '.mp4';
|
||||
|
||||
// close descriptors
|
||||
if (config_api.descriptors[file_obj.id]) {
|
||||
try {
|
||||
for (let i = 0; i < config_api.descriptors[file_obj.id].length; i++) {
|
||||
config_api.descriptors[file_obj.id][i].destroy();
|
||||
}
|
||||
} catch(e) {
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
const full_path = path.join(usersFileFolder, user_uid, type, file_obj.id + ext);
|
||||
users_db.get('users').find({uid: user_uid}).get(`files`)
|
||||
.remove({
|
||||
uid: file_uid
|
||||
}).write();
|
||||
if (await fs.pathExists(full_path)) {
|
||||
// remove json and file
|
||||
const json_path = path.join(usersFileFolder, user_uid, type, file_obj.id + '.info.json');
|
||||
const alternate_json_path = path.join(usersFileFolder, user_uid, type, file_obj.id + ext + '.info.json');
|
||||
let youtube_id = null;
|
||||
if (await fs.pathExists(json_path)) {
|
||||
youtube_id = await fs.readJSON(json_path).id;
|
||||
await fs.unlink(json_path);
|
||||
} else if (await fs.pathExists(alternate_json_path)) {
|
||||
youtube_id = await fs.readJSON(alternate_json_path).id;
|
||||
await fs.unlink(alternate_json_path);
|
||||
}
|
||||
|
||||
await fs.unlink(full_path);
|
||||
|
||||
// do archive stuff
|
||||
|
||||
let useYoutubeDLArchive = config_api.getConfigItem('ytdl_use_youtubedl_archive');
|
||||
if (useYoutubeDLArchive) {
|
||||
const archive_path = path.join(usersFileFolder, user_uid, 'archives', `archive_${type}.txt`);
|
||||
|
||||
// use subscriptions API to remove video from the archive file, and write it to the blacklist
|
||||
if (await fs.pathExists(archive_path)) {
|
||||
const line = youtube_id ? await subscriptions_api.removeIDFromArchive(archive_path, youtube_id) : null;
|
||||
if (blacklistMode && line) {
|
||||
let blacklistPath = path.join(usersFileFolder, user_uid, 'archives', `blacklist_${type}.txt`);
|
||||
// adds newline to the beginning of the line
|
||||
line = '\n' + line;
|
||||
await fs.appendFile(blacklistPath, line);
|
||||
}
|
||||
} else {
|
||||
logger.info(`Could not find archive file for ${type} files. Creating...`);
|
||||
await fs.ensureFile(archive_path);
|
||||
}
|
||||
}
|
||||
}
|
||||
success = true;
|
||||
} else {
|
||||
success = false;
|
||||
logger.warn(`User file ${file_uid} does not exist!`);
|
||||
}
|
||||
|
||||
is_playlist ? await db_api.updateRecord(`playlists`, {id: file_uid}, {sharingEnabled: enabled}) : await db_api.updateRecord(`files`, {uid: file_uid}, {sharingEnabled: enabled});
|
||||
success = true;
|
||||
return success;
|
||||
}
|
||||
|
||||
exports.changeSharingMode = function(user_uid, file_uid, is_playlist, enabled) {
|
||||
let success = false;
|
||||
const user_db_obj = users_db.get('users').find({uid: user_uid});
|
||||
if (user_db_obj.value()) {
|
||||
const file_db_obj = is_playlist ? user_db_obj.get(`playlists`).find({id: file_uid}) : user_db_obj.get(`files`).find({uid: file_uid});
|
||||
if (file_db_obj.value()) {
|
||||
success = true;
|
||||
file_db_obj.assign({sharingEnabled: enabled}).write();
|
||||
}
|
||||
}
|
||||
exports.userHasPermission = async function(user_uid, permission) {
|
||||
|
||||
return success;
|
||||
}
|
||||
|
||||
exports.userHasPermission = function(user_uid, permission) {
|
||||
const user_obj = users_db.get('users').find({uid: user_uid}).value();
|
||||
const user_obj = await db_api.getRecord('users', ({uid: user_uid}));
|
||||
const role = user_obj['role'];
|
||||
if (!role) {
|
||||
// role doesn't exist
|
||||
logger.error('Invalid role ' + role);
|
||||
return false;
|
||||
}
|
||||
const role_permissions = (users_db.get('roles').value())['permissions'];
|
||||
const role_permissions = (await db_api.getRecords('roles'))['permissions'];
|
||||
|
||||
const user_has_explicit_permission = user_obj['permissions'].includes(permission);
|
||||
const permission_in_overrides = user_obj['permission_overrides'].includes(permission);
|
||||
@@ -481,16 +342,17 @@ exports.userHasPermission = function(user_uid, permission) {
|
||||
}
|
||||
}
|
||||
|
||||
exports.userPermissions = function(user_uid) {
|
||||
exports.userPermissions = async function(user_uid) {
|
||||
let user_permissions = [];
|
||||
const user_obj = users_db.get('users').find({uid: user_uid}).value();
|
||||
const user_obj = await db_api.getRecord('users', ({uid: user_uid}));
|
||||
const role = user_obj['role'];
|
||||
if (!role) {
|
||||
// role doesn't exist
|
||||
logger.error('Invalid role ' + role);
|
||||
return null;
|
||||
}
|
||||
const role_permissions = users_db.get('roles').get(role).get('permissions').value()
|
||||
const role_obj = await db_api.getRecord('roles', {key: role});
|
||||
const role_permissions = role_obj['permissions'];
|
||||
|
||||
for (let i = 0; i < consts['AVAILABLE_PERMISSIONS'].length; i++) {
|
||||
let permission = consts['AVAILABLE_PERMISSIONS'][i];
|
||||
@@ -536,14 +398,8 @@ function generateUserObject(userid, username, hash, auth_method = 'internal') {
|
||||
name: username,
|
||||
uid: userid,
|
||||
passhash: auth_method === 'internal' ? hash : null,
|
||||
files: {
|
||||
audio: [],
|
||||
video: []
|
||||
},
|
||||
playlists: {
|
||||
audio: [],
|
||||
video: []
|
||||
},
|
||||
files: [],
|
||||
playlists: [],
|
||||
subscriptions: [],
|
||||
created: Date.now(),
|
||||
role: userid === 'admin' && auth_method === 'internal' ? 'admin' : 'user',
|
||||
|
||||
@@ -1,16 +1,12 @@
|
||||
const config_api = require('./config');
|
||||
const utils = require('./utils');
|
||||
const logger = require('./logger');
|
||||
|
||||
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 setDB(input_db_api) { db_api = input_db_api }
|
||||
|
||||
function initialize(input_db, input_users_db, input_logger, input_db_api) {
|
||||
setDB(input_db, input_users_db, input_db_api);
|
||||
setLogger(input_logger);
|
||||
function initialize(input_db_api) {
|
||||
setDB(input_db_api);
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -33,35 +29,58 @@ Rules:
|
||||
|
||||
*/
|
||||
|
||||
async function categorize(file_json) {
|
||||
async function categorize(file_jsons) {
|
||||
// to make the logic easier, let's assume the file metadata is an array
|
||||
if (!Array.isArray(file_jsons)) file_jsons = [file_jsons];
|
||||
|
||||
let selected_category = null;
|
||||
const categories = getCategories();
|
||||
const categories = await getCategories();
|
||||
if (!categories) {
|
||||
logger.warn('Categories could not be found. Initializing categories...');
|
||||
db.assign({categories: []}).write();
|
||||
logger.warn('Categories could not be found.');
|
||||
return null;
|
||||
return;
|
||||
}
|
||||
|
||||
for (let i = 0; i < categories.length; i++) {
|
||||
const category = categories[i];
|
||||
const rules = category['rules'];
|
||||
|
||||
// if rules for current category apply, then that is the selected category
|
||||
if (applyCategoryRules(file_json, rules, category['name'])) {
|
||||
selected_category = category;
|
||||
logger.verbose(`Selected category ${category['name']} for ${file_json['webpage_url']}`);
|
||||
return selected_category;
|
||||
for (let i = 0; i < file_jsons.length; i++) {
|
||||
const file_json = file_jsons[i];
|
||||
for (let j = 0; j < categories.length; j++) {
|
||||
const category = categories[j];
|
||||
const rules = category['rules'];
|
||||
|
||||
// if rules for current category apply, then that is the selected category
|
||||
if (applyCategoryRules(file_json, rules, category['name'])) {
|
||||
selected_category = category;
|
||||
logger.verbose(`Selected category ${category['name']} for ${file_json['webpage_url']}`);
|
||||
return selected_category;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return selected_category;
|
||||
}
|
||||
|
||||
function getCategories() {
|
||||
const categories = db.get('categories').value();
|
||||
async function getCategories() {
|
||||
const categories = await db_api.getRecords('categories');
|
||||
return categories ? categories : null;
|
||||
}
|
||||
|
||||
async function getCategoriesAsPlaylists(files = null) {
|
||||
const categories_as_playlists = [];
|
||||
const available_categories = await getCategories();
|
||||
if (available_categories && files) {
|
||||
for (let category of available_categories) {
|
||||
const files_that_match = utils.addUIDsToCategory(category, files);
|
||||
if (files_that_match && files_that_match.length > 0) {
|
||||
category['thumbnailURL'] = files_that_match[0].thumbnailURL;
|
||||
category['thumbnailPath'] = files_that_match[0].thumbnailPath;
|
||||
category['duration'] = files_that_match.reduce((a, b) => a + utils.durationStringToNumber(b.duration), 0);
|
||||
category['id'] = category['uid'];
|
||||
categories_as_playlists.push(category);
|
||||
}
|
||||
}
|
||||
}
|
||||
return categories_as_playlists;
|
||||
}
|
||||
|
||||
function applyCategoryRules(file_json, rules, category_name) {
|
||||
let rules_apply = false;
|
||||
for (let i = 0; i < rules.length; i++) {
|
||||
@@ -72,10 +91,10 @@ function applyCategoryRules(file_json, rules, category_name) {
|
||||
|
||||
switch (rule['comparator']) {
|
||||
case 'includes':
|
||||
rule_applies = file_json[rule['property']].includes(rule['value']);
|
||||
rule_applies = file_json[rule['property']].toLowerCase().includes(rule['value'].toLowerCase());
|
||||
break;
|
||||
case 'not_includes':
|
||||
rule_applies = !(file_json[rule['property']].includes(rule['value']));
|
||||
rule_applies = !(file_json[rule['property']].toLowerCase().includes(rule['value'].toLowerCase()));
|
||||
break;
|
||||
case 'equals':
|
||||
rule_applies = file_json[rule['property']] === rule['value'];
|
||||
@@ -101,23 +120,25 @@ function applyCategoryRules(file_json, rules, category_name) {
|
||||
return rules_apply;
|
||||
}
|
||||
|
||||
async function addTagToVideo(tag, video, user_uid) {
|
||||
// TODO: Implement
|
||||
}
|
||||
// async function addTagToVideo(tag, video, user_uid) {
|
||||
// // TODO: Implement
|
||||
// }
|
||||
|
||||
async function removeTagFromVideo(tag, video, user_uid) {
|
||||
// TODO: Implement
|
||||
}
|
||||
// async function removeTagFromVideo(tag, video, user_uid) {
|
||||
// // TODO: Implement
|
||||
// }
|
||||
|
||||
// adds tag to list of existing tags (used for tag suggestions)
|
||||
async function addTagToExistingTags(tag) {
|
||||
const existing_tags = db.get('tags').value();
|
||||
if (!existing_tags.includes(tag)) {
|
||||
db.get('tags').push(tag).write();
|
||||
}
|
||||
}
|
||||
// // adds tag to list of existing tags (used for tag suggestions)
|
||||
// async function addTagToExistingTags(tag) {
|
||||
// const existing_tags = db.get('tags').value();
|
||||
// if (!existing_tags.includes(tag)) {
|
||||
// db.get('tags').push(tag).write();
|
||||
// }
|
||||
// }
|
||||
|
||||
module.exports = {
|
||||
initialize: initialize,
|
||||
categorize: categorize,
|
||||
getCategories: getCategories,
|
||||
getCategoriesAsPlaylists: getCategoriesAsPlaylists
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
const logger = require('./logger');
|
||||
|
||||
const fs = require('fs');
|
||||
|
||||
let CONFIG_ITEMS = require('./consts.js')['CONFIG_ITEMS'];
|
||||
@@ -5,11 +7,7 @@ const debugMode = process.env.YTDL_MODE === 'debug';
|
||||
|
||||
let configPath = debugMode ? '../src/assets/default.json' : 'appdata/default.json';
|
||||
|
||||
var logger = null;
|
||||
function setLogger(input_logger) { logger = input_logger; }
|
||||
|
||||
function initialize(input_logger) {
|
||||
setLogger(input_logger);
|
||||
function initialize() {
|
||||
ensureConfigFileExists();
|
||||
ensureConfigItemsExist();
|
||||
}
|
||||
@@ -97,13 +95,13 @@ function getConfigItem(key) {
|
||||
}
|
||||
let path = CONFIG_ITEMS[key]['path'];
|
||||
const val = Object.byString(config_json, path);
|
||||
if (val === undefined && Object.byString(DEFAULT_CONFIG, path)) {
|
||||
if (val === undefined && Object.byString(DEFAULT_CONFIG, path) !== undefined) {
|
||||
logger.warn(`Cannot find config with key '${key}'. Creating one with the default value...`);
|
||||
setConfigItem(key, Object.byString(DEFAULT_CONFIG, path));
|
||||
return Object.byString(DEFAULT_CONFIG, path);
|
||||
}
|
||||
return Object.byString(config_json, path);
|
||||
};
|
||||
}
|
||||
|
||||
function setConfigItem(key, value) {
|
||||
let success = false;
|
||||
@@ -175,7 +173,7 @@ module.exports = {
|
||||
globalArgsRequiresSafeDownload: globalArgsRequiresSafeDownload
|
||||
}
|
||||
|
||||
DEFAULT_CONFIG = {
|
||||
const DEFAULT_CONFIG = {
|
||||
"YoutubeDLMaterial": {
|
||||
"Host": {
|
||||
"url": "http://example.com",
|
||||
@@ -189,15 +187,18 @@ DEFAULT_CONFIG = {
|
||||
"custom_args": "",
|
||||
"safe_download_override": false,
|
||||
"include_thumbnail": true,
|
||||
"include_metadata": true
|
||||
"include_metadata": true,
|
||||
"max_concurrent_downloads": 5,
|
||||
"download_rate_limit": ""
|
||||
},
|
||||
"Extra": {
|
||||
"title_top": "YoutubeDL-Material",
|
||||
"file_manager_enabled": true,
|
||||
"allow_quality_select": true,
|
||||
"download_only_mode": false,
|
||||
"allow_multi_download_mode": true,
|
||||
"enable_downloads_manager": true
|
||||
"allow_autoplay": true,
|
||||
"enable_downloads_manager": true,
|
||||
"allow_playlist_categorization": true
|
||||
},
|
||||
"API": {
|
||||
"use_API_key": false,
|
||||
@@ -206,7 +207,9 @@ DEFAULT_CONFIG = {
|
||||
"youtube_API_key": "",
|
||||
"use_twitch_API": false,
|
||||
"twitch_API_key": "",
|
||||
"twitch_auto_download_chat": false
|
||||
"twitch_auto_download_chat": false,
|
||||
"use_sponsorblock_API": false,
|
||||
"generate_NFO_files": false
|
||||
},
|
||||
"Themes": {
|
||||
"default_theme": "default",
|
||||
@@ -215,7 +218,7 @@ DEFAULT_CONFIG = {
|
||||
"Subscriptions": {
|
||||
"allow_subscriptions": true,
|
||||
"subscriptions_base_path": "subscriptions/",
|
||||
"subscriptions_check_interval": "300",
|
||||
"subscriptions_check_interval": "86400",
|
||||
"redownload_fresh_uploads": false
|
||||
},
|
||||
"Users": {
|
||||
@@ -230,6 +233,10 @@ DEFAULT_CONFIG = {
|
||||
"searchFilter": "(uid={{username}})"
|
||||
}
|
||||
},
|
||||
"Database": {
|
||||
"use_local_db": true,
|
||||
"mongodb_connection_string": "mongodb://127.0.0.1:27017/?compressors=zlib"
|
||||
},
|
||||
"Advanced": {
|
||||
"default_downloader": "youtube-dl",
|
||||
"use_default_downloading_agent": true,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
let CONFIG_ITEMS = {
|
||||
exports.CONFIG_ITEMS = {
|
||||
// Host
|
||||
'ytdl_url': {
|
||||
'key': 'ytdl_url',
|
||||
@@ -42,6 +42,14 @@ let CONFIG_ITEMS = {
|
||||
'key': 'ytdl_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
|
||||
'ytdl_title_top': {
|
||||
@@ -60,14 +68,18 @@ let CONFIG_ITEMS = {
|
||||
'key': 'ytdl_download_only_mode',
|
||||
'path': 'YoutubeDLMaterial.Extra.download_only_mode'
|
||||
},
|
||||
'ytdl_allow_multi_download_mode': {
|
||||
'key': 'ytdl_allow_multi_download_mode',
|
||||
'path': 'YoutubeDLMaterial.Extra.allow_multi_download_mode'
|
||||
'ytdl_allow_autoplay': {
|
||||
'key': 'ytdl_allow_autoplay',
|
||||
'path': 'YoutubeDLMaterial.Extra.allow_autoplay'
|
||||
},
|
||||
'ytdl_enable_downloads_manager': {
|
||||
'key': 'ytdl_enable_downloads_manager',
|
||||
'path': 'YoutubeDLMaterial.Extra.enable_downloads_manager'
|
||||
},
|
||||
'ytdl_allow_playlist_categorization': {
|
||||
'key': 'ytdl_allow_playlist_categorization',
|
||||
'path': 'YoutubeDLMaterial.Extra.allow_playlist_categorization'
|
||||
},
|
||||
|
||||
// API
|
||||
'ytdl_use_api_key': {
|
||||
@@ -98,6 +110,15 @@ let CONFIG_ITEMS = {
|
||||
'key': 'ytdl_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
|
||||
'ytdl_default_theme': {
|
||||
@@ -122,10 +143,6 @@ let CONFIG_ITEMS = {
|
||||
'key': 'ytdl_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': {
|
||||
'key': 'ytdl_subscriptions_redownload_fresh_uploads',
|
||||
'path': 'YoutubeDLMaterial.Subscriptions.redownload_fresh_uploads'
|
||||
@@ -149,6 +166,16 @@ let CONFIG_ITEMS = {
|
||||
'path': 'YoutubeDLMaterial.Users.ldap_config'
|
||||
},
|
||||
|
||||
// Database
|
||||
'ytdl_use_local_db': {
|
||||
'key': 'ytdl_use_local_db',
|
||||
'path': 'YoutubeDLMaterial.Database.use_local_db'
|
||||
},
|
||||
'ytdl_mongodb_connection_string': {
|
||||
'key': 'ytdl_mongodb_connection_string',
|
||||
'path': 'YoutubeDLMaterial.Database.mongodb_connection_string'
|
||||
},
|
||||
|
||||
// Advanced
|
||||
'ytdl_default_downloader': {
|
||||
'key': 'ytdl_default_downloader',
|
||||
@@ -184,7 +211,7 @@ let CONFIG_ITEMS = {
|
||||
}
|
||||
};
|
||||
|
||||
AVAILABLE_PERMISSIONS = [
|
||||
exports.AVAILABLE_PERMISSIONS = [
|
||||
'filemanager',
|
||||
'settings',
|
||||
'subscriptions',
|
||||
@@ -193,8 +220,6 @@ AVAILABLE_PERMISSIONS = [
|
||||
'downloads_manager'
|
||||
];
|
||||
|
||||
module.exports = {
|
||||
CONFIG_ITEMS: CONFIG_ITEMS,
|
||||
AVAILABLE_PERMISSIONS: AVAILABLE_PERMISSIONS,
|
||||
CURRENT_VERSION: 'v4.2'
|
||||
}
|
||||
exports.DETAILS_BIN_PATH = 'node_modules/youtube-dl/bin/details'
|
||||
|
||||
exports.CURRENT_VERSION = 'v4.2';
|
||||
|
||||
970
backend/db.js
970
backend/db.js
File diff suppressed because it is too large
Load Diff
632
backend/downloader.js
Normal file
632
backend/downloader.js
Normal file
@@ -0,0 +1,632 @@
|
||||
const fs = require('fs-extra');
|
||||
const { uuid } = require('uuidv4');
|
||||
const path = require('path');
|
||||
const mergeFiles = require('merge-files');
|
||||
const NodeID3 = require('node-id3')
|
||||
const glob = require('glob')
|
||||
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');
|
||||
|
||||
let db_api = null;
|
||||
|
||||
const mutex = new Mutex();
|
||||
let should_check_downloads = true;
|
||||
|
||||
const archivePath = path.join(__dirname, 'appdata', 'archives');
|
||||
|
||||
function setDB(input_db_api) { db_api = input_db_api }
|
||||
|
||||
exports.initialize = (input_db_api) => {
|
||||
setDB(input_db_api);
|
||||
categories_api.initialize(db_api);
|
||||
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) => {
|
||||
return await mutex.runExclusive(async () => {
|
||||
const download = {
|
||||
url: url,
|
||||
type: type,
|
||||
title: '',
|
||||
user_uid: user_uid,
|
||||
sub_id: sub_id,
|
||||
sub_name: sub_name,
|
||||
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) {
|
||||
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 = await 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 getVideoInfoByURL(url, args, download_uid);
|
||||
}
|
||||
|
||||
// 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']
|
||||
});
|
||||
}
|
||||
|
||||
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');
|
||||
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; // TODO: fix
|
||||
if (options.customFileFolderPath) {
|
||||
fileFolderPath = options.customFileFolderPath;
|
||||
}
|
||||
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 audioFolderPath = config_api.getConfigItem('ytdl_audio_folder_path');
|
||||
const videoFolderPath = config_api.getConfigItem('ytdl_video_folder_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 = is_audio ? audioFolderPath : videoFolderPath;
|
||||
|
||||
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;
|
||||
} else if (!is_audio && !is_youtube && (url.includes('reddit') || url.includes('pornhub'))) {
|
||||
qualityPath = ['-f', 'bestvideo+bestaudio']
|
||||
}
|
||||
|
||||
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 = downloadConfig.concat(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);
|
||||
}
|
||||
|
||||
const default_downloader = utils.getCurrentDownloader() || config_api.getConfigItem('ytdl_default_downloader');
|
||||
if (default_downloader === 'yt-dlp') {
|
||||
downloadConfig.push('--no-clean-infojson');
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// filter out incompatible args
|
||||
downloadConfig = filterArgs(downloadConfig, is_audio);
|
||||
|
||||
if (!simulated) logger.verbose(`youtube-dl args being used: ${downloadConfig.join(',')}`);
|
||||
return downloadConfig;
|
||||
}
|
||||
|
||||
async function getVideoInfoByURL(url, args = [], download_uid = null) {
|
||||
return new Promise(resolve => {
|
||||
// remove bad args
|
||||
const new_args = [...args];
|
||||
|
||||
const archiveArgIndex = new_args.indexOf('--download-archive');
|
||||
if (archiveArgIndex !== -1) {
|
||||
new_args.splice(archiveArgIndex, 2);
|
||||
}
|
||||
|
||||
new_args.push('--dump-json');
|
||||
|
||||
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'];
|
||||
const args_to_remove = isAudio ? video_only_args : audio_only_args;
|
||||
return args.filter(x => !args_to_remove.includes(x));
|
||||
}
|
||||
|
||||
async function checkDownloadPercent(download_uid) {
|
||||
/*
|
||||
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;
|
||||
glob(`{${files_to_check_for_progress.join(',')}, }*`, async (err, files) => {
|
||||
files.forEach(async file => {
|
||||
try {
|
||||
const file_stats = fs.statSync(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(archivePath);
|
||||
}
|
||||
}
|
||||
8
backend/ecosystem.config.js
Normal file
8
backend/ecosystem.config.js
Normal file
@@ -0,0 +1,8 @@
|
||||
module.exports = {
|
||||
apps : [{
|
||||
name : "YoutubeDL-Material",
|
||||
script : "./app.js",
|
||||
watch : "placeholder",
|
||||
watch_delay: 5000
|
||||
}]
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
CMD="node app.js"
|
||||
CMD="pm2-runtime pm2.config.js"
|
||||
|
||||
# if the first arg starts with "-" pass it to program
|
||||
if [ "${1#-}" != "$1" ]; then
|
||||
|
||||
23
backend/logger.js
Normal file
23
backend/logger.js
Normal file
@@ -0,0 +1,23 @@
|
||||
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;
|
||||
1516
backend/package-lock.json
generated
1516
backend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -5,7 +5,8 @@
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1",
|
||||
"start": "nodemon -q app.js"
|
||||
"start": "nodemon app.js",
|
||||
"debug": "set YTDL_MODE=debug && node app.js"
|
||||
},
|
||||
"nodemonConfig": {
|
||||
"ignore": [
|
||||
@@ -14,7 +15,8 @@
|
||||
"public/*"
|
||||
],
|
||||
"watch": [
|
||||
"restart.json"
|
||||
"restart_update.json",
|
||||
"restart_general.json"
|
||||
]
|
||||
},
|
||||
"repository": {
|
||||
@@ -30,7 +32,8 @@
|
||||
"dependencies": {
|
||||
"archiver": "^3.1.1",
|
||||
"async": "^3.1.0",
|
||||
"axios": "^0.21.0",
|
||||
"async-mutex": "^0.3.1",
|
||||
"axios": "^0.21.2",
|
||||
"bcryptjs": "^2.4.0",
|
||||
"compression": "^1.7.4",
|
||||
"config": "^3.2.3",
|
||||
@@ -43,11 +46,13 @@
|
||||
"lowdb": "^1.0.0",
|
||||
"md5": "^2.2.1",
|
||||
"merge-files": "^0.1.2",
|
||||
"mocha": "^8.4.0",
|
||||
"moment": "^2.29.1",
|
||||
"mongodb": "^3.6.9",
|
||||
"multer": "^1.4.2",
|
||||
"node-fetch": "^2.6.1",
|
||||
"node-id3": "^0.1.14",
|
||||
"nodemon": "^2.0.2",
|
||||
"nodemon": "^2.0.7",
|
||||
"passport": "^0.4.1",
|
||||
"passport-http": "^0.3.0",
|
||||
"passport-jwt": "^4.0.0",
|
||||
@@ -56,10 +61,12 @@
|
||||
"progress": "^2.0.3",
|
||||
"ps-node": "^0.1.6",
|
||||
"read-last-lines": "^1.7.2",
|
||||
"rxjs": "^7.3.0",
|
||||
"shortid": "^2.2.15",
|
||||
"unzipper": "^0.10.10",
|
||||
"uuidv4": "^6.0.6",
|
||||
"winston": "^3.2.1",
|
||||
"winston": "^3.3.3",
|
||||
"xmlbuilder2": "^3.0.2",
|
||||
"youtube-dl": "^3.0.2"
|
||||
}
|
||||
}
|
||||
|
||||
7
backend/pm2.config.js
Normal file
7
backend/pm2.config.js
Normal file
@@ -0,0 +1,7 @@
|
||||
module.exports = {
|
||||
apps : [{
|
||||
name : "YoutubeDL-Material",
|
||||
script : "./app.js",
|
||||
watch : "placeholder"
|
||||
}]
|
||||
}
|
||||
@@ -1,27 +1,21 @@
|
||||
const FileSync = require('lowdb/adapters/FileSync')
|
||||
const fs = require('fs-extra');
|
||||
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 twitch_api = require('./twitch');
|
||||
var utils = require('./utils');
|
||||
const utils = require('./utils');
|
||||
const logger = require('./logger');
|
||||
|
||||
const debugMode = process.env.YTDL_MODE === 'debug';
|
||||
|
||||
var logger = null;
|
||||
var db = null;
|
||||
var users_db = null;
|
||||
var db_api = null;
|
||||
let db_api = null;
|
||||
let downloader_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 setDB(input_db_api) { db_api = input_db_api }
|
||||
|
||||
function initialize(input_db, input_users_db, input_logger, input_db_api) {
|
||||
setDB(input_db, input_users_db, input_db_api);
|
||||
setLogger(input_logger);
|
||||
function initialize(input_db_api, input_downloader_api) {
|
||||
setDB(input_db_api);
|
||||
downloader_api = input_downloader_api;
|
||||
}
|
||||
|
||||
async function subscribe(sub, user_uid = null) {
|
||||
@@ -34,12 +28,7 @@ async function subscribe(sub, user_uid = null) {
|
||||
sub.isPlaylist = sub.url.includes('playlist');
|
||||
sub.videos = [];
|
||||
|
||||
let url_exists = false;
|
||||
|
||||
if (user_uid)
|
||||
url_exists = !!users_db.get('users').find({uid: user_uid}).get('subscriptions').find({url: sub.url}).value()
|
||||
else
|
||||
url_exists = !!db.get('subscriptions').find({url: sub.url}).value();
|
||||
let url_exists = !!(await db_api.getRecord('subscriptions', {url: sub.url, user_uid: user_uid}));
|
||||
|
||||
if (!sub.name && url_exists) {
|
||||
logger.error(`Sub with the same URL "${sub.url}" already exists -- please provide a custom name for this new subscription.`);
|
||||
@@ -48,23 +37,16 @@ async function subscribe(sub, user_uid = null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// add sub to db
|
||||
let sub_db = null;
|
||||
if (user_uid) {
|
||||
users_db.get('users').find({uid: user_uid}).get('subscriptions').push(sub).write();
|
||||
sub_db = users_db.get('users').find({uid: user_uid}).get('subscriptions').find({id: sub.id});
|
||||
} else {
|
||||
db.get('subscriptions').push(sub).write();
|
||||
sub_db = db.get('subscriptions').find({id: sub.id});
|
||||
}
|
||||
let success = await getSubscriptionInfo(sub, user_uid);
|
||||
sub['user_uid'] = user_uid ? user_uid : undefined;
|
||||
await db_api.insertRecordIntoTable('subscriptions', sub);
|
||||
|
||||
let success = await getSubscriptionInfo(sub);
|
||||
|
||||
if (success) {
|
||||
sub = sub_db.value();
|
||||
getVideosForSub(sub, user_uid);
|
||||
} else {
|
||||
logger.error('Subscribe: Failed to get subscription info. Subscribe failed.')
|
||||
};
|
||||
}
|
||||
|
||||
result_obj.success = success;
|
||||
result_obj.sub = sub;
|
||||
@@ -73,13 +55,7 @@ async function subscribe(sub, user_uid = null) {
|
||||
|
||||
}
|
||||
|
||||
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');
|
||||
|
||||
async function getSubscriptionInfo(sub) {
|
||||
// get videos
|
||||
let downloadConfig = ['--dump-json', '--playlist-end', '1'];
|
||||
let useCookies = config_api.getConfigItem('ytdl_use_cookies');
|
||||
@@ -91,8 +67,8 @@ async function getSubscriptionInfo(sub, user_uid = null) {
|
||||
}
|
||||
}
|
||||
|
||||
return new Promise(resolve => {
|
||||
youtubedl.exec(sub.url, downloadConfig, {}, function(err, output) {
|
||||
return new Promise(async resolve => {
|
||||
youtubedl.exec(sub.url, downloadConfig, {maxBuffer: Infinity}, async (err, output) => {
|
||||
if (debugMode) {
|
||||
logger.info('Subscribe: got info for subscription ' + sub.id);
|
||||
}
|
||||
@@ -122,31 +98,10 @@ async function getSubscriptionInfo(sub, user_uid = null) {
|
||||
}
|
||||
// if it's now valid, update
|
||||
if (sub.name) {
|
||||
if (user_uid)
|
||||
users_db.get('users').find({uid: user_uid}).get('subscriptions').find({id: sub.id}).assign({name: sub.name}).write();
|
||||
else
|
||||
db.get('subscriptions').find({id: sub.id}).assign({name: sub.name}).write();
|
||||
await db_api.updateRecord('subscriptions', {id: sub.id}, {name: sub.name});
|
||||
}
|
||||
}
|
||||
|
||||
const useArchive = config_api.getConfigItem('ytdl_use_youtubedl_archive');
|
||||
if (useArchive && !sub.archive) {
|
||||
// must create the archive
|
||||
const archive_dir = path.join(__dirname, 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;
|
||||
if (user_uid)
|
||||
users_db.get('users').find({uid: user_uid}).get('subscriptions').find({id: sub.id}).assign({archive: archive_dir}).write();
|
||||
else
|
||||
db.get('subscriptions').find({id: sub.id}).assign({archive: archive_dir}).write();
|
||||
}
|
||||
|
||||
// TODO: get even more info
|
||||
|
||||
resolve(true);
|
||||
@@ -163,13 +118,25 @@ async function unsubscribe(sub, deleteMode, user_uid = null) {
|
||||
basePath = path.join(config_api.getConfigItem('ytdl_users_base_path'), user_uid, 'subscriptions');
|
||||
else
|
||||
basePath = config_api.getConfigItem('ytdl_subscriptions_base_path');
|
||||
let result_obj = { success: false, error: '' };
|
||||
|
||||
let id = sub.id;
|
||||
if (user_uid)
|
||||
users_db.get('users').find({uid: user_uid}).get('subscriptions').remove({id: id}).write();
|
||||
else
|
||||
db.get('subscriptions').remove({id: id}).write();
|
||||
|
||||
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.removeAllRecords('files', {sub_id: id});
|
||||
|
||||
// failed subs have no name, on unsubscribe they shouldn't error
|
||||
if (!sub.name) {
|
||||
@@ -191,27 +158,23 @@ async function unsubscribe(sub, deleteMode, user_uid = null) {
|
||||
}
|
||||
|
||||
async function deleteSubscriptionFile(sub, file, deleteForever, file_uid = null, user_uid = null) {
|
||||
// TODO: combine this with deletefile
|
||||
let basePath = null;
|
||||
let sub_db = null;
|
||||
if (user_uid) {
|
||||
basePath = path.join(config_api.getConfigItem('ytdl_users_base_path'), user_uid, 'subscriptions');
|
||||
sub_db = users_db.get('users').find({uid: user_uid}).get('subscriptions').find({id: sub.id});
|
||||
} else {
|
||||
basePath = config_api.getConfigItem('ytdl_subscriptions_base_path');
|
||||
sub_db = db.get('subscriptions').find({id: sub.id});
|
||||
}
|
||||
basePath = user_uid ? path.join(config_api.getConfigItem('ytdl_users_base_path'), user_uid, 'subscriptions')
|
||||
: config_api.getConfigItem('ytdl_subscriptions_base_path');
|
||||
const useArchive = config_api.getConfigItem('ytdl_use_youtubedl_archive');
|
||||
const appendedBasePath = getAppendedBasePath(sub, basePath);
|
||||
const name = file;
|
||||
let retrievedID = null;
|
||||
sub_db.get('videos').remove({uid: file_uid}).write();
|
||||
|
||||
await db_api.removeRecord('files', {uid: file_uid});
|
||||
|
||||
let filePath = appendedBasePath;
|
||||
const ext = (sub.type && sub.type === 'audio') ? '.mp3' : '.mp4'
|
||||
var jsonPath = path.join(__dirname,filePath,name+'.info.json');
|
||||
var videoFilePath = path.join(__dirname,filePath,name+ext);
|
||||
var imageFilePath = path.join(__dirname,filePath,name+'.jpg');
|
||||
var altImageFilePath = path.join(__dirname,filePath,name+'.jpg');
|
||||
var altImageFilePath = path.join(__dirname,filePath,name+'.webp');
|
||||
|
||||
const [jsonExists, videoFileExists, imageFileExists, altImageFileExists] = await Promise.all([
|
||||
fs.pathExists(jsonPath),
|
||||
@@ -243,7 +206,7 @@ async function deleteSubscriptionFile(sub, file, deleteForever, file_uid = null,
|
||||
const archive_path = path.join(sub.archive, 'archive.txt')
|
||||
// if archive exists, remove line with video ID
|
||||
if (await fs.pathExists(archive_path)) {
|
||||
await removeIDFromArchive(archive_path, retrievedID);
|
||||
utils.removeIDFromArchive(archive_path, retrievedID);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
@@ -255,14 +218,7 @@ async function deleteSubscriptionFile(sub, file, deleteForever, file_uid = null,
|
||||
}
|
||||
|
||||
async function getVideosForSub(sub, user_uid = null) {
|
||||
// get sub_db
|
||||
let sub_db = null;
|
||||
if (user_uid)
|
||||
sub_db = users_db.get('users').find({uid: user_uid}).get('subscriptions').find({id: sub.id});
|
||||
else
|
||||
sub_db = db.get('subscriptions').find({id: sub.id});
|
||||
|
||||
const latest_sub_obj = sub_db.value();
|
||||
const latest_sub_obj = await getSubscription(sub.id);
|
||||
if (!latest_sub_obj || latest_sub_obj['downloading']) {
|
||||
return false;
|
||||
}
|
||||
@@ -277,42 +233,39 @@ async function getVideosForSub(sub, user_uid = null) {
|
||||
basePath = config_api.getConfigItem('ytdl_subscriptions_base_path');
|
||||
|
||||
let appendedBasePath = getAppendedBasePath(sub, basePath);
|
||||
|
||||
let multiUserMode = null;
|
||||
if (user_uid) {
|
||||
multiUserMode = {
|
||||
user: user_uid,
|
||||
file_path: appendedBasePath
|
||||
}
|
||||
}
|
||||
fs.ensureDirSync(appendedBasePath);
|
||||
|
||||
const downloadConfig = await generateArgsForSubscription(sub, user_uid);
|
||||
|
||||
// get videos
|
||||
logger.verbose('Subscription: getting videos for subscription ' + sub.name);
|
||||
logger.verbose(`Subscription: getting videos for subscription ${sub.name} with args: ${downloadConfig.join(',')}`);
|
||||
|
||||
return new Promise(resolve => {
|
||||
youtubedl.exec(sub.url, downloadConfig, {}, async function(err, output) {
|
||||
return new Promise(async resolve => {
|
||||
youtubedl.exec(sub.url, downloadConfig, {maxBuffer: Infinity}, async function(err, output) {
|
||||
// cleanup
|
||||
updateSubscriptionProperty(sub, {downloading: false}, user_uid);
|
||||
|
||||
logger.verbose('Subscription: finished check for ' + sub.name);
|
||||
if (err && !output) {
|
||||
logger.error(err.stderr ? err.stderr : err.message);
|
||||
if (err.stderr.includes('This video is unavailable')) {
|
||||
logger.info('An error was encountered with at least one video, backup method will be used.')
|
||||
try {
|
||||
const outputs = err.stdout.split(/\r\n|\r|\n/);
|
||||
for (let i = 0; i < outputs.length; i++) {
|
||||
const output = JSON.parse(outputs[i]);
|
||||
handleOutputJSON(sub, sub_db, 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']);
|
||||
}
|
||||
}
|
||||
}
|
||||
// TODO: reimplement
|
||||
|
||||
// const outputs = err.stdout.split(/\r\n|\r|\n/);
|
||||
// for (let i = 0; i < outputs.length; i++) {
|
||||
// const output = JSON.parse(outputs[i]);
|
||||
// await handleOutputJSON(sub, output, i === 0, multiUserMode)
|
||||
// if (err.stderr.includes(output['id']) && archive_path) {
|
||||
// // we found a video that errored! add it to the archive to prevent future errors
|
||||
// if (sub.archive) {
|
||||
// archive_dir = sub.archive;
|
||||
// archive_path = path.join(archive_dir, 'archive.txt')
|
||||
// fs.appendFileSync(archive_path, output['id']);
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
} catch(e) {
|
||||
logger.error('Backup method failed. See error below:');
|
||||
logger.error(e);
|
||||
@@ -325,21 +278,30 @@ async function getVideosForSub(sub, user_uid = null) {
|
||||
resolve(true);
|
||||
return;
|
||||
}
|
||||
const output_jsons = [];
|
||||
for (let i = 0; i < output.length; i++) {
|
||||
let output_json = null;
|
||||
try {
|
||||
output_json = JSON.parse(output[i]);
|
||||
output_jsons.push(output_json);
|
||||
} catch(e) {
|
||||
output_json = null;
|
||||
}
|
||||
if (!output_json) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const reset_videos = i === 0;
|
||||
handleOutputJSON(sub, sub_db, output_json, multiUserMode, reset_videos);
|
||||
}
|
||||
|
||||
const files_to_download = await getFilesToDownload(sub, output_jsons);
|
||||
const base_download_options = generateOptionsForSubscriptionDownload(sub, user_uid);
|
||||
|
||||
for (let j = 0; j < files_to_download.length; j++) {
|
||||
const file_to_download = files_to_download[j];
|
||||
await downloader_api.createDownload(file_to_download['webpage_url'], sub.type || 'video', base_download_options, user_uid, sub.id, sub.name);
|
||||
}
|
||||
|
||||
resolve(files_to_download);
|
||||
|
||||
if (config_api.getConfigItem('ytdl_subscriptions_redownload_fresh_uploads')) {
|
||||
await setFreshUploads(sub, user_uid);
|
||||
checkVideosForFreshUploads(sub, user_uid);
|
||||
@@ -354,6 +316,26 @@ async function getVideosForSub(sub, user_uid = null) {
|
||||
});
|
||||
}
|
||||
|
||||
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(__dirname, basePath, 'archives', sub.name),
|
||||
additionalArgs: sub.custom_args
|
||||
}
|
||||
|
||||
return base_download_options;
|
||||
}
|
||||
|
||||
async function generateArgsForSubscription(sub, user_uid, redownload = false, desired_path = null) {
|
||||
// get basePath
|
||||
let basePath = null;
|
||||
@@ -366,14 +348,16 @@ async function generateArgsForSubscription(sub, user_uid, redownload = false, de
|
||||
|
||||
let appendedBasePath = getAppendedBasePath(sub, basePath);
|
||||
|
||||
let fullOutput = `${appendedBasePath}/%(title)s.%(ext)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`;
|
||||
if (desired_path) {
|
||||
fullOutput = `${desired_path}.%(ext)s`;
|
||||
} else if (sub.custom_output) {
|
||||
fullOutput = `${appendedBasePath}/${sub.custom_output}.%(ext)s`;
|
||||
}
|
||||
|
||||
let downloadConfig = ['-o', fullOutput, !redownload ? '-ciw' : '-ci', '--write-info-json', '--print-json'];
|
||||
let downloadConfig = ['--dump-json', '-o', fullOutput, !redownload ? '-ciw' : '-ci', '--write-info-json', '--print-json'];
|
||||
|
||||
let qualityPath = null;
|
||||
if (sub.type && sub.type === 'audio') {
|
||||
@@ -388,7 +372,7 @@ async function generateArgsForSubscription(sub, user_uid, redownload = false, de
|
||||
downloadConfig.push(...qualityPath)
|
||||
|
||||
if (sub.custom_args) {
|
||||
customArgsArray = sub.custom_args.split(',,');
|
||||
const customArgsArray = sub.custom_args.split(',,');
|
||||
if (customArgsArray.indexOf('-f') !== -1) {
|
||||
// if custom args has a custom quality, replce the original quality with that of custom args
|
||||
const original_output_index = downloadConfig.indexOf('-f');
|
||||
@@ -430,109 +414,72 @@ async function generateArgsForSubscription(sub, user_uid, redownload = false, de
|
||||
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');
|
||||
}
|
||||
|
||||
return downloadConfig;
|
||||
}
|
||||
|
||||
function handleOutputJSON(sub, sub_db, output_json, multiUserMode = null, reset_videos = false) {
|
||||
if (sub.streamingOnly) {
|
||||
if (reset_videos) {
|
||||
sub_db.assign({videos: []}).write();
|
||||
}
|
||||
|
||||
// remove unnecessary info
|
||||
output_json.formats = null;
|
||||
|
||||
// add to db
|
||||
sub_db.get('videos').push(output_json).write();
|
||||
} else {
|
||||
path_object = path.parse(output_json['_filename']);
|
||||
const path_string = path.format(path_object);
|
||||
|
||||
if (sub_db.get('videos').find({path: path_string}).value()) {
|
||||
// file already exists in DB, return early to avoid reseting the download date
|
||||
return;
|
||||
}
|
||||
|
||||
db_api.registerFileDB(path.basename(output_json['_filename']), sub.type, multiUserMode, sub);
|
||||
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);
|
||||
async function getFilesToDownload(sub, output_jsons) {
|
||||
const files_to_download = [];
|
||||
for (let i = 0; i < output_jsons.length; i++) {
|
||||
const output_json = output_jsons[i];
|
||||
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}));
|
||||
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) {
|
||||
// or maybe just overwrite???
|
||||
logger.info(`Skipping adding file ${output_json['_filename']} for subscription ${sub.name} as a file with that path already exists.`)
|
||||
}
|
||||
files_to_download.push(output_json);
|
||||
}
|
||||
}
|
||||
return files_to_download;
|
||||
}
|
||||
|
||||
function getSubscriptions(user_uid = null) {
|
||||
if (user_uid)
|
||||
return users_db.get('users').find({uid: user_uid}).get('subscriptions').value();
|
||||
else
|
||||
return db.get('subscriptions').value();
|
||||
|
||||
async function getSubscriptions(user_uid = null) {
|
||||
return await db_api.getRecords('subscriptions', {user_uid: user_uid});
|
||||
}
|
||||
|
||||
function getAllSubscriptions() {
|
||||
let subscriptions = null;
|
||||
async function getAllSubscriptions() {
|
||||
const all_subs = await db_api.getRecords('subscriptions');
|
||||
const multiUserMode = config_api.getConfigItem('ytdl_multi_user_mode');
|
||||
if (multiUserMode) {
|
||||
subscriptions = [];
|
||||
let users = users_db.get('users').value();
|
||||
for (let i = 0; i < users.length; i++) {
|
||||
if (users[i]['subscriptions']) subscriptions = subscriptions.concat(users[i]['subscriptions']);
|
||||
}
|
||||
} else {
|
||||
subscriptions = getSubscriptions();
|
||||
}
|
||||
return subscriptions;
|
||||
return all_subs.filter(sub => !!(sub.user_uid) === !!multiUserMode);
|
||||
}
|
||||
|
||||
function getSubscription(subID, user_uid = null) {
|
||||
if (user_uid)
|
||||
return users_db.get('users').find({uid: user_uid}).get('subscriptions').find({id: subID}).value();
|
||||
else
|
||||
return db.get('subscriptions').find({id: subID}).value();
|
||||
async function getSubscription(subID) {
|
||||
return await db_api.getRecord('subscriptions', {id: subID});
|
||||
}
|
||||
|
||||
function getSubscriptionByName(subName, user_uid = null) {
|
||||
if (user_uid)
|
||||
return users_db.get('users').find({uid: user_uid}).get('subscriptions').find({name: subName}).value();
|
||||
else
|
||||
return db.get('subscriptions').find({name: subName}).value();
|
||||
async function getSubscriptionByName(subName, user_uid = null) {
|
||||
return await db_api.getRecord('subscriptions', {name: subName, user_uid: user_uid});
|
||||
}
|
||||
|
||||
function updateSubscription(sub, user_uid = null) {
|
||||
if (user_uid) {
|
||||
users_db.get('users').find({uid: user_uid}).get('subscriptions').find({id: sub.id}).assign(sub).write();
|
||||
} else {
|
||||
db.get('subscriptions').find({id: sub.id}).assign(sub).write();
|
||||
}
|
||||
async function updateSubscription(sub) {
|
||||
await db_api.updateRecord('subscriptions', {id: sub.id}, sub);
|
||||
return true;
|
||||
}
|
||||
|
||||
function updateSubscriptionPropertyMultiple(subs, assignment_obj) {
|
||||
subs.forEach(sub => {
|
||||
updateSubscriptionProperty(sub, assignment_obj, sub.user_uid);
|
||||
async function updateSubscriptionPropertyMultiple(subs, assignment_obj) {
|
||||
subs.forEach(async sub => {
|
||||
await updateSubscriptionProperty(sub, assignment_obj, sub.user_uid);
|
||||
});
|
||||
}
|
||||
|
||||
function updateSubscriptionProperty(sub, assignment_obj, user_uid = null) {
|
||||
if (user_uid) {
|
||||
users_db.get('users').find({uid: user_uid}).get('subscriptions').find({id: sub.id}).assign(assignment_obj).write();
|
||||
} else {
|
||||
db.get('subscriptions').find({id: sub.id}).assign(assignment_obj).write();
|
||||
}
|
||||
async function updateSubscriptionProperty(sub, assignment_obj) {
|
||||
// TODO: combine with updateSubscription
|
||||
await db_api.updateRecord('subscriptions', {id: sub.id}, assignment_obj);
|
||||
return true;
|
||||
}
|
||||
|
||||
function subExists(subID, user_uid = null) {
|
||||
if (user_uid)
|
||||
return !!users_db.get('users').find({uid: user_uid}).get('subscriptions').find({id: subID}).value();
|
||||
else
|
||||
return !!db.get('subscriptions').find({id: subID}).value();
|
||||
}
|
||||
|
||||
async function setFreshUploads(sub, user_uid) {
|
||||
const current_date = new Date().toISOString().split('T')[0].replace(/-/g, '');
|
||||
sub.videos.forEach(async video => {
|
||||
@@ -548,7 +495,7 @@ async function checkVideosForFreshUploads(sub, user_uid) {
|
||||
const current_date = new Date().toISOString().split('T')[0].replace(/-/g, '');
|
||||
sub.videos.forEach(async video => {
|
||||
if (video['fresh_upload'] && current_date > video['upload_date'].replace(/-/g, '')) {
|
||||
checkVideoIfBetterExists(video, sub, user_uid)
|
||||
await checkVideoIfBetterExists(video, sub, user_uid)
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -558,14 +505,14 @@ async function checkVideoIfBetterExists(file_obj, sub, user_uid) {
|
||||
const downloadConfig = await generateArgsForSubscription(sub, user_uid, true, new_path);
|
||||
logger.verbose(`Checking if a better version of the fresh upload ${file_obj['id']} exists.`);
|
||||
// simulate a download to verify that a better version exists
|
||||
youtubedl.getInfo(file_obj['url'], downloadConfig, (err, output) => {
|
||||
youtubedl.getInfo(file_obj['url'], downloadConfig, async (err, output) => {
|
||||
if (err) {
|
||||
// video is not available anymore for whatever reason
|
||||
} else if (output) {
|
||||
const metric_to_compare = sub.type === 'audio' ? 'abr' : 'height';
|
||||
if (output[metric_to_compare] > file_obj[metric_to_compare]) {
|
||||
// download new video as the simulated one is better
|
||||
youtubedl.exec(file_obj['url'], downloadConfig, async (err, output) => {
|
||||
youtubedl.exec(file_obj['url'], downloadConfig, {maxBuffer: Infinity}, async (err, output) => {
|
||||
if (err) {
|
||||
logger.verbose(`Failed to download better version of video ${file_obj['id']}`);
|
||||
} else if (output) {
|
||||
@@ -582,37 +529,9 @@ async function checkVideoIfBetterExists(file_obj, sub, user_uid) {
|
||||
// helper functions
|
||||
|
||||
function getAppendedBasePath(sub, base_path) {
|
||||
|
||||
return path.join(base_path, (sub.isPlaylist ? 'playlists/' : 'channels/'), sub.name);
|
||||
}
|
||||
|
||||
async function removeIDFromArchive(archive_path, id) {
|
||||
let data = await fs.readFile(archive_path, {encoding: 'utf-8'});
|
||||
if (!data) {
|
||||
logger.error('Archive could not be found.');
|
||||
return;
|
||||
}
|
||||
|
||||
let dataArray = data.split('\n'); // convert file data in an array
|
||||
const searchKeyword = id; // we are looking for a line, contains, key word id in the file
|
||||
let lastIndex = -1; // let say, we have not found the keyword
|
||||
|
||||
for (let index=0; index<dataArray.length; index++) {
|
||||
if (dataArray[index].includes(searchKeyword)) { // check if a line contains the id keyword
|
||||
lastIndex = index; // found a line includes a id keyword
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const line = dataArray.splice(lastIndex, 1); // remove the keyword id from the data Array
|
||||
|
||||
// UPDATE FILE WITH NEW DATA
|
||||
const updatedData = dataArray.join('\n');
|
||||
await fs.writeFile(archive_path, updatedData);
|
||||
if (line) return line;
|
||||
if (err) throw err;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getSubscription : getSubscription,
|
||||
getSubscriptionByName : getSubscriptionByName,
|
||||
@@ -623,8 +542,7 @@ module.exports = {
|
||||
unsubscribe : unsubscribe,
|
||||
deleteSubscriptionFile : deleteSubscriptionFile,
|
||||
getVideosForSub : getVideosForSub,
|
||||
removeIDFromArchive : removeIDFromArchive,
|
||||
setLogger : setLogger,
|
||||
initialize : initialize,
|
||||
updateSubscriptionPropertyMultiple : updateSubscriptionPropertyMultiple
|
||||
updateSubscriptionPropertyMultiple : updateSubscriptionPropertyMultiple,
|
||||
generateOptionsForSubscriptionDownload: generateOptionsForSubscriptionDownload
|
||||
}
|
||||
|
||||
1
backend/test/sample.info.json
Normal file
1
backend/test/sample.info.json
Normal file
File diff suppressed because one or more lines are too long
352
backend/test/tests.js
Normal file
352
backend/test/tests.js
Normal file
@@ -0,0 +1,352 @@
|
||||
var assert = require('assert');
|
||||
const low = require('lowdb')
|
||||
var winston = require('winston');
|
||||
|
||||
process.chdir('./backend')
|
||||
|
||||
const FileSync = require('lowdb/adapters/FileSync');
|
||||
|
||||
const adapter = new FileSync('./appdata/db.json');
|
||||
const db = low(adapter)
|
||||
|
||||
const users_adapter = new FileSync('./appdata/users.json');
|
||||
const users_db = low(users_adapter);
|
||||
|
||||
const defaultFormat = winston.format.printf(({ level, message, label, timestamp }) => {
|
||||
return `${timestamp} ${level.toUpperCase()}: ${message}`;
|
||||
});
|
||||
|
||||
let debugMode = process.env.YTDL_MODE === 'debug';
|
||||
|
||||
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: 'debug', name: 'console'})
|
||||
]
|
||||
});
|
||||
|
||||
var auth_api = require('../authentication/auth');
|
||||
var db_api = require('../db');
|
||||
const utils = require('../utils');
|
||||
const subscriptions_api = require('../subscriptions');
|
||||
const fs = require('fs-extra');
|
||||
const { uuid } = require('uuidv4');
|
||||
|
||||
db_api.initialize(db, users_db);
|
||||
|
||||
|
||||
describe('Database', async function() {
|
||||
describe('Import', async function() {
|
||||
it('Migrate', async function() {
|
||||
await db_api.connectToDB();
|
||||
await db_api.removeAllRecords();
|
||||
const success = await db_api.importJSONToDB(db.value(), users_db.value());
|
||||
assert(success);
|
||||
});
|
||||
|
||||
it('Transfer to remote', async function() {
|
||||
await db_api.removeAllRecords('test');
|
||||
await db_api.insertRecordIntoTable('test', {test: 'test'});
|
||||
|
||||
await db_api.transferDB(true);
|
||||
const success = await db_api.getRecord('test', {test: 'test'});
|
||||
assert(success);
|
||||
});
|
||||
|
||||
it('Transfer to local', async function() {
|
||||
await db_api.connectToDB();
|
||||
await db_api.removeAllRecords('test');
|
||||
await db_api.insertRecordIntoTable('test', {test: 'test'});
|
||||
|
||||
await db_api.transferDB(false);
|
||||
const success = await db_api.getRecord('test', {test: 'test'});
|
||||
assert(success);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Export', function() {
|
||||
|
||||
});
|
||||
|
||||
|
||||
describe('Basic functions', async function() {
|
||||
beforeEach(async function() {
|
||||
await db_api.connectToDB();
|
||||
await db_api.removeAllRecords('test');
|
||||
});
|
||||
it('Add and read record', async function() {
|
||||
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});
|
||||
assert(added_record['test_add'] === 'test');
|
||||
await db_api.removeRecord('test', {test_add: 'test'});
|
||||
});
|
||||
|
||||
it('Update record', async function() {
|
||||
await db_api.insertRecordIntoTable('test', {test_update: 'test'});
|
||||
await db_api.updateRecord('test', {test_update: 'test'}, {added_field: true});
|
||||
const updated_record = await db_api.getRecord('test', {test_update: 'test'});
|
||||
assert(updated_record['added_field']);
|
||||
await db_api.removeRecord('test', {test_update: 'test'});
|
||||
});
|
||||
|
||||
it('Remove record', async function() {
|
||||
await db_api.insertRecordIntoTable('test', {test_remove: 'test'});
|
||||
const delete_succeeded = await db_api.removeRecord('test', {test_remove: 'test'});
|
||||
assert(delete_succeeded);
|
||||
const deleted_record = await db_api.getRecord('test', {test_remove: 'test'});
|
||||
assert(!deleted_record);
|
||||
});
|
||||
|
||||
it('Push to record array', async function() {
|
||||
await db_api.insertRecordIntoTable('test', {test: 'test', test_array: []});
|
||||
await db_api.pushToRecordsArray('test', {test: 'test'}, 'test_array', 'test_item');
|
||||
const record = await db_api.getRecord('test', {test: 'test'});
|
||||
assert(record);
|
||||
assert(record['test_array'].length === 1);
|
||||
});
|
||||
|
||||
it('Pull from record array', async function() {
|
||||
await db_api.insertRecordIntoTable('test', {test: 'test', test_array: ['test_item']});
|
||||
await db_api.pullFromRecordsArray('test', {test: 'test'}, 'test_array', 'test_item');
|
||||
const record = await db_api.getRecord('test', {test: 'test'});
|
||||
assert(record);
|
||||
assert(record['test_array'].length === 0);
|
||||
});
|
||||
|
||||
it('Bulk add', async function() {
|
||||
const NUM_RECORDS_TO_ADD = 2002; // max batch ops is 1000
|
||||
const test_records = [];
|
||||
for (let i = 0; i < NUM_RECORDS_TO_ADD; i++) {
|
||||
test_records.push({
|
||||
uid: uuid()
|
||||
});
|
||||
}
|
||||
const succcess = await db_api.bulkInsertRecordsIntoTable('test', test_records);
|
||||
|
||||
const received_records = await db_api.getRecords('test');
|
||||
assert(succcess && received_records && received_records.length === NUM_RECORDS_TO_ADD);
|
||||
});
|
||||
|
||||
it('Bulk update', async function() {
|
||||
// bulk add records
|
||||
const NUM_RECORDS_TO_ADD = 100; // max batch ops is 1000
|
||||
const test_records = [];
|
||||
const update_obj = {};
|
||||
for (let i = 0; i < NUM_RECORDS_TO_ADD; i++) {
|
||||
const test_uid = uuid();
|
||||
test_records.push({
|
||||
uid: test_uid
|
||||
});
|
||||
update_obj[test_uid] = {added_field: true};
|
||||
}
|
||||
let success = await db_api.bulkInsertRecordsIntoTable('test', test_records);
|
||||
assert(success);
|
||||
|
||||
// makes sure they are added
|
||||
const received_records = await db_api.getRecords('test');
|
||||
assert(received_records && received_records.length === NUM_RECORDS_TO_ADD);
|
||||
|
||||
success = await db_api.bulkUpdateRecords('test', 'uid', update_obj);
|
||||
assert(success);
|
||||
|
||||
const received_updated_records = await db_api.getRecords('test');
|
||||
for (let i = 0; i < received_updated_records.length; i++) {
|
||||
success &= received_updated_records[i]['added_field'];
|
||||
}
|
||||
assert(success);
|
||||
});
|
||||
|
||||
it('Stats', async function() {
|
||||
const stats = await db_api.getDBStats();
|
||||
assert(stats);
|
||||
});
|
||||
|
||||
it('Query speed', async function() {
|
||||
this.timeout(120000);
|
||||
const NUM_RECORDS_TO_ADD = 300004; // max batch ops is 1000
|
||||
const test_records = [];
|
||||
let random_uid = '06241f83-d1b8-4465-812c-618dfa7f2943';
|
||||
for (let i = 0; i < NUM_RECORDS_TO_ADD; i++) {
|
||||
const uid = uuid();
|
||||
if (i === NUM_RECORDS_TO_ADD/2) random_uid = uid;
|
||||
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();
|
||||
let success = await db_api.bulkInsertRecordsIntoTable('test', test_records);
|
||||
const insert_end = Date.now();
|
||||
|
||||
console.log(`Insert time: ${(insert_end - insert_start)/1000}s`);
|
||||
|
||||
const query_start = Date.now();
|
||||
const random_record = await db_api.getRecord('test', {uid: random_uid});
|
||||
const query_end = Date.now();
|
||||
|
||||
console.log(random_record)
|
||||
|
||||
console.log(`Query time: ${(query_end - query_start)/1000}s`);
|
||||
|
||||
success = !!random_record;
|
||||
|
||||
assert(success);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Multi User', async function() {
|
||||
let user = null;
|
||||
const user_to_test = 'admin';
|
||||
const sub_to_test = 'dc834388-3454-41bf-a618-e11cb8c7de1c';
|
||||
const playlist_to_test = 'ysabVZz4x';
|
||||
beforeEach(async function() {
|
||||
await db_api.connectToDB();
|
||||
auth_api.initialize(db_api, logger);
|
||||
subscriptions_api.initialize(db_api, logger);
|
||||
user = await auth_api.login('admin', 'pass');
|
||||
});
|
||||
describe('Authentication', function() {
|
||||
it('login', async function() {
|
||||
assert(user);
|
||||
});
|
||||
});
|
||||
describe('Video player - normal', function() {
|
||||
const video_to_test = 'ebbcfffb-d6f1-4510-ad25-d1ec82e0477e';
|
||||
it('Get video', async function() {
|
||||
const video_obj = db_api.getVideo(video_to_test, 'admin');
|
||||
assert(video_obj);
|
||||
});
|
||||
|
||||
it('Video access - disallowed', async function() {
|
||||
await db_api.setVideoProperty(video_to_test, {sharingEnabled: false}, user_to_test);
|
||||
const video_obj = auth_api.getUserVideo('admin', video_to_test, true);
|
||||
assert(!video_obj);
|
||||
});
|
||||
|
||||
it('Video access - allowed', async function() {
|
||||
await db_api.setVideoProperty(video_to_test, {sharingEnabled: true}, user_to_test);
|
||||
const video_obj = auth_api.getUserVideo('admin', video_to_test, true);
|
||||
assert(video_obj);
|
||||
});
|
||||
});
|
||||
describe('Zip generators', function() {
|
||||
it('Playlist zip generator', async function() {
|
||||
const playlist = await db_api.getPlaylist(playlist_to_test, user_to_test);
|
||||
assert(playlist);
|
||||
const playlist_files_to_download = [];
|
||||
for (let i = 0; i < playlist['uids'].length; i++) {
|
||||
const uid = playlist['uids'][i];
|
||||
const playlist_file = await db_api.getVideo(uid, user_to_test);
|
||||
playlist_files_to_download.push(playlist_file);
|
||||
}
|
||||
const zip_path = await utils.createContainerZipFile(playlist, playlist_files_to_download);
|
||||
const zip_exists = fs.pathExistsSync(zip_path);
|
||||
assert(zip_exists);
|
||||
if (zip_exists) fs.unlinkSync(zip_path);
|
||||
});
|
||||
|
||||
it('Subscription zip generator', async function() {
|
||||
const sub = await subscriptions_api.getSubscription(sub_to_test, user_to_test);
|
||||
const sub_videos = await db_api.getRecords('files', {sub_id: sub.id});
|
||||
assert(sub);
|
||||
const sub_files_to_download = [];
|
||||
for (let i = 0; i < sub_videos.length; i++) {
|
||||
const sub_file = sub_videos[i];
|
||||
sub_files_to_download.push(sub_file);
|
||||
}
|
||||
const zip_path = await utils.createContainerZipFile(sub, sub_files_to_download);
|
||||
const zip_exists = fs.pathExistsSync(zip_path);
|
||||
assert(zip_exists);
|
||||
if (zip_exists) fs.unlinkSync(zip_path);
|
||||
});
|
||||
});
|
||||
// describe('Video player - subscription', function() {
|
||||
// const sub_to_test = '';
|
||||
// const video_to_test = 'ebbcfffb-d6f1-4510-ad25-d1ec82e0477e';
|
||||
// it('Get video', async function() {
|
||||
// const video_obj = db_api.getVideo(video_to_test, 'admin', );
|
||||
// assert(video_obj);
|
||||
// });
|
||||
|
||||
// it('Video access - disallowed', async function() {
|
||||
// await db_api.setVideoProperty(video_to_test, {sharingEnabled: false}, user_to_test, sub_to_test);
|
||||
// const video_obj = auth_api.getUserVideo('admin', video_to_test, true);
|
||||
// assert(!video_obj);
|
||||
// });
|
||||
|
||||
// it('Video access - allowed', async function() {
|
||||
// await db_api.setVideoProperty(video_to_test, {sharingEnabled: true}, user_to_test, sub_to_test);
|
||||
// const video_obj = auth_api.getUserVideo('admin', video_to_test, true);
|
||||
// assert(video_obj);
|
||||
// });
|
||||
// });
|
||||
|
||||
});
|
||||
|
||||
describe('Downloader', function() {
|
||||
const downloader_api = require('../downloader');
|
||||
downloader_api.initialize(db_api);
|
||||
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() {
|
||||
|
||||
});
|
||||
|
||||
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('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() {
|
||||
|
||||
});
|
||||
|
||||
it('Generate args', async function() {
|
||||
const args = await downloader_api.generateArgs(url, 'video', options);
|
||||
console.log(args);
|
||||
});
|
||||
|
||||
it('Generate args - subscription', async function() {
|
||||
subscriptions_api.initialize(db_api, logger);
|
||||
const sub = await subscriptions_api.getSubscription(sub_id);
|
||||
const sub_options = subscriptions_api.generateOptionsForSubscriptionDownload(sub, 'admin');
|
||||
const args = await downloader_api.generateArgs(url, 'video', sub_options, 'admin');
|
||||
console.log(args);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
252
backend/utils.js
252
backend/utils.js
@@ -1,6 +1,10 @@
|
||||
var fs = require('fs-extra')
|
||||
var path = require('path')
|
||||
const fs = require('fs-extra')
|
||||
const path = require('path')
|
||||
const ffmpeg = require('fluent-ffmpeg');
|
||||
const config_api = require('./config');
|
||||
const logger = require('./logger');
|
||||
const CONSTS = require('./consts')
|
||||
const archiver = require('archiver');
|
||||
|
||||
const is_windows = process.platform === 'win32';
|
||||
|
||||
@@ -41,8 +45,7 @@ async function getDownloadedFilesByType(basePath, type, full_metadata = false) {
|
||||
files.push(jsonobj);
|
||||
continue;
|
||||
}
|
||||
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 upload_date = formatDateString(jsonobj.upload_date);
|
||||
|
||||
var isaudio = type === 'audio';
|
||||
var file_obj = new File(id, jsonobj.title, jsonobj.thumbnail, isaudio, jsonobj.duration, jsonobj.webpage_url, jsonobj.uploader,
|
||||
@@ -52,6 +55,43 @@ async function getDownloadedFilesByType(basePath, type, full_metadata = false) {
|
||||
return files;
|
||||
}
|
||||
|
||||
async function createContainerZipFile(container_obj, container_file_objs) {
|
||||
const container_files_to_download = [];
|
||||
for (let i = 0; i < container_file_objs.length; i++) {
|
||||
const container_file_obj = container_file_objs[i];
|
||||
container_files_to_download.push(container_file_obj.path);
|
||||
}
|
||||
return await createZipFile(path.join('appdata', container_obj.name + '.zip'), container_files_to_download);
|
||||
}
|
||||
|
||||
async function createZipFile(zip_file_path, file_paths) {
|
||||
let output = fs.createWriteStream(zip_file_path);
|
||||
|
||||
var archive = archiver('zip', {
|
||||
gzip: true,
|
||||
zlib: { level: 9 } // Sets the compression level.
|
||||
});
|
||||
|
||||
archive.on('error', function(err) {
|
||||
logger.error(err);
|
||||
throw err;
|
||||
});
|
||||
|
||||
// pipe archive data to the output file
|
||||
archive.pipe(output);
|
||||
|
||||
for (let file_path of file_paths) {
|
||||
const file_name = path.parse(file_path).base;
|
||||
archive.file(file_path, {name: file_name})
|
||||
}
|
||||
|
||||
await archive.finalize();
|
||||
|
||||
// wait a tiny bit for the zip to reload in fs
|
||||
await wait(100);
|
||||
return zip_file_path;
|
||||
}
|
||||
|
||||
function getJSONMp4(name, customPath, openReadPerms = false) {
|
||||
var obj = null; // output
|
||||
if (!customPath) customPath = config_api.getConfigItem('ytdl_video_folder_path');
|
||||
@@ -84,16 +124,31 @@ function getJSONMp3(name, customPath, openReadPerms = false) {
|
||||
return obj;
|
||||
}
|
||||
|
||||
function getJSON(file_path, type) {
|
||||
const ext = type === 'audio' ? '.mp3' : '.mp4';
|
||||
let obj = null;
|
||||
var jsonPath = removeFileExtension(file_path) + '.info.json';
|
||||
var alternateJsonPath = removeFileExtension(file_path) + `${ext}.info.json`;
|
||||
if (fs.existsSync(jsonPath))
|
||||
{
|
||||
obj = JSON.parse(fs.readFileSync(jsonPath, 'utf8'));
|
||||
} else if (fs.existsSync(alternateJsonPath)) {
|
||||
obj = JSON.parse(fs.readFileSync(alternateJsonPath, 'utf8'));
|
||||
}
|
||||
else obj = 0;
|
||||
return obj;
|
||||
}
|
||||
|
||||
function getJSONByType(type, name, customPath, openReadPerms = false) {
|
||||
return type === 'audio' ? getJSONMp3(name, customPath, openReadPerms) : getJSONMp4(name, customPath, openReadPerms)
|
||||
}
|
||||
|
||||
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');
|
||||
function getDownloadedThumbnail(file_path) {
|
||||
const file_path_no_extension = removeFileExtension(file_path);
|
||||
|
||||
let jpgPath = path.join(customPath, name + '.jpg');
|
||||
let webpPath = path.join(customPath, name + '.webp');
|
||||
let pngPath = path.join(customPath, name + '.png');
|
||||
let jpgPath = file_path_no_extension + '.jpg';
|
||||
let webpPath = file_path_no_extension + '.webp';
|
||||
let pngPath = file_path_no_extension + '.png';
|
||||
|
||||
if (fs.existsSync(jpgPath))
|
||||
return jpgPath;
|
||||
@@ -105,39 +160,41 @@ function getDownloadedThumbnail(name, type, customPath = null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function getExpectedFileSize(info_json) {
|
||||
if (info_json['filesize']) {
|
||||
return info_json['filesize'];
|
||||
}
|
||||
function getExpectedFileSize(input_info_jsons) {
|
||||
// treat single videos as arrays to have the file sizes checked/added to. makes the code cleaner
|
||||
const info_jsons = Array.isArray(input_info_jsons) ? input_info_jsons : [input_info_jsons];
|
||||
|
||||
const formats = info_json['format_id'].split('+');
|
||||
let expected_filesize = 0;
|
||||
formats.forEach(format_id => {
|
||||
if (!info_json.formats) return expected_filesize;
|
||||
info_json.formats.forEach(available_format => {
|
||||
if (available_format.format_id === format_id && available_format.filesize) {
|
||||
expected_filesize += available_format.filesize;
|
||||
}
|
||||
info_jsons.forEach(info_json => {
|
||||
const formats = info_json['format_id'].split('+');
|
||||
let individual_expected_filesize = 0;
|
||||
formats.forEach(format_id => {
|
||||
info_json.formats.forEach(available_format => {
|
||||
if (available_format.format_id === format_id && available_format.filesize) {
|
||||
individual_expected_filesize += available_format.filesize;
|
||||
}
|
||||
});
|
||||
});
|
||||
expected_filesize += individual_expected_filesize;
|
||||
});
|
||||
|
||||
return expected_filesize;
|
||||
}
|
||||
|
||||
function fixVideoMetadataPerms(name, type, customPath = null) {
|
||||
function fixVideoMetadataPerms(file_path, type) {
|
||||
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 file_path_no_extension = removeFileExtension(file_path);
|
||||
|
||||
const files_to_fix = [
|
||||
// JSONs
|
||||
path.join(customPath, name + '.info.json'),
|
||||
path.join(customPath, name + ext + '.info.json'),
|
||||
file_path_no_extension + '.info.json',
|
||||
file_path_no_extension + ext + '.info.json',
|
||||
// Thumbnails
|
||||
path.join(customPath, name + '.webp'),
|
||||
path.join(customPath, name + '.jpg')
|
||||
file_path_no_extension + '.webp',
|
||||
file_path_no_extension + '.jpg'
|
||||
];
|
||||
|
||||
for (const file of files_to_fix) {
|
||||
@@ -146,18 +203,68 @@ function fixVideoMetadataPerms(name, type, customPath = null) {
|
||||
}
|
||||
}
|
||||
|
||||
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');
|
||||
|
||||
function deleteJSONFile(file_path, type) {
|
||||
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');
|
||||
|
||||
const file_path_no_extension = removeFileExtension(file_path);
|
||||
|
||||
let json_path = file_path_no_extension + '.info.json';
|
||||
let alternate_json_path = file_path_no_extension + ext + '.info.json';
|
||||
|
||||
if (fs.existsSync(json_path)) fs.unlinkSync(json_path);
|
||||
if (fs.existsSync(alternate_json_path)) fs.unlinkSync(alternate_json_path);
|
||||
}
|
||||
|
||||
async function removeIDFromArchive(archive_path, id) {
|
||||
let data = await fs.readFile(archive_path, {encoding: 'utf-8'});
|
||||
if (!data) {
|
||||
logger.error('Archive could not be found.');
|
||||
return;
|
||||
}
|
||||
|
||||
let dataArray = data.split('\n'); // convert file data in an array
|
||||
const searchKeyword = id; // we are looking for a line, contains, key word id in the file
|
||||
let lastIndex = -1; // let say, we have not found the keyword
|
||||
|
||||
for (let index=0; index<dataArray.length; index++) {
|
||||
if (dataArray[index].includes(searchKeyword)) { // check if a line contains the id keyword
|
||||
lastIndex = index; // found a line includes a id keyword
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const line = dataArray.splice(lastIndex, 1); // remove the keyword id from the data Array
|
||||
|
||||
// UPDATE FILE WITH NEW DATA
|
||||
const updatedData = dataArray.join('\n');
|
||||
await fs.writeFile(archive_path, updatedData);
|
||||
if (line) return line;
|
||||
}
|
||||
|
||||
function durationStringToNumber(dur_str) {
|
||||
if (typeof dur_str === 'number') return dur_str;
|
||||
let num_sum = 0;
|
||||
const dur_str_parts = dur_str.split(':');
|
||||
for (let i = dur_str_parts.length-1; i >= 0; i--) {
|
||||
num_sum += parseInt(dur_str_parts[i])*(60**(dur_str_parts.length-1-i));
|
||||
}
|
||||
return num_sum;
|
||||
}
|
||||
|
||||
function getMatchingCategoryFiles(category, files) {
|
||||
return files && files.filter(file => file.category && file.category.uid === category.uid);
|
||||
}
|
||||
|
||||
function addUIDsToCategory(category, files) {
|
||||
const files_that_match = getMatchingCategoryFiles(category, files);
|
||||
category['uids'] = files_that_match.map(file => file.uid);
|
||||
return files_that_match;
|
||||
}
|
||||
|
||||
function getCurrentDownloader() {
|
||||
const details_json = fs.readJSONSync(CONSTS.DETAILS_BIN_PATH);
|
||||
return details_json['downloader'];
|
||||
}
|
||||
|
||||
async function recFindByExt(base,ext,files,result)
|
||||
{
|
||||
@@ -181,6 +288,73 @@ async function recFindByExt(base,ext,files,result)
|
||||
return result
|
||||
}
|
||||
|
||||
function removeFileExtension(filename) {
|
||||
const filename_parts = filename.split('.');
|
||||
filename_parts.splice(filename_parts.length - 1);
|
||||
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.
|
||||
* @param {number} ms
|
||||
*/
|
||||
async function wait(ms) {
|
||||
await new Promise(resolve => {
|
||||
setTimeout(resolve, ms);
|
||||
});
|
||||
}
|
||||
|
||||
// objects
|
||||
|
||||
function File(id, title, thumbnailURL, isAudio, duration, url, uploader, size, path, upload_date, description, view_count, height, abr) {
|
||||
@@ -203,12 +377,24 @@ function File(id, title, thumbnailURL, isAudio, duration, url, uploader, size, p
|
||||
module.exports = {
|
||||
getJSONMp3: getJSONMp3,
|
||||
getJSONMp4: getJSONMp4,
|
||||
getJSON: getJSON,
|
||||
getTrueFileName: getTrueFileName,
|
||||
getDownloadedThumbnail: getDownloadedThumbnail,
|
||||
getExpectedFileSize: getExpectedFileSize,
|
||||
fixVideoMetadataPerms: fixVideoMetadataPerms,
|
||||
deleteJSONFile: deleteJSONFile,
|
||||
removeIDFromArchive: removeIDFromArchive,
|
||||
getDownloadedFilesByType: getDownloadedFilesByType,
|
||||
createContainerZipFile: createContainerZipFile,
|
||||
durationStringToNumber: durationStringToNumber,
|
||||
getMatchingCategoryFiles: getMatchingCategoryFiles,
|
||||
addUIDsToCategory: addUIDsToCategory,
|
||||
getCurrentDownloader: getCurrentDownloader,
|
||||
recFindByExt: recFindByExt,
|
||||
removeFileExtension: removeFileExtension,
|
||||
formatDateString: formatDateString,
|
||||
cropFile: cropFile,
|
||||
createEdgeNGrams: createEdgeNGrams,
|
||||
wait: wait,
|
||||
File: File
|
||||
}
|
||||
|
||||
23
chart/.helmignore
Normal file
23
chart/.helmignore
Normal file
@@ -0,0 +1,23 @@
|
||||
# Patterns to ignore when building packages.
|
||||
# This supports shell glob matching, relative path matching, and
|
||||
# negation (prefixed with !). Only one pattern per line.
|
||||
.DS_Store
|
||||
# Common VCS dirs
|
||||
.git/
|
||||
.gitignore
|
||||
.bzr/
|
||||
.bzrignore
|
||||
.hg/
|
||||
.hgignore
|
||||
.svn/
|
||||
# Common backup files
|
||||
*.swp
|
||||
*.bak
|
||||
*.tmp
|
||||
*.orig
|
||||
*~
|
||||
# Various IDEs
|
||||
.project
|
||||
.idea/
|
||||
*.tmproj
|
||||
.vscode/
|
||||
24
chart/Chart.yaml
Normal file
24
chart/Chart.yaml
Normal file
@@ -0,0 +1,24 @@
|
||||
apiVersion: v2
|
||||
name: youtubedl-material
|
||||
description: A Helm chart for Kubernetes
|
||||
|
||||
# A chart can be either an 'application' or a 'library' chart.
|
||||
#
|
||||
# Application charts are a collection of templates that can be packaged into versioned archives
|
||||
# to be deployed.
|
||||
#
|
||||
# Library charts provide useful utilities or functions for the chart developer. They're included as
|
||||
# a dependency of application charts to inject those utilities and functions into the rendering
|
||||
# pipeline. Library charts do not define any templates and therefore cannot be deployed.
|
||||
type: application
|
||||
|
||||
# This is the chart version. This version number should be incremented each time you make changes
|
||||
# to the chart and its templates, including the app version.
|
||||
# Versions are expected to follow Semantic Versioning (https://semver.org/)
|
||||
version: 0.1.0
|
||||
|
||||
# This is the version number of the application being deployed. This version number should be
|
||||
# incremented each time you make changes to the application. Versions are not expected to
|
||||
# follow Semantic Versioning. They should reflect the version the application is using.
|
||||
# It is recommended to use it with quotes.
|
||||
appVersion: "4.2"
|
||||
22
chart/templates/NOTES.txt
Normal file
22
chart/templates/NOTES.txt
Normal file
@@ -0,0 +1,22 @@
|
||||
1. Get the application URL by running these commands:
|
||||
{{- if .Values.ingress.enabled }}
|
||||
{{- range $host := .Values.ingress.hosts }}
|
||||
{{- range .paths }}
|
||||
http{{ if $.Values.ingress.tls }}s{{ end }}://{{ $host.host }}{{ .path }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- else if contains "NodePort" .Values.service.type }}
|
||||
export NODE_PORT=$(kubectl get --namespace {{ .Release.Namespace }} -o jsonpath="{.spec.ports[0].nodePort}" services {{ include "youtubedl-material.fullname" . }})
|
||||
export NODE_IP=$(kubectl get nodes --namespace {{ .Release.Namespace }} -o jsonpath="{.items[0].status.addresses[0].address}")
|
||||
echo http://$NODE_IP:$NODE_PORT
|
||||
{{- else if contains "LoadBalancer" .Values.service.type }}
|
||||
NOTE: It may take a few minutes for the LoadBalancer IP to be available.
|
||||
You can watch the status of by running 'kubectl get --namespace {{ .Release.Namespace }} svc -w {{ include "youtubedl-material.fullname" . }}'
|
||||
export SERVICE_IP=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ include "youtubedl-material.fullname" . }} --template "{{"{{ range (index .status.loadBalancer.ingress 0) }}{{.}}{{ end }}"}}")
|
||||
echo http://$SERVICE_IP:{{ .Values.service.port }}
|
||||
{{- else if contains "ClusterIP" .Values.service.type }}
|
||||
export POD_NAME=$(kubectl get pods --namespace {{ .Release.Namespace }} -l "app.kubernetes.io/name={{ include "youtubedl-material.name" . }},app.kubernetes.io/instance={{ .Release.Name }}" -o jsonpath="{.items[0].metadata.name}")
|
||||
export CONTAINER_PORT=$(kubectl get pod --namespace {{ .Release.Namespace }} $POD_NAME -o jsonpath="{.spec.containers[0].ports[0].containerPort}")
|
||||
echo "Visit http://127.0.0.1:8080 to use your application"
|
||||
kubectl --namespace {{ .Release.Namespace }} port-forward $POD_NAME 8080:$CONTAINER_PORT
|
||||
{{- end }}
|
||||
62
chart/templates/_helpers.tpl
Normal file
62
chart/templates/_helpers.tpl
Normal file
@@ -0,0 +1,62 @@
|
||||
{{/*
|
||||
Expand the name of the chart.
|
||||
*/}}
|
||||
{{- define "youtubedl-material.name" -}}
|
||||
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Create a default fully qualified app name.
|
||||
We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec).
|
||||
If release name contains chart name it will be used as a full name.
|
||||
*/}}
|
||||
{{- define "youtubedl-material.fullname" -}}
|
||||
{{- if .Values.fullnameOverride }}
|
||||
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
|
||||
{{- else }}
|
||||
{{- $name := default .Chart.Name .Values.nameOverride }}
|
||||
{{- if contains $name .Release.Name }}
|
||||
{{- .Release.Name | trunc 63 | trimSuffix "-" }}
|
||||
{{- else }}
|
||||
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Create chart name and version as used by the chart label.
|
||||
*/}}
|
||||
{{- define "youtubedl-material.chart" -}}
|
||||
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Common labels
|
||||
*/}}
|
||||
{{- define "youtubedl-material.labels" -}}
|
||||
helm.sh/chart: {{ include "youtubedl-material.chart" . }}
|
||||
{{ include "youtubedl-material.selectorLabels" . }}
|
||||
{{- if .Chart.AppVersion }}
|
||||
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
|
||||
{{- end }}
|
||||
app.kubernetes.io/managed-by: {{ .Release.Service }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Selector labels
|
||||
*/}}
|
||||
{{- define "youtubedl-material.selectorLabels" -}}
|
||||
app.kubernetes.io/name: {{ include "youtubedl-material.name" . }}
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Create the name of the service account to use
|
||||
*/}}
|
||||
{{- define "youtubedl-material.serviceAccountName" -}}
|
||||
{{- if .Values.serviceAccount.create }}
|
||||
{{- default (include "youtubedl-material.fullname" .) .Values.serviceAccount.name }}
|
||||
{{- else }}
|
||||
{{- default "default" .Values.serviceAccount.name }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
21
chart/templates/appdata-pvc.yaml
Normal file
21
chart/templates/appdata-pvc.yaml
Normal file
@@ -0,0 +1,21 @@
|
||||
{{- if and .Values.persistence.appdata.enabled (not .Values.persistence.appdata.existingClaim) }}
|
||||
kind: PersistentVolumeClaim
|
||||
apiVersion: v1
|
||||
metadata:
|
||||
name: {{ template "youtubedl-material.fullname" . }}-appdata
|
||||
labels:
|
||||
{{- include "youtubedl-material.labels" . | nindent 4 }}
|
||||
spec:
|
||||
accessModes:
|
||||
- {{ .Values.persistence.appdata.accessMode | quote }}
|
||||
resources:
|
||||
requests:
|
||||
storage: {{ .Values.persistence.appdata.size | quote }}
|
||||
{{- if .Values.persistence.appdata.storageClass }}
|
||||
{{- if (eq "-" .Values.persistence.appdata.storageClass) }}
|
||||
storageClassName: ""
|
||||
{{- else }}
|
||||
storageClassName: "{{ .Values.persistence.appdata.storageClass }}"
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end -}}
|
||||
21
chart/templates/audio-pvc.yaml
Normal file
21
chart/templates/audio-pvc.yaml
Normal file
@@ -0,0 +1,21 @@
|
||||
{{- if and .Values.persistence.audio.enabled (not .Values.persistence.audio.existingClaim) }}
|
||||
kind: PersistentVolumeClaim
|
||||
apiVersion: v1
|
||||
metadata:
|
||||
name: {{ template "youtubedl-material.fullname" . }}-audio
|
||||
labels:
|
||||
{{- include "youtubedl-material.labels" . | nindent 4 }}
|
||||
spec:
|
||||
accessModes:
|
||||
- {{ .Values.persistence.audio.accessMode | quote }}
|
||||
resources:
|
||||
requests:
|
||||
storage: {{ .Values.persistence.audio.size | quote }}
|
||||
{{- if .Values.persistence.audio.storageClass }}
|
||||
{{- if (eq "-" .Values.persistence.audio.storageClass) }}
|
||||
storageClassName: ""
|
||||
{{- else }}
|
||||
storageClassName: "{{ .Values.persistence.audio.storageClass }}"
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end -}}
|
||||
121
chart/templates/deployment.yaml
Normal file
121
chart/templates/deployment.yaml
Normal file
@@ -0,0 +1,121 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: {{ include "youtubedl-material.fullname" . }}
|
||||
labels:
|
||||
{{- include "youtubedl-material.labels" . | nindent 4 }}
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
{{- include "youtubedl-material.selectorLabels" . | nindent 6 }}
|
||||
template:
|
||||
metadata:
|
||||
{{- with .Values.podAnnotations }}
|
||||
annotations:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
labels:
|
||||
{{- include "youtubedl-material.selectorLabels" . | nindent 8 }}
|
||||
spec:
|
||||
{{- with .Values.imagePullSecrets }}
|
||||
imagePullSecrets:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
serviceAccountName: {{ include "youtubedl-material.serviceAccountName" . }}
|
||||
securityContext:
|
||||
{{- toYaml .Values.podSecurityContext | nindent 8 }}
|
||||
containers:
|
||||
- name: {{ .Chart.Name }}
|
||||
securityContext:
|
||||
{{- toYaml .Values.securityContext | nindent 12 }}
|
||||
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
|
||||
imagePullPolicy: {{ .Values.image.pullPolicy }}
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 17442
|
||||
protocol: TCP
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /
|
||||
port: http
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /
|
||||
port: http
|
||||
resources:
|
||||
{{- toYaml .Values.resources | nindent 12 }}
|
||||
volumeMounts:
|
||||
- mountPath: /app/appdata
|
||||
name: appdata
|
||||
{{- if .Values.persistence.appdata.subPath }}
|
||||
subPath: {{ .Values.persistence.appdata.subPath }}
|
||||
{{- end }}
|
||||
- mountPath: /app/audio
|
||||
name: audio
|
||||
{{- if .Values.persistence.audio.subPath }}
|
||||
subPath: {{ .Values.persistence.audio.subPath }}
|
||||
{{- end }}
|
||||
- mountPath: /app/video
|
||||
name: video
|
||||
{{- if .Values.persistence.video.subPath }}
|
||||
subPath: {{ .Values.persistence.video.subPath }}
|
||||
{{- end }}
|
||||
- mountPath: /app/subscriptions
|
||||
name: subscriptions
|
||||
{{- if .Values.persistence.subscriptions.subPath }}
|
||||
subPath: {{ .Values.persistence.subscriptions.subPath }}
|
||||
{{- end }}
|
||||
- mountPath: /app/users
|
||||
name: users
|
||||
{{- if .Values.persistence.users.subPath }}
|
||||
subPath: {{ .Values.persistence.users.subPath }}
|
||||
{{- end }}
|
||||
volumes:
|
||||
- name: appdata
|
||||
{{- if .Values.persistence.appdata.enabled}}
|
||||
persistentVolumeClaim:
|
||||
claimName: {{ if .Values.persistence.appdata.existingClaim }}{{ .Values.persistence.appdata.existingClaim }}{{- else }}{{ template "youtubedl-material.fullname" . }}-appdata{{- end }}
|
||||
{{- else }}
|
||||
emptyDir: {}
|
||||
{{- end }}
|
||||
- name: audio
|
||||
{{- if .Values.persistence.audio.enabled}}
|
||||
persistentVolumeClaim:
|
||||
claimName: {{ if .Values.persistence.audio.existingClaim }}{{ .Values.persistence.audio.existingClaim }}{{- else }}{{ template "youtubedl-material.fullname" . }}-audio{{- end }}
|
||||
{{- else }}
|
||||
emptyDir: {}
|
||||
{{- end }}
|
||||
- name: subscriptions
|
||||
{{- if .Values.persistence.subscriptions.enabled}}
|
||||
persistentVolumeClaim:
|
||||
claimName: {{ if .Values.persistence.subscriptions.existingClaim }}{{ .Values.persistence.subscriptions.existingClaim }}{{- else }}{{ template "youtubedl-material.fullname" . }}-subscriptions{{- end }}
|
||||
{{- else }}
|
||||
emptyDir: {}
|
||||
{{- end }}
|
||||
- name: users
|
||||
{{- if .Values.persistence.users.enabled}}
|
||||
persistentVolumeClaim:
|
||||
claimName: {{ if .Values.persistence.users.existingClaim }}{{ .Values.persistence.users.existingClaim }}{{- else }}{{ template "youtubedl-material.fullname" . }}-users{{- end }}
|
||||
{{- else }}
|
||||
emptyDir: {}
|
||||
{{- end }}
|
||||
- name: video
|
||||
{{- if .Values.persistence.video.enabled}}
|
||||
persistentVolumeClaim:
|
||||
claimName: {{ if .Values.persistence.video.existingClaim }}{{ .Values.persistence.video.existingClaim }}{{- else }}{{ template "youtubedl-material.fullname" . }}-video{{- end }}
|
||||
{{- else }}
|
||||
emptyDir: {}
|
||||
{{- end }}
|
||||
{{- with .Values.nodeSelector }}
|
||||
nodeSelector:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.affinity }}
|
||||
affinity:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.tolerations }}
|
||||
tolerations:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
41
chart/templates/ingress.yaml
Normal file
41
chart/templates/ingress.yaml
Normal file
@@ -0,0 +1,41 @@
|
||||
{{- if .Values.ingress.enabled -}}
|
||||
{{- $fullName := include "youtubedl-material.fullname" . -}}
|
||||
{{- $svcPort := .Values.service.port -}}
|
||||
{{- if semverCompare ">=1.14-0" .Capabilities.KubeVersion.GitVersion -}}
|
||||
apiVersion: networking.k8s.io/v1beta1
|
||||
{{- else -}}
|
||||
apiVersion: extensions/v1beta1
|
||||
{{- end }}
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: {{ $fullName }}
|
||||
labels:
|
||||
{{- include "youtubedl-material.labels" . | nindent 4 }}
|
||||
{{- with .Values.ingress.annotations }}
|
||||
annotations:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
{{- if .Values.ingress.tls }}
|
||||
tls:
|
||||
{{- range .Values.ingress.tls }}
|
||||
- hosts:
|
||||
{{- range .hosts }}
|
||||
- {{ . | quote }}
|
||||
{{- end }}
|
||||
secretName: {{ .secretName }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
rules:
|
||||
{{- range .Values.ingress.hosts }}
|
||||
- host: {{ .host | quote }}
|
||||
http:
|
||||
paths:
|
||||
{{- range .paths }}
|
||||
- path: {{ .path }}
|
||||
backend:
|
||||
serviceName: {{ $fullName }}
|
||||
servicePort: {{ $svcPort }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
15
chart/templates/service.yaml
Normal file
15
chart/templates/service.yaml
Normal file
@@ -0,0 +1,15 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ include "youtubedl-material.fullname" . }}
|
||||
labels:
|
||||
{{- include "youtubedl-material.labels" . | nindent 4 }}
|
||||
spec:
|
||||
type: {{ .Values.service.type }}
|
||||
ports:
|
||||
- port: {{ .Values.service.port }}
|
||||
targetPort: http
|
||||
protocol: TCP
|
||||
name: http
|
||||
selector:
|
||||
{{- include "youtubedl-material.selectorLabels" . | nindent 4 }}
|
||||
12
chart/templates/serviceaccount.yaml
Normal file
12
chart/templates/serviceaccount.yaml
Normal file
@@ -0,0 +1,12 @@
|
||||
{{- if .Values.serviceAccount.create -}}
|
||||
apiVersion: v1
|
||||
kind: ServiceAccount
|
||||
metadata:
|
||||
name: {{ include "youtubedl-material.serviceAccountName" . }}
|
||||
labels:
|
||||
{{- include "youtubedl-material.labels" . | nindent 4 }}
|
||||
{{- with .Values.serviceAccount.annotations }}
|
||||
annotations:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
21
chart/templates/subscriptions-pvc.yaml
Normal file
21
chart/templates/subscriptions-pvc.yaml
Normal file
@@ -0,0 +1,21 @@
|
||||
{{- if and .Values.persistence.subscriptions.enabled (not .Values.persistence.subscriptions.existingClaim) }}
|
||||
kind: PersistentVolumeClaim
|
||||
apiVersion: v1
|
||||
metadata:
|
||||
name: {{ template "youtubedl-material.fullname" . }}-subscriptions
|
||||
labels:
|
||||
{{- include "youtubedl-material.labels" . | nindent 4 }}
|
||||
spec:
|
||||
accessModes:
|
||||
- {{ .Values.persistence.subscriptions.accessMode | quote }}
|
||||
resources:
|
||||
requests:
|
||||
storage: {{ .Values.persistence.subscriptions.size | quote }}
|
||||
{{- if .Values.persistence.subscriptions.storageClass }}
|
||||
{{- if (eq "-" .Values.persistence.subscriptions.storageClass) }}
|
||||
storageClassName: ""
|
||||
{{- else }}
|
||||
storageClassName: "{{ .Values.persistence.subscriptions.storageClass }}"
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end -}}
|
||||
15
chart/templates/tests/test-connection.yaml
Normal file
15
chart/templates/tests/test-connection.yaml
Normal file
@@ -0,0 +1,15 @@
|
||||
apiVersion: v1
|
||||
kind: Pod
|
||||
metadata:
|
||||
name: "{{ include "youtubedl-material.fullname" . }}-test-connection"
|
||||
labels:
|
||||
{{- include "youtubedl-material.labels" . | nindent 4 }}
|
||||
annotations:
|
||||
"helm.sh/hook": test
|
||||
spec:
|
||||
containers:
|
||||
- name: wget
|
||||
image: busybox
|
||||
command: ['wget']
|
||||
args: ['{{ include "youtubedl-material.fullname" . }}:{{ .Values.service.port }}']
|
||||
restartPolicy: Never
|
||||
21
chart/templates/users-pvc.yaml
Normal file
21
chart/templates/users-pvc.yaml
Normal file
@@ -0,0 +1,21 @@
|
||||
{{- if and .Values.persistence.users.enabled (not .Values.persistence.users.existingClaim) }}
|
||||
kind: PersistentVolumeClaim
|
||||
apiVersion: v1
|
||||
metadata:
|
||||
name: {{ template "youtubedl-material.fullname" . }}-users
|
||||
labels:
|
||||
{{- include "youtubedl-material.labels" . | nindent 4 }}
|
||||
spec:
|
||||
accessModes:
|
||||
- {{ .Values.persistence.users.accessMode | quote }}
|
||||
resources:
|
||||
requests:
|
||||
storage: {{ .Values.persistence.users.size | quote }}
|
||||
{{- if .Values.persistence.users.storageClass }}
|
||||
{{- if (eq "-" .Values.persistence.users.storageClass) }}
|
||||
storageClassName: ""
|
||||
{{- else }}
|
||||
storageClassName: "{{ .Values.persistence.users.storageClass }}"
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end -}}
|
||||
21
chart/templates/video-pvc.yaml
Normal file
21
chart/templates/video-pvc.yaml
Normal file
@@ -0,0 +1,21 @@
|
||||
{{- if and .Values.persistence.video.enabled (not .Values.persistence.video.existingClaim) }}
|
||||
kind: PersistentVolumeClaim
|
||||
apiVersion: v1
|
||||
metadata:
|
||||
name: {{ template "youtubedl-material.fullname" . }}-video
|
||||
labels:
|
||||
{{- include "youtubedl-material.labels" . | nindent 4 }}
|
||||
spec:
|
||||
accessModes:
|
||||
- {{ .Values.persistence.video.accessMode | quote }}
|
||||
resources:
|
||||
requests:
|
||||
storage: {{ .Values.persistence.video.size | quote }}
|
||||
{{- if .Values.persistence.video.storageClass }}
|
||||
{{- if (eq "-" .Values.persistence.video.storageClass) }}
|
||||
storageClassName: ""
|
||||
{{- else }}
|
||||
storageClassName: "{{ .Values.persistence.video.storageClass }}"
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end -}}
|
||||
153
chart/values.yaml
Normal file
153
chart/values.yaml
Normal file
@@ -0,0 +1,153 @@
|
||||
# Default values for youtubedl-material.
|
||||
# This is a YAML-formatted file.
|
||||
# Declare variables to be passed into your templates.
|
||||
|
||||
replicaCount: 1
|
||||
|
||||
image:
|
||||
repository: tzahi12345/youtubedl-material
|
||||
pullPolicy: IfNotPresent
|
||||
# Overrides the image tag whose default is the chart appVersion.
|
||||
tag: ""
|
||||
|
||||
imagePullSecrets: []
|
||||
nameOverride: ""
|
||||
fullnameOverride: ""
|
||||
|
||||
serviceAccount:
|
||||
# Specifies whether a service account should be created
|
||||
create: true
|
||||
# Annotations to add to the service account
|
||||
annotations: {}
|
||||
# The name of the service account to use.
|
||||
# If not set and create is true, a name is generated using the fullname template
|
||||
name: ""
|
||||
|
||||
podAnnotations: {}
|
||||
|
||||
podSecurityContext: {}
|
||||
# fsGroup: 2000
|
||||
|
||||
securityContext: {}
|
||||
# capabilities:
|
||||
# drop:
|
||||
# - ALL
|
||||
# readOnlyRootFilesystem: true
|
||||
# runAsNonRoot: true
|
||||
# runAsUser: 1000
|
||||
|
||||
service:
|
||||
type: ClusterIP
|
||||
port: 17442
|
||||
|
||||
ingress:
|
||||
enabled: false
|
||||
annotations: {}
|
||||
# kubernetes.io/ingress.class: nginx
|
||||
# kubernetes.io/tls-acme: "true"
|
||||
hosts:
|
||||
- host: chart-example.local
|
||||
paths: []
|
||||
tls: []
|
||||
# - secretName: chart-example-tls
|
||||
# hosts:
|
||||
# - chart-example.local
|
||||
|
||||
resources: {}
|
||||
# We usually recommend not to specify default resources and to leave this as a conscious
|
||||
# choice for the user. This also increases chances charts run on environments with little
|
||||
# resources, such as Minikube. If you do want to specify resources, uncomment the following
|
||||
# lines, adjust them as necessary, and remove the curly braces after 'resources:'.
|
||||
# limits:
|
||||
# cpu: 100m
|
||||
# memory: 128Mi
|
||||
# requests:
|
||||
# cpu: 100m
|
||||
# memory: 128Mi
|
||||
|
||||
persistence:
|
||||
appdata:
|
||||
enabled: true
|
||||
## If defined, storageClassName: <storageClass>
|
||||
## If set to "-", storageClassName: "", which disables dynamic provisioning
|
||||
## If undefined (the default) or set to null, no storageClassName spec is
|
||||
## set, choosing the default provisioner. (gp2 on AWS, standard on
|
||||
## GKE, AWS & OpenStack)
|
||||
##
|
||||
# storageClass: "-"
|
||||
## If you want to reuse an existing claim, you can pass the name of the PVC using
|
||||
## the existingClaim variable
|
||||
# existingClaim: your-claim
|
||||
# subPath: some-subpath
|
||||
accessMode: ReadWriteOnce
|
||||
size: 1Gi
|
||||
audio:
|
||||
enabled: true
|
||||
## If defined, storageClassName: <storageClass>
|
||||
## If set to "-", storageClassName: "", which disables dynamic provisioning
|
||||
## If undefined (the default) or set to null, no storageClassName spec is
|
||||
## set, choosing the default provisioner. (gp2 on AWS, standard on
|
||||
## GKE, AWS & OpenStack)
|
||||
##
|
||||
# storageClass: "-"
|
||||
##
|
||||
## If you want to reuse an existing claim, you can pass the name of the PVC using
|
||||
## the existingClaim variable
|
||||
# existingClaim: your-claim
|
||||
# subPath: some-subpath
|
||||
accessMode: ReadWriteOnce
|
||||
size: 50Gi
|
||||
video:
|
||||
enabled: true
|
||||
## If defined, storageClassName: <storageClass>
|
||||
## If set to "-", storageClassName: "", which disables dynamic provisioning
|
||||
## If undefined (the default) or set to null, no storageClassName spec is
|
||||
## set, choosing the default provisioner. (gp2 on AWS, standard on
|
||||
## GKE, AWS & OpenStack)
|
||||
##
|
||||
# storageClass: "-"
|
||||
##
|
||||
## If you want to reuse an existing claim, you can pass the name of the PVC using
|
||||
## the existingClaim variable
|
||||
# existingClaim: your-claim
|
||||
# subPath: some-subpath
|
||||
accessMode: ReadWriteOnce
|
||||
size: 50Gi
|
||||
subscriptions:
|
||||
enabled: true
|
||||
## If defined, storageClassName: <storageClass>
|
||||
## If set to "-", storageClassName: "", which disables dynamic provisioning
|
||||
## If undefined (the default) or set to null, no storageClassName spec is
|
||||
## set, choosing the default provisioner. (gp2 on AWS, standard on
|
||||
## GKE, AWS & OpenStack)
|
||||
##
|
||||
# storageClass: "-"
|
||||
##
|
||||
## If you want to reuse an existing claim, you can pass the name of the PVC using
|
||||
## the existingClaim variable
|
||||
# existingClaim: your-claim
|
||||
# subPath: some-subpath
|
||||
accessMode: ReadWriteOnce
|
||||
size: 50Gi
|
||||
users:
|
||||
enabled: true
|
||||
## If defined, storageClassName: <storageClass>
|
||||
## If set to "-", storageClassName: "", which disables dynamic provisioning
|
||||
## If undefined (the default) or set to null, no storageClassName spec is
|
||||
## set, choosing the default provisioner. (gp2 on AWS, standard on
|
||||
## GKE, AWS & OpenStack)
|
||||
##
|
||||
# storageClass: "-"
|
||||
##
|
||||
## If you want to reuse an existing claim, you can pass the name of the PVC using
|
||||
## the existingClaim variable
|
||||
# existingClaim: your-claim
|
||||
# subPath: some-subpath
|
||||
accessMode: ReadWriteOnce
|
||||
size: 50Gi
|
||||
|
||||
nodeSelector: {}
|
||||
|
||||
tolerations: []
|
||||
|
||||
affinity: {}
|
||||
@@ -3,6 +3,9 @@ services:
|
||||
ytdl_material:
|
||||
environment:
|
||||
ALLOW_CONFIG_MUTATIONS: 'true'
|
||||
ytdl_mongodb_connection_string: 'mongodb://ytdl-mongo-db:27017'
|
||||
ytdl_use_local_db: 'false'
|
||||
write_ytdl_config: 'true'
|
||||
restart: always
|
||||
volumes:
|
||||
- ./appdata:/app/appdata
|
||||
@@ -12,4 +15,13 @@ services:
|
||||
- ./users:/app/users
|
||||
ports:
|
||||
- "8998:17442"
|
||||
image: tzahi12345/youtubedl-material:latest
|
||||
image: tzahi12345/youtubedl-material:nightly
|
||||
ytdl-mongo-db:
|
||||
image: mongo
|
||||
ports:
|
||||
- "27017:27017"
|
||||
logging:
|
||||
driver: "none"
|
||||
container_name: mongo-db
|
||||
volumes:
|
||||
- ./db/:/data/db
|
||||
1303
package-lock.json
generated
1303
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -33,9 +33,11 @@
|
||||
"@ngneat/content-loader": "^5.0.0",
|
||||
"@videogular/ngx-videogular": "^2.1.0",
|
||||
"core-js": "^2.4.1",
|
||||
"crypto-js": "^4.1.1",
|
||||
"file-saver": "^2.0.2",
|
||||
"filesize": "^6.1.0",
|
||||
"fingerprintjs2": "^2.1.0",
|
||||
"material-icons": "^0.5.4",
|
||||
"nan": "^2.14.1",
|
||||
"ng-lazyload-image": "^7.0.1",
|
||||
"ngx-avatar": "^4.0.0",
|
||||
@@ -56,8 +58,11 @@
|
||||
"@types/file-saver": "^2.0.1",
|
||||
"@types/jasmine": "~3.6.0",
|
||||
"@types/node": "^12.11.1",
|
||||
"@typescript-eslint/eslint-plugin": "^4.29.0",
|
||||
"@typescript-eslint/parser": "^4.29.0",
|
||||
"codelyzer": "^6.0.0",
|
||||
"electron": "^8.0.1",
|
||||
"electron": "^9.4.0",
|
||||
"eslint": "^7.32.0",
|
||||
"jasmine-core": "~3.6.0",
|
||||
"jasmine-spec-reporter": "~5.0.0",
|
||||
"karma": "~5.0.0",
|
||||
|
||||
@@ -7,14 +7,16 @@ import { SubscriptionComponent } from './subscription/subscription/subscription.
|
||||
import { PostsService } from './posts.services';
|
||||
import { LoginComponent } from './components/login/login.component';
|
||||
import { DownloadsComponent } from './components/downloads/downloads.component';
|
||||
import { SettingsComponent } from './settings/settings.component';
|
||||
|
||||
const routes: Routes = [
|
||||
{ path: 'home', component: MainComponent, canActivate: [PostsService] },
|
||||
{ path: 'player', component: PlayerComponent, canActivate: [PostsService]},
|
||||
{ path: 'subscriptions', component: SubscriptionsComponent, canActivate: [PostsService] },
|
||||
{ path: 'subscription', component: SubscriptionComponent, canActivate: [PostsService] },
|
||||
{ path: 'settings', component: SettingsComponent, canActivate: [PostsService] },
|
||||
{ path: 'login', component: LoginComponent },
|
||||
{ path: 'downloads', component: DownloadsComponent },
|
||||
{ path: 'downloads', component: DownloadsComponent, canActivate: [PostsService] },
|
||||
{ path: '', redirectTo: '/home', pathMatch: 'full' }
|
||||
];
|
||||
|
||||
|
||||
@@ -23,10 +23,10 @@
|
||||
<span i18n="Dark mode toggle label">Dark</span>
|
||||
<mat-slide-toggle class="theme-slide-toggle" [checked]="postsService.theme.key === 'dark'"></mat-slide-toggle>
|
||||
</button>
|
||||
<button *ngIf="postsService.config && (!postsService.config.Advanced.multi_user_mode || (postsService.isLoggedIn && postsService.permissions.includes('settings')))" (click)="openSettingsDialog()" mat-menu-item>
|
||||
<!-- <button *ngIf="postsService.config && (!postsService.config.Advanced.multi_user_mode || (postsService.isLoggedIn && postsService.permissions.includes('settings')))" (click)="openSettingsDialog()" mat-menu-item>
|
||||
<mat-icon>settings</mat-icon>
|
||||
<span i18n="Settings menu label">Settings</span>
|
||||
</button>
|
||||
</button> -->
|
||||
<button (click)="openAboutDialog()" mat-menu-item>
|
||||
<mat-icon>info</mat-icon>
|
||||
<span i18n="About menu label">About</span>
|
||||
@@ -42,10 +42,14 @@
|
||||
<mat-nav-list>
|
||||
<a *ngIf="postsService.config && (!postsService.config.Advanced.multi_user_mode || postsService.isLoggedIn)" mat-list-item (click)="postsService.sidepanel_mode === 'over' ? sidenav.close() : null" routerLink='/home'><ng-container i18n="Navigation menu Home Page title">Home</ng-container></a>
|
||||
<a *ngIf="postsService.config && postsService.config.Advanced.multi_user_mode && !postsService.isLoggedIn" mat-list-item (click)="sidenav.close()" routerLink='/login'><ng-container i18n="Navigation menu Login Page title">Login</ng-container></a>
|
||||
<a *ngIf="postsService.config && allowSubscriptions && (!postsService.config.Advanced.multi_user_mode || (postsService.isLoggedIn && postsService.permissions.includes('subscriptions')))" mat-list-item (click)="postsService.sidepanel_mode === 'over' ? sidenav.close() : null" routerLink='/subscriptions'><ng-container i18n="Navigation menu Subscriptions Page title">Subscriptions</ng-container></a>
|
||||
<a *ngIf="postsService.config && enableDownloadsManager && (!postsService.config.Advanced.multi_user_mode || (postsService.isLoggedIn && postsService.permissions.includes('downloads_manager')))" mat-list-item (click)="postsService.sidepanel_mode === 'over' ? sidenav.close() : null" routerLink='/downloads'><ng-container i18n="Navigation menu Downloads Page title">Downloads</ng-container></a>
|
||||
<ng-container *ngIf="postsService.config && allowSubscriptions && postsService.subscriptions && (!postsService.config.Advanced.multi_user_mode || (postsService.isLoggedIn && postsService.permissions.includes('subscriptions')))">
|
||||
<a *ngIf="postsService.config && allowSubscriptions && postsService.hasPermission('subscriptions')" mat-list-item (click)="postsService.sidepanel_mode === 'over' ? sidenav.close() : null" routerLink='/subscriptions'><ng-container i18n="Navigation menu Subscriptions Page title">Subscriptions</ng-container></a>
|
||||
<a *ngIf="postsService.config && enableDownloadsManager && postsService.hasPermission('downloads_manager')" mat-list-item (click)="postsService.sidepanel_mode === 'over' ? sidenav.close() : null" routerLink='/downloads'><ng-container i18n="Navigation menu Downloads Page title">Downloads</ng-container></a>
|
||||
<ng-container *ngIf="postsService.config && postsService.hasPermission('settings')">
|
||||
<mat-divider></mat-divider>
|
||||
<a mat-list-item (click)="postsService.sidepanel_mode === 'over' ? sidenav.close() : null" routerLink='/settings'><ng-container i18n="Settings menu label">Settings</ng-container></a>
|
||||
</ng-container>
|
||||
<ng-container *ngIf="postsService.config && allowSubscriptions && postsService.subscriptions && postsService.hasPermission('subscriptions')">
|
||||
<mat-divider *ngIf="postsService.subscriptions.length > 0"></mat-divider>
|
||||
<a *ngFor="let subscription of postsService.subscriptions" mat-list-item (click)="postsService.sidepanel_mode === 'over' ? sidenav.close() : null" [routerLink]="['/subscription', { id: subscription.id }]"><ngx-avatar [style.margin-right]="'10px'" size="32" [name]="subscription.name"></ngx-avatar>{{subscription.name}}</a>
|
||||
</ng-container>
|
||||
</mat-nav-list>
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import { Component, OnInit, ElementRef, ViewChild, HostBinding, AfterViewInit } from '@angular/core';
|
||||
import {MatDialogRef} from '@angular/material/dialog';
|
||||
import {PostsService} from './posts.services';
|
||||
import {FileCardComponent} from './file-card/file-card.component';
|
||||
import { Observable } from 'rxjs/Observable';
|
||||
import {FormControl, Validators} from '@angular/forms';
|
||||
import {BrowserAnimationsModule} from '@angular/platform-browser/animations';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { MatSidenav } from '@angular/material/sidenav';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
@@ -16,7 +13,6 @@ import 'rxjs/add/operator/filter'
|
||||
import 'rxjs/add/operator/debounceTime'
|
||||
import 'rxjs/add/operator/do'
|
||||
import 'rxjs/add/operator/switch'
|
||||
import { YoutubeSearchService, Result } from './youtube-search.service';
|
||||
import { Router, NavigationStart, NavigationEnd } from '@angular/router';
|
||||
import { OverlayContainer } from '@angular/cdk/overlay';
|
||||
import { THEMES_CONFIG } from '../themes';
|
||||
@@ -28,7 +24,11 @@ import { SetDefaultAdminDialogComponent } from './dialogs/set-default-admin-dial
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
templateUrl: './app.component.html',
|
||||
styleUrls: ['./app.component.css']
|
||||
styleUrls: ['./app.component.css'],
|
||||
providers: [{
|
||||
provide: MatDialogRef,
|
||||
useValue: {}
|
||||
}]
|
||||
})
|
||||
export class AppComponent implements OnInit, AfterViewInit {
|
||||
|
||||
@@ -118,6 +118,10 @@ export class AppComponent implements OnInit, AfterViewInit {
|
||||
}
|
||||
|
||||
this.postsService.reloadCategories();
|
||||
|
||||
this.postsService.getVersionInfo().subscribe(res => {
|
||||
this.postsService.version_info = res['version_info'];
|
||||
});
|
||||
}
|
||||
|
||||
// theme stuff
|
||||
|
||||
@@ -34,7 +34,6 @@ import { FormsModule, ReactiveFormsModule } from '@angular/forms';
|
||||
import { AppComponent } from './app.component';
|
||||
import { HttpClientModule, HTTP_INTERCEPTORS } from '@angular/common/http';
|
||||
import { PostsService } from 'app/posts.services';
|
||||
import { FileCardComponent } from './file-card/file-card.component';
|
||||
import { RouterModule } from '@angular/router';
|
||||
import { AppRoutingModule } from './app-routing.module';
|
||||
import { MainComponent } from './main/main.component';
|
||||
@@ -86,6 +85,8 @@ import { EditCategoryDialogComponent } from './dialogs/edit-category-dialog/edit
|
||||
import { TwitchChatComponent } from './components/twitch-chat/twitch-chat.component';
|
||||
import { LinkifyPipe, SeeMoreComponent } from './components/see-more/see-more.component';
|
||||
import { H401Interceptor } from './http.interceptor';
|
||||
import { ConcurrentStreamComponent } from './components/concurrent-stream/concurrent-stream.component';
|
||||
import { SkipAdButtonComponent } from './components/skip-ad-button/skip-ad-button.component';
|
||||
|
||||
registerLocaleData(es, 'es');
|
||||
|
||||
@@ -96,7 +97,6 @@ export function isVisible({ event, element, scrollContainer, offset }: IsVisible
|
||||
@NgModule({
|
||||
declarations: [
|
||||
AppComponent,
|
||||
FileCardComponent,
|
||||
MainComponent,
|
||||
PlayerComponent,
|
||||
InputDialogComponent,
|
||||
@@ -134,7 +134,9 @@ export function isVisible({ event, element, scrollContainer, offset }: IsVisible
|
||||
CustomPlaylistsComponent,
|
||||
EditCategoryDialogComponent,
|
||||
TwitchChatComponent,
|
||||
SeeMoreComponent
|
||||
SeeMoreComponent,
|
||||
ConcurrentStreamComponent,
|
||||
SkipAdButtonComponent
|
||||
],
|
||||
imports: [
|
||||
CommonModule,
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
<div class="buttons-container">
|
||||
<button (click)="startWatching()" *ngIf="!watch_together_clicked" mat-flat-button>Watch together</button>
|
||||
<button (click)="startServer()" *ngIf="watch_together_clicked && !started && server_mode && server_already_exists === false" mat-flat-button>Start stream</button>
|
||||
<button (click)="startClient()" *ngIf="watch_together_clicked && !started && server_already_exists === true" mat-flat-button>Join stream</button>
|
||||
<button style="margin-left: 10px;" (click)="stop()" *ngIf="watch_together_clicked" mat-flat-button>Stop</button>
|
||||
</div>
|
||||
@@ -0,0 +1,7 @@
|
||||
.buttons-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-top: 15px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { ConcurrentStreamComponent } from './concurrent-stream.component';
|
||||
|
||||
describe('ConcurrentStreamComponent', () => {
|
||||
let component: ConcurrentStreamComponent;
|
||||
let fixture: ComponentFixture<ConcurrentStreamComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
declarations: [ ConcurrentStreamComponent ]
|
||||
})
|
||||
.compileComponents();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(ConcurrentStreamComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,140 @@
|
||||
import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
|
||||
import { PostsService } from 'app/posts.services';
|
||||
|
||||
@Component({
|
||||
selector: 'app-concurrent-stream',
|
||||
templateUrl: './concurrent-stream.component.html',
|
||||
styleUrls: ['./concurrent-stream.component.scss']
|
||||
})
|
||||
export class ConcurrentStreamComponent implements OnInit {
|
||||
|
||||
@Input() server_mode = false;
|
||||
@Input() playback_timestamp;
|
||||
@Input() playing;
|
||||
@Input() uid;
|
||||
|
||||
@Output() setPlaybackTimestamp = new EventEmitter<any>();
|
||||
@Output() togglePlayback = new EventEmitter<boolean>();
|
||||
@Output() setPlaybackRate = new EventEmitter<number>();
|
||||
|
||||
started = false;
|
||||
server_started = false;
|
||||
watch_together_clicked = false;
|
||||
|
||||
server_already_exists = null;
|
||||
|
||||
check_timeout: any;
|
||||
update_timeout: any;
|
||||
|
||||
PLAYBACK_TIMESTAMP_DIFFERENCE_THRESHOLD_PLAYBACK_MODIFICATION = 0.5;
|
||||
PLAYBACK_TIMESTAMP_DIFFERENCE_THRESHOLD_SKIP = 2;
|
||||
|
||||
PLAYBACK_MODIFIER = 0.1;
|
||||
|
||||
playback_rate_modified = false;
|
||||
|
||||
constructor(private postsService: PostsService) { }
|
||||
|
||||
// flow: click start watching -> check for available stream to enable join button and if user, display "start stream"
|
||||
// users who join a stream will send continuous requests for info on playback
|
||||
|
||||
ngOnInit(): void {
|
||||
|
||||
}
|
||||
|
||||
startServer() {
|
||||
this.started = true;
|
||||
this.server_started = true;
|
||||
this.update_timeout = setInterval(() => {
|
||||
this.updateStream();
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
updateStream() {
|
||||
this.postsService.updateConcurrentStream(this.uid, this.playback_timestamp, Date.now()/1000, this.playing).subscribe(res => {
|
||||
});
|
||||
}
|
||||
|
||||
startClient() {
|
||||
this.started = true;
|
||||
}
|
||||
|
||||
checkStream() {
|
||||
if (this.server_started) { return; }
|
||||
const current_playback_timestamp = this.playback_timestamp;
|
||||
const current_unix_timestamp = Date.now()/1000;
|
||||
this.postsService.checkConcurrentStream(this.uid).subscribe(res => {
|
||||
const stream = res['stream'];
|
||||
|
||||
if (!stream) {
|
||||
this.server_already_exists = false;
|
||||
return;
|
||||
}
|
||||
|
||||
this.server_already_exists = true;
|
||||
|
||||
// check whether client has joined the stream
|
||||
if (!this.started) { return; }
|
||||
|
||||
if (!stream['playing'] && this.playing) {
|
||||
// tell client to pause and set the timestamp to sync
|
||||
this.togglePlayback.emit(false);
|
||||
this.setPlaybackTimestamp.emit(stream['playback_timestamp']);
|
||||
} else if (stream['playing']) {
|
||||
// sync unpause state
|
||||
if (!this.playing) { this.togglePlayback.emit(true); }
|
||||
|
||||
// sync time
|
||||
const zeroed_local_unix_timestamp = current_unix_timestamp - current_playback_timestamp;
|
||||
const zeroed_server_unix_timestamp = stream['unix_timestamp'] - stream['playback_timestamp'];
|
||||
|
||||
const seconds_behind_locally = zeroed_local_unix_timestamp - zeroed_server_unix_timestamp;
|
||||
|
||||
if (Math.abs(seconds_behind_locally) > this.PLAYBACK_TIMESTAMP_DIFFERENCE_THRESHOLD_SKIP) {
|
||||
// skip to playback timestamp because the difference is too high
|
||||
this.setPlaybackTimestamp.emit(this.playback_timestamp + seconds_behind_locally + 0.3);
|
||||
this.playback_rate_modified = false;
|
||||
} else if (!this.playback_rate_modified && Math.abs(seconds_behind_locally) > this.PLAYBACK_TIMESTAMP_DIFFERENCE_THRESHOLD_PLAYBACK_MODIFICATION) {
|
||||
// increase playback speed to avoid skipping
|
||||
let seconds_to_wait = (Math.abs(seconds_behind_locally)/this.PLAYBACK_MODIFIER);
|
||||
seconds_to_wait += 0.3/this.PLAYBACK_MODIFIER;
|
||||
|
||||
this.playback_rate_modified = true;
|
||||
|
||||
if (seconds_behind_locally > 0) {
|
||||
// increase speed
|
||||
this.setPlaybackRate.emit(1 + this.PLAYBACK_MODIFIER);
|
||||
setTimeout(() => {
|
||||
this.setPlaybackRate.emit(1);
|
||||
this.playback_rate_modified = false;
|
||||
}, seconds_to_wait * 1000);
|
||||
} else {
|
||||
// decrease speed
|
||||
this.setPlaybackRate.emit(1 - this.PLAYBACK_MODIFIER);
|
||||
setTimeout(() => {
|
||||
this.setPlaybackRate.emit(1);
|
||||
this.playback_rate_modified = false;
|
||||
}, seconds_to_wait * 1000);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
startWatching() {
|
||||
this.watch_together_clicked = true;
|
||||
this.check_timeout = setInterval(() => {
|
||||
this.checkStream();
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
stop() {
|
||||
if (this.check_timeout) { clearInterval(this.check_timeout); }
|
||||
if (this.update_timeout) { clearInterval(this.update_timeout); }
|
||||
this.started = false;
|
||||
this.server_started = false;
|
||||
this.watch_together_clicked = false;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -24,11 +24,18 @@ export class CustomPlaylistsComponent implements OnInit {
|
||||
this.getAllPlaylists();
|
||||
}
|
||||
});
|
||||
|
||||
this.postsService.playlists_changed.subscribe(changed => {
|
||||
if (changed) {
|
||||
this.getAllPlaylists();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
getAllPlaylists() {
|
||||
this.playlists_received = false;
|
||||
this.postsService.getAllFiles().subscribe(res => {
|
||||
// must call getAllFiles as we need to get category playlists as well
|
||||
this.postsService.getPlaylists().subscribe(res => {
|
||||
this.playlists = res['playlists'];
|
||||
this.playlists_received = true;
|
||||
});
|
||||
@@ -53,16 +60,15 @@ export class CustomPlaylistsComponent implements OnInit {
|
||||
goToPlaylist(info_obj) {
|
||||
const playlist = info_obj.file;
|
||||
const playlistID = playlist.id;
|
||||
const type = playlist.type;
|
||||
|
||||
if (playlist) {
|
||||
if (this.postsService.config['Extra']['download_only_mode']) {
|
||||
this.downloading_content[type][playlistID] = true;
|
||||
this.downloadPlaylist(playlist.fileNames, type, playlist.name, playlistID);
|
||||
this.downloadPlaylist(playlist.id, playlist.name);
|
||||
} else {
|
||||
localStorage.setItem('player_navigator', this.router.url);
|
||||
const fileNames = playlist.fileNames;
|
||||
this.router.navigate(['/player', {fileNames: fileNames.join('|nvr|'), type: type, id: playlistID, uid: playlistID, auto: playlist.auto}]);
|
||||
const routeParams = {playlist_id: playlistID};
|
||||
if (playlist.auto) { routeParams['auto'] = playlist.auto; }
|
||||
this.router.navigate(['/player', routeParams]);
|
||||
}
|
||||
} else {
|
||||
// playlist not found
|
||||
@@ -70,11 +76,12 @@ export class CustomPlaylistsComponent implements OnInit {
|
||||
}
|
||||
}
|
||||
|
||||
downloadPlaylist(fileNames, type, zipName = null, playlistID = null) {
|
||||
this.postsService.downloadFileFromServer(fileNames, type, zipName).subscribe(res => {
|
||||
if (playlistID) { this.downloading_content[type][playlistID] = false };
|
||||
const blob: Blob = res;
|
||||
saveAs(blob, zipName + '.zip');
|
||||
downloadPlaylist(playlist_id, playlist_name) {
|
||||
this.downloading_content[playlist_id] = true;
|
||||
this.postsService.downloadPlaylistFromServer(playlist_id).subscribe(res => {
|
||||
this.downloading_content[playlist_id] = false;
|
||||
const blob: any = res;
|
||||
saveAs(blob, playlist_name + '.zip');
|
||||
});
|
||||
|
||||
}
|
||||
@@ -97,7 +104,7 @@ export class CustomPlaylistsComponent implements OnInit {
|
||||
const index = args.index;
|
||||
const dialogRef = this.dialog.open(ModifyPlaylistComponent, {
|
||||
data: {
|
||||
playlist: playlist,
|
||||
playlist_id: playlist.id,
|
||||
width: '65vw'
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,27 +1,91 @@
|
||||
<div style="padding: 20px;">
|
||||
<div *ngFor="let session_downloads of downloads | keyvalue">
|
||||
<ng-container *ngIf="keys(session_downloads.value).length > 0">
|
||||
<mat-card style="padding-bottom: 30px; margin-bottom: 15px;">
|
||||
<h4 style="text-align: center;"><ng-container i18n="Session ID">Session ID:</ng-container> {{session_downloads.key}}
|
||||
<span *ngIf="session_downloads.key === postsService.session_id"> <ng-container i18n="Current session">(current)</ng-container></span>
|
||||
</h4>
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div *ngFor="let download of session_downloads.value | keyvalue: sort_downloads; let i = index;" class="col-12 my-1">
|
||||
<mat-card *ngIf="download.value" class="mat-elevation-z3">
|
||||
<app-download-item [download]="download.value" [queueNumber]="i+1" (cancelDownload)="clearDownload(session_downloads.key, download.value.uid)"></app-download-item>
|
||||
</mat-card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<button style="top: 15px;" (click)="clearDownloads(session_downloads.key)" mat-stroked-button color="warn"><ng-container i18n="clear all downloads action button">Clear all downloads</ng-container></button>
|
||||
</div>
|
||||
</mat-card>
|
||||
</ng-container>
|
||||
</div>
|
||||
<div [hidden]="!(downloads && downloads.length > 0)">
|
||||
<div style="overflow: hidden;" [ngClass]="uids ? 'rounded mat-elevation-z2' : 'mat-elevation-z8'">
|
||||
<mat-table style="overflow: hidden" [ngClass]="uids ? 'rounded-top' : null" matSort [dataSource]="dataSource">
|
||||
|
||||
<!-- Date Column -->
|
||||
<ng-container matColumnDef="date">
|
||||
<mat-header-cell *matHeaderCellDef mat-sort-header> <ng-container i18n="Date">Date</ng-container> </mat-header-cell>
|
||||
<mat-cell *matCellDef="let element"> {{element.timestamp_start | date: 'short'}} </mat-cell>
|
||||
</ng-container>
|
||||
|
||||
<!-- Title Column -->
|
||||
<ng-container matColumnDef="title">
|
||||
<mat-header-cell *matHeaderCellDef mat-sort-header> <ng-container i18n="Title">Title</ng-container> </mat-header-cell>
|
||||
<mat-cell *matCellDef="let element">
|
||||
<span class="one-line" [matTooltip]="element.title ? element.title : null">
|
||||
{{element.title}}
|
||||
</span>
|
||||
</mat-cell>
|
||||
</ng-container>
|
||||
|
||||
<div *ngIf="downloads && !downloadsValid()">
|
||||
<h4 style="text-align: center;" i18n="No downloads label">No downloads available!</h4>
|
||||
</div>
|
||||
<!-- Subscription Column -->
|
||||
<ng-container matColumnDef="subscription">
|
||||
<mat-header-cell *matHeaderCellDef mat-sort-header> <ng-container i18n="Subscription">Subscription</ng-container> </mat-header-cell>
|
||||
<mat-cell *matCellDef="let element">
|
||||
<ng-container *ngIf="element.sub_name">
|
||||
{{element.sub_name}}
|
||||
</ng-container>
|
||||
<ng-container *ngIf="!element.sub_name">
|
||||
N/A
|
||||
</ng-container>
|
||||
</mat-cell>
|
||||
</ng-container>
|
||||
|
||||
<!-- Stage Column -->
|
||||
<ng-container matColumnDef="stage">
|
||||
<mat-header-cell *matHeaderCellDef mat-sort-header> <ng-container i18n="Stage">Stage</ng-container> </mat-header-cell>
|
||||
<mat-cell *matCellDef="let element"> {{STEP_INDEX_TO_LABEL[element.step_index]}} </mat-cell>
|
||||
</ng-container>
|
||||
|
||||
<!-- Progress Column -->
|
||||
<ng-container matColumnDef="progress">
|
||||
<mat-header-cell *matHeaderCellDef mat-sort-header> <ng-container i18n="Progress">Progress</ng-container> </mat-header-cell>
|
||||
<mat-cell *matCellDef="let element">
|
||||
<ng-container *ngIf="element.percent_complete">
|
||||
{{+(element.percent_complete) > 100 ? '100' : element.percent_complete}}%
|
||||
</ng-container>
|
||||
<ng-container *ngIf="!element.percent_complete">
|
||||
N/A
|
||||
</ng-container>
|
||||
</mat-cell>
|
||||
</ng-container>
|
||||
|
||||
<!-- Actions Column -->
|
||||
<ng-container matColumnDef="actions">
|
||||
<mat-header-cell *matHeaderCellDef> <ng-container i18n="Actions">Actions</ng-container> </mat-header-cell>
|
||||
<mat-cell *matCellDef="let element">
|
||||
<div>
|
||||
<ng-container *ngIf="!element.finished">
|
||||
<button (click)="pauseDownload(element.uid)" *ngIf="!element.paused || !element.finished_step" [disabled]="element.paused && !element.finished_step" mat-icon-button matTooltip="Pause" i18n-matTooltip="Pause"><mat-spinner [diameter]="28" *ngIf="element.paused && !element.finished_step" class="icon-button-spinner"></mat-spinner><mat-icon>pause</mat-icon></button>
|
||||
<button (click)="resumeDownload(element.uid)" *ngIf="element.paused && element.finished_step" mat-icon-button matTooltip="Resume" i18n-matTooltip="Resume"><mat-icon>play_arrow</mat-icon></button>
|
||||
<button *ngIf="false && !element.paused" (click)="cancelDownload(element.uid)" mat-icon-button matTooltip="Cancel" i18n-matTooltip="Cancel"><mat-icon>cancel</mat-icon></button>
|
||||
</ng-container>
|
||||
<ng-container *ngIf="element.finished">
|
||||
<button *ngIf="!element.error" (click)="watchContent(element)" mat-icon-button matTooltip="Watch content" i18n-matTooltip="Watch content"><mat-icon>smart_display</mat-icon></button>
|
||||
<button *ngIf="element.error" (click)="showError(element)" mat-icon-button matTooltip="Show error" i18n-matTooltip="Show error"><mat-icon>warning</mat-icon></button>
|
||||
<button (click)="restartDownload(element.uid)" mat-icon-button matTooltip="Restart" i18n-matTooltip="Restart"><mat-icon>restart_alt</mat-icon></button>
|
||||
</ng-container>
|
||||
<button *ngIf="element.finished || element.paused" (click)="clearDownload(element.uid)" mat-icon-button matTooltip="Clear" i18n-matTooltip="Clear"><mat-icon>delete</mat-icon></button>
|
||||
</div>
|
||||
</mat-cell>
|
||||
</ng-container>
|
||||
|
||||
<mat-header-row [ngClass]="uids ? 'rounded-top' : null" *matHeaderRowDef="displayedColumns"></mat-header-row>
|
||||
<mat-row *matRowDef="let row; columns: displayedColumns;"></mat-row>
|
||||
</mat-table>
|
||||
|
||||
<mat-paginator [ngClass]="uids ? 'rounded-bottom' : null" [pageSizeOptions]="[5, 10, 20]"
|
||||
showFirstLastButtons
|
||||
aria-label="Select page of downloads">
|
||||
</mat-paginator>
|
||||
</div>
|
||||
<div *ngIf="!uids" class="downloads-action-button-div">
|
||||
<button [disabled]="!running_download_exists" mat-stroked-button (click)="pauseAllDownloads()"><ng-container i18n="Pause all downloads">Pause all downloads</ng-container></button>
|
||||
<button style="margin-left: 10px;" [disabled]="!paused_download_exists" mat-stroked-button (click)="resumeAllDownloads()"><ng-container i18n="Resume all downloads">Resume all downloads</ng-container></button>
|
||||
<button color="warn" style="margin-left: 10px;" mat-stroked-button (click)="clearFinishedDownloads()"><ng-container i18n="Clear finished downloads">Clear finished downloads</ng-container></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div *ngIf="(!downloads || downloads.length === 0) && downloads_retrieved && !uids">
|
||||
<h4 style="text-align: center; margin-top: 10px;" i18n="No downloads label">No downloads available!</h4>
|
||||
</div>
|
||||
@@ -0,0 +1,32 @@
|
||||
mat-header-cell, mat-cell {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.one-line {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.icon-button-spinner {
|
||||
position: absolute;
|
||||
top: 7px;
|
||||
left: 6px;
|
||||
}
|
||||
|
||||
.downloads-action-button-div {
|
||||
margin-top: 10px;
|
||||
margin-left: 5px;
|
||||
}
|
||||
|
||||
.rounded-top {
|
||||
border-radius: 16px 16px 0px 0px !important;
|
||||
}
|
||||
|
||||
.rounded-bottom {
|
||||
border-radius: 0px 0px 16px 16px !important;
|
||||
}
|
||||
|
||||
.rounded {
|
||||
border-radius: 16px 16px 16px 16px !important;
|
||||
}
|
||||
@@ -1,7 +1,13 @@
|
||||
import { Component, OnInit, ViewChildren, QueryList, ElementRef, OnDestroy } from '@angular/core';
|
||||
import { Component, OnInit, OnDestroy, ViewChild, Input, EventEmitter } from '@angular/core';
|
||||
import { PostsService } from 'app/posts.services';
|
||||
import { trigger, transition, animateChild, stagger, query, style, animate } from '@angular/animations';
|
||||
import { Router } from '@angular/router';
|
||||
import { MatPaginator } from '@angular/material/paginator';
|
||||
import { MatTableDataSource } from '@angular/material/table';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { ConfirmDialogComponent } from 'app/dialogs/confirm-dialog/confirm-dialog.component';
|
||||
import { MatSort } from '@angular/material/sort';
|
||||
import { Clipboard } from '@angular/cdk/clipboard';
|
||||
|
||||
@Component({
|
||||
selector: 'app-downloads',
|
||||
@@ -34,138 +40,222 @@ import { Router } from '@angular/router';
|
||||
})
|
||||
export class DownloadsComponent implements OnInit, OnDestroy {
|
||||
|
||||
@Input() uids = null;
|
||||
|
||||
downloads_check_interval = 1000;
|
||||
downloads = {};
|
||||
downloads = [];
|
||||
finished_downloads = [];
|
||||
interval_id = null;
|
||||
|
||||
keys = Object.keys;
|
||||
|
||||
valid_sessions_length = 0;
|
||||
|
||||
paused_download_exists = false;
|
||||
running_download_exists = false;
|
||||
|
||||
STEP_INDEX_TO_LABEL = {
|
||||
0: $localize`Creating download`,
|
||||
1: $localize`Getting info`,
|
||||
2: $localize`Downloading file`,
|
||||
3: $localize`Complete`
|
||||
}
|
||||
|
||||
displayedColumns: string[] = ['date', 'title', 'stage', 'subscription', 'progress', 'actions'];
|
||||
dataSource = null; // new MatTableDataSource<Download>();
|
||||
downloads_retrieved = false;
|
||||
|
||||
@ViewChild(MatPaginator) paginator: MatPaginator;
|
||||
@ViewChild(MatSort) sort: MatSort;
|
||||
|
||||
sort_downloads = (a, b) => {
|
||||
const result = b.value.timestamp_start - a.value.timestamp_start;
|
||||
const result = b.timestamp_start - a.timestamp_start;
|
||||
return result;
|
||||
}
|
||||
|
||||
constructor(public postsService: PostsService, private router: Router) { }
|
||||
constructor(public postsService: PostsService, private router: Router, private dialog: MatDialog, private clipboard: Clipboard) { }
|
||||
|
||||
ngOnInit(): void {
|
||||
if (this.postsService.initialized) {
|
||||
this.getCurrentDownloadsRecurring();
|
||||
} else {
|
||||
this.postsService.service_initialized.subscribe(init => {
|
||||
if (init) {
|
||||
this.getCurrentDownloadsRecurring();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
getCurrentDownloadsRecurring(): void {
|
||||
if (!this.postsService.config['Extra']['enable_downloads_manager']) {
|
||||
this.router.navigate(['/home']);
|
||||
return;
|
||||
}
|
||||
this.getCurrentDownloads();
|
||||
this.interval_id = setInterval(() => {
|
||||
this.getCurrentDownloads();
|
||||
}, this.downloads_check_interval);
|
||||
|
||||
this.postsService.service_initialized.subscribe(init => {
|
||||
if (init) {
|
||||
if (!this.postsService.config['Extra']['enable_downloads_manager']) {
|
||||
this.router.navigate(['/home']);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
ngOnDestroy(): void {
|
||||
if (this.interval_id) { clearInterval(this.interval_id) }
|
||||
}
|
||||
|
||||
getCurrentDownloads() {
|
||||
this.postsService.getCurrentDownloads().subscribe(res => {
|
||||
if (res['downloads']) {
|
||||
this.assignNewValues(res['downloads']);
|
||||
getCurrentDownloads(): void {
|
||||
this.postsService.getCurrentDownloads(this.uids).subscribe(res => {
|
||||
this.downloads_retrieved = true;
|
||||
if (res['downloads'] !== null
|
||||
&& res['downloads'] !== undefined
|
||||
&& JSON.stringify(this.downloads) !== JSON.stringify(res['downloads'])) {
|
||||
this.downloads = this.combineDownloads(this.downloads, res['downloads']);
|
||||
// this.downloads = res['downloads'];
|
||||
this.downloads.sort(this.sort_downloads);
|
||||
this.dataSource = new MatTableDataSource<Download>(this.downloads);
|
||||
this.dataSource.paginator = this.paginator;
|
||||
this.dataSource.sort = this.sort;
|
||||
|
||||
this.paused_download_exists = this.downloads.find(download => download['paused'] && !download['error']);
|
||||
this.running_download_exists = this.downloads.find(download => !download['paused'] && !download['finished']);
|
||||
} else {
|
||||
// failed to get downloads
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
clearDownload(session_id, download_uid) {
|
||||
this.postsService.clearDownloads(false, session_id, download_uid).subscribe(res => {
|
||||
if (res['success']) {
|
||||
// this.downloads = res['downloads'];
|
||||
} else {
|
||||
clearFinishedDownloads(): void {
|
||||
const dialogRef = this.dialog.open(ConfirmDialogComponent, {
|
||||
data: {
|
||||
dialogTitle: $localize`Clear finished downloads`,
|
||||
dialogText: $localize`Would you like to clear your finished downloads?`,
|
||||
submitText: $localize`Clear`,
|
||||
warnSubmitColor: true
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
clearDownloads(session_id) {
|
||||
this.postsService.clearDownloads(false, session_id).subscribe(res => {
|
||||
if (res['success']) {
|
||||
this.downloads = res['downloads'];
|
||||
} else {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
clearAllDownloads() {
|
||||
this.postsService.clearDownloads(true).subscribe(res => {
|
||||
if (res['success']) {
|
||||
this.downloads = res['downloads'];
|
||||
} else {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
assignNewValues(new_downloads_by_session) {
|
||||
const session_keys = Object.keys(new_downloads_by_session);
|
||||
|
||||
// remove missing session IDs
|
||||
const current_session_ids = Object.keys(this.downloads);
|
||||
const missing_session_ids = current_session_ids.filter(session => session_keys.indexOf(session) === -1)
|
||||
|
||||
for (const missing_session_id of missing_session_ids) {
|
||||
delete this.downloads[missing_session_id];
|
||||
}
|
||||
|
||||
// loop through sessions
|
||||
for (let i = 0; i < session_keys.length; i++) {
|
||||
const session_id = session_keys[i];
|
||||
const session_downloads_by_id = new_downloads_by_session[session_id];
|
||||
const session_download_ids = Object.keys(session_downloads_by_id);
|
||||
|
||||
if (this.downloads[session_id]) {
|
||||
// remove missing download IDs
|
||||
const current_download_ids = Object.keys(this.downloads[session_id]);
|
||||
const missing_download_ids = current_download_ids.filter(download => session_download_ids.indexOf(download) === -1)
|
||||
|
||||
for (const missing_download_id of missing_download_ids) {
|
||||
console.log('removing missing download id');
|
||||
delete this.downloads[session_id][missing_download_id];
|
||||
}
|
||||
}
|
||||
|
||||
if (!this.downloads[session_id]) {
|
||||
this.downloads[session_id] = session_downloads_by_id;
|
||||
} else {
|
||||
for (let j = 0; j < session_download_ids.length; j++) {
|
||||
const download_id = session_download_ids[j];
|
||||
const download = new_downloads_by_session[session_id][download_id]
|
||||
if (!this.downloads[session_id][download_id]) {
|
||||
this.downloads[session_id][download_id] = download;
|
||||
} else {
|
||||
const download_to_update = this.downloads[session_id][download_id];
|
||||
download_to_update['percent_complete'] = download['percent_complete'];
|
||||
download_to_update['complete'] = download['complete'];
|
||||
download_to_update['timestamp_end'] = download['timestamp_end'];
|
||||
download_to_update['downloading'] = download['downloading'];
|
||||
download_to_update['error'] = download['error'];
|
||||
dialogRef.afterClosed().subscribe(confirmed => {
|
||||
if (confirmed) {
|
||||
this.postsService.clearFinishedDownloads().subscribe(res => {
|
||||
if (!res['success']) {
|
||||
this.postsService.openSnackBar('Failed to clear finished downloads!');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pauseDownload(download_uid: string): void {
|
||||
this.postsService.pauseDownload(download_uid).subscribe(res => {
|
||||
if (!res['success']) {
|
||||
this.postsService.openSnackBar('Failed to pause download! See server logs for more info.');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pauseAllDownloads(): void {
|
||||
this.postsService.pauseAllDownloads().subscribe(res => {
|
||||
if (!res['success']) {
|
||||
this.postsService.openSnackBar('Failed to pause all downloads! See server logs for more info.');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
resumeDownload(download_uid: string): void {
|
||||
this.postsService.resumeDownload(download_uid).subscribe(res => {
|
||||
if (!res['success']) {
|
||||
this.postsService.openSnackBar('Failed to resume download! See server logs for more info.');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
resumeAllDownloads(): void {
|
||||
this.postsService.resumeAllDownloads().subscribe(res => {
|
||||
if (!res['success']) {
|
||||
this.postsService.openSnackBar('Failed to resume all downloads! See server logs for more info.');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
restartDownload(download_uid: string): void {
|
||||
this.postsService.restartDownload(download_uid).subscribe(res => {
|
||||
if (!res['success']) {
|
||||
this.postsService.openSnackBar('Failed to restart download! See server logs for more info.');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
cancelDownload(download_uid: string): void {
|
||||
this.postsService.cancelDownload(download_uid).subscribe(res => {
|
||||
if (!res['success']) {
|
||||
this.postsService.openSnackBar('Failed to cancel download! See server logs for more info.');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
clearDownload(download_uid: string): void {
|
||||
this.postsService.clearDownload(download_uid).subscribe(res => {
|
||||
if (!res['success']) {
|
||||
this.postsService.openSnackBar('Failed to pause download! See server logs for more info.');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
watchContent(download): void {
|
||||
const container = download['container'];
|
||||
localStorage.setItem('player_navigator', this.router.url.split(';')[0]);
|
||||
const is_playlist = container['uids']; // hacky, TODO: fix
|
||||
if (is_playlist) {
|
||||
this.router.navigate(['/player', {playlist_id: container['id'], type: download['type']}]);
|
||||
} else {
|
||||
this.router.navigate(['/player', {type: download['type'], uid: container['uid']}]);
|
||||
}
|
||||
}
|
||||
|
||||
downloadsValid() {
|
||||
let valid = false;
|
||||
const keys = this.keys(this.downloads);
|
||||
for (let i = 0; i < keys.length; i++) {
|
||||
const key = keys[i];
|
||||
const value = this.downloads[key];
|
||||
if (this.keys(value).length > 0) {
|
||||
valid = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return valid;
|
||||
combineDownloads(downloads_old, downloads_new) {
|
||||
// only keeps downloads that exist in the new set
|
||||
downloads_old = downloads_old.filter(download_old => downloads_new.some(download_new => download_new.uid === download_old.uid));
|
||||
|
||||
// add downloads from the new set that the old one doesn't have
|
||||
const downloads_to_add = downloads_new.filter(download_new => !downloads_old.some(download_old => download_new.uid === download_old.uid));
|
||||
downloads_old.push(...downloads_to_add);
|
||||
downloads_old.forEach(download_old => {
|
||||
const download_new = downloads_new.find(download_to_check => download_old.uid === download_to_check.uid);
|
||||
Object.keys(download_new).forEach(key => {
|
||||
download_old[key] = download_new[key];
|
||||
});
|
||||
|
||||
Object.keys(download_old).forEach(key => {
|
||||
if (!download_new[key]) delete download_old[key];
|
||||
});
|
||||
});
|
||||
|
||||
return downloads_old;
|
||||
}
|
||||
|
||||
showError(download) {
|
||||
const copyToClipboardEmitter = new EventEmitter<boolean>();
|
||||
this.dialog.open(ConfirmDialogComponent, {
|
||||
data: {
|
||||
dialogTitle: $localize`Error for ${download['url']}:url:`,
|
||||
dialogText: download['error'],
|
||||
submitText: $localize`Copy to clipboard`,
|
||||
cancelText: $localize`Close`,
|
||||
closeOnSubmit: false,
|
||||
onlyEmitOnDone: true,
|
||||
doneEmitter: copyToClipboardEmitter
|
||||
}
|
||||
});
|
||||
copyToClipboardEmitter.subscribe(done => {
|
||||
if (done) {
|
||||
this.postsService.openSnackBar($localize`Copied to clipboard!`);
|
||||
this.clipboard.copy(download['error']);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export interface Download {
|
||||
timestamp_start: number;
|
||||
title: string;
|
||||
step_index: number;
|
||||
progress: string;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
<mat-card class="login-card">
|
||||
<mat-tab-group [(selectedIndex)]="selectedTabIndex">
|
||||
<mat-tab-group style="margin-bottom: 20px" [(selectedIndex)]="selectedTabIndex">
|
||||
<mat-tab label="Login">
|
||||
<div style="margin-top: 10px;">
|
||||
<mat-form-field>
|
||||
@@ -11,9 +11,6 @@
|
||||
<input [(ngModel)]="loginPasswordInput" (keyup.enter)="login()" type="password" matInput placeholder="Password">
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div style="margin-bottom: 10px; margin-top: 10px;">
|
||||
<button [disabled]="loggingIn" color="primary" (click)="login()" mat-raised-button><ng-container i18n="Login">Login</ng-container></button>
|
||||
</div>
|
||||
</mat-tab>
|
||||
<mat-tab *ngIf="registrationEnabled" label="Register">
|
||||
<div style="margin-top: 10px;">
|
||||
@@ -31,9 +28,14 @@
|
||||
<input [(ngModel)]="registrationPasswordConfirmationInput" type="password" matInput placeholder="Confirm Password">
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div style="margin-bottom: 10px; margin-top: 10px;">
|
||||
<button [disabled]="registering" color="primary" (click)="register()" mat-raised-button><ng-container i18n="Register">Register</ng-container></button>
|
||||
</div>
|
||||
</mat-tab>
|
||||
</mat-tab-group>
|
||||
<div *ngIf="selectedTabIndex === 0" class="login-button-div">
|
||||
<button [disabled]="loggingIn" color="primary" (click)="login()" mat-raised-button><ng-container i18n="Login">Login</ng-container></button>
|
||||
<mat-progress-bar *ngIf="loggingIn" class="login-progress-bar" mode="indeterminate"></mat-progress-bar>
|
||||
</div>
|
||||
<div *ngIf="selectedTabIndex === 1" class="login-button-div">
|
||||
<button [disabled]="registering" color="primary" (click)="register()" mat-raised-button><ng-container i18n="Register">Register</ng-container></button>
|
||||
<mat-progress-bar *ngIf="registering" class="login-progress-bar" mode="indeterminate"></mat-progress-bar>
|
||||
</div>
|
||||
</mat-card>
|
||||
@@ -1,6 +1,33 @@
|
||||
.login-card {
|
||||
max-width: 600px;
|
||||
max-width: 400px;
|
||||
width: 80%;
|
||||
margin: 0 auto;
|
||||
margin-top: 20px;
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.login-div {
|
||||
height: calc(100% - 170px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.login-button-div {
|
||||
margin-bottom: 10px;
|
||||
margin-top: 10px;
|
||||
margin-left: -16px;
|
||||
margin-right: -16px;
|
||||
bottom: 0px;
|
||||
width: 100%;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.login-button-div > button {
|
||||
width: 100%;
|
||||
border-radius: 0px 0px 4px 4px !important;
|
||||
}
|
||||
|
||||
.login-progress-bar {
|
||||
position: absolute;
|
||||
bottom: 0px;
|
||||
border-radius: 0px 0px 4px 4px;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
<div style="height: 275px;">
|
||||
<div style="height: 100%;">
|
||||
<div *ngIf="logs_loading" style="z-index: 999; position: absolute; top: 40%; left: 50%">
|
||||
<mat-spinner [diameter]="32"></mat-spinner>
|
||||
</div>
|
||||
@@ -10,7 +10,7 @@
|
||||
</cdk-virtual-scroll-viewport>-->
|
||||
|
||||
<!-- Non-virtual mode (slow, bug-free) -->
|
||||
<div style="height: 274px; overflow-y: auto">
|
||||
<div style="height: 100%; overflow-y: auto">
|
||||
<div *ngFor="let log of logs; let i = index" class="example-item">
|
||||
<span [ngStyle]="{'color':log.color}">{{log.text}}</span>
|
||||
</div>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<mat-list-item role="listitem" *ngFor="let permission of available_permissions">
|
||||
<h3 matLine>{{permissionToLabel[permission] ? permissionToLabel[permission] : permission}}</h3>
|
||||
<span matLine>
|
||||
<mat-radio-group [disabled]="permission === 'settings' && role.name === 'admin'" (change)="changeRolePermissions($event, permission, permissions[permission])" [(ngModel)]="permissions[permission]" [attr.aria-label]="'Give role permission for ' + permission">
|
||||
<mat-radio-group [disabled]="permission === 'settings' && role.key === 'admin'" (change)="changeRolePermissions($event, permission, permissions[permission])" [(ngModel)]="permissions[permission]" [attr.aria-label]="'Give role permission for ' + permission">
|
||||
<mat-radio-button value="yes"><ng-container i18n="Yes">Yes</ng-container></mat-radio-button>
|
||||
<mat-radio-button value="no"><ng-container i18n="No">No</ng-container></mat-radio-button>
|
||||
</mat-radio-group>
|
||||
|
||||
@@ -47,7 +47,7 @@ export class ManageRoleComponent implements OnInit {
|
||||
}
|
||||
|
||||
changeRolePermissions(change, permission) {
|
||||
this.postsService.setRolePermission(this.role.name, permission, change.value).subscribe(res => {
|
||||
this.postsService.setRolePermission(this.role.key, permission, change.value).subscribe(res => {
|
||||
if (res['success']) {
|
||||
|
||||
} else {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<div *ngIf="dataSource; else loading">
|
||||
<div style="padding: 15px">
|
||||
<div class="row">
|
||||
<div class="table table-responsive px-5 pb-4 pt-2">
|
||||
<div class="table table-responsive pb-4 pt-2">
|
||||
<div class="example-header">
|
||||
<mat-form-field>
|
||||
<input matInput (keyup)="applyFilter($event.target.value)" placeholder="Search" i18n-placeholder="search field description">
|
||||
@@ -94,7 +94,7 @@
|
||||
</div>
|
||||
<button color="primary" [matMenuTriggerFor]="edit_roles_menu" class="edit-role" mat-raised-button><ng-container i18n="Edit role">Edit Role</ng-container></button>
|
||||
<mat-menu #edit_roles_menu="matMenu">
|
||||
<button (click)="openModifyRole(role)" mat-menu-item *ngFor="let role of roles">{{role.name}}</button>
|
||||
<button (click)="openModifyRole(role)" mat-menu-item *ngFor="let role of roles">{{role.key}}</button>
|
||||
</mat-menu>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
.edit-role {
|
||||
position: relative;
|
||||
top: -50px;
|
||||
left: 35px;
|
||||
}
|
||||
@@ -78,16 +78,7 @@ export class ModifyUsersComponent implements OnInit, AfterViewInit {
|
||||
|
||||
getRoles() {
|
||||
this.postsService.getRoles().subscribe(res => {
|
||||
this.roles = [];
|
||||
const roles = res['roles'];
|
||||
const role_names = Object.keys(roles);
|
||||
for (let i = 0; i < role_names.length; i++) {
|
||||
const role_name = role_names[i];
|
||||
this.roles.push({
|
||||
name: role_name,
|
||||
permissions: roles[role_name]['permissions']
|
||||
});
|
||||
}
|
||||
this.roles = res['roles'];
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -28,13 +28,13 @@
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="container">
|
||||
<div class="container" style="margin-bottom: 16px">
|
||||
<div class="row justify-content-center">
|
||||
<ng-container *ngIf="normal_files_received && paged_data">
|
||||
<div *ngFor="let file of paged_data; let i = index" class="mb-2 mt-2 d-flex justify-content-center" [ngClass]="[ postsService.card_size === 'small' ? 'col-2 small-col' : '', postsService.card_size === 'medium' ? 'col-6 col-lg-4 medium-col' : '', postsService.card_size === 'large' ? 'col-12 large-col' : '' ]">
|
||||
<app-unified-file-card [index]="i" [card_size]="postsService.card_size" [locale]="postsService.locale" (goToFile)="goToFile($event)" (goToSubscription)="goToSubscription($event)" [file_obj]="file" [use_youtubedl_archive]="postsService.config['Downloader']['use_youtubedl_archive']" [loading]="false" (deleteFile)="deleteFile($event)" [baseStreamPath]="postsService.path" [jwtString]="postsService.isLoggedIn ? '?jwt=' + this.postsService.token : ''"></app-unified-file-card>
|
||||
<app-unified-file-card [index]="i" [card_size]="postsService.card_size" [locale]="postsService.locale" (goToFile)="goToFile($event)" (goToSubscription)="goToSubscription($event)" [file_obj]="file" [use_youtubedl_archive]="postsService.config['Downloader']['use_youtubedl_archive']" [availablePlaylists]="playlists" (addFileToPlaylist)="addFileToPlaylist($event)" [loading]="false" (deleteFile)="deleteFile($event)" [baseStreamPath]="postsService.path" [jwtString]="postsService.isLoggedIn ? '?jwt=' + this.postsService.token : ''"></app-unified-file-card>
|
||||
</div>
|
||||
<div *ngIf="filtered_files.length === 0">
|
||||
<div *ngIf="paged_data.length === 0">
|
||||
<ng-container i18n="No videos found">No videos found.</ng-container>
|
||||
</div>
|
||||
</ng-container>
|
||||
@@ -46,8 +46,20 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<mat-paginator class="paginator" #paginator *ngIf="filtered_files && filtered_files.length > 0" (page)="pageChangeEvent($event)" [length]="filtered_files.length"
|
||||
[pageSize]="pageSize"
|
||||
[pageSizeOptions]="[5, 10, 25, 100, this.paged_data && this.paged_data.length > 100 ? this.paged_data.length : 250]">
|
||||
</mat-paginator>
|
||||
<div>
|
||||
<div style="position: absolute; margin-left: -8px; margin-top: 5px; scale: 0.8">
|
||||
<mat-form-field>
|
||||
<mat-label><ng-container i18n="File type">File type</ng-container></mat-label>
|
||||
<mat-select color="accent" [(ngModel)]="fileTypeFilter" (selectionChange)="fileTypeFilterChanged($event.value)">
|
||||
<mat-option value="both"><ng-container i18n="Both">Both</ng-container></mat-option>
|
||||
<mat-option value="video_only"><ng-container i18n="Video only">Video only</ng-container></mat-option>
|
||||
<mat-option value="audio_only"><ng-container i18n="Audio only">Audio only</ng-container></mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<mat-paginator class="paginator" #paginator *ngIf="paged_data && paged_data.length > 0" (page)="pageChangeEvent($event)" [length]="file_count"
|
||||
[pageSize]="pageSize"
|
||||
[pageSizeOptions]="[5, 10, 25, 100, this.paged_data && this.paged_data.length > 100 ? this.paged_data.length : 250]">
|
||||
</mat-paginator>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2,6 +2,8 @@ import { Component, OnInit, ViewChild } from '@angular/core';
|
||||
import { PostsService } from 'app/posts.services';
|
||||
import { Router } from '@angular/router';
|
||||
import { MatPaginator } from '@angular/material/paginator';
|
||||
import { Subject } from 'rxjs';
|
||||
import { distinctUntilChanged } from 'rxjs/operators';
|
||||
|
||||
@Component({
|
||||
selector: 'app-recent-videos',
|
||||
@@ -15,8 +17,8 @@ export class RecentVideosComponent implements OnInit {
|
||||
|
||||
normal_files_received = false;
|
||||
subscription_files_received = false;
|
||||
files: any[] = null;
|
||||
filtered_files: any[] = null;
|
||||
file_count = 10;
|
||||
searchChangedSubject: Subject<string> = new Subject<string>();
|
||||
downloading_content = {'video': {}, 'audio': {}};
|
||||
search_mode = false;
|
||||
search_text = '';
|
||||
@@ -50,6 +52,9 @@ export class RecentVideosComponent implements OnInit {
|
||||
}
|
||||
};
|
||||
filterProperty = this.filterProperties['upload_date'];
|
||||
fileTypeFilter = 'both';
|
||||
|
||||
playlists = null;
|
||||
|
||||
pageSize = 10;
|
||||
paged_data = null;
|
||||
@@ -68,84 +73,108 @@ export class RecentVideosComponent implements OnInit {
|
||||
ngOnInit(): void {
|
||||
if (this.postsService.initialized) {
|
||||
this.getAllFiles();
|
||||
this.getAllPlaylists();
|
||||
}
|
||||
|
||||
this.postsService.service_initialized.subscribe(init => {
|
||||
if (init) {
|
||||
this.getAllFiles();
|
||||
this.getAllPlaylists();
|
||||
}
|
||||
});
|
||||
|
||||
this.postsService.files_changed.subscribe(changed => {
|
||||
if (changed) {
|
||||
this.getAllFiles();
|
||||
}
|
||||
});
|
||||
|
||||
// set filter property to cached
|
||||
this.postsService.playlists_changed.subscribe(changed => {
|
||||
if (changed) {
|
||||
this.getAllPlaylists();
|
||||
}
|
||||
});
|
||||
|
||||
// set filter property to cached value
|
||||
const cached_filter_property = localStorage.getItem('filter_property');
|
||||
if (cached_filter_property && this.filterProperties[cached_filter_property]) {
|
||||
this.filterProperty = this.filterProperties[cached_filter_property];
|
||||
}
|
||||
|
||||
// set file type filter to cached value
|
||||
const cached_file_type_filter = localStorage.getItem('file_type_filter');
|
||||
if (cached_file_type_filter) {
|
||||
this.fileTypeFilter = cached_file_type_filter;
|
||||
}
|
||||
|
||||
const sort_order = localStorage.getItem('recent_videos_sort_order');
|
||||
|
||||
if (sort_order) {
|
||||
this.descendingMode = sort_order === 'descending';
|
||||
}
|
||||
|
||||
this.searchChangedSubject
|
||||
.debounceTime(500)
|
||||
.pipe(distinctUntilChanged()
|
||||
).subscribe(model => {
|
||||
if (model.length > 0) {
|
||||
this.search_mode = true;
|
||||
} else {
|
||||
this.search_mode = false;
|
||||
}
|
||||
this.getAllFiles();
|
||||
});
|
||||
}
|
||||
|
||||
getAllPlaylists() {
|
||||
this.postsService.getPlaylists().subscribe(res => {
|
||||
this.playlists = res['playlists'];
|
||||
});
|
||||
}
|
||||
|
||||
// search
|
||||
|
||||
onSearchInputChanged(newvalue) {
|
||||
if (newvalue.length > 0) {
|
||||
this.search_mode = true;
|
||||
this.filterFiles(newvalue);
|
||||
} else {
|
||||
this.search_mode = false;
|
||||
this.filtered_files = this.files;
|
||||
}
|
||||
}
|
||||
|
||||
private filterFiles(value: string) {
|
||||
const filterValue = value.toLowerCase();
|
||||
this.filtered_files = this.files.filter(option => option.id.toLowerCase().includes(filterValue) || option.category?.name?.toLowerCase().includes(filterValue));
|
||||
this.pageChangeEvent({pageSize: this.pageSize, pageIndex: this.paginator.pageIndex});
|
||||
}
|
||||
|
||||
filterByProperty(prop) {
|
||||
if (this.descendingMode) {
|
||||
this.filtered_files = this.filtered_files.sort((a, b) => (a[prop] > b[prop] ? -1 : 1));
|
||||
} else {
|
||||
this.filtered_files = this.filtered_files.sort((a, b) => (a[prop] > b[prop] ? 1 : -1));
|
||||
}
|
||||
if (this.paginator) { this.pageChangeEvent({pageSize: this.pageSize, pageIndex: this.paginator.pageIndex}) };
|
||||
this.normal_files_received = false;
|
||||
this.searchChangedSubject.next(newvalue);
|
||||
}
|
||||
|
||||
filterOptionChanged(value) {
|
||||
this.filterByProperty(value['property']);
|
||||
localStorage.setItem('filter_property', value['key']);
|
||||
this.getAllFiles();
|
||||
}
|
||||
|
||||
fileTypeFilterChanged(value) {
|
||||
localStorage.setItem('file_type_filter', value);
|
||||
this.getAllFiles();
|
||||
}
|
||||
|
||||
toggleModeChange() {
|
||||
this.descendingMode = !this.descendingMode;
|
||||
this.filterByProperty(this.filterProperty['property']);
|
||||
localStorage.setItem('recent_videos_sort_order', this.descendingMode ? 'descending' : 'ascending');
|
||||
this.getAllFiles();
|
||||
}
|
||||
|
||||
// get files
|
||||
|
||||
getAllFiles() {
|
||||
this.normal_files_received = false;
|
||||
this.postsService.getAllFiles().subscribe(res => {
|
||||
this.files = res['files'];
|
||||
for (let i = 0; i < this.files.length; i++) {
|
||||
const file = this.files[i];
|
||||
getAllFiles(cache_mode = false) {
|
||||
this.normal_files_received = cache_mode;
|
||||
const current_file_index = (this.paginator?.pageIndex ? this.paginator.pageIndex : 0)*this.pageSize;
|
||||
const sort = {by: this.filterProperty['property'], order: this.descendingMode ? -1 : 1};
|
||||
const range = [current_file_index, current_file_index + this.pageSize];
|
||||
this.postsService.getAllFiles(sort, range, this.search_mode ? this.search_text : null, this.fileTypeFilter).subscribe(res => {
|
||||
this.file_count = res['file_count'];
|
||||
this.paged_data = res['files'];
|
||||
for (let i = 0; i < this.paged_data.length; i++) {
|
||||
const file = this.paged_data[i];
|
||||
file.duration = typeof file.duration !== 'string' ? file.duration : this.durationStringToNumber(file.duration);
|
||||
file.index = i;
|
||||
}
|
||||
this.files.sort(this.sortFiles);
|
||||
if (this.search_mode) {
|
||||
this.filterFiles(this.search_text);
|
||||
} else {
|
||||
this.filtered_files = this.files;
|
||||
}
|
||||
this.filterByProperty(this.filterProperty['property']);
|
||||
|
||||
// set cached file count for future use, note that we convert the amount of files to a string
|
||||
localStorage.setItem('cached_file_count', '' + this.files.length);
|
||||
localStorage.setItem('cached_file_count', '' + this.file_count);
|
||||
|
||||
this.normal_files_received = true;
|
||||
|
||||
this.paged_data = this.filtered_files.slice(0, 10);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -167,15 +196,14 @@ export class RecentVideosComponent implements OnInit {
|
||||
const sub = this.postsService.getSubscriptionByID(file.sub_id);
|
||||
if (sub.streamingOnly) {
|
||||
// streaming only mode subscriptions
|
||||
!new_tab ? this.router.navigate(['/player', {name: file.id,
|
||||
url: file.requested_formats ? file.requested_formats[0].url : file.url}])
|
||||
: window.open(`/#/player;name=${file.id};url=${file.requested_formats ? file.requested_formats[0].url : file.url}`);
|
||||
// !new_tab ? this.router.navigate(['/player', {name: file.id,
|
||||
// url: file.requested_formats ? file.requested_formats[0].url : file.url}])
|
||||
// : window.open(`/#/player;name=${file.id};url=${file.requested_formats ? file.requested_formats[0].url : file.url}`);
|
||||
} else {
|
||||
// normal subscriptions
|
||||
!new_tab ? this.router.navigate(['/player', {fileNames: file.id,
|
||||
type: file.isAudio ? 'audio' : 'video', subscriptionName: sub.name,
|
||||
subPlaylist: sub.isPlaylist}])
|
||||
: window.open(`/#/player;fileNames=${file.id};type=${file.isAudio ? 'audio' : 'video'};subscriptionName=${sub.name};subPlaylist=${sub.isPlaylist}`);
|
||||
!new_tab ? this.router.navigate(['/player', {uid: file.uid,
|
||||
type: file.isAudio ? 'audio' : 'video'}])
|
||||
: window.open(`/#/player;uid=${file.uid};type=${file.isAudio ? 'audio' : 'video'}`);
|
||||
}
|
||||
} else {
|
||||
// normal files
|
||||
@@ -202,8 +230,7 @@ export class RecentVideosComponent implements OnInit {
|
||||
const type = file.isAudio ? 'audio' : 'video';
|
||||
const ext = type === 'audio' ? '.mp3' : '.mp4'
|
||||
const sub = this.postsService.getSubscriptionByID(file.sub_id);
|
||||
this.postsService.downloadFileFromServer(file.id, type, null, null, sub.name, sub.isPlaylist,
|
||||
this.postsService.user ? this.postsService.user.uid : null, null).subscribe(res => {
|
||||
this.postsService.downloadFileFromServer(file.uid).subscribe(res => {
|
||||
const blob: Blob = res;
|
||||
saveAs(blob, file.id + ext);
|
||||
}, err => {
|
||||
@@ -216,14 +243,14 @@ export class RecentVideosComponent implements OnInit {
|
||||
const ext = type === 'audio' ? '.mp3' : '.mp4'
|
||||
const name = file.id;
|
||||
this.downloading_content[type][name] = true;
|
||||
this.postsService.downloadFileFromServer(name, type).subscribe(res => {
|
||||
this.postsService.downloadFileFromServer(file.uid).subscribe(res => {
|
||||
this.downloading_content[type][name] = false;
|
||||
const blob: Blob = res;
|
||||
saveAs(blob, decodeURIComponent(name) + ext);
|
||||
|
||||
if (!this.postsService.config.Extra.file_manager_enabled) {
|
||||
// tell server to delete the file once downloaded
|
||||
this.postsService.deleteFile(name, type).subscribe(delRes => {
|
||||
this.postsService.deleteFile(file.uid).subscribe(delRes => {
|
||||
// reload mp4s
|
||||
this.getAllFiles();
|
||||
});
|
||||
@@ -239,19 +266,17 @@ export class RecentVideosComponent implements OnInit {
|
||||
const blacklistMode = args.blacklistMode;
|
||||
|
||||
if (file.sub_id) {
|
||||
this.deleteSubscriptionFile(file, index, blacklistMode);
|
||||
this.deleteSubscriptionFile(file, blacklistMode);
|
||||
} else {
|
||||
this.deleteNormalFile(file, index, blacklistMode);
|
||||
this.deleteNormalFile(file, blacklistMode);
|
||||
}
|
||||
}
|
||||
|
||||
deleteNormalFile(file, index, blacklistMode = false) {
|
||||
this.postsService.deleteFile(file.uid, file.isAudio ? 'audio' : 'video', blacklistMode).subscribe(result => {
|
||||
deleteNormalFile(file, blacklistMode = false) {
|
||||
this.postsService.deleteFile(file.uid, blacklistMode).subscribe(result => {
|
||||
if (result) {
|
||||
this.postsService.openSnackBar('Delete success!', 'OK.');
|
||||
this.files.splice(file.index, 1);
|
||||
for (let i = 0; i < this.files.length; i++) { this.files[i].index = i }
|
||||
this.filterByProperty(this.filterProperty['property']);
|
||||
this.removeFileCard(file);
|
||||
} else {
|
||||
this.postsService.openSnackBar('Delete failed!', 'OK.');
|
||||
}
|
||||
@@ -260,27 +285,50 @@ export class RecentVideosComponent implements OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
deleteSubscriptionFile(file, index, blacklistMode = false) {
|
||||
deleteSubscriptionFile(file, blacklistMode = false) {
|
||||
if (blacklistMode) {
|
||||
this.deleteForever(file, index);
|
||||
this.deleteForever(file);
|
||||
} else {
|
||||
this.deleteAndRedownload(file, index);
|
||||
this.deleteAndRedownload(file);
|
||||
}
|
||||
}
|
||||
|
||||
deleteAndRedownload(file, index) {
|
||||
deleteAndRedownload(file) {
|
||||
const sub = this.postsService.getSubscriptionByID(file.sub_id);
|
||||
this.postsService.deleteSubscriptionFile(sub, file.id, false, file.uid).subscribe(res => {
|
||||
this.postsService.openSnackBar(`Successfully deleted file: '${file.id}'`);
|
||||
this.files.splice(index, 1);
|
||||
this.removeFileCard(file);
|
||||
});
|
||||
}
|
||||
|
||||
deleteForever(file, index) {
|
||||
deleteForever(file) {
|
||||
const sub = this.postsService.getSubscriptionByID(file.sub_id);
|
||||
this.postsService.deleteSubscriptionFile(sub, file.id, true, file.uid).subscribe(res => {
|
||||
this.postsService.openSnackBar(`Successfully deleted file: '${file.id}'`);
|
||||
this.files.splice(index, 1);
|
||||
this.removeFileCard(file);
|
||||
});
|
||||
}
|
||||
|
||||
removeFileCard(file_to_remove) {
|
||||
const index = this.paged_data.map(e => e.uid).indexOf(file_to_remove.uid);
|
||||
this.paged_data.splice(index, 1);
|
||||
this.getAllFiles(true);
|
||||
}
|
||||
|
||||
addFileToPlaylist(info_obj) {
|
||||
const file = info_obj['file'];
|
||||
const playlist_id = info_obj['playlist_id'];
|
||||
const playlist = this.playlists.find(potential_playlist => potential_playlist['id'] === playlist_id);
|
||||
this.postsService.addFileToPlaylist(playlist_id, file['uid']).subscribe(res => {
|
||||
if (res['success']) {
|
||||
this.postsService.openSnackBar(`Successfully added ${file.title} to ${playlist.title}!`);
|
||||
this.postsService.playlists_changed.next(true);
|
||||
} else {
|
||||
this.postsService.openSnackBar(`Failed to add ${file.title} to ${playlist.title}! Unknown error.`);
|
||||
}
|
||||
}, err => {
|
||||
console.error(err);
|
||||
this.postsService.openSnackBar(`Failed to add ${file.title} to ${playlist.title}! See browser console for error.`);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -302,7 +350,8 @@ export class RecentVideosComponent implements OnInit {
|
||||
}
|
||||
|
||||
pageChangeEvent(event) {
|
||||
const offset = ((event.pageIndex + 1) - 1) * event.pageSize;
|
||||
this.paged_data = this.filtered_files.slice(offset).slice(0, event.pageSize);
|
||||
this.pageSize = event.pageSize;
|
||||
this.loading_files = Array(this.pageSize).fill(0);
|
||||
this.getAllFiles();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<button *ngIf="show_skip_ad_button" (click)="skipAdButtonClicked()" mat-flat-button><ng-container i18n="Skip ad button">Skip ad</ng-container></button>
|
||||
@@ -0,0 +1,25 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { SkipAdButtonComponent } from './skip-ad-button.component';
|
||||
|
||||
describe('SkipAdButtonComponent', () => {
|
||||
let component: SkipAdButtonComponent;
|
||||
let fixture: ComponentFixture<SkipAdButtonComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
declarations: [ SkipAdButtonComponent ]
|
||||
})
|
||||
.compileComponents();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(SkipAdButtonComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
115
src/app/components/skip-ad-button/skip-ad-button.component.ts
Normal file
115
src/app/components/skip-ad-button/skip-ad-button.component.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
|
||||
import { PostsService } from 'app/posts.services';
|
||||
import CryptoJS from 'crypto-js';
|
||||
|
||||
@Component({
|
||||
selector: 'app-skip-ad-button',
|
||||
templateUrl: './skip-ad-button.component.html',
|
||||
styleUrls: ['./skip-ad-button.component.scss']
|
||||
})
|
||||
export class SkipAdButtonComponent implements OnInit {
|
||||
|
||||
@Input() current_video = null;
|
||||
@Input() playback_timestamp = null;
|
||||
|
||||
@Output() setPlaybackTimestamp = new EventEmitter<any>();
|
||||
|
||||
sponsor_block_cache = {};
|
||||
show_skip_ad_button = false;
|
||||
|
||||
skip_ad_button_check_interval = null;
|
||||
|
||||
constructor(private postsService: PostsService) { }
|
||||
|
||||
ngOnInit(): void {
|
||||
this.skip_ad_button_check_interval = setInterval(() => this.skipAdButtonCheck(), 500);
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
clearInterval(this.skip_ad_button_check_interval);
|
||||
}
|
||||
|
||||
checkSponsorBlock(video_to_check) {
|
||||
if (!video_to_check) return;
|
||||
|
||||
// check cache, null means it has been checked and confirmed not to exist (limits API calls)
|
||||
if (this.sponsor_block_cache[video_to_check.url] || this.sponsor_block_cache[video_to_check.url] === null) return;
|
||||
|
||||
// sponsor block needs first 4 chars from video ID hash
|
||||
const video_id = this.getVideoIDFromURL(video_to_check.url);
|
||||
const id_hash = this.getVideoIDHashFromURL(video_id);
|
||||
if (!id_hash || id_hash.length < 4) return;
|
||||
const truncated_id_hash = id_hash.substring(0, 4);
|
||||
|
||||
// we couldn't get the data from the cache, let's get it from sponsor block directly
|
||||
|
||||
this.postsService.getSponsorBlockDataForVideo(truncated_id_hash).subscribe(res => {
|
||||
if (res && res['length'] && res['length'] === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const found_data = res['find'](data => data['videoID'] === video_id);
|
||||
if (found_data) {
|
||||
this.sponsor_block_cache[video_to_check.url] = found_data;
|
||||
} else {
|
||||
this.sponsor_block_cache[video_to_check.url] = null;
|
||||
}
|
||||
}, err => {
|
||||
// likely doesn't exist
|
||||
this.sponsor_block_cache[video_to_check.url] = null;
|
||||
});
|
||||
}
|
||||
|
||||
getVideoIDHashFromURL(video_id) {
|
||||
if (!video_id) return null;
|
||||
return CryptoJS.SHA256(video_id).toString(CryptoJS.enc.Hex);;
|
||||
}
|
||||
|
||||
getVideoIDFromURL(url) {
|
||||
const regex_exp = /^.*((youtu.be\/)|(v\/)|(\/u\/\w\/)|(embed\/)|(watch\?))\??v?=?([^#&?]*).*/;
|
||||
const match = url.match(regex_exp);
|
||||
return (match && match[7].length==11) ? match[7] : null;
|
||||
}
|
||||
|
||||
skipAdButtonCheck() {
|
||||
const sponsor_block_data = this.sponsor_block_cache[this.current_video.url];
|
||||
if (!sponsor_block_data && sponsor_block_data !== null) {
|
||||
// we haven't yet tried to get the sponsor block data for the video
|
||||
this.checkSponsorBlock(this.current_video);
|
||||
} else if (!sponsor_block_data) {
|
||||
this.show_skip_ad_button = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.getTimeToSkipTo()) {
|
||||
this.show_skip_ad_button = true;
|
||||
} else {
|
||||
this.show_skip_ad_button = false;
|
||||
}
|
||||
}
|
||||
|
||||
getTimeToSkipTo() {
|
||||
const sponsor_block_data = this.sponsor_block_cache[this.current_video.url];
|
||||
|
||||
if (!sponsor_block_data) return;
|
||||
|
||||
// check if we're in between an ad segment
|
||||
const found_segment = sponsor_block_data['segments'].find(segment_data => this.playback_timestamp > segment_data.segment[0] && this.playback_timestamp < segment_data.segment[1] - 0.5);
|
||||
|
||||
if (found_segment) {
|
||||
return found_segment['segment'][1];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
skipAdButtonClicked() {
|
||||
const time_to_skip_to = this.getTimeToSkipTo();
|
||||
if (!time_to_skip_to) return;
|
||||
|
||||
this.setPlaybackTimestamp.emit(time_to_skip_to);
|
||||
|
||||
this.show_skip_ad_button = false;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
<div (mouseover)="elevated=true" (mouseout)="elevated=false" (contextmenu)="onRightClick($event)" style="position: relative; width: fit-content;">
|
||||
<div (mouseenter)="onMouseOver()" (mouseleave)="onMouseOut()" (contextmenu)="onRightClick($event)" style="position: relative; width: fit-content;">
|
||||
<div *ngIf="!loading" class="download-time">
|
||||
<mat-icon class="audio-video-icon">{{(file_obj.type === 'audio' || file_obj.isAudio) ? 'audiotrack' : 'movie'}}</mat-icon>
|
||||
|
||||
@@ -23,6 +23,12 @@
|
||||
<ng-container *ngIf="!is_playlist && !loading">
|
||||
<button (click)="openFileInfoDialog()" mat-menu-item><mat-icon>info</mat-icon><ng-container i18n="Video info button">Info</ng-container></button>
|
||||
<button (click)="navigateToSubscription()" mat-menu-item *ngIf="file_obj.sub_id"><mat-icon>{{file_obj.isAudio ? 'library_music' : 'video_library'}}</mat-icon> <ng-container i18n="Go to subscription menu item">Go to subscription</ng-container></button>
|
||||
<button *ngIf="availablePlaylists" [matMenuTriggerFor]="addtoplaylist" mat-menu-item><mat-icon>playlist_add</mat-icon> <ng-container i18n="Add to playlist menu item">Add to playlist</ng-container></button>
|
||||
<mat-menu #addtoplaylist="matMenu">
|
||||
<ng-container *ngFor="let playlist of availablePlaylists">
|
||||
<button *ngIf="(playlist.type === 'audio') === file_obj.isAudio" [disabled]="playlist.uids?.includes(file_obj.uid)" (click)="emitAddFileToPlaylist(playlist.id)" mat-menu-item>{{playlist.name}}</button>
|
||||
</ng-container>
|
||||
</mat-menu>
|
||||
<mat-divider></mat-divider>
|
||||
<button *ngIf="file_obj.sub_id" (click)="emitDeleteFile()" mat-menu-item>
|
||||
<mat-icon>restore</mat-icon><ng-container i18n="Delete and redownload subscription video button">Delete and redownload</ng-container>
|
||||
@@ -45,8 +51,10 @@
|
||||
<mat-card [matTooltip]="null" (click)="navigateToFile($event)" matRipple class="file-mat-card" [ngClass]="{'small-mat-card': card_size === 'small', 'file-mat-card': card_size === 'medium', 'large-mat-card': card_size === 'large', 'mat-elevation-z4': !elevated, 'mat-elevation-z8': elevated}">
|
||||
<div style="padding:5px">
|
||||
<div *ngIf="!loading && file_obj.thumbnailURL" class="img-div">
|
||||
<div style="position: relative">
|
||||
<img [ngClass]="{'image-small': card_size === 'small', 'image': card_size === 'medium', 'image-large': card_size === 'large'}" [src]="file_obj.thumbnailPath ? thumbnailBlobURL : file_obj.thumbnailURL" alt="Thumbnail">
|
||||
<div [ngClass]="{'image-small': card_size === 'small', 'image': card_size === 'medium', 'image-large': card_size === 'large'}" style="position: relative">
|
||||
<img *ngIf="!hide_image || is_playlist || (file_obj.type === 'audio' || file_obj.isAudio)" [ngClass]="{'image-small': card_size === 'small', 'image': card_size === 'medium', 'image-large': card_size === 'large'}" [src]="file_obj.thumbnailPath ? thumbnailBlobURL : file_obj.thumbnailURL" alt="Thumbnail">
|
||||
<video *ngIf="elevated && !is_playlist && !(file_obj.type === 'audio' || file_obj.isAudio)" autoplay loop muted [muted]="true" [ngClass]="{'video-small': card_size === 'small', 'video': card_size === 'medium', 'video-large': card_size === 'large'}" [src]="streamURL">
|
||||
</video>
|
||||
<div class="duration-time">
|
||||
{{file_length}}
|
||||
</div>
|
||||
|
||||
@@ -51,6 +51,30 @@
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.video-large {
|
||||
width: 300px;
|
||||
height: 167.5px;
|
||||
object-fit: cover;
|
||||
position: absolute;
|
||||
top: 0px;
|
||||
}
|
||||
|
||||
.video {
|
||||
width: 200px;
|
||||
height: 112.5px;
|
||||
object-fit: cover;
|
||||
position: absolute;
|
||||
top: 0px;
|
||||
}
|
||||
|
||||
.video-small {
|
||||
width: 150px;
|
||||
height: 84.5px;
|
||||
object-fit: cover;
|
||||
position: absolute;
|
||||
top: 0px;
|
||||
}
|
||||
|
||||
.example-full-width-height {
|
||||
width: 100%;
|
||||
height: 100%
|
||||
@@ -110,7 +134,7 @@
|
||||
position: absolute;
|
||||
top: 1px;
|
||||
left: 5px;
|
||||
z-index: 99999;
|
||||
z-index: 999;
|
||||
width: calc(100% - 8px);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
|
||||
@@ -35,6 +35,9 @@ export class UnifiedFileCardComponent implements OnInit {
|
||||
// optional vars
|
||||
thumbnailBlobURL = null;
|
||||
|
||||
streamURL = null;
|
||||
hide_image = false;
|
||||
|
||||
// input/output
|
||||
@Input() loading = true;
|
||||
@Input() theme = null;
|
||||
@@ -46,9 +49,11 @@ export class UnifiedFileCardComponent implements OnInit {
|
||||
@Input() locale = null;
|
||||
@Input() baseStreamPath = null;
|
||||
@Input() jwtString = null;
|
||||
@Input() availablePlaylists = null;
|
||||
@Output() goToFile = new EventEmitter<any>();
|
||||
@Output() goToSubscription = new EventEmitter<any>();
|
||||
@Output() deleteFile = new EventEmitter<any>();
|
||||
@Output() addFileToPlaylist = new EventEmitter<any>();
|
||||
@Output() editPlaylist = new EventEmitter<any>();
|
||||
|
||||
|
||||
@@ -76,6 +81,8 @@ export class UnifiedFileCardComponent implements OnInit {
|
||||
const bloburl = URL.createObjectURL(blob);
|
||||
this.thumbnailBlobURL = this.sanitizer.bypassSecurityTrustUrl(bloburl);*/
|
||||
}
|
||||
|
||||
if (this.file_obj) this.streamURL = this.generateStreamURL();
|
||||
}
|
||||
|
||||
emitDeleteFile(blacklistMode = false) {
|
||||
@@ -86,6 +93,13 @@ export class UnifiedFileCardComponent implements OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
emitAddFileToPlaylist(playlist_id) {
|
||||
this.addFileToPlaylist.emit({
|
||||
file: this.file_obj,
|
||||
playlist_id: playlist_id
|
||||
});
|
||||
}
|
||||
|
||||
navigateToFile(event) {
|
||||
this.goToFile.emit({file: this.file_obj, event: event});
|
||||
}
|
||||
@@ -119,6 +133,33 @@ export class UnifiedFileCardComponent implements OnInit {
|
||||
this.contextMenu.openMenu();
|
||||
}
|
||||
|
||||
generateStreamURL() {
|
||||
let baseLocation = 'stream/';
|
||||
let fullLocation = this.baseStreamPath + baseLocation + `?test=test&uid=${this.file_obj['uid']}`;
|
||||
|
||||
if (this.jwtString) {
|
||||
fullLocation += `&jwt=${this.jwtString}`;
|
||||
}
|
||||
|
||||
fullLocation += '&t=,10';
|
||||
|
||||
return fullLocation;
|
||||
}
|
||||
|
||||
onMouseOver() {
|
||||
this.elevated = true;
|
||||
setTimeout(() => {
|
||||
if (this.elevated) {
|
||||
this.hide_image = true;
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
|
||||
onMouseOut() {
|
||||
this.elevated = false;
|
||||
this.hide_image = false;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
function fancyTimeFormat(time) {
|
||||
|
||||
@@ -19,9 +19,9 @@
|
||||
<mat-label *ngIf="type === 'audio'"><ng-container i18n="Audio files title">Audio files</ng-container></mat-label>
|
||||
<mat-label *ngIf="type === 'video'"><ng-container i18n="Videos title">Videos</ng-container></mat-label>
|
||||
<mat-select [formControl]="filesSelect" multiple required aria-required>
|
||||
<ng-container *ngIf="filesToSelectFrom"><mat-option *ngFor="let file of filesToSelectFrom" [value]="file.id">{{file.id}}</mat-option></ng-container>
|
||||
<ng-container *ngIf="audiosToSelectFrom && type === 'audio'"><mat-option *ngFor="let file of audiosToSelectFrom" [value]="file.id">{{file.id}}</mat-option></ng-container>
|
||||
<ng-container *ngIf="videosToSelectFrom && type === 'video'"><mat-option *ngFor="let file of videosToSelectFrom" [value]="file.id">{{file.id}}</mat-option></ng-container>
|
||||
<ng-container *ngIf="filesToSelectFrom"><mat-option *ngFor="let file of filesToSelectFrom" [value]="file.uid">{{file.id}}</mat-option></ng-container>
|
||||
<ng-container *ngIf="audiosToSelectFrom && type === 'audio'"><mat-option *ngFor="let file of audiosToSelectFrom" [value]="file.uid">{{file.id}}</mat-option></ng-container>
|
||||
<ng-container *ngIf="videosToSelectFrom && type === 'video'"><mat-option *ngFor="let file of videosToSelectFrom" [value]="file.uid">{{file.id}}</mat-option></ng-container>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<!-- No videos available -->
|
||||
|
||||
@@ -51,9 +51,8 @@ export class CreatePlaylistComponent implements OnInit {
|
||||
|
||||
createPlaylist() {
|
||||
const thumbnailURL = this.getThumbnailURL();
|
||||
const duration = this.calculateDuration();
|
||||
this.create_in_progress = true;
|
||||
this.postsService.createPlaylist(this.name, this.filesSelect.value, this.type, thumbnailURL, duration).subscribe(res => {
|
||||
this.postsService.createPlaylist(this.name, this.filesSelect.value, this.type, thumbnailURL).subscribe(res => {
|
||||
this.create_in_progress = false;
|
||||
if (res['success']) {
|
||||
this.dialogRef.close(true);
|
||||
@@ -78,36 +77,4 @@ export class CreatePlaylistComponent implements OnInit {
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
getDuration(file_id) {
|
||||
let properFilesToSelectFrom = this.filesToSelectFrom;
|
||||
if (!this.filesToSelectFrom) {
|
||||
properFilesToSelectFrom = this.type === 'audio' ? this.audiosToSelectFrom : this.videosToSelectFrom;
|
||||
}
|
||||
for (let i = 0; i < properFilesToSelectFrom.length; i++) {
|
||||
const file = properFilesToSelectFrom[i];
|
||||
if (file.id === file_id) {
|
||||
return file.duration;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
calculateDuration() {
|
||||
let sum = 0;
|
||||
for (let i = 0; i < this.filesSelect.value.length; i++) {
|
||||
const duration_val = this.getDuration(this.filesSelect.value[i]);
|
||||
sum += typeof duration_val === 'string' ? this.durationStringToNumber(duration_val) : duration_val;
|
||||
}
|
||||
return sum;
|
||||
}
|
||||
|
||||
durationStringToNumber(dur_str) {
|
||||
let num_sum = 0;
|
||||
const dur_str_parts = dur_str.split(':');
|
||||
for (let i = dur_str_parts.length-1; i >= 0; i--) {
|
||||
num_sum += parseInt(dur_str_parts[i])*(60**(dur_str_parts.length-1-i));
|
||||
}
|
||||
return num_sum;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,17 @@
|
||||
<mat-icon *ngIf="!checking_for_updates" class="version-checked-icon">done</mat-icon> <ng-container *ngIf="!checking_for_updates && latestGithubRelease['tag_name'] !== current_version_tag"><a [href]="latestUpdateLink" target="_blank"><ng-container i18n="View latest update">Update available</ng-container> - {{latestGithubRelease['tag_name']}}</a>. <ng-container i18n="Update through settings menu hint">You can update from the settings menu.</ng-container></ng-container>
|
||||
<span *ngIf="!checking_for_updates && latestGithubRelease['tag_name'] === current_version_tag">You are up to date.</span>
|
||||
</p>
|
||||
<p>
|
||||
<ng-container i18n="Installation type">Installation type:</ng-container> {{postsService.version_info.type}}
|
||||
<br>
|
||||
<ng-container *ngIf="postsService.version_info.type === 'docker'">
|
||||
<ng-container i18n="Docker tag">Docker tag:</ng-container> {{postsService.version_info.tag}}
|
||||
<br>
|
||||
</ng-container>
|
||||
<ng-container i18n="Commit hash">Commit hash:</ng-container> {{postsService.version_info.commit}}
|
||||
<br>
|
||||
<ng-container i18n="Build date">Build date:</ng-container> {{postsService.version_info.date}}
|
||||
</p>
|
||||
<p>
|
||||
<ng-container i18n="About bug prefix">Found a bug or have a suggestion?</ng-container> <a [href]="issuesLink" target="_blank"><ng-container i18n="About bug click here">Click here</ng-container></a> <ng-container i18n="About bug suffix">to create an issue!</ng-container>
|
||||
</p>
|
||||
|
||||
@@ -19,7 +19,7 @@ export class AboutDialogComponent implements OnInit {
|
||||
sidepanel_mode = this.postsService.sidepanel_mode;
|
||||
card_size = this.postsService.card_size;
|
||||
|
||||
constructor(private postsService: PostsService) { }
|
||||
constructor(public postsService: PostsService) { }
|
||||
|
||||
ngOnInit(): void {
|
||||
this.getLatestGithubRelease();
|
||||
|
||||
@@ -11,5 +11,8 @@
|
||||
<mat-spinner [diameter]="25"></mat-spinner>
|
||||
</div>
|
||||
<span class="spacer"></span>
|
||||
<button style="float: right;" mat-stroked-button mat-dialog-close>Cancel</button>
|
||||
<button style="float: right;" mat-stroked-button mat-dialog-close>
|
||||
<ng-container *ngIf="cancelText">{{cancelText}}</ng-container>
|
||||
<ng-container *ngIf="!cancelText" i18n="Cancel">Cancel</ng-container>
|
||||
</button>
|
||||
</mat-dialog-actions>
|
||||
@@ -11,18 +11,23 @@ export class ConfirmDialogComponent implements OnInit {
|
||||
dialogTitle = 'Confirm';
|
||||
dialogText = 'Would you like to confirm?';
|
||||
submitText = 'Yes'
|
||||
cancelText = null;
|
||||
submitClicked = false;
|
||||
closeOnSubmit = true;
|
||||
|
||||
doneEmitter: EventEmitter<any> = null;
|
||||
doneEmitter: EventEmitter<boolean> = null;
|
||||
onlyEmitOnDone = false;
|
||||
|
||||
warnSubmitColor = false;
|
||||
|
||||
constructor(@Inject(MAT_DIALOG_DATA) public data: any, public dialogRef: MatDialogRef<ConfirmDialogComponent>) {
|
||||
if (this.data.dialogTitle) { this.dialogTitle = this.data.dialogTitle };
|
||||
if (this.data.dialogText) { this.dialogText = this.data.dialogText };
|
||||
if (this.data.submitText) { this.submitText = this.data.submitText };
|
||||
if (this.data.warnSubmitColor) { this.warnSubmitColor = this.data.warnSubmitColor };
|
||||
if (this.data.dialogTitle !== undefined) { this.dialogTitle = this.data.dialogTitle }
|
||||
if (this.data.dialogText !== undefined) { this.dialogText = this.data.dialogText }
|
||||
if (this.data.submitText !== undefined) { this.submitText = this.data.submitText }
|
||||
if (this.data.cancelText !== undefined) { this.cancelText = this.data.cancelText }
|
||||
if (this.data.warnSubmitColor !== undefined) { this.warnSubmitColor = this.data.warnSubmitColor }
|
||||
if (this.data.warnSubmitColor !== undefined) { this.warnSubmitColor = this.data.warnSubmitColor }
|
||||
if (this.data.closeOnSubmit !== undefined) { this.closeOnSubmit = this.data.closeOnSubmit }
|
||||
|
||||
// checks if emitter exists, if so don't autoclose as it should be handled by caller
|
||||
if (this.data.doneEmitter) {
|
||||
@@ -34,9 +39,9 @@ export class ConfirmDialogComponent implements OnInit {
|
||||
confirmClicked() {
|
||||
if (this.onlyEmitOnDone) {
|
||||
this.doneEmitter.emit(true);
|
||||
this.submitClicked = true;
|
||||
if (this.closeOnSubmit) this.submitClicked = true;
|
||||
} else {
|
||||
this.dialogRef.close(true);
|
||||
if (this.closeOnSubmit) this.dialogRef.close(true);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,38 +1,44 @@
|
||||
<h4 mat-dialog-title><ng-container i18n="Modify playlist dialog title">Modify playlist</ng-container></h4>
|
||||
|
||||
<mat-dialog-content>
|
||||
<!-- Playlist info -->
|
||||
<div>
|
||||
<mat-form-field color="accent">
|
||||
<input matInput placeholder="Name" i18n-placeholder="Name" [(ngModel)]="playlist.name">
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom: 10px; height: 40px;">
|
||||
<div style="float: left">
|
||||
<span *ngIf="reverse_order === false" i18n="Normal order">Normal order </span>
|
||||
<span *ngIf="reverse_order === true" i18n="Reverse order">Reverse order </span>
|
||||
<button (click)="togglePlaylistOrder()" mat-icon-button><mat-icon>{{!reverse_order ? 'arrow_downward' : 'arrow_upward'}}</mat-icon></button>
|
||||
<div *ngIf="playlist">
|
||||
<!-- Playlist info -->
|
||||
<div>
|
||||
<mat-form-field color="accent">
|
||||
<input matInput placeholder="Name" i18n-placeholder="Name" [(ngModel)]="playlist.name">
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<div style="float: right">
|
||||
<button [disabled]="available_files.length === 0" mat-stroked-button [matMenuTriggerFor]="menu"><ng-container i18n="Add content">Add content</ng-container></button>
|
||||
<div>
|
||||
<mat-checkbox [(ngModel)]="playlist.randomize_order"><ng-container i18n="Randomize order when playing checkbox label">Randomize order when playing</ng-container></mat-checkbox>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Playlist order -->
|
||||
<mat-button-toggle-group class="media-list" cdkDropList (cdkDropListDropped)="drop($event)" style="width: 80%; left: 9%" vertical name="videoSelect" aria-label="Video Select" #group="matButtonToggleGroup">
|
||||
<!-- The following for loop can be optimized but it requires a pipe https://stackoverflow.com/a/35703364/8088021 -->
|
||||
<mat-button-toggle class="media-box" cdkDrag *ngFor="let playlist_item of (reverse_order ? playlist.fileNames.slice().reverse() : playlist.fileNames); let i = index" [checked]="false"><div><div class="playlist-item-text">{{playlist_item}}</div> <button (click)="removeContent(i)" class="remove-item-button" mat-icon-button><mat-icon>cancel</mat-icon></button></div></mat-button-toggle>
|
||||
</mat-button-toggle-group>
|
||||
|
||||
<mat-menu #menu="matMenu">
|
||||
<button *ngFor="let file of available_files" (click)="addContent(file)" mat-menu-item>{{file}}</button>
|
||||
</mat-menu>
|
||||
<div style="margin-bottom: 10px; height: 40px;">
|
||||
<div style="float: left">
|
||||
<span *ngIf="reverse_order === false" i18n="Normal order">Normal order </span>
|
||||
<span *ngIf="reverse_order === true" i18n="Reverse order">Reverse order </span>
|
||||
<button (click)="togglePlaylistOrder()" mat-icon-button><mat-icon>{{!reverse_order ? 'arrow_downward' : 'arrow_upward'}}</mat-icon></button>
|
||||
</div>
|
||||
|
||||
<div style="float: right">
|
||||
<button [disabled]="available_files.length === 0" mat-stroked-button [matMenuTriggerFor]="menu"><ng-container i18n="Add content">Add content</ng-container></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Playlist order -->
|
||||
<mat-button-toggle-group class="media-list" cdkDropList (cdkDropListDropped)="drop($event)" style="width: 80%; left: 9%" vertical name="videoSelect" aria-label="Video Select" #group="matButtonToggleGroup">
|
||||
<!-- The following for loop can be optimized but it requires a pipe https://stackoverflow.com/a/35703364/8088021 -->
|
||||
<mat-button-toggle class="media-box" cdkDrag *ngFor="let playlist_item of (reverse_order ? playlist_file_objs.slice().reverse() : playlist_file_objs); let i = index" [checked]="false"><div><div class="playlist-item-text">{{playlist_item.title}}</div> <button (click)="removeContent(i)" class="remove-item-button" mat-icon-button><mat-icon>cancel</mat-icon></button></div></mat-button-toggle>
|
||||
</mat-button-toggle-group>
|
||||
|
||||
<mat-menu #menu="matMenu">
|
||||
<button *ngFor="let file of available_files" (click)="addContent(file)" mat-menu-item>{{file.title}}</button>
|
||||
</mat-menu>
|
||||
</div>
|
||||
|
||||
</mat-dialog-content>
|
||||
|
||||
<mat-dialog-actions>
|
||||
<!-- Save -->
|
||||
<button [disabled]="!playlistChanged()" (click)="updatePlaylist()" [disabled]="playlistChanged()" mat-raised-button color="accent"><ng-container i18n="Save">Save</ng-container></button>
|
||||
<button [disabled]="!playlist || !playlistChanged()" (click)="updatePlaylist()" mat-raised-button color="accent"><ng-container i18n="Save">Save</ng-container></button>
|
||||
</mat-dialog-actions>
|
||||
@@ -10,8 +10,12 @@ import { PostsService } from 'app/posts.services';
|
||||
})
|
||||
export class ModifyPlaylistComponent implements OnInit {
|
||||
|
||||
playlist_id = null;
|
||||
|
||||
original_playlist = null;
|
||||
playlist = null;
|
||||
playlist_file_objs = null;
|
||||
|
||||
available_files = [];
|
||||
all_files = [];
|
||||
playlist_updated = false;
|
||||
@@ -23,9 +27,8 @@ export class ModifyPlaylistComponent implements OnInit {
|
||||
|
||||
ngOnInit(): void {
|
||||
if (this.data) {
|
||||
this.playlist = JSON.parse(JSON.stringify(this.data.playlist));
|
||||
this.original_playlist = JSON.parse(JSON.stringify(this.data.playlist));
|
||||
this.getFiles();
|
||||
this.playlist_id = this.data.playlist_id;
|
||||
this.getPlaylist();
|
||||
}
|
||||
|
||||
this.reverse_order = localStorage.getItem('default_playlist_order_reversed') === 'true';
|
||||
@@ -44,41 +47,47 @@ export class ModifyPlaylistComponent implements OnInit {
|
||||
}
|
||||
|
||||
processFiles(new_files = null) {
|
||||
if (new_files) { this.all_files = new_files.map(file => file.id); }
|
||||
this.available_files = this.all_files.filter(e => !this.playlist.fileNames.includes(e))
|
||||
if (new_files) { this.all_files = new_files; }
|
||||
this.available_files = this.all_files.filter(e => !this.playlist_file_objs.includes(e))
|
||||
}
|
||||
|
||||
updatePlaylist() {
|
||||
this.playlist['uids'] = this.playlist_file_objs.map(playlist_file_obj => playlist_file_obj['uid'])
|
||||
this.postsService.updatePlaylist(this.playlist).subscribe(res => {
|
||||
this.playlist_updated = true;
|
||||
this.postsService.openSnackBar('Playlist updated successfully.');
|
||||
this.getPlaylist();
|
||||
this.postsService.playlists_changed.next(true);
|
||||
});
|
||||
}
|
||||
|
||||
playlistChanged() {
|
||||
return JSON.stringify(this.playlist) === JSON.stringify(this.original_playlist);
|
||||
return JSON.stringify(this.playlist) !== JSON.stringify(this.original_playlist);
|
||||
}
|
||||
|
||||
getPlaylist() {
|
||||
this.postsService.getPlaylist(this.playlist.id, this.playlist.type, null).subscribe(res => {
|
||||
this.postsService.getPlaylist(this.playlist_id, null, true).subscribe(res => {
|
||||
if (res['playlist']) {
|
||||
this.playlist = res['playlist'];
|
||||
this.playlist_file_objs = res['file_objs'];
|
||||
this.original_playlist = JSON.parse(JSON.stringify(this.playlist));
|
||||
this.getFiles();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
addContent(file) {
|
||||
this.playlist.fileNames.push(file);
|
||||
this.playlist_file_objs.push(file);
|
||||
this.playlist.uids.push(file.uid);
|
||||
this.processFiles();
|
||||
}
|
||||
|
||||
removeContent(index) {
|
||||
if (this.reverse_order) {
|
||||
index = this.playlist.fileNames.length - 1 - index;
|
||||
index = this.playlist_file_objs.length - 1 - index;
|
||||
}
|
||||
this.playlist.fileNames.splice(index, 1);
|
||||
this.playlist_file_objs.splice(index, 1);
|
||||
this.playlist.uids.splice(index, 1);
|
||||
this.processFiles();
|
||||
}
|
||||
|
||||
@@ -89,10 +98,10 @@ export class ModifyPlaylistComponent implements OnInit {
|
||||
|
||||
drop(event: CdkDragDrop<string[]>) {
|
||||
if (this.reverse_order) {
|
||||
event.previousIndex = this.playlist.fileNames.length - 1 - event.previousIndex;
|
||||
event.currentIndex = this.playlist.fileNames.length - 1 - event.currentIndex;
|
||||
event.previousIndex = this.playlist_file_objs.length - 1 - event.previousIndex;
|
||||
event.currentIndex = this.playlist_file_objs.length - 1 - event.currentIndex;
|
||||
}
|
||||
moveItemInArray(this.playlist.fileNames, event.previousIndex, event.currentIndex);
|
||||
moveItemInArray(this.playlist_file_objs, event.previousIndex, event.currentIndex);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<h4 mat-dialog-title>
|
||||
<ng-container *ngIf="is_playlist" i18n="Share playlist dialog title">Share playlist</ng-container>
|
||||
<ng-container *ngIf="!is_playlist && type === 'video'" i18n="Share video dialog title">Share video</ng-container>
|
||||
<ng-container *ngIf="!is_playlist && type === 'audio'" i18n="Share audio dialog title">Share audio</ng-container>
|
||||
<ng-container *ngIf="!is_playlist" i18n="Share video dialog title">Share file</ng-container>
|
||||
</h4>
|
||||
|
||||
<mat-dialog-content>
|
||||
|
||||
@@ -11,7 +11,6 @@ import { PostsService } from 'app/posts.services';
|
||||
})
|
||||
export class ShareMediaDialogComponent implements OnInit {
|
||||
|
||||
type = null;
|
||||
uid = null;
|
||||
uuid = null;
|
||||
share_url = null;
|
||||
@@ -26,14 +25,13 @@ export class ShareMediaDialogComponent implements OnInit {
|
||||
|
||||
ngOnInit(): void {
|
||||
if (this.data) {
|
||||
this.type = this.data.type;
|
||||
this.uid = this.data.uid;
|
||||
this.uuid = this.data.uuid;
|
||||
this.sharing_enabled = this.data.sharing_enabled;
|
||||
this.is_playlist = this.data.is_playlist;
|
||||
this.current_timestamp = (this.data.current_timestamp / 1000).toFixed(2);
|
||||
|
||||
const arg = (this.is_playlist ? ';id=' : ';uid=');
|
||||
const arg = (this.is_playlist ? ';playlist_id=' : ';uid=');
|
||||
this.default_share_url = window.location.href.split(';')[0] + arg + this.uid;
|
||||
if (this.uuid) {
|
||||
this.default_share_url += ';uuid=' + this.uuid;
|
||||
@@ -65,7 +63,7 @@ export class ShareMediaDialogComponent implements OnInit {
|
||||
|
||||
sharingChanged(event) {
|
||||
if (event.checked) {
|
||||
this.postsService.enableSharing(this.uid, this.type, this.is_playlist).subscribe(res => {
|
||||
this.postsService.enableSharing(this.uid, this.is_playlist).subscribe(res => {
|
||||
if (res['success']) {
|
||||
this.openSnackBar('Sharing enabled.');
|
||||
this.sharing_enabled = true;
|
||||
@@ -76,7 +74,7 @@ export class ShareMediaDialogComponent implements OnInit {
|
||||
this.openSnackBar('Failed to enable sharing - server error.');
|
||||
});
|
||||
} else {
|
||||
this.postsService.disableSharing(this.uid, this.type, this.is_playlist).subscribe(res => {
|
||||
this.postsService.disableSharing(this.uid, this.is_playlist).subscribe(res => {
|
||||
if (res['success']) {
|
||||
this.openSnackBar('Sharing disabled.');
|
||||
this.sharing_enabled = false;
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
.example-card {
|
||||
width: 150px;
|
||||
height: 125px;
|
||||
padding: 0px;
|
||||
}
|
||||
|
||||
.deleteButton {
|
||||
top:-5px;
|
||||
right:-5px;
|
||||
position:absolute;
|
||||
}
|
||||
|
||||
/* Coerce the <span> icon container away from display:inline */
|
||||
.mat-icon-button .mat-button-wrapper {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.image {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.example-full-width-height {
|
||||
width: 100%;
|
||||
height: 100%
|
||||
}
|
||||
|
||||
.centered {
|
||||
margin: 0 auto;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
}
|
||||
|
||||
.img-div {
|
||||
height: 60px;
|
||||
padding: 0px;
|
||||
margin: 8px 0px 0px -5px;
|
||||
width: calc(100% + 5px + 5px);
|
||||
overflow: hidden;
|
||||
border-radius: 0px 0px 4px 4px;
|
||||
}
|
||||
|
||||
.max-two-lines {
|
||||
display: -webkit-box;
|
||||
display: -moz-box;
|
||||
max-height: 2.4em;
|
||||
line-height: 1.2em;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
|
||||
.file-link {
|
||||
width: 80%;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: block;
|
||||
}
|
||||
|
||||
@media (max-width: 576px){
|
||||
|
||||
.example-card {
|
||||
width: 125px !important;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
<mat-card class="example-card mat-elevation-z6">
|
||||
<div style="padding:5px">
|
||||
<div style="height: 52px;">
|
||||
<div>
|
||||
<b><a class="file-link" href="javascript:void(0)" (click)="!playlist ? mainComponent.goToFile(name, isAudio, uid) : mainComponent.goToPlaylist(name, type)">{{title}}</a></b>
|
||||
</div>
|
||||
<span class="max-two-lines"><ng-container i18n="File or playlist ID">ID:</ng-container> {{name}}</span>
|
||||
<div *ngIf="playlist"><ng-container i18n="Playlist video count">Count:</ng-container> {{count}}</div>
|
||||
</div>
|
||||
<div *ngIf="!image_errored && thumbnailURL" class="img-div">
|
||||
<img class="image" (error) ="onImgError($event)" [id]="type" [lazyLoad]="thumbnailURL" [customObservable]="scrollAndLoad" (onLoad)="imageLoaded($event)" alt="Thumbnail">
|
||||
<span *ngIf="!image_loaded">
|
||||
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<button [matMenuTriggerFor]="playlist_menu" *ngIf="playlist" class="deleteButton" mat-icon-button><mat-icon>more_vert</mat-icon></button>
|
||||
<mat-menu #playlist_menu="matMenu">
|
||||
<button (click)="editPlaylistDialog()" mat-menu-item><mat-icon>edit</mat-icon><ng-container i18n="Playlist edit button">Edit</ng-container></button>
|
||||
<button (click)="deleteFile()" mat-menu-item><mat-icon>delete_forever</mat-icon><ng-container i18n="Delete playlist">Delete</ng-container></button>
|
||||
</mat-menu>
|
||||
<button [matMenuTriggerFor]="action_menu" *ngIf="!playlist" class="deleteButton" mat-icon-button><mat-icon>more_vert</mat-icon></button>
|
||||
<mat-menu #action_menu="matMenu">
|
||||
<button (click)="openVideoInfoDialog()" mat-menu-item><mat-icon>info</mat-icon><ng-container i18n="Video info button">Info</ng-container></button>
|
||||
<button (click)="deleteFile()" mat-menu-item><mat-icon>delete</mat-icon><ng-container i18n="Delete video button">Delete</ng-container></button>
|
||||
<button *ngIf="use_youtubedl_archive" (click)="deleteFile(true)" mat-menu-item><mat-icon>delete_forever</mat-icon><ng-container i18n="Delete and blacklist video button">Delete and blacklist</ng-container></button>
|
||||
</mat-menu>
|
||||
|
||||
</mat-card>
|
||||
@@ -1,25 +0,0 @@
|
||||
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
|
||||
|
||||
import { FileCardComponent } from './file-card.component';
|
||||
|
||||
describe('FileCardComponent', () => {
|
||||
let component: FileCardComponent;
|
||||
let fixture: ComponentFixture<FileCardComponent>;
|
||||
|
||||
beforeEach(waitForAsync(() => {
|
||||
TestBed.configureTestingModule({
|
||||
declarations: [ FileCardComponent ]
|
||||
})
|
||||
.compileComponents();
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(FileCardComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -1,120 +0,0 @@
|
||||
import { Component, OnInit, Input, Output } from '@angular/core';
|
||||
import {PostsService} from '../posts.services';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import {EventEmitter} from '@angular/core';
|
||||
import { MainComponent } from 'app/main/main.component';
|
||||
import { Subject, Observable } from 'rxjs';
|
||||
import 'rxjs/add/observable/merge';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { VideoInfoDialogComponent } from 'app/dialogs/video-info-dialog/video-info-dialog.component';
|
||||
import { ModifyPlaylistComponent } from '../dialogs/modify-playlist/modify-playlist.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-file-card',
|
||||
templateUrl: './file-card.component.html',
|
||||
styleUrls: ['./file-card.component.css']
|
||||
})
|
||||
export class FileCardComponent implements OnInit {
|
||||
@Input() file: any;
|
||||
@Input() title: string;
|
||||
@Input() length: string;
|
||||
@Input() name: string;
|
||||
@Input() uid: string;
|
||||
@Input() thumbnailURL: string;
|
||||
@Input() isAudio = true;
|
||||
@Output() removeFile: EventEmitter<string> = new EventEmitter<string>();
|
||||
@Input() playlist = null;
|
||||
@Input() count = null;
|
||||
@Input() use_youtubedl_archive = false;
|
||||
type;
|
||||
image_loaded = false;
|
||||
image_errored = false;
|
||||
|
||||
scrollSubject;
|
||||
scrollAndLoad;
|
||||
|
||||
constructor(private postsService: PostsService, public snackBar: MatSnackBar, public mainComponent: MainComponent,
|
||||
private dialog: MatDialog) {
|
||||
|
||||
this.scrollSubject = new Subject();
|
||||
this.scrollAndLoad = Observable.merge(
|
||||
Observable.fromEvent(window, 'scroll'),
|
||||
this.scrollSubject
|
||||
);
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
this.type = this.isAudio ? 'audio' : 'video';
|
||||
|
||||
if (this.file && this.file.url && this.file.url.includes('youtu')) {
|
||||
const string_id = (this.playlist ? '?list=' : '?v=')
|
||||
const index_offset = (this.playlist ? 6 : 3);
|
||||
const end_index = this.file.url.indexOf(string_id) + index_offset;
|
||||
this.name = this.file.url.substring(end_index, this.file.url.length);
|
||||
}
|
||||
}
|
||||
|
||||
deleteFile(blacklistMode = false) {
|
||||
if (!this.playlist) {
|
||||
this.postsService.deleteFile(this.uid, this.isAudio ? 'audio' : 'video', blacklistMode).subscribe(result => {
|
||||
if (result) {
|
||||
this.openSnackBar('Delete success!', 'OK.');
|
||||
this.removeFile.emit(this.name);
|
||||
} else {
|
||||
this.openSnackBar('Delete failed!', 'OK.');
|
||||
}
|
||||
}, err => {
|
||||
this.openSnackBar('Delete failed!', 'OK.');
|
||||
});
|
||||
} else {
|
||||
this.removeFile.emit(this.name);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
openVideoInfoDialog() {
|
||||
const dialogRef = this.dialog.open(VideoInfoDialogComponent, {
|
||||
data: {
|
||||
file: this.file,
|
||||
},
|
||||
minWidth: '50vw'
|
||||
});
|
||||
}
|
||||
|
||||
editPlaylistDialog() {
|
||||
const dialogRef = this.dialog.open(ModifyPlaylistComponent, {
|
||||
data: {
|
||||
playlist: this.playlist,
|
||||
width: '65vw'
|
||||
}
|
||||
});
|
||||
|
||||
dialogRef.afterClosed().subscribe(res => {
|
||||
// updates playlist in file manager if it changed
|
||||
if (dialogRef.componentInstance.playlist_updated) {
|
||||
this.playlist = dialogRef.componentInstance.original_playlist;
|
||||
this.title = this.playlist.name;
|
||||
this.count = this.playlist.fileNames.length;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onImgError(event) {
|
||||
this.image_errored = true;
|
||||
}
|
||||
|
||||
onHoverResponse() {
|
||||
this.scrollSubject.next();
|
||||
}
|
||||
|
||||
imageLoaded(loaded) {
|
||||
this.image_loaded = true;
|
||||
}
|
||||
|
||||
public openSnackBar(message: string, action: string) {
|
||||
this.snackBar.open(message, action, {
|
||||
duration: 2000,
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
@@ -14,7 +14,7 @@ export class H401Interceptor implements HttpInterceptor {
|
||||
return next.handle(request).pipe(catchError(err => {
|
||||
if (err.status === 401) {
|
||||
localStorage.setItem('jwt_token', null);
|
||||
if (this.router.url !== '/login') {
|
||||
if (this.router.url !== '/login' && !this.router.url.includes('player')) {
|
||||
this.router.navigate(['/login']).then(() => {
|
||||
this.openSnackBar('Login expired, please login again.');
|
||||
});
|
||||
|
||||
@@ -124,17 +124,27 @@ mat-form-field.mat-form-field {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.edit-button {
|
||||
.advanced-input-time {
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.edit-button {
|
||||
margin-left: 5px;
|
||||
margin-top: -6px;
|
||||
margin-bottom: -5px;
|
||||
top: -5px;
|
||||
}
|
||||
|
||||
.border-radius-both {
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.no-border-radius-bottom {
|
||||
border-radius: 4px 4px 0px 0px;
|
||||
border-radius: 16px 16px 0px 0px;
|
||||
}
|
||||
|
||||
.no-border-radius-top {
|
||||
border-radius: 0px 0px 4px 4px;
|
||||
border-radius: 0px 0px 16px 16px;
|
||||
}
|
||||
|
||||
@media (max-width: 576px) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<br/>
|
||||
<div class="big demo-basic">
|
||||
<mat-card id="card" style="margin-right: 20px; margin-left: 20px;" [ngClass]="(allowAdvancedDownload) ? 'no-border-radius-bottom' : null">
|
||||
<mat-card id="card" style="margin-right: 20px; margin-left: 20px;" [ngClass]="(allowAdvancedDownload) ? 'no-border-radius-bottom' : 'border-radius-both'">
|
||||
<mat-card-content style="padding: 0px 8px 0px 8px;">
|
||||
<div style="position: relative; margin-right: 15px;">
|
||||
<form class="example-form">
|
||||
@@ -19,12 +19,17 @@
|
||||
Quality
|
||||
</ng-container>
|
||||
</mat-label>
|
||||
<mat-select [ngModelOptions]="{standalone: true}" [(ngModel)]="selectedQuality">
|
||||
<ng-container *ngFor="let option of qualityOptions[(audioOnly) ? 'audio' : 'video']">
|
||||
<mat-option *ngIf="option.value === '' || url && cachedAvailableFormats[url] && cachedAvailableFormats[url]['formats'] && cachedAvailableFormats[url]['formats'][(audioOnly) ? 'audio' : 'video'][option.value]" [value]="option.value">
|
||||
{{option.label}}
|
||||
<mat-select [ngModelOptions]="{standalone: true}" [(ngModel)]="selectedQuality" (ngModelChange)="argsChanged($event)">
|
||||
<mat-option [value]="''">
|
||||
Max
|
||||
</mat-option>
|
||||
</ng-container>
|
||||
<ng-container *ngIf="url && cachedAvailableFormats && cachedAvailableFormats[url]?.formats">
|
||||
<ng-container *ngFor="let option of cachedAvailableFormats[url]['formats'][audioOnly ? 'audio' : 'video']">
|
||||
<mat-option *ngIf="option.key !== 'best_audio_format'" [value]="option">
|
||||
{{option.key}}
|
||||
</mat-option>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
</mat-select>
|
||||
<div class="spinner-div" *ngIf="url !== '' && cachedAvailableFormats[url] && cachedAvailableFormats[url]['formats_loading']">
|
||||
<mat-spinner [diameter]="25"></mat-spinner>
|
||||
@@ -60,9 +65,9 @@
|
||||
Only Audio
|
||||
</ng-container>
|
||||
</mat-checkbox>
|
||||
<mat-checkbox *ngIf="allowMultiDownloadMode" [disabled]="current_download" (change)="multiDownloadModeChanged($event)" [(ngModel)]="multiDownloadMode" style="float: right; margin-top: -12px">
|
||||
<ng-container i18n="Multi-download Mode checkbox">
|
||||
Multi-download Mode
|
||||
<mat-checkbox *ngIf="allowAutoplay" (change)="autoplayChanged($event)" [(ngModel)]="autoplay" style="float: right; margin-top: -12px">
|
||||
<ng-container i18n="Autoplay checkbox">
|
||||
Autoplay
|
||||
</ng-container>
|
||||
</mat-checkbox>
|
||||
|
||||
@@ -106,8 +111,13 @@
|
||||
</ng-container>
|
||||
</mat-checkbox>
|
||||
<button class="edit-button" (click)="openArgsModifierDialog()" mat-icon-button><mat-icon>edit</mat-icon></button>
|
||||
<mat-checkbox color="accent" [disabled]="!customArgsEnabled || current_download" (change)="replaceArgsChanged($event)" [(ngModel)]="replaceArgs" style="z-index: 999; margin-left: 10px" [ngModelOptions]="{standalone: true}">
|
||||
<ng-container i18n="Replace args">
|
||||
Replace args
|
||||
</ng-container>
|
||||
</mat-checkbox>
|
||||
<mat-form-field color="accent" style="margin-bottom: 42px;" class="advanced-input">
|
||||
<input [(ngModel)]="customArgs" [ngModelOptions]="{standalone: true}" [disabled]="!customArgsEnabled" matInput placeholder="Custom args" i18n-placeholder="Custom args placeholder">
|
||||
<input [(ngModel)]="customArgs" [ngModelOptions]="{standalone: true}" [disabled]="!customArgsEnabled" matInput (ngModelChange)="argsChanged()" placeholder="Custom args" i18n-placeholder="Custom args placeholder">
|
||||
<mat-hint>
|
||||
<ng-container i18n="Custom Args input hint">
|
||||
No need to include URL, just everything after. Args are delimited using two commas like so: ,,
|
||||
@@ -122,26 +132,41 @@
|
||||
</ng-container>
|
||||
</mat-checkbox>
|
||||
<mat-form-field style="margin-bottom: 42px;" color="accent" class="advanced-input">
|
||||
<input [(ngModel)]="customOutput" [ngModelOptions]="{standalone: true}" [disabled]="!customOutputEnabled" matInput placeholder="Custom output" i18n-placeholder="Custom output placeholder">
|
||||
<input [(ngModel)]="customOutput" [ngModelOptions]="{standalone: true}" [disabled]="!customOutputEnabled" matInput (ngModelChange)="argsChanged()" placeholder="Custom output" i18n-placeholder="Custom output placeholder">
|
||||
<mat-hint><a target="_blank" href="https://github.com/ytdl-org/youtube-dl/blob/master/README.md#output-template">
|
||||
<ng-container i18n="Youtube-dl output template documentation link">Documentation</ng-container></a>.
|
||||
<ng-container i18n="Custom Output input hint">Path is relative to the config download path. Don't include extension.</ng-container>
|
||||
</mat-hint>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div *ngIf="!youtubeAuthDisabledOverride" class="col-12 col-sm-6 mt-2">
|
||||
<div *ngIf="!youtubeAuthDisabledOverride" class="col-12 col-sm-6 mt-3">
|
||||
<mat-checkbox color="accent" [disabled]="current_download" (change)="youtubeAuthEnabledChanged($event)" [(ngModel)]="youtubeAuthEnabled" style="z-index: 999" [ngModelOptions]="{standalone: true}">
|
||||
<ng-container i18n="Use authentication checkbox">
|
||||
Use authentication
|
||||
</ng-container>
|
||||
</mat-checkbox>
|
||||
<mat-form-field color="accent" class="advanced-input">
|
||||
<input [(ngModel)]="youtubeUsername" [ngModelOptions]="{standalone: true}" [disabled]="!youtubeAuthEnabled" matInput placeholder="Username" i18n-placeholder="YT Username placeholder">
|
||||
<mat-form-field *ngIf="youtubeAuthEnabled" color="accent" class="advanced-input">
|
||||
<input [(ngModel)]="youtubeUsername" [ngModelOptions]="{standalone: true}" matInput (ngModelChange)="argsChanged()" placeholder="Username" i18n-placeholder="YT Username placeholder">
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div *ngIf="!youtubeAuthDisabledOverride" class="col-12 col-sm-6 mt-2">
|
||||
<mat-form-field style="margin-top: 31px;" color="accent" class="advanced-input">
|
||||
<input [(ngModel)]="youtubePassword" type="password" [ngModelOptions]="{standalone: true}" [disabled]="!youtubeAuthEnabled" matInput placeholder="Password" i18n-placeholder="YT Password placeholder">
|
||||
<div *ngIf="!youtubeAuthDisabledOverride" class="col-12 col-sm-6 mt-3">
|
||||
<mat-form-field *ngIf="youtubeAuthEnabled" style="margin-top: 31px;" color="accent" class="advanced-input">
|
||||
<input [(ngModel)]="youtubePassword" type="password" [ngModelOptions]="{standalone: true}" matInput (ngModelChange)="argsChanged()" placeholder="Password" i18n-placeholder="YT Password placeholder">
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="col-12 col-sm-6 mt-3">
|
||||
<mat-checkbox color="accent" [disabled]="current_download" [(ngModel)]="cropFile" style="z-index: 999" [ngModelOptions]="{standalone: true}">
|
||||
<ng-container i18n="Crop video checkbox">
|
||||
Crop file
|
||||
</ng-container>
|
||||
</mat-checkbox>
|
||||
<mat-form-field *ngIf="cropFile" color="accent" class="advanced-input">
|
||||
<input [(ngModel)]="cropFileStart" type="number" [ngModelOptions]="{standalone: true}" matInput placeholder="Crop from (seconds)" i18n-placeholder="Crop from placeholder">
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="col-12 col-sm-6 mt-3">
|
||||
<mat-form-field *ngIf="cropFile" style="margin-top: 31px;" color="accent" class="advanced-input">
|
||||
<input [(ngModel)]="cropFileEnd" type="number" [ngModelOptions]="{standalone: true}" matInput placeholder="Crop to (seconds)" i18n-placeholder="Crop to placeholder">
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</div>
|
||||
@@ -149,20 +174,8 @@
|
||||
</mat-expansion-panel>
|
||||
</form>
|
||||
</div>
|
||||
<div *ngIf="multiDownloadMode && downloads.length > 0 && !current_download" style="margin-top: 15px;" class="big demo-basic">
|
||||
<mat-card id="card" style="margin-right: 20px; margin-left: 20px;">
|
||||
<div class="container">
|
||||
<div *ngFor="let download of downloads; let i = index;" class="row">
|
||||
<ng-container *ngIf="current_download !== download && download['downloading']">
|
||||
<app-download-item style="width: 100%" [download]="download" [queueNumber]="i+1" (cancelDownload)="cancelDownload($event)"></app-download-item>
|
||||
<mat-divider style="position: relative" *ngIf="i !== downloads.length - 1"></mat-divider>
|
||||
</ng-container>
|
||||
</div>
|
||||
</div>
|
||||
</mat-card>
|
||||
</div>
|
||||
<br/>
|
||||
<div class="centered big" id="bar_div" *ngIf="current_download && current_download.downloading; else nofile">
|
||||
<div class="centered big" id="bar_div" *ngIf="current_download && autoplay">
|
||||
<div class="margined">
|
||||
<div [ngClass]="(+percentDownloaded > 99)?'make-room-for-spinner':'equal-sizes'" style="display: inline-block; width: 100%; padding-left: 20px" *ngIf="current_download.percent_complete && current_download.percent_complete > 1;else indeterminateprogress">
|
||||
<mat-progress-bar style="border-radius: 5px;" mode="determinate" value="{{percentDownloaded}}"></mat-progress-bar>
|
||||
@@ -177,9 +190,10 @@
|
||||
</div>
|
||||
<br/>
|
||||
</div>
|
||||
<ng-template #nofile>
|
||||
|
||||
</ng-template>
|
||||
<div style="display: flex; justify-content: center;" *ngIf="downloads && downloads.length > 0 && !autoplay">
|
||||
<app-downloads style="width: 80%; margin-bottom: 10px" [uids]="download_uids"></app-downloads>
|
||||
</div>
|
||||
|
||||
<ng-container *ngIf="cachedFileManagerEnabled || fileManagerEnabled">
|
||||
<app-recent-videos #recentVideos></app-recent-videos>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Component, OnInit, ElementRef, ViewChild, ViewChildren, QueryList } from '@angular/core';
|
||||
import {PostsService} from '../posts.services';
|
||||
import {FileCardComponent} from '../file-card/file-card.component';
|
||||
import { Observable } from 'rxjs';
|
||||
import { Observable, Subject } from 'rxjs';
|
||||
import {FormControl, Validators} from '@angular/forms';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
@@ -9,7 +8,6 @@ import { saveAs } from 'file-saver';
|
||||
import { YoutubeSearchService, Result } from '../youtube-search.service';
|
||||
import { Router, ActivatedRoute } from '@angular/router';
|
||||
import { Platform } from '@angular/cdk/platform';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { ArgModifierDialogComponent } from 'app/dialogs/arg-modifier-dialog/arg-modifier-dialog.component';
|
||||
import { RecentVideosComponent } from 'app/components/recent-videos/recent-videos.component';
|
||||
|
||||
@@ -46,14 +44,18 @@ export class MainComponent implements OnInit {
|
||||
determinateProgress = false;
|
||||
downloadingfile = false;
|
||||
audioOnly: boolean;
|
||||
multiDownloadMode = false;
|
||||
autoplay = false;
|
||||
customArgsEnabled = false;
|
||||
customArgs = null;
|
||||
customOutputEnabled = false;
|
||||
replaceArgs = false;
|
||||
customOutput = null;
|
||||
youtubeAuthEnabled = false;
|
||||
youtubeUsername = null;
|
||||
youtubePassword = null;
|
||||
cropFile = false;
|
||||
cropFileStart = null;
|
||||
cropFileEnd = null;
|
||||
urlError = false;
|
||||
path = '';
|
||||
url = '';
|
||||
@@ -65,7 +67,7 @@ export class MainComponent implements OnInit {
|
||||
fileManagerEnabled = false;
|
||||
allowQualitySelect = false;
|
||||
downloadOnlyMode = false;
|
||||
allowMultiDownloadMode = false;
|
||||
allowAutoplay = false;
|
||||
audioFolderPath;
|
||||
videoFolderPath;
|
||||
use_youtubedl_archive = false;
|
||||
@@ -87,11 +89,11 @@ export class MainComponent implements OnInit {
|
||||
|
||||
mp3s: any[] = [];
|
||||
mp4s: any[] = [];
|
||||
files_cols = null;
|
||||
playlists = {'audio': [], 'video': []};
|
||||
playlist_thumbnails = {};
|
||||
downloading_content = {'audio': {}, 'video': {}};
|
||||
downloads: Download[] = [];
|
||||
download_uids: string[] = [];
|
||||
current_download: Download = null;
|
||||
|
||||
urlForm = new FormControl('', [Validators.required]);
|
||||
@@ -193,8 +195,6 @@ export class MainComponent implements OnInit {
|
||||
|
||||
@ViewChild('urlinput', { read: ElementRef }) urlInput: ElementRef;
|
||||
@ViewChild('recentVideos') recentVideos: RecentVideosComponent;
|
||||
@ViewChildren('audiofilecard') audioFileCards: QueryList<FileCardComponent>;
|
||||
@ViewChildren('videofilecard') videoFileCards: QueryList<FileCardComponent>;
|
||||
last_valid_url = '';
|
||||
last_url_check = 0;
|
||||
|
||||
@@ -208,6 +208,7 @@ export class MainComponent implements OnInit {
|
||||
error: false
|
||||
};
|
||||
|
||||
argsChangedSubject: Subject<boolean> = new Subject<boolean>();
|
||||
simulatedOutput = '';
|
||||
|
||||
constructor(public postsService: PostsService, private youtubeSearch: YoutubeSearchService, public snackBar: MatSnackBar,
|
||||
@@ -215,21 +216,19 @@ export class MainComponent implements OnInit {
|
||||
this.audioOnly = false;
|
||||
}
|
||||
|
||||
async configLoad() {
|
||||
async configLoad(): Promise<void> {
|
||||
await this.loadConfig();
|
||||
if (this.autoStartDownload) {
|
||||
this.downloadClicked();
|
||||
}
|
||||
|
||||
setInterval(() => this.getSimulatedOutput(), 1000);
|
||||
}
|
||||
|
||||
async loadConfig() {
|
||||
async loadConfig(): Promise<boolean> {
|
||||
// loading config
|
||||
this.fileManagerEnabled = this.postsService.config['Extra']['file_manager_enabled']
|
||||
&& (!this.postsService.isLoggedIn || this.postsService.permissions.includes('filemanager'));
|
||||
&& this.postsService.hasPermission('filemanager');
|
||||
this.downloadOnlyMode = this.postsService.config['Extra']['download_only_mode'];
|
||||
this.allowMultiDownloadMode = this.postsService.config['Extra']['allow_multi_download_mode'];
|
||||
this.allowAutoplay = this.postsService.config['Extra']['allow_autoplay'];
|
||||
this.audioFolderPath = this.postsService.config['Downloader']['path-audio'];
|
||||
this.videoFolderPath = this.postsService.config['Downloader']['path-video'];
|
||||
this.use_youtubedl_archive = this.postsService.config['Downloader']['use_youtubedl_archive'];
|
||||
@@ -239,15 +238,10 @@ export class MainComponent implements OnInit {
|
||||
this.youtubeAPIKey = this.youtubeSearchEnabled ? this.postsService.config['API']['youtube_API_key'] : null;
|
||||
this.allowQualitySelect = this.postsService.config['Extra']['allow_quality_select'];
|
||||
this.allowAdvancedDownload = this.postsService.config['Advanced']['allow_advanced_download']
|
||||
&& (!this.postsService.isLoggedIn || this.postsService.permissions.includes('advanced_download'));
|
||||
&& this.postsService.hasPermission('advanced_download');
|
||||
this.useDefaultDownloadingAgent = this.postsService.config['Advanced']['use_default_downloading_agent'];
|
||||
this.customDownloadingAgent = this.postsService.config['Advanced']['custom_downloading_agent'];
|
||||
|
||||
if (this.youtubeSearchEnabled && this.youtubeAPIKey) {
|
||||
this.youtubeSearch.initializeAPI(this.youtubeAPIKey);
|
||||
this.attachToInput();
|
||||
}
|
||||
|
||||
// set final cache items
|
||||
|
||||
localStorage.setItem('cached_filemanager_enabled', this.fileManagerEnabled.toString());
|
||||
@@ -262,6 +256,10 @@ export class MainComponent implements OnInit {
|
||||
this.customOutputEnabled = localStorage.getItem('customOutputEnabled') === 'true';
|
||||
}
|
||||
|
||||
if (localStorage.getItem('replaceArgs') !== null) {
|
||||
this.replaceArgs = localStorage.getItem('replaceArgs') === 'true';
|
||||
}
|
||||
|
||||
if (localStorage.getItem('youtubeAuthEnabled') !== null) {
|
||||
this.youtubeAuthEnabled = localStorage.getItem('youtubeAuthEnabled') === 'true';
|
||||
}
|
||||
@@ -271,9 +269,9 @@ export class MainComponent implements OnInit {
|
||||
const customOutput = localStorage.getItem('customOutput');
|
||||
const youtubeUsername = localStorage.getItem('youtubeUsername');
|
||||
|
||||
if (customArgs && customArgs !== 'null') { this.customArgs = customArgs };
|
||||
if (customOutput && customOutput !== 'null') { this.customOutput = customOutput };
|
||||
if (youtubeUsername && youtubeUsername !== 'null') { this.youtubeUsername = youtubeUsername };
|
||||
if (customArgs && customArgs !== 'null') { this.customArgs = customArgs }
|
||||
if (customOutput && customOutput !== 'null') { this.customOutput = customOutput }
|
||||
if (youtubeUsername && youtubeUsername !== 'null') { this.youtubeUsername = youtubeUsername }
|
||||
}
|
||||
|
||||
// get downloads routine
|
||||
@@ -287,7 +285,7 @@ export class MainComponent implements OnInit {
|
||||
}
|
||||
|
||||
// app initialization.
|
||||
ngOnInit() {
|
||||
ngOnInit(): void {
|
||||
if (this.postsService.initialized) {
|
||||
this.configLoad();
|
||||
} else {
|
||||
@@ -311,8 +309,8 @@ export class MainComponent implements OnInit {
|
||||
this.audioOnly = localStorage.getItem('audioOnly') === 'true';
|
||||
}
|
||||
|
||||
if (localStorage.getItem('multiDownloadMode') !== null) {
|
||||
this.multiDownloadMode = localStorage.getItem('multiDownloadMode') === 'true';
|
||||
if (localStorage.getItem('autoplay') !== null) {
|
||||
this.autoplay = localStorage.getItem('autoplay') === 'true';
|
||||
}
|
||||
|
||||
// check if params exist
|
||||
@@ -324,241 +322,113 @@ export class MainComponent implements OnInit {
|
||||
this.autoStartDownload = true;
|
||||
}
|
||||
|
||||
this.setCols();
|
||||
this.argsChangedSubject
|
||||
.debounceTime(500)
|
||||
.subscribe((should_simulate) => {
|
||||
if (should_simulate) this.getSimulatedOutput();
|
||||
});
|
||||
}
|
||||
|
||||
public setCols() {
|
||||
if (window.innerWidth <= 350) {
|
||||
this.files_cols = 1;
|
||||
} else if (window.innerWidth <= 500) {
|
||||
this.files_cols = 2;
|
||||
} else if (window.innerWidth <= 750) {
|
||||
this.files_cols = 3
|
||||
} else {
|
||||
this.files_cols = 4;
|
||||
ngAfterViewInit(): void {
|
||||
if (this.youtubeSearchEnabled && this.youtubeAPIKey) {
|
||||
this.youtubeSearch.initializeAPI(this.youtubeAPIKey);
|
||||
this.attachToInput();
|
||||
}
|
||||
}
|
||||
|
||||
public goToFile(name, isAudio, uid) {
|
||||
if (isAudio) {
|
||||
this.downloadHelperMp3(name, uid, false, false, null, true);
|
||||
} else {
|
||||
this.downloadHelperMp4(name, uid, false, false, null, true);
|
||||
}
|
||||
}
|
||||
|
||||
public goToPlaylist(playlistID, type) {
|
||||
const playlist = this.getPlaylistObjectByID(playlistID, type);
|
||||
if (playlist) {
|
||||
if (this.downloadOnlyMode) {
|
||||
this.downloading_content[type][playlistID] = true;
|
||||
this.downloadPlaylist(playlist.fileNames, type, playlist.name, playlistID);
|
||||
} else {
|
||||
localStorage.setItem('player_navigator', this.router.url);
|
||||
const fileNames = playlist.fileNames;
|
||||
this.router.navigate(['/player', {fileNames: fileNames.join('|nvr|'), type: type, id: playlistID, uid: playlistID}]);
|
||||
}
|
||||
} else {
|
||||
// playlist not found
|
||||
console.error(`Playlist with ID ${playlistID} not found!`);
|
||||
}
|
||||
}
|
||||
|
||||
getPlaylistObjectByID(playlistID, type) {
|
||||
for (let i = 0; i < this.playlists[type].length; i++) {
|
||||
const playlist = this.playlists[type][i];
|
||||
if (playlist.id === playlistID) {
|
||||
return playlist;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// download helpers
|
||||
|
||||
downloadHelperMp3(name, uid, is_playlist = false, forceView = false, new_download = null, navigate_mode = false) {
|
||||
downloadHelper(container, type: string, is_playlist = false, force_view = false, navigate_mode = false): void {
|
||||
this.downloadingfile = false;
|
||||
if (this.multiDownloadMode && !this.downloadOnlyMode && !navigate_mode) {
|
||||
if (!this.autoplay && !this.downloadOnlyMode && !navigate_mode) {
|
||||
// do nothing
|
||||
this.reloadRecentVideos();
|
||||
} else {
|
||||
// if download only mode, just download the file. no redirect
|
||||
if (forceView === false && this.downloadOnlyMode && !this.iOS) {
|
||||
if (force_view === false && this.downloadOnlyMode && !this.iOS) {
|
||||
if (is_playlist) {
|
||||
const zipName = name[0].split(' ')[0] + name[1].split(' ')[0];
|
||||
this.downloadPlaylist(name, 'audio', zipName);
|
||||
this.downloadPlaylist(container['uid']);
|
||||
} else {
|
||||
this.downloadAudioFile(decodeURI(name));
|
||||
this.downloadFileFromServer(container, type);
|
||||
}
|
||||
this.reloadRecentVideos();
|
||||
} else {
|
||||
localStorage.setItem('player_navigator', this.router.url.split(';')[0]);
|
||||
if (is_playlist) {
|
||||
this.router.navigate(['/player', {fileNames: name.join('|nvr|'), type: 'audio'}]);
|
||||
this.router.navigate(['/player', {playlist_id: container['id'], type: type}]);
|
||||
} else {
|
||||
this.router.navigate(['/player', {type: 'audio', uid: uid}]);
|
||||
this.router.navigate(['/player', {type: type, uid: container['uid']}]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// remove download from current downloads
|
||||
this.removeDownloadFromCurrentDownloads(new_download);
|
||||
}
|
||||
|
||||
downloadHelperMp4(name, uid, is_playlist = false, forceView = false, new_download = null, navigate_mode = false) {
|
||||
this.downloadingfile = false;
|
||||
if (this.multiDownloadMode && !this.downloadOnlyMode && !navigate_mode) {
|
||||
// do nothing
|
||||
this.reloadRecentVideos();
|
||||
} else {
|
||||
// if download only mode, just download the file. no redirect
|
||||
if (forceView === false && this.downloadOnlyMode) {
|
||||
if (is_playlist) {
|
||||
const zipName = name[0].split(' ')[0] + name[1].split(' ')[0];
|
||||
this.downloadPlaylist(name, 'video', zipName);
|
||||
} else {
|
||||
this.downloadVideoFile(decodeURI(name));
|
||||
}
|
||||
this.reloadRecentVideos();
|
||||
} else {
|
||||
localStorage.setItem('player_navigator', this.router.url.split(';')[0]);
|
||||
if (is_playlist) {
|
||||
this.router.navigate(['/player', {fileNames: name.join('|nvr|'), type: 'video'}]);
|
||||
} else {
|
||||
this.router.navigate(['/player', {type: 'video', uid: uid}]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// remove download from current downloads
|
||||
this.removeDownloadFromCurrentDownloads(new_download);
|
||||
}
|
||||
|
||||
// download click handler
|
||||
downloadClicked() {
|
||||
if (this.ValidURL(this.url)) {
|
||||
this.urlError = false;
|
||||
this.path = '';
|
||||
|
||||
// get common args
|
||||
const customArgs = (this.customArgsEnabled ? this.customArgs : null);
|
||||
const customOutput = (this.customOutputEnabled ? this.customOutput : null);
|
||||
const youtubeUsername = (this.youtubeAuthEnabled && this.youtubeUsername ? this.youtubeUsername : null);
|
||||
const youtubePassword = (this.youtubeAuthEnabled && this.youtubePassword ? this.youtubePassword : null);
|
||||
|
||||
// set advanced inputs
|
||||
if (this.allowAdvancedDownload) {
|
||||
if (customArgs) {
|
||||
localStorage.setItem('customArgs', customArgs);
|
||||
}
|
||||
if (customOutput) {
|
||||
localStorage.setItem('customOutput', customOutput);
|
||||
}
|
||||
if (youtubeUsername) {
|
||||
localStorage.setItem('youtubeUsername', youtubeUsername);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.audioOnly) {
|
||||
// create download object
|
||||
const new_download: Download = {
|
||||
uid: uuid(),
|
||||
type: 'audio',
|
||||
percent_complete: 0,
|
||||
url: this.url,
|
||||
downloading: true,
|
||||
is_playlist: this.url.includes('playlist'),
|
||||
error: false
|
||||
};
|
||||
this.downloads.push(new_download);
|
||||
if (!this.current_download && !this.multiDownloadMode) { this.current_download = new_download };
|
||||
this.downloadingfile = true;
|
||||
|
||||
let customQualityConfiguration = null;
|
||||
if (this.selectedQuality !== '') {
|
||||
customQualityConfiguration = this.getSelectedAudioFormat();
|
||||
}
|
||||
|
||||
this.postsService.makeMP3(this.url, (this.selectedQuality === '' ? null : this.selectedQuality),
|
||||
customQualityConfiguration, customArgs, customOutput, youtubeUsername, youtubePassword, new_download.uid).subscribe(posts => {
|
||||
// update download object
|
||||
new_download.downloading = false;
|
||||
new_download.percent_complete = 100;
|
||||
|
||||
const is_playlist = !!(posts['file_names']);
|
||||
this.path = is_playlist ? posts['file_names'] : posts['audiopathEncoded'];
|
||||
|
||||
this.current_download = null;
|
||||
|
||||
if (this.path !== '-1') {
|
||||
this.downloadHelperMp3(this.path, posts['uid'], is_playlist, false, new_download);
|
||||
}
|
||||
}, error => { // can't access server or failed to download for other reasons
|
||||
this.downloadingfile = false;
|
||||
this.current_download = null;
|
||||
new_download['downloading'] = false;
|
||||
// removes download from list of downloads
|
||||
const downloads_index = this.downloads.indexOf(new_download);
|
||||
if (downloads_index !== -1) {
|
||||
this.downloads.splice(downloads_index)
|
||||
}
|
||||
this.openSnackBar('Download failed!', 'OK.');
|
||||
});
|
||||
} else {
|
||||
// create download object
|
||||
const new_download: Download = {
|
||||
uid: uuid(),
|
||||
type: 'video',
|
||||
percent_complete: 0,
|
||||
url: this.url,
|
||||
downloading: true,
|
||||
is_playlist: this.url.includes('playlist'),
|
||||
error: false
|
||||
};
|
||||
this.downloads.push(new_download);
|
||||
if (!this.current_download && !this.multiDownloadMode) { this.current_download = new_download };
|
||||
this.downloadingfile = true;
|
||||
|
||||
const customQualityConfiguration = this.getSelectedVideoFormat();
|
||||
|
||||
this.postsService.makeMP4(this.url, (this.selectedQuality === '' ? null : this.selectedQuality),
|
||||
customQualityConfiguration, customArgs, customOutput, youtubeUsername, youtubePassword, new_download.uid).subscribe(posts => {
|
||||
// update download object
|
||||
new_download.downloading = false;
|
||||
new_download.percent_complete = 100;
|
||||
|
||||
const is_playlist = !!(posts['file_names']);
|
||||
this.path = is_playlist ? posts['file_names'] : posts['videopathEncoded'];
|
||||
|
||||
this.current_download = null;
|
||||
|
||||
if (this.path !== '-1') {
|
||||
this.downloadHelperMp4(this.path, posts['uid'], is_playlist, false, new_download);
|
||||
}
|
||||
}, error => { // can't access server
|
||||
this.downloadingfile = false;
|
||||
this.current_download = null;
|
||||
new_download['downloading'] = false;
|
||||
// removes download from list of downloads
|
||||
const downloads_index = this.downloads.indexOf(new_download);
|
||||
if (downloads_index !== -1) {
|
||||
this.downloads.splice(downloads_index)
|
||||
}
|
||||
this.openSnackBar('Download failed!', 'OK.');
|
||||
});
|
||||
}
|
||||
|
||||
if (this.multiDownloadMode) {
|
||||
this.url = '';
|
||||
this.downloadingfile = false;
|
||||
}
|
||||
} else {
|
||||
downloadClicked(): void {
|
||||
if (!this.ValidURL(this.url)) {
|
||||
this.urlError = true;
|
||||
return;
|
||||
}
|
||||
|
||||
this.urlError = false;
|
||||
|
||||
// get common args
|
||||
const customArgs = (this.customArgsEnabled && this.replaceArgs ? this.customArgs : null);
|
||||
const additionalArgs = (this.customArgsEnabled && !this.replaceArgs ? this.customArgs : null);
|
||||
const customOutput = (this.customOutputEnabled ? this.customOutput : null);
|
||||
const youtubeUsername = (this.youtubeAuthEnabled && this.youtubeUsername ? this.youtubeUsername : null);
|
||||
const youtubePassword = (this.youtubeAuthEnabled && this.youtubePassword ? this.youtubePassword : null);
|
||||
|
||||
// set advanced inputs
|
||||
if (this.allowAdvancedDownload) {
|
||||
if (customArgs) {
|
||||
localStorage.setItem('customArgs', customArgs);
|
||||
}
|
||||
if (customOutput) {
|
||||
localStorage.setItem('customOutput', customOutput);
|
||||
}
|
||||
if (youtubeUsername) {
|
||||
localStorage.setItem('youtubeUsername', youtubeUsername);
|
||||
}
|
||||
}
|
||||
|
||||
const type = this.audioOnly ? 'audio' : 'video';
|
||||
|
||||
const customQualityConfiguration = type === 'audio' ? this.getSelectedAudioFormat() : this.getSelectedVideoFormat();
|
||||
|
||||
let cropFileSettings = null;
|
||||
|
||||
if (this.cropFile) {
|
||||
cropFileSettings = {
|
||||
cropFileStart: this.cropFileStart,
|
||||
cropFileEnd: this.cropFileEnd
|
||||
}
|
||||
}
|
||||
|
||||
const selected_quality = this.selectedQuality;
|
||||
this.selectedQuality = '';
|
||||
|
||||
this.downloadingfile = true;
|
||||
this.postsService.downloadFile(this.url, type, (selected_quality === '' ? null : selected_quality),
|
||||
customQualityConfiguration, customArgs, additionalArgs, customOutput, youtubeUsername, youtubePassword, cropFileSettings).subscribe(res => {
|
||||
this.current_download = res['download'];
|
||||
this.downloads.push(res['download']);
|
||||
this.download_uids.push(res['download']['uid']);
|
||||
}, () => { // can't access server
|
||||
this.downloadingfile = false;
|
||||
this.current_download = null;
|
||||
this.postsService.openSnackBar('Download failed!', 'OK.');
|
||||
});
|
||||
|
||||
if (!this.autoplay) {
|
||||
const download_queued_message = $localize`Download for ${this.url}:url: has been queued!`;
|
||||
this.postsService.openSnackBar(download_queued_message);
|
||||
this.url = '';
|
||||
this.downloadingfile = false;
|
||||
}
|
||||
}
|
||||
|
||||
// download canceled handler
|
||||
cancelDownload(download_to_cancel = null) {
|
||||
cancelDownload(download_to_cancel = null): void {
|
||||
// if one is provided, cancel that one. otherwise, remove the current one
|
||||
if (download_to_cancel) {
|
||||
this.removeDownloadFromCurrentDownloads(download_to_cancel)
|
||||
@@ -569,30 +439,32 @@ export class MainComponent implements OnInit {
|
||||
this.current_download = null;
|
||||
}
|
||||
|
||||
getSelectedAudioFormat() {
|
||||
if (this.selectedQuality === '') { return null };
|
||||
getSelectedAudioFormat(): string {
|
||||
if (this.selectedQuality === '') { return null; }
|
||||
const cachedFormatsExists = this.cachedAvailableFormats[this.url] && this.cachedAvailableFormats[this.url]['formats'];
|
||||
if (cachedFormatsExists) {
|
||||
const audio_formats = this.cachedAvailableFormats[this.url]['formats']['audio'];
|
||||
return audio_formats[this.selectedQuality]['format_id'];
|
||||
return this.selectedQuality['format_id'];
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
getSelectedVideoFormat() {
|
||||
if (this.selectedQuality === '') { return null };
|
||||
const cachedFormatsExists = this.cachedAvailableFormats[this.url] && this.cachedAvailableFormats[this.url]['formats'];
|
||||
if (cachedFormatsExists) {
|
||||
const video_formats = this.cachedAvailableFormats[this.url]['formats']['video'];
|
||||
if (video_formats['best_audio_format'] && this.selectedQuality !== '') {
|
||||
return video_formats[this.selectedQuality]['format_id'] + '+' + video_formats['best_audio_format'];
|
||||
getSelectedVideoFormat(): string {
|
||||
if (this.selectedQuality === '') { return null; }
|
||||
const cachedFormats = this.cachedAvailableFormats[this.url] && this.cachedAvailableFormats[this.url]['formats'];
|
||||
if (cachedFormats) {
|
||||
if (this.selectedQuality) {
|
||||
let selected_video_format = this.selectedQuality['format_id'];
|
||||
// add in audio format if necessary
|
||||
const audio_missing = !this.selectedQuality['acodec'] || this.selectedQuality['acodec'] === 'none';
|
||||
if (audio_missing && cachedFormats['best_audio_format']) selected_video_format += `+${cachedFormats['best_audio_format']}`;
|
||||
return selected_video_format;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
getDownloadByUID(uid) {
|
||||
getDownloadByUID(uid: string) {
|
||||
const index = this.downloads.findIndex(download => download.uid === uid);
|
||||
if (index !== -1) {
|
||||
return this.downloads[index];
|
||||
@@ -601,7 +473,7 @@ export class MainComponent implements OnInit {
|
||||
}
|
||||
}
|
||||
|
||||
removeDownloadFromCurrentDownloads(download_to_remove) {
|
||||
removeDownloadFromCurrentDownloads(download_to_remove): boolean {
|
||||
if (this.current_download === download_to_remove) {
|
||||
this.current_download = null;
|
||||
}
|
||||
@@ -614,64 +486,49 @@ export class MainComponent implements OnInit {
|
||||
}
|
||||
}
|
||||
|
||||
downloadAudioFile(name) {
|
||||
this.downloading_content['audio'][name] = true;
|
||||
this.postsService.downloadFileFromServer(name, 'audio').subscribe(res => {
|
||||
this.downloading_content['audio'][name] = false;
|
||||
downloadFileFromServer(file, type: string): void {
|
||||
const ext = type === 'audio' ? 'mp3' : 'mp4'
|
||||
this.downloading_content[type][file.id] = true;
|
||||
this.postsService.downloadFileFromServer(file.uid).subscribe(res => {
|
||||
this.downloading_content[type][file.id] = false;
|
||||
const blob: Blob = res;
|
||||
saveAs(blob, decodeURIComponent(name) + '.mp3');
|
||||
saveAs(blob, decodeURIComponent(file.id) + `.${ext}`);
|
||||
|
||||
if (!this.fileManagerEnabled) {
|
||||
// tell server to delete the file once downloaded
|
||||
this.postsService.deleteFile(name, 'video').subscribe(delRes => {
|
||||
});
|
||||
this.postsService.deleteFile(file.uid).subscribe(() => {});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
downloadVideoFile(name) {
|
||||
this.downloading_content['video'][name] = true;
|
||||
this.postsService.downloadFileFromServer(name, 'video').subscribe(res => {
|
||||
this.downloading_content['video'][name] = false;
|
||||
downloadPlaylist(playlist): void {
|
||||
this.postsService.downloadPlaylistFromServer(playlist.id).subscribe(res => {
|
||||
if (playlist.id) { this.downloading_content[playlist.type][playlist.id] = false };
|
||||
const blob: Blob = res;
|
||||
saveAs(blob, decodeURIComponent(name) + '.mp4');
|
||||
|
||||
if (!this.fileManagerEnabled) {
|
||||
// tell server to delete the file once downloaded
|
||||
this.postsService.deleteFile(name, 'audio').subscribe(delRes => {
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
downloadPlaylist(fileNames, type, zipName = null, playlistID = null) {
|
||||
this.postsService.downloadFileFromServer(fileNames, type, zipName).subscribe(res => {
|
||||
if (playlistID) { this.downloading_content[type][playlistID] = false };
|
||||
const blob: Blob = res;
|
||||
saveAs(blob, zipName + '.zip');
|
||||
saveAs(blob, playlist.name + '.zip');
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
clearInput() {
|
||||
clearInput(): void {
|
||||
this.url = '';
|
||||
this.results_showing = false;
|
||||
}
|
||||
|
||||
onInputBlur() {
|
||||
onInputBlur(): void {
|
||||
this.results_showing = false;
|
||||
}
|
||||
|
||||
visitURL(url) {
|
||||
visitURL(url: string): void {
|
||||
window.open(url);
|
||||
}
|
||||
|
||||
useURL(url) {
|
||||
useURL(url: string): void {
|
||||
this.results_showing = false;
|
||||
this.url = url;
|
||||
}
|
||||
|
||||
inputChanged(new_val) {
|
||||
inputChanged(new_val: string): void {
|
||||
if (new_val === '' || !new_val) {
|
||||
this.results_showing = false;
|
||||
} else {
|
||||
@@ -682,7 +539,7 @@ export class MainComponent implements OnInit {
|
||||
}
|
||||
|
||||
// checks if url is a valid URL
|
||||
ValidURL(str) {
|
||||
ValidURL(str: string): boolean {
|
||||
// tslint:disable-next-line: max-line-length
|
||||
const strRegex = /((([A-Za-z]{3,9}:(?:\/\/)?)(?:[-;:&=\+\$,\w]+@)?[A-Za-z0-9.-]+|(?:www.|[-;:&=\+\$,\w]+@)[A-Za-z0-9.-]+)((?:\/[\+~%\/.\w-_]*)?\??(?:[-\+=&;%@.\w_]*)#?(?:[\w]*))?)/;
|
||||
const re = new RegExp(strRegex);
|
||||
@@ -698,20 +555,14 @@ export class MainComponent implements OnInit {
|
||||
if (str !== this.last_valid_url && this.allowQualitySelect) {
|
||||
// get info
|
||||
this.getURLInfo(str);
|
||||
this.argsChanged();
|
||||
}
|
||||
this.last_valid_url = str;
|
||||
}
|
||||
return valid;
|
||||
}
|
||||
|
||||
// snackbar helper
|
||||
public openSnackBar(message: string, action: string) {
|
||||
this.snackBar.open(message, action, {
|
||||
duration: 2000,
|
||||
});
|
||||
}
|
||||
|
||||
getURLInfo(url) {
|
||||
getURLInfo(url: string): void {
|
||||
// if url is a youtube playlist, skip getting url info
|
||||
if (url.includes('playlist')) {
|
||||
return;
|
||||
@@ -721,103 +572,62 @@ export class MainComponent implements OnInit {
|
||||
}
|
||||
if (!(this.cachedAvailableFormats[url] && this.cachedAvailableFormats[url]['formats'])) {
|
||||
this.cachedAvailableFormats[url]['formats_loading'] = true;
|
||||
this.postsService.getFileInfo([url], 'irrelevant', true).subscribe(res => {
|
||||
this.postsService.getFileFormats([url]).subscribe(res => {
|
||||
this.cachedAvailableFormats[url]['formats_loading'] = false;
|
||||
const infos = res['result'];
|
||||
if (!infos || !infos.formats) {
|
||||
this.errorFormats(url);
|
||||
return;
|
||||
}
|
||||
const parsed_infos = this.getAudioAndVideoFormats(infos.formats);
|
||||
const available_formats = {audio: parsed_infos[0], video: parsed_infos[1]};
|
||||
this.cachedAvailableFormats[url]['formats'] = available_formats;
|
||||
}, err => {
|
||||
this.cachedAvailableFormats[url]['formats'] = this.getAudioAndVideoFormats(infos.formats);
|
||||
}, () => {
|
||||
this.errorFormats(url);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
getSimulatedOutput() {
|
||||
const customArgsExists = this.customArgsEnabled && this.customArgs;
|
||||
const globalArgsExists = this.globalCustomArgs && this.globalCustomArgs !== '';
|
||||
getSimulatedOutput(): void {
|
||||
// this function should be very similar to downloadClicked()
|
||||
const customArgs = (this.customArgsEnabled && this.replaceArgs ? this.customArgs : null);
|
||||
const additionalArgs = (this.customArgsEnabled && !this.replaceArgs ? this.customArgs : null);
|
||||
const customOutput = (this.customOutputEnabled ? this.customOutput : null);
|
||||
const youtubeUsername = (this.youtubeAuthEnabled && this.youtubeUsername ? this.youtubeUsername : null);
|
||||
const youtubePassword = (this.youtubeAuthEnabled && this.youtubePassword ? this.youtubePassword : null);
|
||||
|
||||
let full_string_array: string[] = [];
|
||||
const base_string_array = ['youtube-dl', this.url];
|
||||
const type = this.audioOnly ? 'audio' : 'video';
|
||||
|
||||
if (customArgsExists) {
|
||||
this.simulatedOutput = base_string_array.join(' ') + ' ' + this.customArgs.split(',,').join(' ');
|
||||
return this.simulatedOutput;
|
||||
}
|
||||
const customQualityConfiguration = type === 'audio' ? this.getSelectedAudioFormat() : this.getSelectedVideoFormat();
|
||||
|
||||
full_string_array.push(...base_string_array);
|
||||
let cropFileSettings = null;
|
||||
|
||||
const base_path = this.audioOnly ? this.audioFolderPath : this.videoFolderPath;
|
||||
const ext = this.audioOnly ? '.mp3' : '.mp4';
|
||||
// gets output
|
||||
let output_string_array = ['-o', base_path + '%(title)s' + ext];
|
||||
if (this.customOutputEnabled && this.customOutput) {
|
||||
output_string_array = ['-o', base_path + this.customOutput + ext];
|
||||
}
|
||||
// before pushing output, should check if using an external downloader
|
||||
if (!this.useDefaultDownloadingAgent && this.customDownloadingAgent === 'aria2c') {
|
||||
full_string_array.push('--external-downloader', 'aria2c');
|
||||
}
|
||||
// pushes output
|
||||
full_string_array.push(...output_string_array);
|
||||
|
||||
// logic splits into audio and video modes
|
||||
if (this.audioOnly) {
|
||||
// adds base audio string
|
||||
const format_array = [];
|
||||
const audio_format = this.getSelectedAudioFormat();
|
||||
if (audio_format) {
|
||||
format_array.push('-f', audio_format);
|
||||
} else if (this.selectedQuality) {
|
||||
format_array.push('--audio-quality', this.selectedQuality);
|
||||
if (this.cropFile) {
|
||||
cropFileSettings = {
|
||||
cropFileStart: this.cropFileStart,
|
||||
cropFileEnd: this.cropFileEnd
|
||||
}
|
||||
|
||||
// pushes formats
|
||||
full_string_array.splice(2, 0, ...format_array);
|
||||
|
||||
const additional_params = ['-x', '--audio-format', 'mp3', '--write-info-json', '--print-json'];
|
||||
|
||||
full_string_array.push(...additional_params);
|
||||
} else {
|
||||
// adds base video string
|
||||
let format_array = ['-f', 'bestvideo[ext=mp4]+bestaudio[ext=m4a]/mp4'];
|
||||
const video_format = this.getSelectedVideoFormat();
|
||||
if (video_format) {
|
||||
format_array = ['-f', video_format];
|
||||
} else if (this.selectedQuality) {
|
||||
format_array = [`bestvideo[height=${this.selectedQuality}]+bestaudio/best[height=${this.selectedQuality}]`];
|
||||
}
|
||||
|
||||
// pushes formats
|
||||
full_string_array.splice(2, 0, ...format_array);
|
||||
|
||||
const additional_params = ['--write-info-json', '--print-json'];
|
||||
|
||||
full_string_array.push(...additional_params);
|
||||
}
|
||||
|
||||
if (this.use_youtubedl_archive) {
|
||||
full_string_array.push('--download-archive', 'archive.txt');
|
||||
}
|
||||
|
||||
if (globalArgsExists) {
|
||||
full_string_array = full_string_array.concat(this.globalCustomArgs.split(',,'));
|
||||
}
|
||||
|
||||
this.simulatedOutput = full_string_array.join(' ');
|
||||
return this.simulatedOutput;
|
||||
this.postsService.generateArgs(this.url, type, (this.selectedQuality === '' ? null : this.selectedQuality),
|
||||
customQualityConfiguration, customArgs, additionalArgs, customOutput, youtubeUsername, youtubePassword, cropFileSettings).subscribe(res => {
|
||||
const simulated_args = res['args'];
|
||||
if (simulated_args) {
|
||||
// hide password if needed
|
||||
const passwordIndex = simulated_args.indexOf('--password');
|
||||
console.log(passwordIndex);
|
||||
if (passwordIndex !== -1 && passwordIndex !== simulated_args.length - 1) {
|
||||
simulated_args[passwordIndex + 1] = simulated_args[passwordIndex + 1].replace(/./g, '*');
|
||||
}
|
||||
this.simulatedOutput = `youtube-dl ${this.url} ${simulated_args.join(' ')}`;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
errorFormats(url) {
|
||||
errorFormats(url: string): void {
|
||||
this.cachedAvailableFormats[url]['formats_loading'] = false;
|
||||
console.error('Could not load formats for url ' + url);
|
||||
}
|
||||
|
||||
attachToInput() {
|
||||
attachToInput(): void {
|
||||
Observable.fromEvent(this.urlInput.nativeElement, 'keyup')
|
||||
.map((e: any) => e.target.value) // extract the value of input
|
||||
.filter((text: string) => text.length > 1) // filter out if empty
|
||||
@@ -846,49 +656,43 @@ export class MainComponent implements OnInit {
|
||||
);
|
||||
}
|
||||
|
||||
onResize(event) {
|
||||
this.setCols();
|
||||
argsChanged(): void {
|
||||
this.argsChangedSubject.next(true);
|
||||
}
|
||||
|
||||
videoModeChanged(new_val) {
|
||||
videoModeChanged(new_val): void {
|
||||
this.selectedQuality = '';
|
||||
localStorage.setItem('audioOnly', new_val.checked.toString());
|
||||
this.argsChanged();
|
||||
}
|
||||
|
||||
multiDownloadModeChanged(new_val) {
|
||||
localStorage.setItem('multiDownloadMode', new_val.checked.toString());
|
||||
autoplayChanged(new_val): void {
|
||||
localStorage.setItem('autoplay', new_val.checked.toString());
|
||||
}
|
||||
|
||||
customArgsEnabledChanged(new_val) {
|
||||
customArgsEnabledChanged(new_val): void {
|
||||
localStorage.setItem('customArgsEnabled', new_val.checked.toString());
|
||||
if (new_val.checked === true && this.customOutputEnabled) {
|
||||
this.customOutputEnabled = false;
|
||||
localStorage.setItem('customOutputEnabled', 'false');
|
||||
|
||||
this.youtubeAuthEnabled = false;
|
||||
localStorage.setItem('youtubeAuthEnabled', 'false');
|
||||
}
|
||||
this.argsChanged();
|
||||
}
|
||||
|
||||
customOutputEnabledChanged(new_val) {
|
||||
replaceArgsChanged(new_val): void {
|
||||
localStorage.setItem('replaceArgs', new_val.checked.toString());
|
||||
this.argsChanged();
|
||||
}
|
||||
|
||||
customOutputEnabledChanged(new_val): void {
|
||||
localStorage.setItem('customOutputEnabled', new_val.checked.toString());
|
||||
if (new_val.checked === true && this.customArgsEnabled) {
|
||||
this.customArgsEnabled = false;
|
||||
localStorage.setItem('customArgsEnabled', 'false');
|
||||
}
|
||||
this.argsChanged();
|
||||
}
|
||||
|
||||
youtubeAuthEnabledChanged(new_val) {
|
||||
youtubeAuthEnabledChanged(new_val): void {
|
||||
localStorage.setItem('youtubeAuthEnabled', new_val.checked.toString());
|
||||
if (new_val.checked === true && this.customArgsEnabled) {
|
||||
this.customArgsEnabled = false;
|
||||
localStorage.setItem('customArgsEnabled', 'false');
|
||||
}
|
||||
this.argsChanged();
|
||||
}
|
||||
|
||||
getAudioAndVideoFormats(formats): any[] {
|
||||
const audio_formats = {};
|
||||
const video_formats = {};
|
||||
getAudioAndVideoFormats(formats): void {
|
||||
const audio_formats: any = {};
|
||||
const video_formats: any = {};
|
||||
|
||||
for (let i = 0; i < formats.length; i++) {
|
||||
const format_obj = {type: null};
|
||||
@@ -899,9 +703,12 @@ export class MainComponent implements OnInit {
|
||||
format_obj.type = format_type;
|
||||
if (format_obj.type === 'audio' && format.abr) {
|
||||
const key = format.abr.toString() + 'K';
|
||||
format_obj['key'] = key;
|
||||
format_obj['bitrate'] = format.abr;
|
||||
format_obj['format_id'] = format.format_id;
|
||||
format_obj['ext'] = format.ext;
|
||||
format_obj['label'] = key;
|
||||
|
||||
// don't overwrite if not m4a
|
||||
if (audio_formats[key]) {
|
||||
if (format.ext === 'm4a') {
|
||||
@@ -912,11 +719,14 @@ export class MainComponent implements OnInit {
|
||||
}
|
||||
} else if (format_obj.type === 'video') {
|
||||
// check if video format is mp4
|
||||
const key = format.format_note.replace('p', '');
|
||||
const key = `${format.height}p${Math.round(format.fps)}`;
|
||||
if (format.ext === 'mp4' || format.ext === 'mkv' || format.ext === 'webm') {
|
||||
format_obj['key'] = key;
|
||||
format_obj['height'] = format.height;
|
||||
format_obj['acodec'] = format.acodec;
|
||||
format_obj['format_id'] = format.format_id;
|
||||
format_obj['label'] = key;
|
||||
format_obj['fps'] = Math.round(format.fps);
|
||||
|
||||
// no acodec means no overwrite
|
||||
if (!(video_formats[key]) || format_obj['acodec'] !== 'none') {
|
||||
@@ -926,12 +736,20 @@ export class MainComponent implements OnInit {
|
||||
}
|
||||
}
|
||||
|
||||
video_formats['best_audio_format'] = this.getBestAudioFormatForMp4(audio_formats);
|
||||
const parsed_formats: any = {};
|
||||
|
||||
return [audio_formats, video_formats]
|
||||
parsed_formats['best_audio_format'] = this.getBestAudioFormatForMp4(audio_formats);
|
||||
|
||||
parsed_formats['video'] = Object.values(video_formats);
|
||||
parsed_formats['audio'] = Object.values(audio_formats);
|
||||
|
||||
parsed_formats['video'] = parsed_formats['video'].sort((a, b) => b.height - a.height || b.fps - a.fps);
|
||||
parsed_formats['audio'] = parsed_formats['audio'].sort((a, b) => b.bitrate - a.bitrate);
|
||||
|
||||
return parsed_formats;
|
||||
}
|
||||
|
||||
getBestAudioFormatForMp4(audio_formats) {
|
||||
getBestAudioFormatForMp4(audio_formats): void {
|
||||
let best_audio_format_for_mp4 = null;
|
||||
let best_audio_format_bitrate = 0;
|
||||
const available_audio_format_keys = Object.keys(audio_formats);
|
||||
@@ -947,46 +765,8 @@ export class MainComponent implements OnInit {
|
||||
return best_audio_format_for_mp4;
|
||||
}
|
||||
|
||||
accordionEntered(type) {
|
||||
if (type === 'audio') {
|
||||
audioFilesMouseHovering = true;
|
||||
this.audioFileCards.forEach(filecard => {
|
||||
filecard.onHoverResponse();
|
||||
});
|
||||
} else if (type === 'video') {
|
||||
videoFilesMouseHovering = true;
|
||||
this.videoFileCards.forEach(filecard => {
|
||||
filecard.onHoverResponse();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
accordionLeft(type) {
|
||||
if (type === 'audio') {
|
||||
audioFilesMouseHovering = false;
|
||||
} else if (type === 'video') {
|
||||
videoFilesMouseHovering = false;
|
||||
}
|
||||
}
|
||||
|
||||
accordionOpened(type) {
|
||||
if (type === 'audio') {
|
||||
audioFilesOpened = true;
|
||||
} else if (type === 'video') {
|
||||
videoFilesOpened = true;
|
||||
}
|
||||
}
|
||||
|
||||
accordionClosed(type) {
|
||||
if (type === 'audio') {
|
||||
audioFilesOpened = false;
|
||||
} else if (type === 'video') {
|
||||
videoFilesOpened = false;
|
||||
}
|
||||
}
|
||||
|
||||
// modify custom args
|
||||
openArgsModifierDialog() {
|
||||
openArgsModifierDialog(): void {
|
||||
const dialogRef = this.dialog.open(ArgModifierDialogComponent, {
|
||||
data: {
|
||||
initial_args: this.customArgs
|
||||
@@ -999,16 +779,24 @@ export class MainComponent implements OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
getCurrentDownload() {
|
||||
getCurrentDownload(): void {
|
||||
if (!this.current_download) {
|
||||
return;
|
||||
}
|
||||
const ui_uid = this.current_download['ui_uid'] ? this.current_download['ui_uid'] : this.current_download['uid'];
|
||||
this.postsService.getCurrentDownload(this.postsService.session_id, ui_uid).subscribe(res => {
|
||||
this.postsService.getCurrentDownload(this.current_download['uid']).subscribe(res => {
|
||||
if (res['download']) {
|
||||
if (ui_uid === res['download']['ui_uid']) {
|
||||
this.current_download = res['download'];
|
||||
this.percentDownloaded = this.current_download.percent_complete;
|
||||
this.current_download = res['download'];
|
||||
this.percentDownloaded = this.current_download.percent_complete;
|
||||
|
||||
if (this.current_download['finished'] && !this.current_download['error']) {
|
||||
const container = this.current_download['container'];
|
||||
const is_playlist = this.current_download['file_uids'].length > 1;
|
||||
this.downloadHelper(container, this.current_download['type'], is_playlist, false);
|
||||
this.current_download = null;
|
||||
} else if (this.current_download['finished'] && this.current_download['error']) {
|
||||
this.downloadingfile = false;
|
||||
this.current_download = null;
|
||||
this.postsService.openSnackBar('Download failed!', 'OK.');
|
||||
}
|
||||
} else {
|
||||
// console.log('failed to get new download');
|
||||
@@ -1016,9 +804,7 @@ export class MainComponent implements OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
reloadRecentVideos() {
|
||||
if (this.recentVideos) {
|
||||
this.recentVideos.getAllFiles();
|
||||
}
|
||||
reloadRecentVideos(): void {
|
||||
this.postsService.files_changed.next(true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
|
||||
.audio-styles {
|
||||
height: 50px;
|
||||
background-color: transparent;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@@ -90,4 +89,10 @@
|
||||
display: inline-block;
|
||||
margin-right: 12px;
|
||||
top: 8px;
|
||||
}
|
||||
|
||||
.skip-ad-button {
|
||||
position: absolute;
|
||||
right: 20px;
|
||||
bottom: 75px;
|
||||
}
|
||||
@@ -1,14 +1,15 @@
|
||||
<div style="height: 100%" *ngIf="playlist.length > 0 && show_player">
|
||||
<div style="height: 100%" [ngClass]="(type === 'audio') ? null : 'container-video'">
|
||||
<div style="height: 100%" [ngClass]="(currentItem.type === 'audio/mp3') ? null : 'container-video'">
|
||||
<div style="max-width: 100%; margin-left: 0px; height: 100%">
|
||||
<mat-drawer-container style="height: 100%" class="example-container" autosize>
|
||||
<div style="height: fit-content" [ngClass]="(type === 'audio') ? 'audio-col' : 'video-col'">
|
||||
<vg-player style="height: fit-content; max-height: 75vh" (onPlayerReady)="onPlayerReady($event)" [style.background-color]="(type === 'audio') ? 'transparent' : 'black'">
|
||||
<video [ngClass]="(type === 'audio') ? 'audio-styles' : 'video-styles'" #media class="video-player" [vgMedia]="media" [src]="currentItem.src" id="singleVideo" preload="auto" controls>
|
||||
<div style="height: fit-content" [ngClass]="(currentItem.type === 'audio/mp3') ? 'audio-col' : 'video-col'">
|
||||
<vg-player style="height: fit-content; max-height: 75vh" (onPlayerReady)="onPlayerReady($event)" [style.background-color]="(currentItem.type === 'audio/mp3') ? postsService.theme.drawer_color : 'black'">
|
||||
<video [ngClass]="(currentItem.type === 'audio/mp3') ? 'audio-styles' : 'video-styles'" #media class="video-player" [vgMedia]="media" [src]="currentItem.src" id="singleVideo" preload="auto" controls playsinline>
|
||||
</video>
|
||||
<app-skip-ad-button *ngIf="postsService['config']['API']['use_sponsorblock_API'] && api && playlist?.length > 0 && playlist[currentIndex]['type'] === 'video/mp4'" (setPlaybackTimestamp)="setPlaybackTimestamp($event)" [current_video]="playlist[currentIndex]" [playback_timestamp]="api.currentTime" [sponsor_block_cache]="sponsor_block_cache" class="skip-ad-button"></app-skip-ad-button>
|
||||
</vg-player>
|
||||
</div>
|
||||
<div *ngIf="db_file" style="height: fit-content; width: 100%; margin-top: 10px;">
|
||||
<div style="height: fit-content; width: 100%; margin-top: 10px;">
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div class="col-2 col-lg-1">
|
||||
@@ -27,14 +28,14 @@
|
||||
</ng-container>
|
||||
</div>
|
||||
<div class="col-2">
|
||||
<ng-container *ngIf="playlist.length > 1">
|
||||
<ng-container *ngIf="db_playlist">
|
||||
<button (click)="downloadContent()" [disabled]="downloading" mat-icon-button><mat-icon>save</mat-icon><mat-spinner *ngIf="downloading" class="spinner" [diameter]="35"></mat-spinner></button>
|
||||
<button *ngIf="!id" color="accent" (click)="namePlaylistDialog()" mat-icon-button><mat-icon>favorite</mat-icon></button>
|
||||
<button *ngIf="!is_shared && id && (!postsService.isLoggedIn || postsService.permissions.includes('sharing'))" (click)="openShareDialog()" mat-icon-button><mat-icon>share</mat-icon></button>
|
||||
<button *ngIf="(!postsService.isLoggedIn || postsService.permissions.includes('sharing')) && !auto" (click)="openShareDialog()" mat-icon-button><mat-icon>share</mat-icon></button>
|
||||
</ng-container>
|
||||
<ng-container *ngIf="playlist.length === 1">
|
||||
<ng-container *ngIf="db_file">
|
||||
<button (click)="downloadFile()" [disabled]="downloading" mat-icon-button><mat-icon>save</mat-icon><mat-spinner *ngIf="downloading" class="spinner" [diameter]="35"></mat-spinner></button>
|
||||
<button *ngIf="!is_shared && uid && uid !== 'false' && type !== 'subscription' && (!postsService.isLoggedIn || postsService.permissions.includes('sharing'))" (click)="openShareDialog()" mat-icon-button><mat-icon>share</mat-icon></button>
|
||||
<button *ngIf="type !== 'subscription' && (!postsService.isLoggedIn || postsService.permissions.includes('sharing'))" (click)="openShareDialog()" mat-icon-button><mat-icon>share</mat-icon></button>
|
||||
<button (click)="openFileInfoDialog()" *ngIf="db_file" mat-icon-button><mat-icon>info</mat-icon></button>
|
||||
</ng-container>
|
||||
<button *ngIf="db_file && db_file.url.includes('twitch.tv/videos/') && postsService['config']['API']['use_twitch_API']" (click)="drawer.toggle()" mat-icon-button><mat-icon>chat</mat-icon></button>
|
||||
</div>
|
||||
@@ -46,19 +47,22 @@
|
||||
<mat-button-toggle cdkDrag *ngFor="let playlist_item of playlist; let i = index" [checked]="currentItem.title === playlist_item.title" (click)="onClickPlaylistItem(playlist_item, i)" class="toggle-button" [value]="playlist_item.title">{{playlist_item.label}}</mat-button-toggle>
|
||||
</mat-button-toggle-group>
|
||||
</div>
|
||||
|
||||
<app-concurrent-stream *ngIf="db_file && api && postsService.config" (setPlaybackRate)="setPlaybackRate($event)" (togglePlayback)="togglePlayback($event)" (setPlaybackTimestamp)="setPlaybackTimestamp($event)" [playing]="api.state === 'playing'" [uid]="uid" [playback_timestamp]="api.time.current/1000" [server_mode]="!postsService.config.Advanced.multi_user_mode || postsService.isLoggedIn"></app-concurrent-stream>
|
||||
|
||||
<mat-drawer #drawer class="example-sidenav" mode="side" position="end" [opened]="db_file && db_file['chat_exists'] && postsService['config']['API']['use_twitch_API']">
|
||||
<ng-container *ngIf="api_ready && db_file && db_file.url.includes('twitch.tv/videos/')">
|
||||
<app-twitch-chat #twitchchat [db_file]="db_file" [current_timestamp]="api.currentTime" [sub]="subscription"></app-twitch-chat>
|
||||
</ng-container>
|
||||
</mat-drawer>
|
||||
|
||||
<div class="update-playlist-button-div" *ngIf="id && playlistChanged()">
|
||||
<!-- <div class="update-playlist-button-div" *ngIf="id && playlistChanged()">
|
||||
<div class="spinner-div">
|
||||
<mat-spinner *ngIf="playlist_updating" [diameter]="25"></mat-spinner>
|
||||
</div>
|
||||
<button color="primary" [disabled]="playlist_updating" (click)="updatePlaylist()" mat-raised-button><ng-container i18n="Playlist save changes button">Save changes</ng-container> <mat-icon>update</mat-icon></button>
|
||||
|
||||
</div>
|
||||
</div> -->
|
||||
</mat-drawer-container>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
import { Component, OnInit, HostListener, EventEmitter, OnDestroy, AfterViewInit, ViewChild } from '@angular/core';
|
||||
import { Component, OnInit, HostListener, OnDestroy, AfterViewInit, ViewChild, ChangeDetectorRef } from '@angular/core';
|
||||
import { VgApiService } from '@videogular/ngx-videogular/core';
|
||||
import { PostsService } from 'app/posts.services';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { InputDialogComponent } from 'app/input-dialog/input-dialog.component';
|
||||
import { CdkDragDrop, moveItemInArray } from '@angular/cdk/drag-drop';
|
||||
import { ShareMediaDialogComponent } from '../dialogs/share-media-dialog/share-media-dialog.component';
|
||||
import { TwitchChatComponent } from 'app/components/twitch-chat/twitch-chat.component';
|
||||
import { VideoInfoDialogComponent } from 'app/dialogs/video-info-dialog/video-info-dialog.component';
|
||||
|
||||
export interface IMedia {
|
||||
title: string;
|
||||
src: string;
|
||||
type: string;
|
||||
label: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
@@ -35,17 +36,16 @@ export class PlayerComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
api_ready = false;
|
||||
|
||||
// params
|
||||
fileNames: string[];
|
||||
uids: string[];
|
||||
type: string;
|
||||
id = null; // used for playlists (not subscription)
|
||||
playlist_id = null; // used for playlists (not subscription)
|
||||
uid = null; // used for non-subscription files (audio, video, playlist)
|
||||
subscription = null;
|
||||
subscriptionName = null;
|
||||
sub_id = null;
|
||||
subPlaylist = null;
|
||||
uuid = null; // used for sharing in multi-user mode, uuid is the user that downloaded the video
|
||||
timestamp = null;
|
||||
|
||||
is_shared = false;
|
||||
auto = null;
|
||||
|
||||
db_playlist = null;
|
||||
db_file = null;
|
||||
@@ -55,8 +55,6 @@ export class PlayerComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
videoFolderPath = null;
|
||||
subscriptionFolderPath = null;
|
||||
|
||||
sharingEnabled = null;
|
||||
|
||||
// url-mode params
|
||||
url = null;
|
||||
name = null;
|
||||
@@ -78,15 +76,14 @@ export class PlayerComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
ngOnInit(): void {
|
||||
this.innerWidth = window.innerWidth;
|
||||
|
||||
this.type = this.route.snapshot.paramMap.get('type');
|
||||
this.id = this.route.snapshot.paramMap.get('id');
|
||||
this.playlist_id = this.route.snapshot.paramMap.get('playlist_id');
|
||||
this.uid = this.route.snapshot.paramMap.get('uid');
|
||||
this.subscriptionName = this.route.snapshot.paramMap.get('subscriptionName');
|
||||
this.subPlaylist = this.route.snapshot.paramMap.get('subPlaylist');
|
||||
this.sub_id = this.route.snapshot.paramMap.get('sub_id');
|
||||
this.url = this.route.snapshot.paramMap.get('url');
|
||||
this.name = this.route.snapshot.paramMap.get('name');
|
||||
this.uuid = this.route.snapshot.paramMap.get('uuid');
|
||||
this.timestamp = this.route.snapshot.paramMap.get('timestamp');
|
||||
this.auto = this.route.snapshot.paramMap.get('auto');
|
||||
|
||||
// loading config
|
||||
if (this.postsService.initialized) {
|
||||
@@ -101,6 +98,7 @@ export class PlayerComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
}
|
||||
|
||||
ngAfterViewInit() {
|
||||
this.cdr.detectChanges();
|
||||
this.postsService.sidenav.close();
|
||||
}
|
||||
|
||||
@@ -110,7 +108,7 @@ export class PlayerComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
}
|
||||
|
||||
constructor(public postsService: PostsService, private route: ActivatedRoute, private dialog: MatDialog, private router: Router,
|
||||
public snackBar: MatSnackBar) {
|
||||
public snackBar: MatSnackBar, private cdr: ChangeDetectorRef) {
|
||||
|
||||
}
|
||||
|
||||
@@ -119,19 +117,14 @@ export class PlayerComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
this.audioFolderPath = this.postsService.config['Downloader']['path-audio'];
|
||||
this.videoFolderPath = this.postsService.config['Downloader']['path-video'];
|
||||
this.subscriptionFolderPath = this.postsService.config['Subscriptions']['subscriptions_base_path'];
|
||||
this.fileNames = this.route.snapshot.paramMap.get('fileNames') ? this.route.snapshot.paramMap.get('fileNames').split('|nvr|') : null;
|
||||
|
||||
if (!this.fileNames && !this.type) {
|
||||
this.is_shared = true;
|
||||
}
|
||||
|
||||
if (this.uid && !this.id) {
|
||||
this.getFile();
|
||||
} else if (this.id) {
|
||||
this.getPlaylistFiles();
|
||||
} else if (this.subscriptionName) {
|
||||
if (this.sub_id) {
|
||||
this.getSubscription();
|
||||
}
|
||||
} else if (this.playlist_id) {
|
||||
this.getPlaylistFiles();
|
||||
} else if (this.uid) {
|
||||
this.getFile();
|
||||
}
|
||||
|
||||
if (this.url) {
|
||||
// if a url is given, just stream the URL
|
||||
@@ -140,20 +133,17 @@ export class PlayerComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
title: this.name,
|
||||
label: this.name,
|
||||
src: this.url,
|
||||
type: 'video/mp4'
|
||||
type: 'video/mp4',
|
||||
url: this.url
|
||||
}
|
||||
this.playlist.push(imedia);
|
||||
this.currentItem = this.playlist[0];
|
||||
this.currentIndex = 0;
|
||||
this.show_player = true;
|
||||
} else if (this.fileNames && !this.subscriptionName) {
|
||||
this.show_player = true;
|
||||
this.parseFileNames();
|
||||
}
|
||||
}
|
||||
|
||||
getFile() {
|
||||
const already_has_filenames = !!this.fileNames;
|
||||
this.postsService.getFile(this.uid, null, this.uuid).subscribe(res => {
|
||||
this.db_file = res['file'];
|
||||
if (!this.db_file) {
|
||||
@@ -164,57 +154,31 @@ export class PlayerComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
console.error('Failed to increment view count');
|
||||
console.error(err);
|
||||
});
|
||||
this.sharingEnabled = this.db_file.sharingEnabled;
|
||||
if (!this.fileNames) {
|
||||
// means it's a shared video
|
||||
if (!this.id) {
|
||||
// regular video/audio file (not playlist)
|
||||
this.fileNames = [this.db_file['id']];
|
||||
this.type = this.db_file['isAudio'] ? 'audio' : 'video';
|
||||
if (!already_has_filenames) { this.parseFileNames(); }
|
||||
}
|
||||
}
|
||||
if (this.db_file['sharingEnabled'] || !this.uuid) {
|
||||
this.show_player = true;
|
||||
} else if (!already_has_filenames) {
|
||||
this.openSnackBar('Error: Sharing has been disabled for this video!', 'Dismiss');
|
||||
}
|
||||
// regular video/audio file (not playlist)
|
||||
this.uids = [this.db_file['uid']];
|
||||
this.type = this.db_file['isAudio'] ? 'audio' : 'video';
|
||||
this.parseFileNames();
|
||||
});
|
||||
}
|
||||
|
||||
getSubscription() {
|
||||
this.postsService.getSubscription(null, this.subscriptionName).subscribe(res => {
|
||||
this.postsService.getSubscription(this.sub_id).subscribe(res => {
|
||||
const subscription = res['subscription'];
|
||||
this.subscription = subscription;
|
||||
if (this.fileNames) {
|
||||
subscription.videos.forEach(video => {
|
||||
if (video['id'] === this.fileNames[0]) {
|
||||
this.db_file = video;
|
||||
this.postsService.incrementViewCount(this.db_file['uid'], this.subscription['id'], this.uuid).subscribe(res => {}, err => {
|
||||
console.error('Failed to increment view count');
|
||||
console.error(err);
|
||||
});
|
||||
this.show_player = true;
|
||||
this.parseFileNames();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
console.log('no file name specified');
|
||||
}
|
||||
this.type === this.subscription.type;
|
||||
this.uids = this.subscription.videos.map(video => video['uid']);
|
||||
this.parseFileNames();
|
||||
}, err => {
|
||||
this.openSnackBar(`Failed to find subscription ${this.subscriptionName}`, 'Dismiss');
|
||||
this.openSnackBar(`Failed to find subscription ${this.sub_id}`, 'Dismiss');
|
||||
});
|
||||
}
|
||||
|
||||
getPlaylistFiles() {
|
||||
if (this.route.snapshot.paramMap.get('auto') === 'true') {
|
||||
this.show_player = true;
|
||||
return;
|
||||
}
|
||||
this.postsService.getPlaylist(this.id, null, this.uuid).subscribe(res => {
|
||||
this.postsService.getPlaylist(this.playlist_id, this.uuid, true).subscribe(res => {
|
||||
if (res['playlist']) {
|
||||
this.db_playlist = res['playlist'];
|
||||
this.fileNames = this.db_playlist['fileNames'];
|
||||
this.db_playlist['file_objs'] = res['file_objs'];
|
||||
this.uids = this.db_playlist.uids;
|
||||
this.type = res['type'];
|
||||
this.show_player = true;
|
||||
this.parseFileNames();
|
||||
@@ -226,69 +190,58 @@ export class PlayerComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
});
|
||||
}
|
||||
|
||||
parseFileNames() {
|
||||
let fileType = null;
|
||||
if (this.type === 'audio') {
|
||||
fileType = 'audio/mp3';
|
||||
} else if (this.type === 'video') {
|
||||
fileType = 'video/mp4';
|
||||
} else {
|
||||
// error
|
||||
console.error('Must have valid file type! Use \'audio\', \'video\', or \'subscription\'.');
|
||||
}
|
||||
parseFileNames() {
|
||||
this.playlist = [];
|
||||
for (let i = 0; i < this.fileNames.length; i++) {
|
||||
const fileName = this.fileNames[i];
|
||||
let baseLocation = null;
|
||||
let fullLocation = null;
|
||||
|
||||
// adds user token if in multi-user-mode
|
||||
const uuid_str = this.uuid ? `&uuid=${this.uuid}` : '';
|
||||
const uid_str = (this.id || !this.db_file) ? '' : `&uid=${this.db_file.uid}`;
|
||||
const type_str = (this.type || !this.db_file) ? `&type=${this.type}` : `&type=${this.db_file.type}`
|
||||
const id_str = this.id ? `&id=${this.id}` : '';
|
||||
const file_path_str = (!this.db_file) ? '' : `&file_path=${encodeURIComponent(this.db_file.path)}`;
|
||||
|
||||
if (!this.subscriptionName) {
|
||||
baseLocation = 'stream/';
|
||||
fullLocation = this.baseStreamPath + baseLocation + encodeURIComponent(fileName) + `?test=test${type_str}${file_path_str}`;
|
||||
for (let i = 0; i < this.uids.length; i++) {
|
||||
let file_obj = null;
|
||||
if (this.playlist_id) {
|
||||
file_obj = this.db_playlist['file_objs'][i];
|
||||
} else if (this.sub_id) {
|
||||
file_obj = this.subscription['videos'][i];
|
||||
} else {
|
||||
// default to video but include subscription name param
|
||||
baseLocation = 'stream/';
|
||||
fullLocation = this.baseStreamPath + baseLocation + encodeURIComponent(fileName) + '?subName=' + this.subscriptionName +
|
||||
'&subPlaylist=' + this.subPlaylist + `${file_path_str}${type_str}`;
|
||||
file_obj = this.db_file;
|
||||
}
|
||||
|
||||
const mime_type = file_obj.isAudio ? 'audio/mp3' : 'video/mp4'
|
||||
|
||||
let baseLocation = 'stream/';
|
||||
let fullLocation = this.baseStreamPath + baseLocation + `?test=test&uid=${file_obj['uid']}`;
|
||||
|
||||
if (this.postsService.isLoggedIn) {
|
||||
fullLocation += (this.subscriptionName ? '&' : '&') + `jwt=${this.postsService.token}`;
|
||||
if (this.is_shared) { fullLocation += `${uuid_str}${uid_str}${type_str}${id_str}`; }
|
||||
} else if (this.is_shared) {
|
||||
fullLocation += (this.subscriptionName ? '&' : '?') + `test=test${uuid_str}${uid_str}${type_str}${id_str}`;
|
||||
fullLocation += `&jwt=${this.postsService.token}`;
|
||||
}
|
||||
// if it has a slash (meaning it's in a directory), only get the file name for the label
|
||||
let label = null;
|
||||
const decodedName = decodeURIComponent(fileName);
|
||||
const hasSlash = decodedName.includes('/') || decodedName.includes('\\');
|
||||
if (hasSlash) {
|
||||
label = decodedName.replace(/^.*[\\\/]/, '');
|
||||
} else {
|
||||
label = decodedName;
|
||||
|
||||
if (this.uuid) {
|
||||
fullLocation += `&uuid=${this.uuid}`;
|
||||
}
|
||||
|
||||
if (this.sub_id) {
|
||||
fullLocation += `&sub_id=${this.sub_id}`;
|
||||
} else if (this.playlist_id) {
|
||||
fullLocation += `&playlist_id=${this.playlist_id}`;
|
||||
}
|
||||
|
||||
const mediaObject: IMedia = {
|
||||
title: fileName,
|
||||
title: file_obj['title'],
|
||||
src: fullLocation,
|
||||
type: fileType,
|
||||
label: label
|
||||
type: mime_type,
|
||||
label: file_obj['title'],
|
||||
url: file_obj['url']
|
||||
}
|
||||
this.playlist.push(mediaObject);
|
||||
}
|
||||
if (this.db_playlist && this.db_playlist['randomize_order']) {
|
||||
this.shuffleArray(this.playlist);
|
||||
}
|
||||
this.currentItem = this.playlist[this.currentIndex];
|
||||
this.original_playlist = JSON.stringify(this.playlist);
|
||||
this.show_player = true;
|
||||
}
|
||||
|
||||
onPlayerReady(api: VgApiService) {
|
||||
this.api = api;
|
||||
this.api_ready = true;
|
||||
this.cdr.detectChanges();
|
||||
|
||||
// checks if volume has been previously set. if so, use that as default
|
||||
if (localStorage.getItem('player_volume')) {
|
||||
@@ -333,13 +286,6 @@ export class PlayerComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
this.currentItem = item;
|
||||
}
|
||||
|
||||
getFileInfos() {
|
||||
const fileNames = this.getFileNames();
|
||||
this.postsService.getFileInfo(fileNames, this.type, false).subscribe(res => {
|
||||
|
||||
});
|
||||
}
|
||||
|
||||
getFileNames() {
|
||||
const fileNames = [];
|
||||
for (let i = 0; i < this.playlist.length; i++) {
|
||||
@@ -353,15 +299,9 @@ export class PlayerComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
}
|
||||
|
||||
downloadContent() {
|
||||
const fileNames = [];
|
||||
for (let i = 0; i < this.playlist.length; i++) {
|
||||
fileNames.push(this.playlist[i].title);
|
||||
}
|
||||
|
||||
const zipName = fileNames[0].split(' ')[0] + fileNames[1].split(' ')[0];
|
||||
const zipName = this.db_playlist.name;
|
||||
this.downloading = true;
|
||||
this.postsService.downloadFileFromServer(fileNames, this.type, zipName, null, null, null, null,
|
||||
!this.uuid ? this.postsService.user.uid : this.uuid, this.id).subscribe(res => {
|
||||
this.postsService.downloadPlaylistFromServer(this.playlist_id, this.uuid).subscribe(res => {
|
||||
this.downloading = false;
|
||||
const blob: Blob = res;
|
||||
saveAs(blob, zipName + '.zip');
|
||||
@@ -372,11 +312,10 @@ export class PlayerComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
}
|
||||
|
||||
downloadFile() {
|
||||
const ext = (this.type === 'audio') ? '.mp3' : '.mp4';
|
||||
const filename = this.playlist[0].title;
|
||||
const ext = (this.playlist[0].type === 'audio/mp3') ? '.mp3' : '.mp4';
|
||||
this.downloading = true;
|
||||
this.postsService.downloadFileFromServer(filename, this.type, null, null, this.subscriptionName, this.subPlaylist,
|
||||
this.is_shared ? this.db_file['uid'] : null, this.uuid).subscribe(res => {
|
||||
this.postsService.downloadFileFromServer(this.uid, this.uuid, this.sub_id).subscribe(res => {
|
||||
this.downloading = false;
|
||||
const blob: Blob = res;
|
||||
saveAs(blob, filename + ext);
|
||||
@@ -386,50 +325,10 @@ export class PlayerComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
});
|
||||
}
|
||||
|
||||
namePlaylistDialog() {
|
||||
const done = new EventEmitter<any>();
|
||||
const dialogRef = this.dialog.open(InputDialogComponent, {
|
||||
width: '300px',
|
||||
data: {
|
||||
inputTitle: 'Name the playlist',
|
||||
inputPlaceholder: 'Name',
|
||||
submitText: 'Favorite',
|
||||
doneEmitter: done
|
||||
}
|
||||
});
|
||||
|
||||
done.subscribe(name => {
|
||||
|
||||
// Eventually do additional checks on name
|
||||
if (name) {
|
||||
const fileNames = this.getFileNames();
|
||||
this.postsService.createPlaylist(name, fileNames, this.type, null).subscribe(res => {
|
||||
if (res['success']) {
|
||||
dialogRef.close();
|
||||
const new_playlist = res['new_playlist'];
|
||||
this.db_playlist = new_playlist;
|
||||
this.openSnackBar('Playlist \'' + name + '\' successfully created!', '')
|
||||
this.playlistPostCreationHandler(new_playlist.id);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/*
|
||||
createPlaylist(name) {
|
||||
this.postsService.createPlaylist(name, this.fileNames, this.type, null).subscribe(res => {
|
||||
if (res['success']) {
|
||||
console.log('Success!');
|
||||
}
|
||||
});
|
||||
}
|
||||
*/
|
||||
|
||||
playlistPostCreationHandler(playlistID) {
|
||||
// changes the route without moving from the current view or
|
||||
// triggering a navigation event
|
||||
this.id = playlistID;
|
||||
this.playlist_id = playlistID;
|
||||
this.router.navigateByUrl(this.router.url + ';id=' + playlistID);
|
||||
}
|
||||
|
||||
@@ -441,29 +340,12 @@ export class PlayerComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
return JSON.stringify(this.playlist) !== this.original_playlist;
|
||||
}
|
||||
|
||||
updatePlaylist() {
|
||||
const fileNames = this.getFileNames();
|
||||
this.playlist_updating = true;
|
||||
this.postsService.updatePlaylistFiles(this.id, fileNames, this.type).subscribe(res => {
|
||||
this.playlist_updating = false;
|
||||
if (res['success']) {
|
||||
const fileNamesEncoded = fileNames.join('|nvr|');
|
||||
this.router.navigate(['/player', {fileNames: fileNamesEncoded, type: this.type, id: this.id}]);
|
||||
this.openSnackBar('Successfully updated playlist.', '');
|
||||
this.original_playlist = JSON.stringify(this.playlist);
|
||||
} else {
|
||||
this.openSnackBar('ERROR: Failed to update playlist.', '');
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
openShareDialog() {
|
||||
const dialogRef = this.dialog.open(ShareMediaDialogComponent, {
|
||||
data: {
|
||||
uid: this.id ? this.id : this.uid,
|
||||
type: this.type,
|
||||
sharing_enabled: this.id ? this.db_playlist.sharingEnabled : this.db_file.sharingEnabled,
|
||||
is_playlist: !!this.id,
|
||||
uid: this.playlist_id ? this.playlist_id : this.uid,
|
||||
sharing_enabled: this.playlist_id ? this.db_playlist.sharingEnabled : this.db_file.sharingEnabled,
|
||||
is_playlist: !!this.playlist_id,
|
||||
uuid: this.postsService.isLoggedIn ? this.postsService.user.uid : null,
|
||||
current_timestamp: this.api.time.current
|
||||
},
|
||||
@@ -471,13 +353,45 @@ export class PlayerComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
});
|
||||
|
||||
dialogRef.afterClosed().subscribe(res => {
|
||||
if (!this.id) {
|
||||
if (!this.playlist_id) {
|
||||
this.getFile();
|
||||
} else {
|
||||
this.getPlaylistFiles();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
openFileInfoDialog() {
|
||||
this.dialog.open(VideoInfoDialogComponent, {
|
||||
data: {
|
||||
file: this.db_file,
|
||||
},
|
||||
minWidth: '50vw'
|
||||
})
|
||||
}
|
||||
|
||||
setPlaybackTimestamp(time) {
|
||||
this.api.seekTime(time);
|
||||
}
|
||||
|
||||
togglePlayback(to_play) {
|
||||
if (to_play) {
|
||||
this.api.play();
|
||||
} else {
|
||||
this.api.pause();
|
||||
}
|
||||
}
|
||||
|
||||
setPlaybackRate(speed) {
|
||||
this.api.playbackRate = speed;
|
||||
}
|
||||
|
||||
shuffleArray(array) {
|
||||
for (let i = array.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[array[i], array[j]] = [array[j], array[i]];
|
||||
}
|
||||
}
|
||||
|
||||
// snackbar helper
|
||||
public openSnackBar(message: string, action: string) {
|
||||
|
||||
@@ -48,6 +48,9 @@ export class PostsService implements CanActivate {
|
||||
settings_changed = new BehaviorSubject<boolean>(false);
|
||||
open_create_default_admin_dialog = new BehaviorSubject<boolean>(false);
|
||||
|
||||
files_changed = new BehaviorSubject<boolean>(false);
|
||||
playlists_changed = new BehaviorSubject<boolean>(false);
|
||||
|
||||
// app status
|
||||
initialized = false;
|
||||
|
||||
@@ -57,6 +60,7 @@ export class PostsService implements CanActivate {
|
||||
categories = null;
|
||||
sidenav = null;
|
||||
locale = isoLangs['en'];
|
||||
version_info = null;
|
||||
|
||||
constructor(private http: HttpClient, private router: Router, @Inject(DOCUMENT) private document: Document,
|
||||
public snackBar: MatSnackBar, private titleService: Title) {
|
||||
@@ -171,33 +175,52 @@ export class PostsService implements CanActivate {
|
||||
}
|
||||
|
||||
// tslint:disable-next-line: max-line-length
|
||||
makeMP3(url: string, selectedQuality: string, customQualityConfiguration: string, customArgs: string = null, customOutput: string = null, youtubeUsername: string = null, youtubePassword: string = null, ui_uid = null) {
|
||||
return this.http.post(this.path + 'tomp3', {url: url,
|
||||
maxBitrate: selectedQuality,
|
||||
customQualityConfiguration: customQualityConfiguration,
|
||||
customArgs: customArgs,
|
||||
customOutput: customOutput,
|
||||
youtubeUsername: youtubeUsername,
|
||||
youtubePassword: youtubePassword,
|
||||
ui_uid: ui_uid}, this.httpOptions);
|
||||
}
|
||||
|
||||
// tslint:disable-next-line: max-line-length
|
||||
makeMP4(url: string, selectedQuality: string, customQualityConfiguration: string, customArgs: string = null, customOutput: string = null, youtubeUsername: string = null, youtubePassword: string = null, ui_uid = null) {
|
||||
return this.http.post(this.path + 'tomp4', {url: url,
|
||||
downloadFile(url: string, type: string, selectedQuality: string, customQualityConfiguration: string, customArgs: string = null, additionalArgs: string = null, customOutput: string = null, youtubeUsername: string = null, youtubePassword: string = null, cropFileSettings = null) {
|
||||
return this.http.post(this.path + 'downloadFile', {url: url,
|
||||
selectedHeight: selectedQuality,
|
||||
customQualityConfiguration: customQualityConfiguration,
|
||||
customArgs: customArgs,
|
||||
additionalArgs: additionalArgs,
|
||||
customOutput: customOutput,
|
||||
youtubeUsername: youtubeUsername,
|
||||
youtubePassword: youtubePassword,
|
||||
ui_uid: ui_uid}, this.httpOptions);
|
||||
type: type,
|
||||
cropFileSettings: cropFileSettings}, this.httpOptions);
|
||||
}
|
||||
|
||||
generateArgs(url: string, type: string, selectedQuality: string, customQualityConfiguration: string, customArgs: string = null, additionalArgs: string = null, customOutput: string = null, youtubeUsername: string = null, youtubePassword: string = null, cropFileSettings = null) {
|
||||
return this.http.post(this.path + 'generateArgs', {url: url,
|
||||
selectedHeight: selectedQuality,
|
||||
customQualityConfiguration: customQualityConfiguration,
|
||||
customArgs: customArgs,
|
||||
additionalArgs: additionalArgs,
|
||||
customOutput: customOutput,
|
||||
youtubeUsername: youtubeUsername,
|
||||
youtubePassword: youtubePassword,
|
||||
type: type,
|
||||
cropFileSettings: cropFileSettings}, this.httpOptions);
|
||||
}
|
||||
|
||||
getDBInfo() {
|
||||
return this.http.post(this.path + 'getDBInfo', {}, this.httpOptions);
|
||||
}
|
||||
|
||||
transferDB(local_to_remote) {
|
||||
return this.http.post(this.path + 'transferDB', {local_to_remote: local_to_remote}, this.httpOptions);
|
||||
}
|
||||
|
||||
testConnectionString(connection_string) {
|
||||
return this.http.post(this.path + 'testConnectionString', {connection_string: connection_string}, this.httpOptions);
|
||||
}
|
||||
|
||||
killAllDownloads() {
|
||||
return this.http.post(this.path + 'killAllDownloads', {}, this.httpOptions);
|
||||
}
|
||||
|
||||
restartServer() {
|
||||
return this.http.post(this.path + 'restartServer', {}, this.httpOptions);
|
||||
}
|
||||
|
||||
loadNavItems() {
|
||||
if (isDevMode()) {
|
||||
return this.http.get('./assets/default.json');
|
||||
@@ -214,8 +237,8 @@ export class PostsService implements CanActivate {
|
||||
return this.http.post(this.path + 'setConfig', {new_config_file: config}, this.httpOptions);
|
||||
}
|
||||
|
||||
deleteFile(uid: string, type: string, blacklistMode = false) {
|
||||
return this.http.post(this.path + 'deleteFile', {uid: uid, type: type, blacklistMode: blacklistMode}, this.httpOptions);
|
||||
deleteFile(uid: string, blacklistMode = false) {
|
||||
return this.http.post(this.path + 'deleteFile', {uid: uid, blacklistMode: blacklistMode}, this.httpOptions);
|
||||
}
|
||||
|
||||
getMp3s() {
|
||||
@@ -230,8 +253,8 @@ export class PostsService implements CanActivate {
|
||||
return this.http.post(this.path + 'getFile', {uid: uid, type: type, uuid: uuid}, this.httpOptions);
|
||||
}
|
||||
|
||||
getAllFiles() {
|
||||
return this.http.post(this.path + 'getAllFiles', {}, this.httpOptions);
|
||||
getAllFiles(sort, range, text_search, file_type_filter) {
|
||||
return this.http.post(this.path + 'getAllFiles', {sort: sort, range: range, text_search: text_search, file_type_filter: file_type_filter}, this.httpOptions);
|
||||
}
|
||||
|
||||
getFullTwitchChat(id, type, uuid = null, sub = null) {
|
||||
@@ -242,22 +265,43 @@ export class PostsService implements CanActivate {
|
||||
return this.http.post(this.path + 'downloadTwitchChatByVODID', {id: id, type: type, vodId: vodId, uuid: uuid, sub: sub}, this.httpOptions);
|
||||
}
|
||||
|
||||
downloadFileFromServer(fileName, type, outputName = null, fullPathProvided = null, subscriptionName = null, subPlaylist = null,
|
||||
uid = null, uuid = null, id = null) {
|
||||
return this.http.post(this.path + 'downloadFile', {fileNames: fileName,
|
||||
type: type,
|
||||
zip_mode: Array.isArray(fileName),
|
||||
outputName: outputName,
|
||||
fullPathProvided: fullPathProvided,
|
||||
subscriptionName: subscriptionName,
|
||||
subPlaylist: subPlaylist,
|
||||
uuid: uuid,
|
||||
downloadFileFromServer(uid, uuid = null, sub_id = null, is_playlist = null) {
|
||||
return this.http.post(this.path + 'downloadFileFromServer', {
|
||||
uid: uid,
|
||||
id: id
|
||||
uuid: uuid,
|
||||
sub_id: sub_id,
|
||||
is_playlist: is_playlist
|
||||
},
|
||||
{responseType: 'blob', params: this.httpOptions.params});
|
||||
}
|
||||
|
||||
downloadPlaylistFromServer(playlist_id, uuid = null) {
|
||||
return this.http.post(this.path + 'downloadFileFromServer', {
|
||||
uuid: uuid,
|
||||
playlist_id: playlist_id
|
||||
},
|
||||
{responseType: 'blob', params: this.httpOptions.params});
|
||||
}
|
||||
|
||||
downloadSubFromServer(sub_id, uuid = null) {
|
||||
return this.http.post(this.path + 'downloadFileFromServer', {
|
||||
uuid: uuid,
|
||||
sub_id: sub_id
|
||||
},
|
||||
{responseType: 'blob', params: this.httpOptions.params});
|
||||
}
|
||||
|
||||
checkConcurrentStream(uid) {
|
||||
return this.http.post(this.path + 'checkConcurrentStream', {uid: uid}, this.httpOptions);
|
||||
}
|
||||
|
||||
updateConcurrentStream(uid, playback_timestamp, unix_timestamp, playing) {
|
||||
return this.http.post(this.path + 'updateConcurrentStream', {uid: uid,
|
||||
playback_timestamp: playback_timestamp,
|
||||
unix_timestamp: unix_timestamp,
|
||||
playing: playing}, this.httpOptions);
|
||||
}
|
||||
|
||||
uploadCookiesFile(fileFormData) {
|
||||
return this.http.post(this.path + 'uploadCookies', fileFormData, this.httpOptions);
|
||||
}
|
||||
@@ -266,8 +310,8 @@ export class PostsService implements CanActivate {
|
||||
return this.http.post(this.path + 'downloadArchive', {sub: sub}, {responseType: 'blob', params: this.httpOptions.params});
|
||||
}
|
||||
|
||||
getFileInfo(fileNames, type, urlMode) {
|
||||
return this.http.post(this.path + 'getVideoInfos', {fileNames: fileNames, type: type, urlMode: urlMode}, this.httpOptions);
|
||||
getFileFormats(url) {
|
||||
return this.http.post(this.path + 'getFileFormats', {url: url}, this.httpOptions);
|
||||
}
|
||||
|
||||
getLogs(lines = 50) {
|
||||
@@ -282,43 +326,47 @@ export class PostsService implements CanActivate {
|
||||
return this.http.post(this.path + 'generateNewAPIKey', {}, this.httpOptions);
|
||||
}
|
||||
|
||||
enableSharing(uid, type, is_playlist) {
|
||||
return this.http.post(this.path + 'enableSharing', {uid: uid, type: type, is_playlist: is_playlist}, this.httpOptions);
|
||||
enableSharing(uid, is_playlist) {
|
||||
return this.http.post(this.path + 'enableSharing', {uid: uid, is_playlist: is_playlist}, this.httpOptions);
|
||||
}
|
||||
|
||||
disableSharing(uid, is_playlist) {
|
||||
return this.http.post(this.path + 'disableSharing', {uid: uid, is_playlist: is_playlist}, this.httpOptions);
|
||||
}
|
||||
|
||||
incrementViewCount(file_uid, sub_id, uuid) {
|
||||
return this.http.post(this.path + 'incrementViewCount', {file_uid: file_uid, sub_id: sub_id, uuid: uuid}, this.httpOptions);
|
||||
}
|
||||
|
||||
disableSharing(uid, type, is_playlist) {
|
||||
return this.http.post(this.path + 'disableSharing', {uid: uid, type: type, is_playlist: is_playlist}, this.httpOptions);
|
||||
}
|
||||
|
||||
createPlaylist(playlistName, fileNames, type, thumbnailURL, duration = null) {
|
||||
createPlaylist(playlistName, uids, type, thumbnailURL) {
|
||||
return this.http.post(this.path + 'createPlaylist', {playlistName: playlistName,
|
||||
fileNames: fileNames,
|
||||
uids: uids,
|
||||
type: type,
|
||||
thumbnailURL: thumbnailURL,
|
||||
duration: duration}, this.httpOptions);
|
||||
thumbnailURL: thumbnailURL}, this.httpOptions);
|
||||
}
|
||||
|
||||
getPlaylist(playlistID, type, uuid = null) {
|
||||
return this.http.post(this.path + 'getPlaylist', {playlistID: playlistID,
|
||||
type: type, uuid: uuid}, this.httpOptions);
|
||||
getPlaylist(playlist_id, uuid = null, include_file_metadata = false) {
|
||||
return this.http.post(this.path + 'getPlaylist', {playlist_id: playlist_id,
|
||||
uuid: uuid,
|
||||
include_file_metadata: include_file_metadata}, this.httpOptions);
|
||||
}
|
||||
|
||||
getPlaylists() {
|
||||
return this.http.post(this.path + 'getPlaylists', {}, this.httpOptions);
|
||||
}
|
||||
|
||||
updatePlaylist(playlist) {
|
||||
return this.http.post(this.path + 'updatePlaylist', {playlist: playlist}, this.httpOptions);
|
||||
}
|
||||
|
||||
updatePlaylistFiles(playlistID, fileNames, type) {
|
||||
return this.http.post(this.path + 'updatePlaylistFiles', {playlistID: playlistID,
|
||||
fileNames: fileNames,
|
||||
type: type}, this.httpOptions);
|
||||
addFileToPlaylist(playlist_id, file_uid) {
|
||||
return this.http.post(this.path + 'addFileToPlaylist', {playlist_id: playlist_id,
|
||||
file_uid: file_uid},
|
||||
this.httpOptions);
|
||||
}
|
||||
|
||||
removePlaylist(playlistID, type) {
|
||||
return this.http.post(this.path + 'deletePlaylist', {playlistID: playlistID, type: type}, this.httpOptions);
|
||||
removePlaylist(playlist_id, type) {
|
||||
return this.http.post(this.path + 'deletePlaylist', {playlist_id: playlist_id, type: type}, this.httpOptions);
|
||||
}
|
||||
|
||||
// categories
|
||||
@@ -357,14 +405,17 @@ export class PostsService implements CanActivate {
|
||||
}
|
||||
|
||||
updateSubscription(subscription) {
|
||||
delete subscription['videos'];
|
||||
return this.http.post(this.path + 'updateSubscription', {subscription: subscription}, this.httpOptions);
|
||||
}
|
||||
|
||||
unsubscribe(sub, deleteMode = false) {
|
||||
delete sub['videos'];
|
||||
return this.http.post(this.path + 'unsubscribe', {sub: sub, deleteMode: deleteMode}, this.httpOptions)
|
||||
}
|
||||
|
||||
deleteSubscriptionFile(sub, file, deleteForever, file_uid) {
|
||||
delete sub['videos'];
|
||||
return this.http.post(this.path + 'deleteSubscriptionFile', {sub: sub, file: file, deleteForever: deleteForever,
|
||||
file_uid: file_uid}, this.httpOptions)
|
||||
}
|
||||
@@ -377,24 +428,50 @@ export class PostsService implements CanActivate {
|
||||
return this.http.post(this.path + 'getSubscriptions', {}, this.httpOptions);
|
||||
}
|
||||
|
||||
// current downloads
|
||||
getCurrentDownloads() {
|
||||
return this.http.get(this.path + 'downloads', this.httpOptions);
|
||||
getCurrentDownloads(uids = null) {
|
||||
return this.http.post(this.path + 'downloads', {uids: uids}, this.httpOptions);
|
||||
}
|
||||
|
||||
// current download
|
||||
getCurrentDownload(session_id, download_id) {
|
||||
return this.http.post(this.path + 'download', {download_id: download_id, session_id: session_id}, this.httpOptions);
|
||||
getCurrentDownload(download_uid) {
|
||||
return this.http.post(this.path + 'download', {download_uid: download_uid}, this.httpOptions);
|
||||
}
|
||||
|
||||
// clear downloads. download_id is optional, if it exists only 1 download will be cleared
|
||||
clearDownloads(delete_all = false, session_id = null, download_id = null) {
|
||||
return this.http.post(this.path + 'clearDownloads', {delete_all: delete_all,
|
||||
download_id: download_id,
|
||||
session_id: session_id ? session_id : this.session_id}, this.httpOptions);
|
||||
pauseDownload(download_uid) {
|
||||
return this.http.post(this.path + 'pauseDownload', {download_uid: download_uid}, this.httpOptions);
|
||||
}
|
||||
|
||||
pauseAllDownloads() {
|
||||
return this.http.post(this.path + 'pauseAllDownloads', {}, this.httpOptions);
|
||||
}
|
||||
|
||||
resumeDownload(download_uid) {
|
||||
return this.http.post(this.path + 'resumeDownload', {download_uid: download_uid}, this.httpOptions);
|
||||
}
|
||||
|
||||
resumeAllDownloads() {
|
||||
return this.http.post(this.path + 'resumeAllDownloads', {}, this.httpOptions);
|
||||
}
|
||||
|
||||
restartDownload(download_uid) {
|
||||
return this.http.post(this.path + 'restartDownload', {download_uid: download_uid}, this.httpOptions);
|
||||
}
|
||||
|
||||
cancelDownload(download_uid) {
|
||||
return this.http.post(this.path + 'cancelDownload', {download_uid: download_uid}, this.httpOptions);
|
||||
}
|
||||
|
||||
clearDownload(download_uid) {
|
||||
return this.http.post(this.path + 'clearDownload', {download_uid: download_uid}, this.httpOptions);
|
||||
}
|
||||
|
||||
clearFinishedDownloads() {
|
||||
return this.http.post(this.path + 'clearFinishedDownloads', {}, this.httpOptions);
|
||||
}
|
||||
|
||||
getVersionInfo() {
|
||||
return this.http.get(this.path + 'versionInfo', this.httpOptions);
|
||||
}
|
||||
|
||||
// updates the server to the latest version
|
||||
updateServer(tag) {
|
||||
return this.http.post(this.path + 'updateServer', {tag: tag}, this.httpOptions);
|
||||
}
|
||||
@@ -468,6 +545,12 @@ export class PostsService implements CanActivate {
|
||||
this.resetHttpParams();
|
||||
}
|
||||
|
||||
hasPermission(permission) {
|
||||
// assume not logged in users never have permission
|
||||
if (this.config.Advanced.multi_user_mode && !this.isLoggedIn) return false;
|
||||
return this.config.Advanced.multi_user_mode ? this.permissions.includes(permission) : true;
|
||||
}
|
||||
|
||||
// user methods
|
||||
register(username, password) {
|
||||
const call = this.http.post(this.path + 'auth/register', {userid: username,
|
||||
@@ -565,6 +648,11 @@ export class PostsService implements CanActivate {
|
||||
this.httpOptions);
|
||||
}
|
||||
|
||||
getSponsorBlockDataForVideo(id_hash) {
|
||||
const sponsor_block_api_path = 'https://sponsor.ajay.app/api/';
|
||||
return this.http.get(sponsor_block_api_path + `skipSegments/${id_hash}`);
|
||||
}
|
||||
|
||||
public openSnackBar(message: string, action: string = '') {
|
||||
this.snackBar.open(message, action, {
|
||||
duration: 2000,
|
||||
|
||||
@@ -105,7 +105,7 @@ export const isoLangs = {
|
||||
'nativeName': 'ဗမာစာ'
|
||||
},
|
||||
'ca': {
|
||||
'name': 'Catalan; Valencian',
|
||||
'name': 'Catalan',
|
||||
'nativeName': 'Català'
|
||||
},
|
||||
'ch': {
|
||||
@@ -159,7 +159,7 @@ export const isoLangs = {
|
||||
},
|
||||
'nl': {
|
||||
'name': 'Dutch',
|
||||
'nativeName': 'Nederlands, Vlaams'
|
||||
'nativeName': 'Nederlands'
|
||||
},
|
||||
'en': {
|
||||
'name': 'English',
|
||||
|
||||
@@ -1,13 +1,5 @@
|
||||
<h4 i18n="Settings title" mat-dialog-title>Settings</h4>
|
||||
<!-- <ng-container i18n="Allow subscriptions setting"></ng-container> -->
|
||||
<mat-dialog-content>
|
||||
|
||||
<!-- Language
|
||||
<div style="margin-bottom: 10px;">
|
||||
|
||||
</div> -->
|
||||
|
||||
<mat-tab-group>
|
||||
<h4 class="settings-title" i18n="Settings title">Settings</h4>
|
||||
<mat-tab-group style="height: 76vh" mat-align-tabs="center" [selectedIndex]="tabIndex" (selectedTabChange)="tabChanged($event)">
|
||||
<!-- Server -->
|
||||
<mat-tab label="Main" i18n-label="Main settings label">
|
||||
<ng-template matTabContent style="padding: 15px;">
|
||||
@@ -59,7 +51,7 @@
|
||||
<mat-hint><ng-container i18n="Check interval setting input hint">Unit is seconds, only include numbers.</ng-container></mat-hint>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="col-12 mt-2 mb-3">
|
||||
<div class="col-12 mt-4 mb-3">
|
||||
<mat-checkbox color="accent" [(ngModel)]="new_config['Subscriptions']['redownload_fresh_uploads']" matTooltip="Sometimes new videos are downloaded before being fully processed. This setting will mean new videos will be checked for a higher quality version the following day." i18n-matTooltip="Redownload fresh uploads tooltip"><ng-container i18n="Redownload fresh uploads">Redownload fresh uploads</ng-container></mat-checkbox>
|
||||
</div>
|
||||
</div>
|
||||
@@ -111,14 +103,14 @@
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<div class="col-12 mt-5">
|
||||
<div class="col-12 mt-3">
|
||||
<mat-form-field class="text-field" color="accent">
|
||||
<input matInput [(ngModel)]="new_config['Downloader']['path-video']" placeholder="Video folder path" i18n-placeholder="Video folder path input placeholder" required>
|
||||
<mat-hint><ng-container i18n="Video path setting input hint">Path for video downloads. It is relative to YTDL-Material's root folder.</ng-container></mat-hint>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<div class="col-12 mt-4">
|
||||
<div class="col-12 mt-3">
|
||||
<mat-form-field class="text-field" color="accent">
|
||||
<input matInput [(ngModel)]="new_config['Downloader']['default_file_output']" matInput placeholder="Default file output" i18n-placeholder="Default file output placeholder">
|
||||
<mat-hint><a target="_blank" href="https://github.com/ytdl-org/youtube-dl/blob/master/README.md#output-template">
|
||||
@@ -128,7 +120,7 @@
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<div class="col-12 mt-4 mb-5">
|
||||
<div class="col-12 mt-3 mb-4">
|
||||
<mat-form-field class="text-field" style="margin-right: 12px;" color="accent">
|
||||
<textarea matInput [(ngModel)]="new_config['Downloader']['custom_args']" placeholder="Global custom args" i18n-placeholder="Custom args input placeholder"></textarea>
|
||||
<mat-hint><ng-container i18n="Custom args setting input hint">Global custom args for downloads on the home page. Args are delimited using two commas like so: ,,</ng-container></mat-hint>
|
||||
@@ -140,9 +132,9 @@
|
||||
<mat-divider></mat-divider>
|
||||
<div *ngIf="new_config" class="container-fluid">
|
||||
<div class="row">
|
||||
<div class="col-12 mt-3 mb-2">
|
||||
<div class="col-12 mt-3">
|
||||
<h6 i18n="Categories">Categories</h6>
|
||||
<div cdkDropList class="category-list" (cdkDropListDropped)="dropCategory($event)">
|
||||
<div *ngIf="postsService.categories && postsService.categories.length > 0" kDropList class="category-list" (cdkDropListDropped)="dropCategory($event)">
|
||||
<div class="category-box" *ngFor="let category of postsService.categories" cdkDrag>
|
||||
<div class="category-custom-placeholder" *cdkDragPlaceholder></div>
|
||||
{{category['name']}}
|
||||
@@ -154,6 +146,9 @@
|
||||
</div>
|
||||
<button style="margin-top: 10px;" mat-mini-fab (click)="openAddCategoryDialog()"><mat-icon>add</mat-icon></button>
|
||||
</div>
|
||||
<div class="col-12 mt-2 mb-2">
|
||||
<mat-checkbox [(ngModel)]="new_config['Extra']['allow_playlist_categorization']" matTooltip="With this setting enabled, if a single video matches a category, the entire playlist will receive that category." i18n-matTooltip="Allow playlist categorization setting tooltip"><ng-container i18n="Allow playlist categorization setting label">Allow playlist categorization</ng-container></mat-checkbox>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<mat-divider></mat-divider>
|
||||
@@ -167,11 +162,32 @@
|
||||
<mat-checkbox color="accent" [(ngModel)]="new_config['Downloader']['include_thumbnail']"><ng-container i18n="Include thumbnail setting">Include thumbnail</ng-container></mat-checkbox>
|
||||
</div>
|
||||
|
||||
<div class="col-12 mt-2">
|
||||
<div class="col-12 mt-2 mb-2">
|
||||
<mat-checkbox color="accent" [(ngModel)]="new_config['Downloader']['include_metadata']"><ng-container i18n="Include metadata setting">Include metadata</ng-container></mat-checkbox>
|
||||
</div>
|
||||
|
||||
<div class="col-12 mt-2">
|
||||
</div>
|
||||
</div>
|
||||
<mat-divider></mat-divider>
|
||||
<div *ngIf="new_config" class="container-fluid">
|
||||
<div class="row">
|
||||
<div class="col-12 mt-3 mb-4">
|
||||
<mat-form-field class="text-field" color="accent">
|
||||
<input type="number" [(ngModel)]="new_config['Downloader']['max_concurrent_downloads']" matInput placeholder="Max concurrent downloads" i18n-placeholder="Max concurrent downloads">
|
||||
<mat-hint><ng-container i18n="Max concurrent downloads input hint">Limits the amount of downloads that can be simultaneously downloaded. Use -1 for no limit.</ng-container></mat-hint>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="col-12 mt-2 mb-4">
|
||||
<mat-form-field class="text-field" color="accent">
|
||||
<input [(ngModel)]="new_config['Downloader']['download_rate_limit']" matInput placeholder="Download rate limit" i18n-placeholder="Download rate limit input placeholder">
|
||||
<mat-hint><ng-container i18n="Download rate limit input hint">Rate limits your downloads to the specified amount. Ex: 200K</ng-container></mat-hint>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<mat-divider></mat-divider>
|
||||
<div *ngIf="new_config" class="container-fluid">
|
||||
<div class="row">
|
||||
<div class="col-12 mt-3">
|
||||
<button (click)="killAllDownloads()" mat-stroked-button color="warn"><ng-container i18n="Kill all downloads button">Kill all downloads</ng-container></button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -202,7 +218,7 @@
|
||||
<mat-checkbox color="accent" [(ngModel)]="new_config['Extra']['download_only_mode']"><ng-container i18n="Download only mode setting">Download only mode</ng-container></mat-checkbox>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<mat-checkbox color="accent" [(ngModel)]="new_config['Extra']['allow_multi_download_mode']"><ng-container i18n="Allow multi-download mode setting">Allow multi-download mode</ng-container></mat-checkbox>
|
||||
<mat-checkbox color="accent" [(ngModel)]="new_config['Extra']['allow_autoplay']"><ng-container i18n="Allow autoplay setting">Allow autoplay</ng-container></mat-checkbox>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -216,7 +232,7 @@
|
||||
<div class="enable-api-key-div">
|
||||
<mat-form-field class="text-field" color="accent">
|
||||
<input [disabled]="!new_config['API']['use_API_key']" [(ngModel)]="new_config['API']['API_key']" matInput placeholder="Public API Key" i18n-placeholder="Public API Key setting placeholder" required>
|
||||
<mat-hint><a target="_blank" href="https://stoplight.io/p/docs/gh/tzahi12345/youtubedl-material"><ng-container i18n="View API docs setting hint">View documentation</ng-container></a></mat-hint>
|
||||
<mat-hint><a target="_blank" href="https://youtubedl-material.stoplight.io/docs/youtubedl-material/Public%20API%20v1.yaml"><ng-container i18n="View API docs setting hint">View documentation</ng-container></a></mat-hint>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="api-key-div">
|
||||
@@ -243,12 +259,18 @@
|
||||
<div *ngIf="new_config['API']['use_twitch_API']" class="col-12 mt-1">
|
||||
<mat-checkbox color="accent" [(ngModel)]="new_config['API']['twitch_auto_download_chat']"><ng-container i18n="Auto download Twitch Chat setting">Auto-download Twitch Chat</ng-container></mat-checkbox>
|
||||
</div>
|
||||
<div class="col-12 mb-5">
|
||||
<div class="col-12">
|
||||
<mat-form-field class="text-field" color="accent">
|
||||
<input [disabled]="!new_config['API']['use_twitch_API']" [(ngModel)]="new_config['API']['twitch_API_key']" matInput placeholder="Twitch API Key" i18n-placeholder="Twitch API Key setting placeholder" required>
|
||||
<mat-hint><ng-container i18n="Twitch API Key setting hint AKA preamble">Also known as a Client ID.</ng-container> <a target="_blank" href="https://dev.twitch.tv/docs/api/"><ng-container i18n="Twitch API Key setting hint">Generating a key is easy!</ng-container></a></mat-hint>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="col-12 mt-4">
|
||||
<mat-checkbox color="accent" [(ngModel)]="new_config['API']['use_sponsorblock_API']" matTooltip="Enables a button to skip ads when viewing supported videos." i18n-matTooltip="SponsorBlock API tooltip"><ng-container i18n="Use SponsorBlock API setting">Use SponsorBlock API</ng-container></mat-checkbox>
|
||||
</div>
|
||||
<div class="col-12 mt-2 mb-3">
|
||||
<mat-checkbox color="accent" [(ngModel)]="new_config['API']['generate_NFO_files']" matTooltip="Generates NFO files with every download, primarily used by Kodi." i18n-matTooltip="Generate NFO files tooltip"><ng-container i18n="Generate NFO files setting">Generate NFO files</ng-container></mat-checkbox>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<mat-divider></mat-divider>
|
||||
@@ -277,6 +299,42 @@
|
||||
</div>
|
||||
</ng-template>
|
||||
</mat-tab>
|
||||
<!-- Database -->
|
||||
<mat-tab label="Database" i18n-label="Database settings label">
|
||||
<ng-template matTabContent>
|
||||
<div *ngIf="new_config" class="container-fluid">
|
||||
<div class="row">
|
||||
<div class="col-12 mt-3">
|
||||
<div *ngIf="db_info">
|
||||
<p><ng-container i18n="Database location label">Database location:</ng-container> <strong>{{db_info['using_local_db'] ? 'Local' : 'MongoDB'}}</strong></p>
|
||||
<h6 i18n="Records per table label">Records per table</h6>
|
||||
<mat-list style="padding-top: 0px">
|
||||
<mat-list-item style="height: 28px" *ngFor="let table_stats of db_info['stats_by_table'] | keyvalue">
|
||||
{{table_stats.key}}: {{table_stats.value.records_count}}
|
||||
</mat-list-item>
|
||||
</mat-list>
|
||||
|
||||
<mat-form-field style="width: 100%; margin-top: 15px; margin-bottom: 10px" color="accent">
|
||||
<input [(ngModel)]="new_config['Database']['mongodb_connection_string']" matInput placeholder="MongoDB Connection String" i18n-placeholder="MongoDB Connection String" required>
|
||||
<mat-hint><ng-container i18n="MongoDB Connection String setting hint AKA preamble">Example:</ng-container> mongodb://127.0.0.1:27017/?compressors=zlib<br>Docker: mongodb://<container name>:27017/?compressors=zlib</mat-hint>
|
||||
</mat-form-field>
|
||||
|
||||
<div class="test-connection-div">
|
||||
<button (click)="testConnectionString(new_config['Database']['mongodb_connection_string'])" [disabled]="testing_connection_string" mat-flat-button color="accent"><ng-container i18n="Test connection string button">Test connection string</ng-container></button>
|
||||
</div>
|
||||
|
||||
<div class="transfer-db-div">
|
||||
<button [disabled]="db_transferring" color="accent" (click)="transferDB()" mat-raised-button><ng-container i18n="Transfer DB button">Transfer DB to </ng-container>{{db_info['using_local_db'] ? 'MongoDB' : 'Local'}}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div *ngIf="!db_info">
|
||||
<ng-container i18n="Database info not retrieved error message">Database information could not be retrieved. Check the server logs for more information.</ng-container>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ng-template>
|
||||
</mat-tab>
|
||||
<!-- Advanced -->
|
||||
<mat-tab label="Advanced" i18n-label="Host settings label">
|
||||
<ng-template matTabContent>
|
||||
@@ -286,8 +344,9 @@
|
||||
<mat-form-field>
|
||||
<mat-label><ng-container i18n="Default downloader select label">Select a downloader</ng-container></mat-label>
|
||||
<mat-select color="accent" [(ngModel)]="new_config['Advanced']['default_downloader']">
|
||||
<mat-option value="youtube-dlc">youtube-dlc</mat-option>
|
||||
<mat-option value="youtube-dl">youtube-dl</mat-option>
|
||||
<mat-option value="youtube-dlc">youtube-dlc</mat-option>
|
||||
<mat-option value="yt-dlp">yt-dlp</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
@@ -350,73 +409,85 @@
|
||||
<div *ngIf="new_config" class="container-fluid mt-1">
|
||||
<app-updater></app-updater>
|
||||
</div>
|
||||
<mat-divider></mat-divider>
|
||||
<div *ngIf="new_config" class="container-fluid">
|
||||
<div class="row">
|
||||
<div class="col-12 mt-4">
|
||||
<button (click)="restartServer()" mat-stroked-button color="warn"><ng-container i18n="Restart server button">Restart server</ng-container></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ng-template>
|
||||
</mat-tab>
|
||||
<mat-tab *ngIf="postsService.config && postsService.config.Advanced.multi_user_mode" label="Users" i18n-label="Users settings label">
|
||||
|
||||
<div style="margin-left: 48px; margin-top: 24px; margin-bottom: -25px;">
|
||||
<div>
|
||||
<mat-checkbox color="accent" [(ngModel)]="new_config['Users']['allow_registration']"><ng-container i18n="Allow registration setting">Allow user registration</ng-container></mat-checkbox>
|
||||
<mat-tab [disabled]="!postsService.config?.Advanced.multi_user_mode">
|
||||
<ng-template mat-tab-label>
|
||||
<div [matTooltip]="!postsService.config?.Advanced.multi_user_mode ? usersTabDisabledTooltip : null">
|
||||
<ng-container i18n="Users settings label">Users</ng-container>
|
||||
</div>
|
||||
<mat-divider></mat-divider>
|
||||
<mat-form-field style="margin-top: 15px;">
|
||||
<mat-select [(ngModel)]="new_config['Users']['auth_method']" placeholder="Auth method" i18n-placeholder="Auth method select">
|
||||
<mat-option value="internal">
|
||||
<ng-container i18n="Internal auth method">Internal</ng-container>
|
||||
</mat-option>
|
||||
<mat-option value="ldap">
|
||||
<ng-container i18n="LDAP auth method">LDAP</ng-container>
|
||||
</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<div *ngIf="new_config['Users']['auth_method'] === 'ldap'">
|
||||
</ng-template>
|
||||
<ng-container *ngIf="postsService.config?.Advanced.multi_user_mode">
|
||||
<div *ngIf="new_config" style="margin-top: 24px; margin-bottom: -25px;">
|
||||
<div>
|
||||
<mat-form-field>
|
||||
<input matInput i18n-placeholder="LDAP URL" placeholder="LDAP URL" [(ngModel)]="new_config['Users']['ldap_config']['url']">
|
||||
</mat-form-field>
|
||||
<mat-checkbox color="accent" [(ngModel)]="new_config['Users']['allow_registration']"><ng-container i18n="Allow registration setting">Allow user registration</ng-container></mat-checkbox>
|
||||
</div>
|
||||
<div>
|
||||
<mat-form-field>
|
||||
<input matInput i18n-placeholder="Bind DN" placeholder="Bind DN" [(ngModel)]="new_config['Users']['ldap_config']['bindDN']">
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div>
|
||||
<mat-form-field>
|
||||
<input matInput i18n-placeholder="Bind Credentials" placeholder="Bind Credentials" [(ngModel)]="new_config['Users']['ldap_config']['bindCredentials']">
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div>
|
||||
<mat-form-field>
|
||||
<input matInput i18n-placeholder="Search Base" placeholder="Search Base" [(ngModel)]="new_config['Users']['ldap_config']['searchBase']">
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div>
|
||||
<mat-form-field>
|
||||
<input matInput i18n-placeholder="Search Filter" placeholder="Search Filter" [(ngModel)]="new_config['Users']['ldap_config']['searchFilter']">
|
||||
</mat-form-field>
|
||||
<mat-divider></mat-divider>
|
||||
<mat-form-field style="margin-top: 15px;">
|
||||
<mat-select [(ngModel)]="new_config['Users']['auth_method']" placeholder="Auth method" i18n-placeholder="Auth method select">
|
||||
<mat-option value="internal">
|
||||
<ng-container i18n="Internal auth method">Internal</ng-container>
|
||||
</mat-option>
|
||||
<mat-option value="ldap">
|
||||
<ng-container i18n="LDAP auth method">LDAP</ng-container>
|
||||
</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<div *ngIf="new_config['Users']['auth_method'] === 'ldap'">
|
||||
<div>
|
||||
<mat-form-field>
|
||||
<input matInput i18n-placeholder="LDAP URL" placeholder="LDAP URL" [(ngModel)]="new_config['Users']['ldap_config']['url']">
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div>
|
||||
<mat-form-field>
|
||||
<input matInput i18n-placeholder="Bind DN" placeholder="Bind DN" [(ngModel)]="new_config['Users']['ldap_config']['bindDN']">
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div>
|
||||
<mat-form-field>
|
||||
<input matInput i18n-placeholder="Bind Credentials" placeholder="Bind Credentials" [(ngModel)]="new_config['Users']['ldap_config']['bindCredentials']">
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div>
|
||||
<mat-form-field>
|
||||
<input matInput i18n-placeholder="Search Base" placeholder="Search Base" [(ngModel)]="new_config['Users']['ldap_config']['searchBase']">
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div>
|
||||
<mat-form-field>
|
||||
<input matInput i18n-placeholder="Search Filter" placeholder="Search Filter" [(ngModel)]="new_config['Users']['ldap_config']['searchFilter']">
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</div>
|
||||
<mat-divider></mat-divider>
|
||||
</div>
|
||||
<mat-divider></mat-divider>
|
||||
</div>
|
||||
<app-modify-users></app-modify-users>
|
||||
<app-modify-users *ngIf="new_config"></app-modify-users>
|
||||
</ng-container>
|
||||
</mat-tab>
|
||||
<mat-tab *ngIf="postsService.config" label="Logs" i18n-label="Logs settings label">
|
||||
<ng-template matTabContent>
|
||||
<div style="margin-left: 48px; margin-top: 24px; height: 340px">
|
||||
<div style="margin-top: 15px; height: 84%;">
|
||||
<app-logs-viewer></app-logs-viewer>
|
||||
</div>
|
||||
</ng-template>
|
||||
</mat-tab>
|
||||
</mat-tab-group>
|
||||
</mat-dialog-content>
|
||||
|
||||
<mat-dialog-actions>
|
||||
<div style="margin-bottom: 10px;">
|
||||
<button color="accent" (click)="saveSettings()" [disabled]="settingsSame()" mat-raised-button><mat-icon>done</mat-icon>
|
||||
<ng-container i18n="Settings save button">Save</ng-container>
|
||||
</button>
|
||||
<button mat-flat-button [mat-dialog-close]="false"><mat-icon>cancel</mat-icon>
|
||||
<span i18n="Settings cancel and close button">{settingsAreTheSame + "", select, true {Close} false {Cancel} other {otha}}</span>
|
||||
</button>
|
||||
</div>
|
||||
</mat-dialog-actions>
|
||||
<div class="action-buttons">
|
||||
<button style="margin-left: 10px; height: 37.3px" color="accent" (click)="saveSettings()" [disabled]="settingsSame()" mat-raised-button><mat-icon>done</mat-icon>
|
||||
<ng-container i18n="Settings save button">Save</ng-container>
|
||||
</button>
|
||||
<button style="margin-left: 10px;" mat-flat-button (click)="cancelSettings()" [disabled]="settingsSame()"><mat-icon>cancel</mat-icon>
|
||||
<span i18n="Settings cancel button">Cancel</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -2,6 +2,15 @@
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.settings-title {
|
||||
text-align: center;
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
::ng-deep .mat-tab-body {
|
||||
margin-left: 15px;
|
||||
}
|
||||
|
||||
.ext-divider {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
@@ -23,7 +32,8 @@
|
||||
}
|
||||
|
||||
.text-field {
|
||||
min-width: 30%;
|
||||
width: 95%;
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
.checkbox-button {
|
||||
@@ -77,8 +87,22 @@
|
||||
}
|
||||
|
||||
.category-custom-placeholder {
|
||||
background: #ccc;
|
||||
border: dotted 3px #999;
|
||||
min-height: 60px;
|
||||
transition: transform 250ms cubic-bezier(0, 0, 0.2, 1);
|
||||
background: #ccc;
|
||||
border: dotted 3px #999;
|
||||
min-height: 60px;
|
||||
transition: transform 250ms cubic-bezier(0, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.test-connection-div {
|
||||
margin-top: 15px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.transfer-db-div {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
position: absolute;
|
||||
bottom: 15px;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user