mirror of
https://github.com/Tzahi12345/YoutubeDL-Material.git
synced 2026-03-07 20:10:03 +03:00
Compare commits
131 Commits
ui-updates
...
angular-17
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e2baa30d9a | ||
|
|
a74bca05cc | ||
|
|
7d3458ea41 | ||
|
|
6a7c1c9d0b | ||
|
|
e2e3dd280a | ||
|
|
25bf7a6fdd | ||
|
|
c56987ddd5 | ||
|
|
72399b09e4 | ||
|
|
4258b82040 | ||
|
|
7ac6a50b41 | ||
|
|
fb92975b73 | ||
|
|
b4cf1e39b9 | ||
|
|
026f24a327 | ||
|
|
1bf348f481 | ||
|
|
eb8cd3fd06 | ||
|
|
f96ffab530 | ||
|
|
dcb53691e3 | ||
|
|
2cf21541bb | ||
|
|
13e46397e9 | ||
|
|
7f079c56d0 | ||
|
|
e082919cd0 | ||
|
|
a89378b99f | ||
|
|
4dc899439e | ||
|
|
9b38c56528 | ||
|
|
0644b194d0 | ||
|
|
344d959c05 | ||
|
|
3912655912 | ||
|
|
cdf82abf3f | ||
|
|
84464db0e0 | ||
|
|
4bf03bfd1a | ||
|
|
75cbe4d5d0 | ||
|
|
9556f9c94f | ||
|
|
4a97fa4ef5 | ||
|
|
2c155b74a9 | ||
|
|
25e4c114e8 | ||
|
|
6152df3486 | ||
|
|
7cf5d86fc3 | ||
|
|
f57e0ab187 | ||
|
|
517c9e169d | ||
|
|
69d8751484 | ||
|
|
c3c8f50a92 | ||
|
|
caadf4f9d2 | ||
|
|
d10401cead | ||
|
|
d02d100001 | ||
|
|
6b59446a37 | ||
|
|
4fd25e1e49 | ||
|
|
d30c338189 | ||
|
|
509e996107 | ||
|
|
240e87b453 | ||
|
|
eaefcc5b96 | ||
|
|
85577ac528 | ||
|
|
41050ce923 | ||
|
|
55bc5339f5 | ||
|
|
0e33b2db2b | ||
|
|
1456c25978 | ||
|
|
67c38039b0 | ||
|
|
8f246d905f | ||
|
|
91c2fdc701 | ||
|
|
2c97403027 | ||
|
|
3151200d33 | ||
|
|
c5ed835b09 | ||
|
|
8a588cf858 | ||
|
|
2396c86486 | ||
|
|
2cc2428db2 | ||
|
|
80e83ba817 | ||
|
|
0565cf24a6 | ||
|
|
353c35cd8d | ||
|
|
169a057c37 | ||
|
|
ab6d0f199e | ||
|
|
ae48a4c195 | ||
|
|
241473b99d | ||
|
|
ba98548662 | ||
|
|
72419d7be9 | ||
|
|
50079d2ab7 | ||
|
|
ee21f79fff | ||
|
|
097a3509c1 | ||
|
|
cc0fa03aca | ||
|
|
477cba93cd | ||
|
|
eda3dfcac7 | ||
|
|
188876e383 | ||
|
|
2c70e1367d | ||
|
|
7012524c61 | ||
|
|
cc6dfbf928 | ||
|
|
6ebda81225 | ||
|
|
a50476ac58 | ||
|
|
99c5cf590e | ||
|
|
8ec787c3e3 | ||
|
|
69b5fb50ce | ||
|
|
682c3c98d9 | ||
|
|
5fe2110711 | ||
|
|
3d24b1dc82 | ||
|
|
71086a3bc7 | ||
|
|
9b0cb1a66b | ||
|
|
ace2d83acd | ||
|
|
90f46f0c1c | ||
|
|
609b55754d | ||
|
|
15ca3f27b9 | ||
|
|
3ef8a576b7 | ||
|
|
c807ca2844 | ||
|
|
c823e28a26 | ||
|
|
3170b6aec3 | ||
|
|
57f5d2822a | ||
|
|
9950663326 | ||
|
|
5c8602e1b7 | ||
|
|
d3cb957991 | ||
|
|
098c5a3c25 | ||
|
|
be71a9bd8c | ||
|
|
42c600cea9 | ||
|
|
0427f91cfc | ||
|
|
51552b3092 | ||
|
|
1a7ca0343a | ||
|
|
525e8e04e8 | ||
|
|
5a824cee82 | ||
|
|
13a03a722c | ||
|
|
f7d3835111 | ||
|
|
8212acbac6 | ||
|
|
2a1af69f1f | ||
|
|
1f615a2379 | ||
|
|
f50d3104de | ||
|
|
f23ca61dab | ||
|
|
6eadb37532 | ||
|
|
2c61260e0f | ||
|
|
ba0de7f95c | ||
|
|
3dfdbcb151 | ||
|
|
c207e56855 | ||
|
|
441131e930 | ||
|
|
7447ca038a | ||
|
|
c5d1b3ffcf | ||
|
|
c64140b605 | ||
|
|
6579f2b59e | ||
|
|
9f5b584593 |
39
.devcontainer/devcontainer.json
Normal file
39
.devcontainer/devcontainer.json
Normal file
@@ -0,0 +1,39 @@
|
||||
// README at: https://github.com/devcontainers/templates/tree/main/src/javascript-node
|
||||
{
|
||||
"name": "Node.js",
|
||||
// Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile
|
||||
"image": "mcr.microsoft.com/devcontainers/javascript-node:0-18-bullseye",
|
||||
"features": {
|
||||
"ghcr.io/devcontainers-contrib/features/jshint:2": {},
|
||||
"ghcr.io/devcontainers-contrib/features/angular-cli:2": {},
|
||||
"ghcr.io/devcontainers/features/github-cli:1": {}
|
||||
},
|
||||
|
||||
// Use 'forwardPorts' to make a list of ports inside the container available locally.
|
||||
"forwardPorts": [4200, 17442],
|
||||
|
||||
// Use 'postCreateCommand' to run commands after the container is created.
|
||||
"postCreateCommand": "npm install && cd backend && npm install",
|
||||
|
||||
// Configure tool-specific properties.
|
||||
"customizations": {
|
||||
// Configure properties specific to VS Code.
|
||||
"vscode": {
|
||||
// Add the IDs of extensions you want installed when the container is created.
|
||||
"extensions": [
|
||||
"ms-python.python",
|
||||
"Angular.ng-template",
|
||||
"dbaeumer.vscode-eslint",
|
||||
"waderyan.gitblame",
|
||||
"42Crunch.vscode-openapi",
|
||||
"christian-kohler.npm-intellisense",
|
||||
"redhat.vscode-yaml",
|
||||
"hbenl.vscode-mocha-test-adapter",
|
||||
"DavidAnson.vscode-markdownlint"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
// Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
|
||||
// "remoteUser": "root"
|
||||
}
|
||||
8
.github/workflows/build.yml
vendored
8
.github/workflows/build.yml
vendored
@@ -13,11 +13,11 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: checkout code
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v4
|
||||
- name: setup node
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: '16'
|
||||
node-version: '18'
|
||||
cache: 'npm'
|
||||
- name: install dependencies
|
||||
run: |
|
||||
@@ -65,7 +65,7 @@ jobs:
|
||||
if: contains(github.ref, '/tags/v')
|
||||
steps:
|
||||
- name: checkout code
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v4
|
||||
- name: create release
|
||||
id: create_release
|
||||
uses: actions/create-release@v1
|
||||
@@ -81,7 +81,7 @@ jobs:
|
||||
draft: true
|
||||
prerelease: false
|
||||
- name: download build artifact
|
||||
uses: actions/download-artifact@v1
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: youtubedl-material
|
||||
path: ${{runner.temp}}/youtubedl-material
|
||||
|
||||
8
.github/workflows/codeql-analysis.yml
vendored
8
.github/workflows/codeql-analysis.yml
vendored
@@ -30,7 +30,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
# We must fetch at least the immediate parents so that if this is
|
||||
# a pull request then we can checkout the head.
|
||||
@@ -43,7 +43,7 @@ jobs:
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v1
|
||||
uses: github/codeql-action/init@v2
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||
@@ -54,7 +54,7 @@ jobs:
|
||||
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
||||
# If this step fails, then you should remove it and run the build manually (see below)
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@v1
|
||||
uses: github/codeql-action/autobuild@v2
|
||||
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 https://git.io/JvXDl
|
||||
@@ -68,4 +68,4 @@ jobs:
|
||||
# make release
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v1
|
||||
uses: github/codeql-action/analyze@v2
|
||||
|
||||
8
.github/workflows/docker-pr.yml
vendored
8
.github/workflows/docker-pr.yml
vendored
@@ -9,7 +9,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: checkout code
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v4
|
||||
- name: Set hash
|
||||
id: vars
|
||||
run: echo "::set-output name=sha_short::$(git rev-parse --short HEAD)"
|
||||
@@ -24,11 +24,11 @@ jobs:
|
||||
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
|
||||
uses: docker/setup-qemu-action@v2
|
||||
- name: setup multi-arch docker build
|
||||
uses: docker/setup-buildx-action@v1
|
||||
uses: docker/setup-buildx-action@v2
|
||||
- name: build & push images
|
||||
uses: docker/build-push-action@v2
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
|
||||
12
.github/workflows/docker-release.yml
vendored
12
.github/workflows/docker-release.yml
vendored
@@ -15,7 +15,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: checkout code
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set hash
|
||||
id: vars
|
||||
@@ -47,7 +47,7 @@ jobs:
|
||||
|
||||
- name: Generate Docker image metadata
|
||||
id: docker-meta
|
||||
uses: docker/metadata-action@v4
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
${{ secrets.DOCKERHUB_USERNAME }}/${{ secrets.DOCKERHUB_REPO }}
|
||||
@@ -57,26 +57,26 @@ jobs:
|
||||
type=raw,value=latest
|
||||
|
||||
- name: setup platform emulator
|
||||
uses: docker/setup-qemu-action@v1
|
||||
uses: docker/setup-qemu-action@v2
|
||||
|
||||
- name: setup multi-arch docker build
|
||||
uses: docker/setup-buildx-action@v2
|
||||
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v2
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v2
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: build & push images
|
||||
uses: docker/build-push-action@v2
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
|
||||
14
.github/workflows/docker.yml
vendored
14
.github/workflows/docker.yml
vendored
@@ -22,7 +22,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: checkout code
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set hash
|
||||
id: vars
|
||||
@@ -41,14 +41,14 @@ jobs:
|
||||
dir: 'backend/'
|
||||
|
||||
- name: setup platform emulator
|
||||
uses: docker/setup-qemu-action@v1
|
||||
uses: docker/setup-qemu-action@v2
|
||||
|
||||
- name: setup multi-arch docker build
|
||||
uses: docker/setup-buildx-action@v2
|
||||
|
||||
- name: Generate Docker image metadata
|
||||
id: docker-meta
|
||||
uses: docker/metadata-action@v4
|
||||
uses: docker/metadata-action@v5
|
||||
# Defaults:
|
||||
# DOCKERHUB_USERNAME : tzahi12345
|
||||
# DOCKERHUB_REPO : youtubedl-material
|
||||
@@ -63,24 +63,24 @@ jobs:
|
||||
type=sha,prefix=sha-,format=short
|
||||
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v2
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v2
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: build & push images
|
||||
uses: docker/build-push-action@v2
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
platforms: linux/amd64,linux/arm64/v8
|
||||
platforms: linux/amd64,linux/arm64/v8,linux/arm/v7
|
||||
push: true
|
||||
tags: ${{ steps.docker-meta.outputs.tags }}
|
||||
labels: ${{ steps.docker-meta.outputs.labels }}
|
||||
|
||||
40
.github/workflows/mocha.yml
vendored
Normal file
40
.github/workflows/mocha.yml
vendored
Normal file
@@ -0,0 +1,40 @@
|
||||
name: Tests
|
||||
'on':
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
pull_request:
|
||||
types:
|
||||
- opened
|
||||
- synchronize
|
||||
- reopened
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: 'Backend - mocha'
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
node:
|
||||
- 18
|
||||
steps:
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: '${{ matrix.node }}'
|
||||
- uses: actions/checkout@v4
|
||||
- name: 'Cache node_modules'
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ~/.npm
|
||||
key: ${{ runner.os }}-node-v${{ matrix.node }}-${{ hashFiles('**/package-lock.json') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-node-v${{ matrix.node }}-
|
||||
working-directory: ./backend
|
||||
- uses: FedericoCarboni/setup-ffmpeg@v2
|
||||
id: setup-ffmpeg
|
||||
- name: Install Dependencies
|
||||
run: npm install --dev
|
||||
working-directory: ./backend
|
||||
- name: Run All Node.js Tests
|
||||
run: npm run test
|
||||
working-directory: ./backend
|
||||
2
.vscode/settings.json
vendored
2
.vscode/settings.json
vendored
@@ -3,6 +3,6 @@
|
||||
"mochaExplorer.cwd": "backend",
|
||||
"mochaExplorer.globImplementation": "vscode",
|
||||
"mochaExplorer.env": {
|
||||
"YTDL_MODE": "debug"
|
||||
// "YTDL_MODE": "debug"
|
||||
}
|
||||
}
|
||||
50
Dockerfile
50
Dockerfile
@@ -1,5 +1,5 @@
|
||||
# Fetching our utils
|
||||
FROM ubuntu:22.04 AS utils
|
||||
FROM ubuntu:23.04 AS utils
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
# Use script due local build compability
|
||||
COPY docker-utils/*.sh .
|
||||
@@ -8,41 +8,39 @@ RUN sh ./ffmpeg-fetch.sh
|
||||
RUN sh ./fetch-twitchdownloader.sh
|
||||
|
||||
|
||||
# Create our Ubuntu 22.04 with node 16.14.2 (that specific version is required as per: https://stackoverflow.com/a/72855258/8088021)
|
||||
# Go to 20.04
|
||||
FROM ubuntu:22.04 AS base
|
||||
# Create our Ubuntu 22.04 with node 18.19.0
|
||||
FROM ubuntu:23.04 AS base
|
||||
ARG TARGETPLATFORM
|
||||
ARG DEBIAN_FRONTEND=noninteractive
|
||||
ENV UID=1000
|
||||
ENV GID=1000
|
||||
ENV UID=1001
|
||||
ENV GID=1001
|
||||
ENV USER=youtube
|
||||
ENV NO_UPDATE_NOTIFIER=true
|
||||
ENV PM2_HOME=/app/pm2
|
||||
ENV ALLOW_CONFIG_MUTATIONS=true
|
||||
# Directy fetch specific version
|
||||
## https://deb.nodesource.com/node_16.x/pool/main/n/nodejs/nodejs_16.14.2-deb-1nodesource1_amd64.deb
|
||||
ENV npm_config_cache=/app/.npm
|
||||
|
||||
# Use NVM to get specific node version
|
||||
ENV NODE_VERSION=18.19.0
|
||||
RUN groupadd -g $GID $USER && useradd --system -m -g $USER --uid $UID $USER && \
|
||||
apt update && \
|
||||
apt install -y --no-install-recommends curl ca-certificates tzdata libicu70 && \
|
||||
apt install -y --no-install-recommends curl ca-certificates tzdata libatomic1 && \
|
||||
apt clean && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
RUN case ${TARGETPLATFORM} in \
|
||||
"linux/amd64") NODE_ARCH=amd64 ;; \
|
||||
"linux/arm") NODE_ARCH=armhf ;; \
|
||||
"linux/arm/v7") NODE_ARCH=armhf ;; \
|
||||
"linux/arm64") NODE_ARCH=arm64 ;; \
|
||||
esac \
|
||||
&& curl -L https://deb.nodesource.com/node_16.x/pool/main/n/nodejs/nodejs_16.14.2-deb-1nodesource1_$NODE_ARCH.deb -o ./nodejs.deb && \
|
||||
apt update && \
|
||||
apt install -y ./nodejs.deb && \
|
||||
apt clean && \
|
||||
rm -rf /var/lib/apt/lists/* &&\
|
||||
rm nodejs.deb;
|
||||
|
||||
RUN mkdir /usr/local/nvm
|
||||
ENV PATH="/usr/local/nvm/versions/node/v${NODE_VERSION}/bin/:${PATH}"
|
||||
ENV NVM_DIR=/usr/local/nvm
|
||||
RUN curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.5/install.sh | bash
|
||||
RUN . "$NVM_DIR/nvm.sh" && nvm install ${NODE_VERSION}
|
||||
RUN . "$NVM_DIR/nvm.sh" && nvm use v${NODE_VERSION}
|
||||
RUN . "$NVM_DIR/nvm.sh" && nvm alias default v${NODE_VERSION}
|
||||
|
||||
RUN npm install -g npm
|
||||
|
||||
# Build frontend
|
||||
ARG BUILDPLATFORM
|
||||
FROM --platform=${BUILDPLATFORM} node:16 as frontend
|
||||
FROM --platform=${BUILDPLATFORM} node:18 as frontend
|
||||
RUN npm install -g @angular/cli
|
||||
WORKDIR /build
|
||||
COPY [ "package.json", "package-lock.json", "angular.json", "tsconfig.json", "/build/" ]
|
||||
@@ -59,6 +57,8 @@ FROM base as backend
|
||||
WORKDIR /app
|
||||
COPY [ "backend/","/app/" ]
|
||||
RUN npm config set strict-ssl false && \
|
||||
npm config set registry https://registry.npm.taobao.org && \
|
||||
npm config set fetch-retry-maxtimeout 60000 && \
|
||||
npm install --prod && \
|
||||
ls -al
|
||||
|
||||
@@ -75,10 +75,10 @@ RUN npm config set strict-ssl false && \
|
||||
|
||||
# Final image
|
||||
FROM base
|
||||
RUN npm install -g pm2 && \
|
||||
apt update && \
|
||||
RUN apt update && \
|
||||
curl -sL https://raw.githubusercontent.com/Unitech/pm2/master/packager/setup.deb.sh | bash && \
|
||||
apt install -y --no-install-recommends gosu python3-minimal python-is-python3 python3-pip atomicparsley build-essential && \
|
||||
pip install pycryptodomex && \
|
||||
pip install pycryptodomex --break-system-packages && \
|
||||
apt remove -y --purge build-essential && \
|
||||
apt autoremove -y --purge && \
|
||||
apt clean && \
|
||||
|
||||
@@ -293,6 +293,48 @@ paths:
|
||||
$ref: '#/components/schemas/UnsubscribeResponse'
|
||||
security:
|
||||
- Auth query parameter: []
|
||||
/api/checkSubscription:
|
||||
post:
|
||||
tags:
|
||||
- subscriptions
|
||||
summary: Run a check for videos for a subscription
|
||||
description: Runs a subscription check
|
||||
operationId: post-api-checksubscription
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/CheckSubscriptionRequest'
|
||||
responses:
|
||||
'200':
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/SuccessObject'
|
||||
security:
|
||||
- Auth query parameter: []
|
||||
/api/cancelCheckSubscription:
|
||||
post:
|
||||
tags:
|
||||
- subscriptions
|
||||
summary: Cancels check for videos for a subscription
|
||||
description: Cancels subscription check
|
||||
operationId: post-api-checksubscription
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/CheckSubscriptionRequest'
|
||||
responses:
|
||||
'200':
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/SuccessObject'
|
||||
security:
|
||||
- Auth query parameter: []
|
||||
/api/deleteSubscriptionFile:
|
||||
post:
|
||||
tags:
|
||||
@@ -1758,14 +1800,14 @@ components:
|
||||
type: object
|
||||
properties:
|
||||
task_key:
|
||||
type: string
|
||||
$ref: '#/components/schemas/TaskType'
|
||||
required:
|
||||
- task_key
|
||||
UpdateTaskScheduleRequest:
|
||||
type: object
|
||||
properties:
|
||||
task_key:
|
||||
type: string
|
||||
$ref: '#/components/schemas/TaskType'
|
||||
new_schedule:
|
||||
$ref: '#/components/schemas/Schedule'
|
||||
required:
|
||||
@@ -1775,7 +1817,7 @@ components:
|
||||
type: object
|
||||
properties:
|
||||
task_key:
|
||||
type: string
|
||||
$ref: '#/components/schemas/TaskType'
|
||||
new_data:
|
||||
type: object
|
||||
required:
|
||||
@@ -1785,7 +1827,7 @@ components:
|
||||
type: object
|
||||
properties:
|
||||
task_key:
|
||||
type: string
|
||||
$ref: '#/components/schemas/TaskType'
|
||||
new_options:
|
||||
type: object
|
||||
required:
|
||||
@@ -1981,11 +2023,11 @@ components:
|
||||
type: string
|
||||
UnsubscribeRequest:
|
||||
required:
|
||||
- sub
|
||||
- sub_id
|
||||
type: object
|
||||
properties:
|
||||
sub:
|
||||
$ref: '#/components/schemas/SubscriptionRequestData'
|
||||
sub_id:
|
||||
type: string
|
||||
deleteMode:
|
||||
type: boolean
|
||||
description: Defaults to false
|
||||
@@ -1998,6 +2040,13 @@ components:
|
||||
type: boolean
|
||||
error:
|
||||
type: string
|
||||
CheckSubscriptionRequest:
|
||||
required:
|
||||
- sub_id
|
||||
type: object
|
||||
properties:
|
||||
sub_id:
|
||||
type: string
|
||||
DeleteAllFilesResponse:
|
||||
type: object
|
||||
properties:
|
||||
@@ -2683,6 +2732,8 @@ components:
|
||||
type: boolean
|
||||
paused:
|
||||
type: boolean
|
||||
cancelled:
|
||||
type: boolean
|
||||
finished_step:
|
||||
type: boolean
|
||||
url:
|
||||
@@ -2726,7 +2777,7 @@ components:
|
||||
type: object
|
||||
properties:
|
||||
key:
|
||||
type: string
|
||||
$ref: '#/components/schemas/TaskType'
|
||||
title:
|
||||
type: string
|
||||
last_ran:
|
||||
@@ -2745,6 +2796,17 @@ components:
|
||||
$ref: '#/components/schemas/Schedule'
|
||||
options:
|
||||
type: object
|
||||
TaskType:
|
||||
type: string
|
||||
enum:
|
||||
- backup_local_db
|
||||
- missing_files_check
|
||||
- missing_db_records
|
||||
- duplicate_files_check
|
||||
- youtubedl_update_check
|
||||
- delete_old_files
|
||||
- import_legacy_archives
|
||||
- rebuild_database
|
||||
Schedule:
|
||||
required:
|
||||
- type
|
||||
@@ -2830,6 +2892,8 @@ components:
|
||||
nullable: true
|
||||
isPlaylist:
|
||||
type: boolean
|
||||
child_process:
|
||||
type: object
|
||||
archive:
|
||||
type: string
|
||||
timerange:
|
||||
@@ -2838,6 +2902,10 @@ components:
|
||||
type: string
|
||||
custom_output:
|
||||
type: string
|
||||
downloading:
|
||||
type: boolean
|
||||
paused:
|
||||
type: boolean
|
||||
videos:
|
||||
type: array
|
||||
items:
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
[](https://github.com/Tzahi12345/YoutubeDL-Material/issues)
|
||||
[](https://github.com/Tzahi12345/YoutubeDL-Material/blob/master/LICENSE.md)
|
||||
|
||||
YoutubeDL-Material is a Material Design frontend for [youtube-dl](https://rg3.github.io/youtube-dl/). It's coded using [Angular 15](https://angular.io/) for the frontend, and [Node.js](https://nodejs.org/) on the backend.
|
||||
YoutubeDL-Material is a Material Design frontend for [youtube-dl](https://rg3.github.io/youtube-dl/). It's coded using [Angular 17](https://angular.io/) for the frontend, and [Node.js](https://nodejs.org/) on the backend.
|
||||
|
||||
Now with [Docker](#Docker) support!
|
||||
|
||||
@@ -30,7 +30,7 @@ NOTE: If you would like to use Docker, you can skip down to the [Docker](#Docker
|
||||
|
||||
Required dependencies:
|
||||
|
||||
* Node.js 16
|
||||
* Node.js 18
|
||||
* Python
|
||||
|
||||
Optional dependencies:
|
||||
@@ -42,7 +42,7 @@ Optional dependencies:
|
||||
<summary>Debian/Ubuntu</summary>
|
||||
|
||||
```bash
|
||||
curl -fsSL https://deb.nodesource.com/setup_16.x | sudo -E bash -
|
||||
curl -fsSL https://deb.nodesource.com/setup_18.x | sudo -E bash -
|
||||
sudo apt-get install nodejs youtube-dl ffmpeg unzip python npm
|
||||
```
|
||||
|
||||
@@ -57,7 +57,7 @@ sudo yum localinstall --nogpgcheck https://download1.rpmfusion.org/free/el/rpmfu
|
||||
sudo yum install centos-release-scl-rh
|
||||
sudo yum install rh-nodejs12
|
||||
scl enable rh-nodejs12 bash
|
||||
curl -fsSL https://rpm.nodesource.com/setup_16.x | sudo bash -
|
||||
curl -fsSL https://rpm.nodesource.com/setup_18.x | sudo bash -
|
||||
sudo yum install nodejs youtube-dl ffmpeg ffmpeg-devel
|
||||
```
|
||||
|
||||
|
||||
19
angular.json
19
angular.json
@@ -66,6 +66,14 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"codespaces": {
|
||||
"fileReplacements": [
|
||||
{
|
||||
"replace": "src/environments/environment.ts",
|
||||
"with": "src/environments/environment.codespaces.ts"
|
||||
}
|
||||
]
|
||||
},
|
||||
"es": {
|
||||
"localize": ["es"]
|
||||
}
|
||||
@@ -75,21 +83,24 @@
|
||||
"serve": {
|
||||
"builder": "@angular-devkit/build-angular:dev-server",
|
||||
"options": {
|
||||
"browserTarget": "youtube-dl-material:build"
|
||||
"buildTarget": "youtube-dl-material:build"
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
"browserTarget": "youtube-dl-material:build:production"
|
||||
"buildTarget": "youtube-dl-material:build:production"
|
||||
},
|
||||
"es": {
|
||||
"browserTarget": "youtube-dl-material:build:es"
|
||||
"buildTarget": "youtube-dl-material:build:es"
|
||||
},
|
||||
"codespaces": {
|
||||
"buildTarget": "youtube-dl-material:build:codespaces"
|
||||
}
|
||||
}
|
||||
},
|
||||
"extract-i18n": {
|
||||
"builder": "@angular-devkit/build-angular:extract-i18n",
|
||||
"options": {
|
||||
"browserTarget": "youtube-dl-material:build"
|
||||
"buildTarget": "youtube-dl-material:build"
|
||||
}
|
||||
},
|
||||
"serve-electron": {
|
||||
|
||||
233
backend/app.js
233
backend/app.js
@@ -1,4 +1,4 @@
|
||||
const { uuid } = require('uuidv4');
|
||||
const { v4: uuid } = require('uuid');
|
||||
const fs = require('fs-extra');
|
||||
const { promisify } = require('util');
|
||||
const auth_api = require('./authentication/auth');
|
||||
@@ -20,11 +20,6 @@ const ps = require('ps-node');
|
||||
const Feed = require('feed').Feed;
|
||||
const session = require('express-session');
|
||||
|
||||
// needed if bin/details somehow gets deleted
|
||||
if (!fs.existsSync(CONSTS.DETAILS_BIN_PATH)) fs.writeJSONSync(CONSTS.DETAILS_BIN_PATH, {"version":"2000.06.06","path":"node_modules\\youtube-dl\\bin\\youtube-dl.exe","exec":"youtube-dl.exe","downloader":"youtube-dl"})
|
||||
|
||||
const youtubedl = require('youtube-dl');
|
||||
|
||||
const logger = require('./logger');
|
||||
const config_api = require('./config.js');
|
||||
const downloader_api = require('./downloader');
|
||||
@@ -35,6 +30,7 @@ const twitch_api = require('./twitch');
|
||||
const youtubedl_api = require('./youtube-dl');
|
||||
const archive_api = require('./archive');
|
||||
const files_api = require('./files');
|
||||
const notifications_api = require('./notifications');
|
||||
|
||||
var app = express();
|
||||
|
||||
@@ -535,15 +531,10 @@ async function loadConfig() {
|
||||
if (allowSubscriptions) {
|
||||
// set downloading to false
|
||||
let subscriptions = await subscriptions_api.getAllSubscriptions();
|
||||
subscriptions_api.updateSubscriptionPropertyMultiple(subscriptions, {downloading: false});
|
||||
subscriptions.forEach(async sub => subscriptions_api.writeSubscriptionMetadata(sub));
|
||||
subscriptions_api.updateSubscriptionPropertyMultiple(subscriptions, {downloading: false, child_process: null});
|
||||
// runs initially, then runs every ${subscriptionCheckInterval} seconds
|
||||
const watchSubscriptionsInterval = function() {
|
||||
watchSubscriptions();
|
||||
const subscriptionsCheckInterval = config_api.getConfigItem('ytdl_subscriptions_check_interval');
|
||||
setTimeout(watchSubscriptionsInterval, subscriptionsCheckInterval*1000);
|
||||
}
|
||||
|
||||
watchSubscriptionsInterval();
|
||||
subscriptions_api.watchSubscriptionsInterval();
|
||||
}
|
||||
|
||||
// start the server here
|
||||
@@ -573,63 +564,8 @@ function loadConfigValues() {
|
||||
utils.updateLoggerLevel(logger_level);
|
||||
}
|
||||
|
||||
function calculateSubcriptionRetrievalDelay(subscriptions_amount) {
|
||||
// frequency is once every 5 mins by default
|
||||
const subscriptionsCheckInterval = config_api.getConfigItem('ytdl_subscriptions_check_interval');
|
||||
let interval_in_ms = subscriptionsCheckInterval * 1000;
|
||||
const subinterval_in_ms = interval_in_ms/subscriptions_amount;
|
||||
return subinterval_in_ms;
|
||||
}
|
||||
|
||||
async function watchSubscriptions() {
|
||||
let subscriptions = await subscriptions_api.getAllSubscriptions();
|
||||
|
||||
if (!subscriptions) return;
|
||||
|
||||
// auto pause deprecated streamingOnly mode
|
||||
const streaming_only_subs = subscriptions.filter(sub => sub.streamingOnly);
|
||||
subscriptions_api.updateSubscriptionPropertyMultiple(streaming_only_subs, {paused: true});
|
||||
|
||||
const valid_subscriptions = subscriptions.filter(sub => !sub.paused && !sub.streamingOnly);
|
||||
|
||||
let subscriptions_amount = valid_subscriptions.length;
|
||||
let delay_interval = calculateSubcriptionRetrievalDelay(subscriptions_amount);
|
||||
|
||||
let current_delay = 0;
|
||||
|
||||
const multiUserMode = config_api.getConfigItem('ytdl_multi_user_mode');
|
||||
for (let i = 0; i < valid_subscriptions.length; i++) {
|
||||
let sub = valid_subscriptions[i];
|
||||
|
||||
// don't check the sub if the last check for the same subscription has not completed
|
||||
if (subscription_timeouts[sub.id]) {
|
||||
logger.verbose(`Subscription: skipped checking ${sub.name} as the last check for ${sub.name} has not completed.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!sub.name) {
|
||||
logger.verbose(`Subscription: skipped check for subscription with uid ${sub.id} as name has not been retrieved yet.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
logger.verbose('Watching ' + sub.name + ' with delay interval of ' + delay_interval);
|
||||
setTimeout(async () => {
|
||||
const multiUserModeChanged = config_api.getConfigItem('ytdl_multi_user_mode') !== multiUserMode;
|
||||
if (multiUserModeChanged) {
|
||||
logger.verbose(`Skipping subscription ${sub.name} due to multi-user mode change.`);
|
||||
return;
|
||||
}
|
||||
await subscriptions_api.getVideosForSub(sub, sub.user_uid);
|
||||
subscription_timeouts[sub.id] = false;
|
||||
}, current_delay);
|
||||
subscription_timeouts[sub.id] = true;
|
||||
current_delay += delay_interval;
|
||||
const subscriptionsCheckInterval = config_api.getConfigItem('ytdl_subscriptions_check_interval');
|
||||
if (current_delay >= subscriptionsCheckInterval * 1000) current_delay = 0;
|
||||
}
|
||||
}
|
||||
|
||||
function getOrigin() {
|
||||
if (process.env.CODESPACES) return `https://${process.env.CODESPACE_NAME}-4200.${process.env.GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN}`;
|
||||
return url_domain.origin;
|
||||
}
|
||||
|
||||
@@ -654,38 +590,11 @@ function generateEnvVarConfigItem(key) {
|
||||
return {key: key, value: process['env'][key]};
|
||||
}
|
||||
|
||||
// currently only works for single urls
|
||||
async function getUrlInfos(url) {
|
||||
let startDate = Date.now();
|
||||
let result = [];
|
||||
return new Promise(resolve => {
|
||||
youtubedl.exec(url, ['--dump-json'], {maxBuffer: Infinity}, (err, output) => {
|
||||
let new_date = Date.now();
|
||||
let difference = (new_date - startDate)/1000;
|
||||
logger.debug(`URL info retrieval delay: ${difference} seconds.`);
|
||||
if (err) {
|
||||
logger.error(`Error during retrieving formats for ${url}: ${err}`);
|
||||
resolve(null);
|
||||
}
|
||||
let try_putput = null;
|
||||
try {
|
||||
try_putput = JSON.parse(output);
|
||||
result = try_putput;
|
||||
} catch(e) {
|
||||
logger.error(`Failed to retrieve available formats for url: ${url}`);
|
||||
}
|
||||
resolve(result);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// youtube-dl functions
|
||||
|
||||
async function startYoutubeDL() {
|
||||
// auto update youtube-dl
|
||||
youtubedl_api.verifyBinaryExistsLinux();
|
||||
const update_available = await youtubedl_api.checkForYoutubeDLUpdate();
|
||||
if (update_available) await youtubedl_api.updateYoutubeDL(update_available);
|
||||
await youtubedl_api.checkForYoutubeDLUpdate();
|
||||
}
|
||||
|
||||
app.use(function(req, res, next) {
|
||||
@@ -705,7 +614,7 @@ app.use(function(req, res, next) {
|
||||
next();
|
||||
} else if (req.query.apiKey && config_api.getConfigItem('ytdl_use_api_key') && req.query.apiKey === config_api.getConfigItem('ytdl_api_key')) {
|
||||
next();
|
||||
} else if (req.path.includes('/api/stream/') || req.path.includes('/api/thumbnail/') || req.path.includes('/api/rss')) {
|
||||
} else if (req.path.includes('/api/stream/') || req.path.includes('/api/thumbnail/') || req.path.includes('/api/rss') || req.path.includes('/api/telegramRequest')) {
|
||||
next();
|
||||
} else {
|
||||
logger.verbose(`Rejecting request - invalid API use for endpoint: ${req.path}. API key received: ${req.query.apiKey}`);
|
||||
@@ -1211,10 +1120,10 @@ app.post('/api/subscribe', optionalJwt, async (req, res) => {
|
||||
|
||||
app.post('/api/unsubscribe', optionalJwt, async (req, res) => {
|
||||
let deleteMode = req.body.deleteMode
|
||||
let sub = req.body.sub;
|
||||
let sub_id = req.body.sub_id;
|
||||
let user_uid = req.isAuthenticated() ? req.user.uid : null;
|
||||
|
||||
let result_obj = subscriptions_api.unsubscribe(sub, deleteMode, user_uid);
|
||||
let result_obj = subscriptions_api.unsubscribe(sub_id, deleteMode, user_uid);
|
||||
if (result_obj.success) {
|
||||
res.send({
|
||||
success: result_obj.success
|
||||
@@ -1284,21 +1193,49 @@ app.post('/api/getSubscription', optionalJwt, async (req, res) => {
|
||||
});
|
||||
|
||||
app.post('/api/downloadVideosForSubscription', optionalJwt, async (req, res) => {
|
||||
let subID = req.body.subID;
|
||||
let user_uid = req.isAuthenticated() ? req.user.uid : null;
|
||||
const subID = req.body.subID;
|
||||
|
||||
let sub = subscriptions_api.getSubscription(subID, user_uid);
|
||||
subscriptions_api.getVideosForSub(sub, user_uid);
|
||||
const sub = subscriptions_api.getSubscription(subID);
|
||||
subscriptions_api.getVideosForSub(sub.id);
|
||||
res.send({
|
||||
success: true
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/api/updateSubscription', optionalJwt, async (req, res) => {
|
||||
let updated_sub = req.body.subscription;
|
||||
const updated_sub = req.body.subscription;
|
||||
|
||||
const success = subscriptions_api.updateSubscription(updated_sub);
|
||||
res.send({
|
||||
success: success
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/api/checkSubscription', optionalJwt, async (req, res) => {
|
||||
let sub_id = req.body.sub_id;
|
||||
let user_uid = req.isAuthenticated() ? req.user.uid : null;
|
||||
|
||||
let success = subscriptions_api.updateSubscription(updated_sub, user_uid);
|
||||
const success = subscriptions_api.getVideosForSub(sub_id, user_uid);
|
||||
res.send({
|
||||
success: success
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/api/cancelCheckSubscription', optionalJwt, async (req, res) => {
|
||||
let sub_id = req.body.sub_id;
|
||||
let user_uid = req.isAuthenticated() ? req.user.uid : null;
|
||||
|
||||
const success = subscriptions_api.cancelCheckSubscription(sub_id, user_uid);
|
||||
res.send({
|
||||
success: success
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/api/cancelSubscriptionCheck', optionalJwt, async (req, res) => {
|
||||
let sub_id = req.body.sub_id;
|
||||
let user_uid = req.isAuthenticated() ? req.user.uid : null;
|
||||
|
||||
const success = subscriptions_api.getVideosForSub(sub_id, user_uid);
|
||||
res.send({
|
||||
success: success
|
||||
});
|
||||
@@ -1642,6 +1579,7 @@ app.get('/api/stream', optionalJwt, async (req, res) => {
|
||||
}
|
||||
if (!fs.existsSync(file_path)) {
|
||||
logger.error(`File ${file_path} could not be found! UID: ${uid}, ID: ${file_obj && file_obj.id}`);
|
||||
return;
|
||||
}
|
||||
const stat = fs.statSync(file_path);
|
||||
const fileSize = stat.size;
|
||||
@@ -1776,6 +1714,10 @@ app.post('/api/cancelDownload', optionalJwt, async (req, res) => {
|
||||
app.post('/api/getTasks', optionalJwt, async (req, res) => {
|
||||
const tasks = await db_api.getRecords('tasks');
|
||||
for (let task of tasks) {
|
||||
if (!tasks_api.TASKS[task['key']]) {
|
||||
logger.verbose(`Task ${task['key']} does not exist!`);
|
||||
continue;
|
||||
}
|
||||
if (task['schedule']) task['next_invocation'] = tasks_api.TASKS[task['key']]['job'].nextInvocation().getTime();
|
||||
}
|
||||
res.send({tasks: tasks});
|
||||
@@ -1918,19 +1860,48 @@ app.post('/api/clearAllLogs', optionalJwt, async function(req, res) {
|
||||
});
|
||||
|
||||
app.post('/api/getFileFormats', optionalJwt, async (req, res) => {
|
||||
let url = req.body.url;
|
||||
let result = await getUrlInfos(url);
|
||||
const url = req.body.url;
|
||||
const result = await downloader_api.getVideoInfoByURL(url);
|
||||
res.send({
|
||||
result: result,
|
||||
success: !!result
|
||||
result: result && result.length === 1 ? result[0] : null,
|
||||
success: result && result.length === 0
|
||||
})
|
||||
});
|
||||
|
||||
// user authentication
|
||||
|
||||
app.post('/api/auth/register'
|
||||
, optionalJwt
|
||||
, auth_api.registerUser);
|
||||
app.post('/api/auth/register', optionalJwt, async (req, res) => {
|
||||
const userid = req.body.userid;
|
||||
const username = req.body.username;
|
||||
const plaintextPassword = req.body.password;
|
||||
|
||||
if (userid !== 'admin' && !config_api.getConfigItem('ytdl_allow_registration') && !req.isAuthenticated() && (!req.user || !exports.userHasPermission(req.user.uid, 'settings'))) {
|
||||
logger.error(`Registration failed for user ${userid}. Registration is disabled.`);
|
||||
res.sendStatus(409);
|
||||
return;
|
||||
}
|
||||
|
||||
if (plaintextPassword === "") {
|
||||
logger.error(`Registration failed for user ${userid}. A password must be provided.`);
|
||||
res.sendStatus(409);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!userid || !username) {
|
||||
logger.error(`Registration failed for user ${userid}. Username or userid is invalid.`);
|
||||
}
|
||||
|
||||
const new_user = await auth_api.registerUser(userid, username, plaintextPassword);
|
||||
|
||||
if (!new_user) {
|
||||
res.sendStatus(409);
|
||||
return;
|
||||
}
|
||||
|
||||
res.send({
|
||||
user: new_user
|
||||
});
|
||||
});
|
||||
app.post('/api/auth/login'
|
||||
, auth_api.passport.authenticate(['local', 'ldapauth'], {})
|
||||
, auth_api.generateJWT
|
||||
@@ -1982,18 +1953,7 @@ app.post('/api/updateUser', optionalJwt, async (req, res) => {
|
||||
app.post('/api/deleteUser', optionalJwt, async (req, res) => {
|
||||
let uid = req.body.uid;
|
||||
try {
|
||||
let success = false;
|
||||
let usersFileFolder = config_api.getConfigItem('ytdl_users_base_path');
|
||||
const user_folder = path.join(__dirname, usersFileFolder, uid);
|
||||
const user_db_obj = await db_api.getRecord('users', {uid: uid});
|
||||
if (user_db_obj) {
|
||||
// user exists, let's delete
|
||||
await fs.remove(user_folder);
|
||||
await db_api.removeRecord('users', {uid: uid});
|
||||
success = true;
|
||||
} else {
|
||||
logger.error(`Could not find user with uid ${uid}`);
|
||||
}
|
||||
const success = await auth_api.deleteUser(uid);
|
||||
res.send({success: success});
|
||||
} catch (err) {
|
||||
logger.error(err);
|
||||
@@ -2066,6 +2026,25 @@ app.post('/api/deleteAllNotifications', optionalJwt, async (req, res) => {
|
||||
res.send({success: success});
|
||||
});
|
||||
|
||||
app.post('/api/telegramRequest', async (req, res) => {
|
||||
if (!req.body.message && !req.body.message.text) {
|
||||
logger.error('Invalid Telegram request received!');
|
||||
res.sendStatus(400);
|
||||
return;
|
||||
}
|
||||
const text = req.body.message.text;
|
||||
const regex_exp = /https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*)?/gi;
|
||||
const url_regex = new RegExp(regex_exp);
|
||||
if (text.match(url_regex)) {
|
||||
downloader_api.createDownload(text, 'video', {}, req.query.user_uid ? req.query.user_uid : null);
|
||||
res.sendStatus(200);
|
||||
} else {
|
||||
logger.error('Invalid Telegram request received! Make sure you only send a valid URL.');
|
||||
notifications_api.sendTelegramNotification({title: 'Invalid Telegram Request', body: 'Make sure you only send a valid URL.', url: text});
|
||||
res.sendStatus(400);
|
||||
}
|
||||
});
|
||||
|
||||
// rss feed
|
||||
|
||||
app.get('/api/rss', async function (req, res) {
|
||||
@@ -2133,6 +2112,8 @@ app.use(function(req, res, next) {
|
||||
|
||||
let index_path = path.join(__dirname, 'public', 'index.html');
|
||||
|
||||
res.setHeader('Content-Type', 'text/html');
|
||||
|
||||
fs.createReadStream(index_path).pipe(res);
|
||||
|
||||
});
|
||||
|
||||
@@ -49,6 +49,7 @@
|
||||
"use_telegram_API": false,
|
||||
"telegram_bot_token": "",
|
||||
"telegram_chat_id": "",
|
||||
"telegram_webhook_proxy": "",
|
||||
"webhook_URL": "",
|
||||
"discord_webhook_URL": "",
|
||||
"slack_webhook_URL": ""
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
const path = require('path');
|
||||
const fs = require('fs-extra');
|
||||
const { uuid } = require('uuidv4');
|
||||
const { v4: uuid } = require('uuid');
|
||||
|
||||
const db_api = require('./db');
|
||||
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
const config_api = require('../config');
|
||||
const consts = require('../consts');
|
||||
const CONSTS = require('../consts');
|
||||
const logger = require('../logger');
|
||||
const db_api = require('../db');
|
||||
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { uuid } = require('uuidv4');
|
||||
const { v4: uuid } = require('uuid');
|
||||
const bcrypt = require('bcryptjs');
|
||||
const fs = require('fs-extra');
|
||||
const path = require('path');
|
||||
|
||||
var LocalStrategy = require('passport-local').Strategy;
|
||||
var LdapStrategy = require('passport-ldapauth');
|
||||
@@ -16,7 +18,7 @@ var JwtStrategy = require('passport-jwt').Strategy,
|
||||
let SERVER_SECRET = null;
|
||||
let JWT_EXPIRATION = null;
|
||||
let opts = null;
|
||||
let saltRounds = null;
|
||||
let saltRounds = 10;
|
||||
|
||||
exports.initialize = function () {
|
||||
/*************************
|
||||
@@ -31,8 +33,6 @@ exports.initialize = function () {
|
||||
});
|
||||
}
|
||||
|
||||
saltRounds = 10;
|
||||
|
||||
// Sometimes this value is not properly typed: https://github.com/Tzahi12345/YoutubeDL-Material/issues/813
|
||||
JWT_EXPIRATION = config_api.getConfigItem('ytdl_jwt_expiration');
|
||||
if (!(+JWT_EXPIRATION)) {
|
||||
@@ -68,7 +68,7 @@ exports.initialize = function () {
|
||||
const setupRoles = async () => {
|
||||
const required_roles = {
|
||||
admin: {
|
||||
permissions: consts.AVAILABLE_PERMISSIONS
|
||||
permissions: CONSTS.AVAILABLE_PERMISSIONS
|
||||
},
|
||||
user: {
|
||||
permissions: [
|
||||
@@ -106,55 +106,41 @@ exports.passport.deserializeUser(function(user, done) {
|
||||
/***************************************
|
||||
* Register user with hashed password
|
||||
**************************************/
|
||||
exports.registerUser = async function(req, res) {
|
||||
var userid = req.body.userid;
|
||||
var username = req.body.username;
|
||||
var plaintextPassword = req.body.password;
|
||||
|
||||
if (userid !== 'admin' && !config_api.getConfigItem('ytdl_allow_registration') && !req.isAuthenticated() && (!req.user || !exports.userHasPermission(req.user.uid, 'settings'))) {
|
||||
res.sendStatus(409);
|
||||
logger.error(`Registration failed for user ${userid}. Registration is disabled.`);
|
||||
return;
|
||||
exports.registerUser = async (userid, username, plaintextPassword) => {
|
||||
const hash = await bcrypt.hash(plaintextPassword, saltRounds);
|
||||
const new_user = generateUserObject(userid, username, hash);
|
||||
// check if user exists
|
||||
if (await db_api.getRecord('users', {uid: userid})) {
|
||||
// user id is taken!
|
||||
logger.error('Registration failed: UID is already taken!');
|
||||
return null;
|
||||
} else if (await db_api.getRecord('users', {name: username})) {
|
||||
// user name is taken!
|
||||
logger.error('Registration failed: User name is already taken!');
|
||||
return null;
|
||||
} else {
|
||||
// add to db
|
||||
await db_api.insertRecordIntoTable('users', new_user);
|
||||
logger.verbose(`New user created: ${new_user.name}`);
|
||||
return new_user;
|
||||
}
|
||||
}
|
||||
|
||||
if (plaintextPassword === "") {
|
||||
res.sendStatus(400);
|
||||
logger.error(`Registration failed for user ${userid}. A password must be provided.`);
|
||||
return;
|
||||
exports.deleteUser = async (uid) => {
|
||||
let success = false;
|
||||
let usersFileFolder = config_api.getConfigItem('ytdl_users_base_path');
|
||||
const user_folder = path.join(__dirname, usersFileFolder, uid);
|
||||
const user_db_obj = await db_api.getRecord('users', {uid: uid});
|
||||
if (user_db_obj) {
|
||||
// user exists, let's delete
|
||||
await fs.remove(user_folder);
|
||||
await db_api.removeRecord('users', {uid: uid});
|
||||
success = true;
|
||||
} else {
|
||||
logger.error(`Could not find user with uid ${uid}`);
|
||||
}
|
||||
|
||||
bcrypt.hash(plaintextPassword, saltRounds)
|
||||
.then(async function(hash) {
|
||||
let new_user = generateUserObject(userid, username, hash);
|
||||
// check if user exists
|
||||
if (await db_api.getRecord('users', {uid: userid})) {
|
||||
// user id is taken!
|
||||
logger.error('Registration failed: UID is already taken!');
|
||||
res.status(409).send('UID is already taken!');
|
||||
} else if (await db_api.getRecord('users', {name: username})) {
|
||||
// user name is taken!
|
||||
logger.error('Registration failed: User name is already taken!');
|
||||
res.status(409).send('User name is already taken!');
|
||||
} else {
|
||||
// add to db
|
||||
await db_api.insertRecordIntoTable('users', new_user);
|
||||
logger.verbose(`New user created: ${new_user.name}`);
|
||||
res.send({
|
||||
user: new_user
|
||||
});
|
||||
}
|
||||
})
|
||||
.then(function(result) {
|
||||
|
||||
})
|
||||
.catch(function(err) {
|
||||
logger.error(err);
|
||||
if( err.code == 'ER_DUP_ENTRY' ) {
|
||||
res.status(409).send('UserId already taken');
|
||||
} else {
|
||||
res.sendStatus(409);
|
||||
}
|
||||
});
|
||||
return success;
|
||||
}
|
||||
|
||||
/***************************************
|
||||
@@ -235,7 +221,7 @@ exports.returnAuthResponse = async function(req, res) {
|
||||
user: req.user,
|
||||
token: req.token,
|
||||
permissions: await exports.userPermissions(req.user.uid),
|
||||
available_permissions: consts['AVAILABLE_PERMISSIONS']
|
||||
available_permissions: CONSTS.AVAILABLE_PERMISSIONS
|
||||
});
|
||||
}
|
||||
|
||||
@@ -319,7 +305,7 @@ exports.getUserVideos = async function(user_uid, type) {
|
||||
}
|
||||
|
||||
exports.getUserVideo = async function(user_uid, file_uid, requireSharing = false) {
|
||||
let file = await db_api.getRecord('files', {file_uid: file_uid});
|
||||
let file = await db_api.getRecord('files', {uid: file_uid});
|
||||
|
||||
// prevent unauthorized users from accessing the file info
|
||||
if (file && !file['sharingEnabled'] && requireSharing) file = null;
|
||||
@@ -406,8 +392,8 @@ exports.userPermissions = async function(user_uid) {
|
||||
const role_obj = await db_api.getRecord('roles', {key: role});
|
||||
const role_permissions = role_obj['permissions'];
|
||||
|
||||
for (let i = 0; i < consts['AVAILABLE_PERMISSIONS'].length; i++) {
|
||||
let permission = consts['AVAILABLE_PERMISSIONS'][i];
|
||||
for (let i = 0; i < CONSTS.AVAILABLE_PERMISSIONS.length; i++) {
|
||||
let permission = CONSTS.AVAILABLE_PERMISSIONS[i];
|
||||
|
||||
const user_has_explicit_permission = user_obj['permissions'].includes(permission);
|
||||
const permission_in_overrides = user_obj['permission_overrides'].includes(permission);
|
||||
|
||||
@@ -32,10 +32,8 @@ async function categorize(file_jsons) {
|
||||
return null;
|
||||
}
|
||||
|
||||
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];
|
||||
for (const file_json of file_jsons) {
|
||||
for (const category of categories) {
|
||||
const rules = category['rules'];
|
||||
|
||||
// if rules for current category apply, then that is the selected category
|
||||
|
||||
@@ -1,22 +1,26 @@
|
||||
const logger = require('./logger');
|
||||
|
||||
const fs = require('fs');
|
||||
const { BehaviorSubject } = require('rxjs');
|
||||
|
||||
exports.CONFIG_ITEMS = require('./consts.js')['CONFIG_ITEMS'];
|
||||
exports.descriptors = {}; // to get rid of file locks when needed, TODO: move to youtube-dl.js
|
||||
|
||||
let CONFIG_ITEMS = require('./consts.js')['CONFIG_ITEMS'];
|
||||
const debugMode = process.env.YTDL_MODE === 'debug';
|
||||
|
||||
let configPath = debugMode ? '../src/assets/default.json' : 'appdata/default.json';
|
||||
exports.config_updated = new BehaviorSubject();
|
||||
|
||||
function initialize() {
|
||||
exports.initialize = () => {
|
||||
ensureConfigFileExists();
|
||||
ensureConfigItemsExist();
|
||||
}
|
||||
|
||||
function ensureConfigItemsExist() {
|
||||
const config_keys = Object.keys(CONFIG_ITEMS);
|
||||
const config_keys = Object.keys(exports.CONFIG_ITEMS);
|
||||
for (let i = 0; i < config_keys.length; i++) {
|
||||
const config_key = config_keys[i];
|
||||
getConfigItem(config_key);
|
||||
exports.getConfigItem(config_key);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,17 +61,17 @@ function getElementNameInConfig(path) {
|
||||
/**
|
||||
* Check if config exists. If not, write default config to config path
|
||||
*/
|
||||
function configExistsCheck() {
|
||||
exports.configExistsCheck = () => {
|
||||
let exists = fs.existsSync(configPath);
|
||||
if (!exists) {
|
||||
setConfigFile(DEFAULT_CONFIG);
|
||||
exports.setConfigFile(DEFAULT_CONFIG);
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Gets config file and returns as a json
|
||||
*/
|
||||
function getConfigFile() {
|
||||
exports.getConfigFile = () => {
|
||||
try {
|
||||
let raw_data = fs.readFileSync(configPath);
|
||||
let parsed_data = JSON.parse(raw_data);
|
||||
@@ -78,35 +82,40 @@ function getConfigFile() {
|
||||
}
|
||||
}
|
||||
|
||||
function setConfigFile(config) {
|
||||
exports.setConfigFile = (config) => {
|
||||
try {
|
||||
const old_config = exports.getConfigFile();
|
||||
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
|
||||
const changes = exports.findChangedConfigItems(old_config, config);
|
||||
if (changes.length > 0) {
|
||||
for (const change of changes) exports.config_updated.next(change);
|
||||
}
|
||||
return true;
|
||||
} catch(e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function getConfigItem(key) {
|
||||
let config_json = getConfigFile();
|
||||
if (!CONFIG_ITEMS[key]) {
|
||||
exports.getConfigItem = (key) => {
|
||||
let config_json = exports.getConfigFile();
|
||||
if (!exports.CONFIG_ITEMS[key]) {
|
||||
logger.error(`Config item with key '${key}' is not recognized.`);
|
||||
return null;
|
||||
}
|
||||
let path = CONFIG_ITEMS[key]['path'];
|
||||
let path = exports.CONFIG_ITEMS[key]['path'];
|
||||
const val = Object.byString(config_json, path);
|
||||
if (val === undefined && Object.byString(DEFAULT_CONFIG, path) !== undefined) {
|
||||
logger.warn(`Cannot find config with key '${key}'. Creating one with the default value...`);
|
||||
setConfigItem(key, Object.byString(DEFAULT_CONFIG, path));
|
||||
exports.setConfigItem(key, Object.byString(DEFAULT_CONFIG, path));
|
||||
return Object.byString(DEFAULT_CONFIG, path);
|
||||
}
|
||||
return Object.byString(config_json, path);
|
||||
}
|
||||
|
||||
function setConfigItem(key, value) {
|
||||
exports.setConfigItem = (key, value) => {
|
||||
let success = false;
|
||||
let config_json = getConfigFile();
|
||||
let path = CONFIG_ITEMS[key]['path'];
|
||||
let config_json = exports.getConfigFile();
|
||||
let path = exports.CONFIG_ITEMS[key]['path'];
|
||||
let element_name = getElementNameInConfig(path);
|
||||
let parent_path = getParentPath(path);
|
||||
let parent_object = Object.byString(config_json, parent_path);
|
||||
@@ -118,20 +127,18 @@ function setConfigItem(key, value) {
|
||||
parent_parent_object[parent_parent_single_key] = {};
|
||||
parent_object = Object.byString(config_json, parent_path);
|
||||
}
|
||||
if (value === 'false') value = false;
|
||||
if (value === 'true') value = true;
|
||||
parent_object[element_name] = value;
|
||||
|
||||
if (value === 'false' || value === 'true') {
|
||||
parent_object[element_name] = (value === 'true');
|
||||
} else {
|
||||
parent_object[element_name] = value;
|
||||
}
|
||||
success = setConfigFile(config_json);
|
||||
success = exports.setConfigFile(config_json);
|
||||
|
||||
return success;
|
||||
}
|
||||
|
||||
function setConfigItems(items) {
|
||||
exports.setConfigItems = (items) => {
|
||||
let success = false;
|
||||
let config_json = getConfigFile();
|
||||
let config_json = exports.getConfigFile();
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
let key = items[i].key;
|
||||
let value = items[i].value;
|
||||
@@ -141,7 +148,7 @@ function setConfigItems(items) {
|
||||
value = (value === 'true');
|
||||
}
|
||||
|
||||
let item_path = CONFIG_ITEMS[key]['path'];
|
||||
let item_path = exports.CONFIG_ITEMS[key]['path'];
|
||||
let item_parent_path = getParentPath(item_path);
|
||||
let item_element_name = getElementNameInConfig(item_path);
|
||||
|
||||
@@ -149,28 +156,41 @@ function setConfigItems(items) {
|
||||
item_parent_object[item_element_name] = value;
|
||||
}
|
||||
|
||||
success = setConfigFile(config_json);
|
||||
success = exports.setConfigFile(config_json);
|
||||
return success;
|
||||
}
|
||||
|
||||
function globalArgsRequiresSafeDownload() {
|
||||
const globalArgs = getConfigItem('ytdl_custom_args').split(',,');
|
||||
exports.globalArgsRequiresSafeDownload = () => {
|
||||
const globalArgs = exports.getConfigItem('ytdl_custom_args').split(',,');
|
||||
const argsThatRequireSafeDownload = ['--write-sub', '--write-srt', '--proxy'];
|
||||
const failedArgs = globalArgs.filter(arg => argsThatRequireSafeDownload.includes(arg));
|
||||
return failedArgs && failedArgs.length > 0;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getConfigItem: getConfigItem,
|
||||
setConfigItem: setConfigItem,
|
||||
setConfigItems: setConfigItems,
|
||||
getConfigFile: getConfigFile,
|
||||
setConfigFile: setConfigFile,
|
||||
configExistsCheck: configExistsCheck,
|
||||
CONFIG_ITEMS: CONFIG_ITEMS,
|
||||
initialize: initialize,
|
||||
descriptors: {},
|
||||
globalArgsRequiresSafeDownload: globalArgsRequiresSafeDownload
|
||||
exports.findChangedConfigItems = (old_config, new_config, path = '', changedConfigItems = [], depth = 0) => {
|
||||
if (typeof old_config === 'object' && typeof new_config === 'object' && depth < 3) {
|
||||
for (const key in old_config) {
|
||||
if (Object.prototype.hasOwnProperty.call(new_config, key)) {
|
||||
exports.findChangedConfigItems(old_config[key], new_config[key], `${path}${path ? '.' : ''}${key}`, changedConfigItems, depth + 1);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (JSON.stringify(old_config) !== JSON.stringify(new_config)) {
|
||||
const key = getConfigItemKeyByPath(path);
|
||||
changedConfigItems.push({
|
||||
key: key ? key : path.split('.')[path.split('.').length - 1], // return key in CONFIG_ITEMS or the object key
|
||||
old_value: JSON.parse(JSON.stringify(old_config)),
|
||||
new_value: JSON.parse(JSON.stringify(new_config))
|
||||
});
|
||||
}
|
||||
}
|
||||
return changedConfigItems;
|
||||
}
|
||||
|
||||
function getConfigItemKeyByPath(path) {
|
||||
const found_item = Object.values(exports.CONFIG_ITEMS).find(item => item.path === path);
|
||||
if (found_item) return found_item['key'];
|
||||
else return null;
|
||||
}
|
||||
|
||||
const DEFAULT_CONFIG = {
|
||||
@@ -219,6 +239,7 @@ const DEFAULT_CONFIG = {
|
||||
"use_telegram_API": false,
|
||||
"telegram_bot_token": "",
|
||||
"telegram_chat_id": "",
|
||||
"telegram_webhook_proxy": "",
|
||||
"webhook_URL": "",
|
||||
"discord_webhook_URL": "",
|
||||
"slack_webhook_URL": "",
|
||||
|
||||
@@ -154,6 +154,10 @@ exports.CONFIG_ITEMS = {
|
||||
'key': 'ytdl_telegram_chat_id',
|
||||
'path': 'YoutubeDLMaterial.API.telegram_chat_id'
|
||||
},
|
||||
'ytdl_telegram_webhook_proxy': {
|
||||
'key': 'ytdl_telegram_webhook_proxy',
|
||||
'path': 'YoutubeDLMaterial.API.telegram_webhook_proxy'
|
||||
},
|
||||
'ytdl_webhook_url': {
|
||||
'key': 'ytdl_webhook_url',
|
||||
'path': 'YoutubeDLMaterial.API.webhook_URL'
|
||||
@@ -269,7 +273,8 @@ exports.AVAILABLE_PERMISSIONS = [
|
||||
'tasks_manager'
|
||||
];
|
||||
|
||||
exports.DETAILS_BIN_PATH = 'node_modules/youtube-dl/bin/details'
|
||||
exports.DETAILS_BIN_PATH = 'appdata/youtube-dl.json'
|
||||
exports.OUTDATED_YOUTUBEDL_VERSION = "2020.00.00";
|
||||
|
||||
// args that have a value after it (e.g. -o <output> or -f <format>)
|
||||
const YTDL_ARGS_WITH_VALUES = [
|
||||
@@ -347,9 +352,11 @@ const YTDL_ARGS_WITH_VALUES = [
|
||||
'--convert-subs'
|
||||
];
|
||||
|
||||
exports.SUBSCRIPTION_BACKUP_PATH = 'subscription_backup.json'
|
||||
|
||||
// we're using a Set here for performance
|
||||
exports.YTDL_ARGS_WITH_VALUES = new Set(YTDL_ARGS_WITH_VALUES);
|
||||
|
||||
exports.ICON_URL = 'https://i.imgur.com/IKOlr0N.png';
|
||||
|
||||
exports.CURRENT_VERSION = 'v4.3.1';
|
||||
exports.CURRENT_VERSION = 'v4.3.2';
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
const fs = require('fs-extra')
|
||||
const path = require('path')
|
||||
const { MongoClient } = require("mongodb");
|
||||
const { uuid } = require('uuidv4');
|
||||
const _ = require('lodash');
|
||||
|
||||
const config_api = require('./config');
|
||||
@@ -11,9 +10,8 @@ const logger = require('./logger');
|
||||
const low = require('lowdb')
|
||||
const FileSync = require('lowdb/adapters/FileSync');
|
||||
const { BehaviorSubject } = require('rxjs');
|
||||
const local_adapter = new FileSync('./appdata/local_db.json');
|
||||
const local_db = low(local_adapter);
|
||||
|
||||
let local_db = null;
|
||||
let database = null;
|
||||
exports.database_initialized = false;
|
||||
exports.database_initialized_bs = new BehaviorSubject(false);
|
||||
@@ -73,10 +71,6 @@ const tables = {
|
||||
|
||||
const tables_list = Object.keys(tables);
|
||||
|
||||
const local_db_defaults = {}
|
||||
tables_list.forEach(table => {local_db_defaults[table] = []});
|
||||
local_db.defaults(local_db_defaults).write();
|
||||
|
||||
let using_local_db = null;
|
||||
|
||||
function setDB(input_db, input_users_db) {
|
||||
@@ -85,11 +79,18 @@ function setDB(input_db, input_users_db) {
|
||||
exports.users_db = input_users_db
|
||||
}
|
||||
|
||||
exports.initialize = (input_db, input_users_db) => {
|
||||
exports.initialize = (input_db, input_users_db, db_name = 'local_db.json') => {
|
||||
setDB(input_db, input_users_db);
|
||||
|
||||
// must be done here to prevent getConfigItem from being called before init
|
||||
using_local_db = config_api.getConfigItem('ytdl_use_local_db');
|
||||
|
||||
const local_adapter = new FileSync(`./appdata/${db_name}`);
|
||||
local_db = low(local_adapter);
|
||||
|
||||
const local_db_defaults = {}
|
||||
tables_list.forEach(table => {local_db_defaults[table] = []});
|
||||
local_db.defaults(local_db_defaults).write();
|
||||
}
|
||||
|
||||
exports.connectToDB = async (retries = 5, no_fallback = false, custom_connection_string = null) => {
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
const fs = require('fs-extra');
|
||||
const { uuid } = require('uuidv4');
|
||||
const { v4: uuid } = require('uuid');
|
||||
const path = require('path');
|
||||
const NodeID3 = require('node-id3')
|
||||
const Mutex = require('async-mutex').Mutex;
|
||||
|
||||
const youtubedl = require('youtube-dl');
|
||||
|
||||
const logger = require('./logger');
|
||||
const youtubedl_api = require('./youtube-dl');
|
||||
const config_api = require('./config');
|
||||
const twitch_api = require('./twitch');
|
||||
const { create } = require('xmlbuilder2');
|
||||
@@ -20,11 +19,13 @@ const archive_api = require('./archive');
|
||||
const mutex = new Mutex();
|
||||
let should_check_downloads = true;
|
||||
|
||||
const download_to_child_process = {};
|
||||
|
||||
if (db_api.database_initialized) {
|
||||
setupDownloads();
|
||||
exports.setupDownloads();
|
||||
} else {
|
||||
db_api.database_initialized_bs.subscribe(init => {
|
||||
if (init) setupDownloads();
|
||||
if (init) exports.setupDownloads();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -47,7 +48,7 @@ We use checkDownloads() to move downloads through the steps and call their respe
|
||||
|
||||
*/
|
||||
|
||||
exports.createDownload = async (url, type, options, user_uid = null, sub_id = null, sub_name = null, prefetched_info = null) => {
|
||||
exports.createDownload = async (url, type, options, user_uid = null, sub_id = null, sub_name = null, prefetched_info = null, paused = false) => {
|
||||
return await mutex.runExclusive(async () => {
|
||||
const download = {
|
||||
url: url,
|
||||
@@ -60,7 +61,7 @@ exports.createDownload = async (url, type, options, user_uid = null, sub_id = nu
|
||||
options: options,
|
||||
uid: uuid(),
|
||||
step_index: 0,
|
||||
paused: false,
|
||||
paused: paused,
|
||||
running: false,
|
||||
finished_step: true,
|
||||
error: null,
|
||||
@@ -83,8 +84,11 @@ exports.pauseDownload = async (download_uid) => {
|
||||
} else if (download['finished']) {
|
||||
logger.info(`Download ${download_uid} could not be paused before completing.`);
|
||||
return false;
|
||||
} else {
|
||||
logger.info(`Pausing download ${download_uid}`);
|
||||
}
|
||||
|
||||
killActiveDownload(download);
|
||||
return await db_api.updateRecord('download_queue', {uid: download_uid}, {paused: true, running: false});
|
||||
}
|
||||
|
||||
@@ -119,21 +123,28 @@ exports.cancelDownload = async (download_uid) => {
|
||||
} else if (download['finished']) {
|
||||
logger.info(`Download ${download_uid} could not be cancelled before completing.`);
|
||||
return false;
|
||||
} else {
|
||||
logger.info(`Cancelling download ${download_uid}`);
|
||||
}
|
||||
return await db_api.updateRecord('download_queue', {uid: download_uid}, {cancelled: true, running: false});
|
||||
|
||||
killActiveDownload(download);
|
||||
await handleDownloadError(download_uid, 'Cancelled', 'cancelled');
|
||||
return await db_api.updateRecord('download_queue', {uid: download_uid}, {cancelled: true});
|
||||
}
|
||||
|
||||
exports.clearDownload = async (download_uid) => {
|
||||
return await db_api.removeRecord('download_queue', {uid: download_uid});
|
||||
}
|
||||
|
||||
async function handleDownloadError(download, error_message, error_type = null) {
|
||||
if (!download || !download['uid']) return;
|
||||
async function handleDownloadError(download_uid, error_message, error_type = null) {
|
||||
if (!download_uid) return;
|
||||
const download = await db_api.getRecord('download_queue', {uid: download_uid});
|
||||
if (!download || download['error']) return;
|
||||
notifications_api.sendDownloadErrorNotification(download, download['user_uid'], error_message, error_type);
|
||||
await db_api.updateRecord('download_queue', {uid: download['uid']}, {error: error_message, finished: true, running: false, error_type: error_type});
|
||||
}
|
||||
|
||||
async function setupDownloads() {
|
||||
exports.setupDownloads = async () => {
|
||||
await fixDownloadState();
|
||||
setInterval(checkDownloads, 1000);
|
||||
}
|
||||
@@ -179,22 +190,30 @@ async function checkDownloads() {
|
||||
if (waiting_download['sub_id']) {
|
||||
const sub_missing = !(await db_api.getRecord('subscriptions', {id: waiting_download['sub_id']}));
|
||||
if (sub_missing) {
|
||||
handleDownloadError(waiting_download, `Download failed as subscription with id '${waiting_download['sub_id']}' is missing!`, 'sub_id_missing');
|
||||
handleDownloadError(waiting_download['uid'], `Download failed as subscription with id '${waiting_download['sub_id']}' is missing!`, 'sub_id_missing');
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// move to next step
|
||||
running_downloads_count++;
|
||||
if (waiting_download['step_index'] === 0) {
|
||||
collectInfo(waiting_download['uid']);
|
||||
exports.collectInfo(waiting_download['uid']);
|
||||
} else if (waiting_download['step_index'] === 1) {
|
||||
downloadQueuedFile(waiting_download['uid']);
|
||||
exports.downloadQueuedFile(waiting_download['uid']);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function collectInfo(download_uid) {
|
||||
function killActiveDownload(download) {
|
||||
const child_process = download_to_child_process[download['uid']];
|
||||
if (download['step_index'] === 2 && child_process) {
|
||||
youtubedl_api.killYoutubeDLProcess(child_process);
|
||||
delete download_to_child_process[download['uid']];
|
||||
}
|
||||
}
|
||||
|
||||
exports.collectInfo = async (download_uid) => {
|
||||
const download = await db_api.getRecord('download_queue', {uid: download_uid});
|
||||
if (download['paused']) {
|
||||
return;
|
||||
@@ -217,21 +236,21 @@ async function collectInfo(download_uid) {
|
||||
// get video info prior to download
|
||||
let info = download['prefetched_info'] ? download['prefetched_info'] : await exports.getVideoInfoByURL(url, args, download_uid);
|
||||
|
||||
if (!info) {
|
||||
if (!info || info.length === 0) {
|
||||
// info failed, error presumably already recorded
|
||||
return;
|
||||
}
|
||||
|
||||
// in subscriptions we don't care if archive mode is enabled, but we already removed archived videos from subs by this point
|
||||
const useYoutubeDLArchive = config_api.getConfigItem('ytdl_use_youtubedl_archive');
|
||||
if (useYoutubeDLArchive && !options.ignoreArchive) {
|
||||
const exists_in_archive = await archive_api.existsInArchive(info['extractor'], info['id'], type, download['user_uid'], download['sub_id']);
|
||||
if (useYoutubeDLArchive && !options.ignoreArchive && info.length === 1) {
|
||||
const info_obj = info[0];
|
||||
const exists_in_archive = await archive_api.existsInArchive(info['extractor'], info_obj['id'], type, download['user_uid'], download['sub_id']);
|
||||
if (exists_in_archive) {
|
||||
const error = `File '${info['title']}' already exists in archive! Disable the archive or override to continue downloading.`;
|
||||
const error = `File '${info_obj['title']}' already exists in archive! Disable the archive or override to continue downloading.`;
|
||||
logger.warn(error);
|
||||
if (download_uid) {
|
||||
const download = await db_api.getRecord('download_queue', {uid: download_uid});
|
||||
await handleDownloadError(download, error, 'exists_in_archive');
|
||||
await handleDownloadError(download_uid, error, 'exists_in_archive');
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -240,7 +259,7 @@ async function collectInfo(download_uid) {
|
||||
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);
|
||||
if (info.length === 1 || 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']) {
|
||||
@@ -259,26 +278,22 @@ async function collectInfo(download_uid) {
|
||||
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']));
|
||||
}
|
||||
for (let info_obj of info) files_to_check_for_progress.push(utils.removeFileExtension(info_obj['_filename']));
|
||||
|
||||
const playlist_title = Array.isArray(info) ? info[0]['playlist_title'] || info[0]['playlist'] : null;
|
||||
const title = info.length > 1 ? info[0]['playlist_title'] || info[0]['playlist'] : info[0]['title'];
|
||||
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'],
|
||||
title: title,
|
||||
category: stripped_category,
|
||||
prefetched_info: null
|
||||
});
|
||||
}
|
||||
|
||||
async function downloadQueuedFile(download_uid) {
|
||||
exports.downloadQueuedFile = async(download_uid, customDownloadHandler = null) => {
|
||||
const download = await db_api.getRecord('download_queue', {uid: download_uid});
|
||||
if (download['paused']) {
|
||||
return;
|
||||
@@ -306,121 +321,112 @@ async function downloadQueuedFile(download_uid) {
|
||||
const start_time = Date.now();
|
||||
|
||||
const download_checker = setInterval(() => checkDownloadPercent(download['uid']), 1000);
|
||||
|
||||
const file_objs = [];
|
||||
// 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, err.stderr, 'unknown_error');
|
||||
let {child_process, callback} = await youtubedl_api.runYoutubeDL(url, args, customDownloadHandler);
|
||||
if (child_process) download_to_child_process[download['uid']] = child_process;
|
||||
const {parsed_output, err} = await callback;
|
||||
clearInterval(download_checker);
|
||||
let end_time = Date.now();
|
||||
let difference = (end_time - start_time)/1000;
|
||||
logger.debug(`${type === 'audio' ? 'Audio' : 'Video'} download delay: ${difference} seconds.`);
|
||||
if (!parsed_output) {
|
||||
const errored_download = await db_api.getRecord('download_queue', {uid: download_uid});
|
||||
if (errored_download && errored_download['paused']) return;
|
||||
logger.error(err.toString());
|
||||
await handleDownloadError(download_uid, err.toString(), 'unknown_error');
|
||||
resolve(false);
|
||||
return;
|
||||
} else if (parsed_output) {
|
||||
if (parsed_output.length === 0 || parsed_output[0].length === 0) {
|
||||
// ERROR!
|
||||
const error_message = `No output received for video download, check if it exists in your archive.`;
|
||||
await handleDownloadError(download_uid, error_message, 'no_output');
|
||||
logger.warn(error_message);
|
||||
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, error_message, 'no_output');
|
||||
logger.warn(error_message);
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
|
||||
for (let i = 0; i < output.length; i++) {
|
||||
let output_json = null;
|
||||
try {
|
||||
// we have to do this because sometimes there will be leading characters before the actual json
|
||||
const start_idx = output[i].indexOf('{"');
|
||||
const clean_output = output[i].slice(start_idx, output[i].length);
|
||||
output_json = JSON.parse(clean_output);
|
||||
} 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_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 files_api.registerFileDB(full_file_path, type, download['user_uid'], category, download['sub_id'] ? download['sub_id'] : null, options.cropFileSettings);
|
||||
|
||||
await archive_api.addToArchive(output_json['extractor'], output_json['id'], type, output_json['title'], download['user_uid'], download['sub_id']);
|
||||
|
||||
notifications_api.sendDownloadNotification(file_obj, download['user_uid']);
|
||||
|
||||
file_objs.push(file_obj);
|
||||
}
|
||||
|
||||
let container = null;
|
||||
|
||||
if (file_objs.length > 1) {
|
||||
// create playlist
|
||||
const playlist_name = file_objs.map(file_obj => file_obj.title).join(', ');
|
||||
container = await files_api.createPlaylist(playlist_name, file_objs.map(file_obj => file_obj.uid), download['user_uid']);
|
||||
} else if (file_objs.length === 1) {
|
||||
container = file_objs[0];
|
||||
} else {
|
||||
const error_message = 'Downloaded file failed to result in metadata object.';
|
||||
logger.error(error_message);
|
||||
await handleDownloadError(download, error_message, 'no_metadata');
|
||||
}
|
||||
|
||||
const file_uids = file_objs.map(file_obj => file_obj.uid);
|
||||
await db_api.updateRecord('download_queue', {uid: download_uid}, {finished_step: true, finished: true, running: false, step_index: 3, percent_complete: 100, file_uids: file_uids, container: container});
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
|
||||
for (const output_json of parsed_output) {
|
||||
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_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 files_api.registerFileDB(full_file_path, type, download['user_uid'], category, download['sub_id'] ? download['sub_id'] : null, options.cropFileSettings);
|
||||
|
||||
await archive_api.addToArchive(output_json['extractor'], output_json['id'], type, output_json['title'], download['user_uid'], download['sub_id']);
|
||||
|
||||
notifications_api.sendDownloadNotification(file_obj, download['user_uid']);
|
||||
|
||||
file_objs.push(file_obj);
|
||||
}
|
||||
|
||||
let container = null;
|
||||
|
||||
if (file_objs.length > 1) {
|
||||
// create playlist
|
||||
container = await files_api.createPlaylist(download['title'], file_objs.map(file_obj => file_obj.uid), download['user_uid']);
|
||||
} else if (file_objs.length === 1) {
|
||||
container = file_objs[0];
|
||||
} else {
|
||||
const error_message = 'Downloaded file failed to result in metadata object.';
|
||||
logger.error(error_message);
|
||||
await handleDownloadError(download_uid, error_message, 'no_metadata');
|
||||
}
|
||||
|
||||
const file_uids = file_objs.map(file_obj => file_obj.uid);
|
||||
await db_api.updateRecord('download_queue', {uid: download_uid}, {finished_step: true, finished: true, running: false, step_index: 3, percent_complete: 100, file_uids: file_uids, container: container});
|
||||
resolve(file_uids);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// helper functions
|
||||
|
||||
exports.generateArgs = async (url, type, options, user_uid = null, simulated = false) => {
|
||||
const default_downloader = utils.getCurrentDownloader() || config_api.getConfigItem('ytdl_default_downloader');
|
||||
const default_downloader = config_api.getConfigItem('ytdl_default_downloader');
|
||||
|
||||
if (!simulated && (default_downloader === 'youtube-dl' || default_downloader === 'youtube-dlc')) {
|
||||
logger.warn('It is recommended you use yt-dlp! To prevent failed downloads, change the downloader in your settings menu to yt-dlp and restart your instance.')
|
||||
@@ -515,6 +521,8 @@ exports.generateArgs = async (url, type, options, user_uid = null, simulated = f
|
||||
downloadConfig.push('--write-thumbnail');
|
||||
}
|
||||
|
||||
downloadConfig.push('-i');
|
||||
|
||||
if (globalArgs && globalArgs !== '') {
|
||||
// adds global args
|
||||
if (downloadConfig.indexOf('-o') !== -1 && globalArgs.split(',,').indexOf('-o') !== -1) {
|
||||
@@ -551,58 +559,30 @@ exports.generateArgs = async (url, type, options, user_uid = null, simulated = f
|
||||
}
|
||||
|
||||
exports.getVideoInfoByURL = async (url, args = [], download_uid = null) => {
|
||||
return new Promise(resolve => {
|
||||
// remove bad args
|
||||
const temp_args = utils.filterArgs(args, ['--no-simulate']);
|
||||
const new_args = [...temp_args];
|
||||
// remove bad args
|
||||
const temp_args = utils.filterArgs(args, ['--no-simulate']);
|
||||
const new_args = [...temp_args];
|
||||
|
||||
const archiveArgIndex = new_args.indexOf('--download-archive');
|
||||
if (archiveArgIndex !== -1) {
|
||||
new_args.splice(archiveArgIndex, 2);
|
||||
const archiveArgIndex = new_args.indexOf('--download-archive');
|
||||
if (archiveArgIndex !== -1) {
|
||||
new_args.splice(archiveArgIndex, 2);
|
||||
}
|
||||
|
||||
new_args.push('--dump-json');
|
||||
|
||||
let {callback} = await youtubedl_api.runYoutubeDL(url, new_args);
|
||||
const {parsed_output, err} = await callback;
|
||||
if (!parsed_output || parsed_output.length === 0) {
|
||||
let error_message = `Error while retrieving info on video with URL ${url}`;
|
||||
if (err.stderr) error_message += ` with the following message: \n${err.stderr}`;
|
||||
logger.error(error_message);
|
||||
if (download_uid) {
|
||||
await handleDownloadError(download_uid, error_message, 'info_retrieve_failed');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
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) {
|
||||
const download = await db_api.getRecord('download_queue', {uid: download_uid});
|
||||
await handleDownloadError(download, error, 'parse_failed');
|
||||
}
|
||||
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) {
|
||||
const download = await db_api.getRecord('download_queue', {uid: download_uid});
|
||||
await handleDownloadError(download, error_message, 'info_retrieve_failed');
|
||||
}
|
||||
resolve(null);
|
||||
}
|
||||
});
|
||||
});
|
||||
return parsed_output;
|
||||
}
|
||||
|
||||
function filterArgs(args, isAudio) {
|
||||
@@ -621,6 +601,7 @@ async function checkDownloadPercent(download_uid) {
|
||||
*/
|
||||
|
||||
const download = await db_api.getRecord('download_queue', {uid: download_uid});
|
||||
if (!download) return;
|
||||
const files_to_check_for_progress = download['files_to_check_for_progress'];
|
||||
const resulting_file_size = download['expected_file_size'];
|
||||
|
||||
|
||||
@@ -1,17 +1,7 @@
|
||||
#!/bin/sh
|
||||
#!/bin/bash
|
||||
set -eu
|
||||
|
||||
CMD="npm start && pm2 start"
|
||||
|
||||
# if the first arg starts with "-" pass it to program
|
||||
if [ "${1#-}" != "$1" ]; then
|
||||
set -- "$CMD" "$@"
|
||||
fi
|
||||
|
||||
# chown current working directory to current user
|
||||
if [ "$*" = "$CMD" ] && [ "$(id -u)" = "0" ]; then
|
||||
find . \! -user "$UID" -exec chown "$UID:$GID" -R '{}' + || echo "WARNING! Could not change directory ownership. If you manage permissions externally this is fine, otherwise you may experience issues when downloading or deleting videos."
|
||||
exec gosu "$UID:$GID" "$0" "$@"
|
||||
fi
|
||||
|
||||
exec "$@"
|
||||
echo "[entrypoint] setup permission, this may take a while"
|
||||
find . \! -user "$UID" -exec chown "$UID:$GID" '{}' + || echo "WARNING! Could not change directory ownership. If you manage permissions externally this is fine, otherwise you may experience issues when downloading or deleting videos."
|
||||
exec gosu "$UID:$GID" "$@"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
const fs = require('fs-extra')
|
||||
const path = require('path')
|
||||
const { uuid } = require('uuidv4');
|
||||
const { v4: uuid } = require('uuid');
|
||||
|
||||
const config_api = require('./config');
|
||||
const db_api = require('./db');
|
||||
|
||||
@@ -4,11 +4,12 @@ const logger = require('./logger');
|
||||
const utils = require('./utils');
|
||||
const consts = require('./consts');
|
||||
|
||||
const { uuid } = require('uuidv4');
|
||||
const { v4: uuid } = require('uuid');
|
||||
|
||||
const fetch = require('node-fetch');
|
||||
const { gotify } = require("gotify");
|
||||
const TelegramBot = require('node-telegram-bot-api');
|
||||
const TelegramBotAPI = require('node-telegram-bot-api');
|
||||
let telegram_bot = null;
|
||||
const REST = require('@discordjs/rest').REST;
|
||||
const API = require('@discordjs/core').API;
|
||||
const EmbedBuilder = require('@discordjs/builders').EmbedBuilder;
|
||||
@@ -56,7 +57,7 @@ exports.sendNotification = async (notification) => {
|
||||
sendGotifyNotification(data);
|
||||
}
|
||||
if (config_api.getConfigItem('ytdl_use_telegram_API') && config_api.getConfigItem('ytdl_telegram_bot_token') && config_api.getConfigItem('ytdl_telegram_chat_id')) {
|
||||
sendTelegramNotification(data);
|
||||
exports.sendTelegramNotification(data);
|
||||
}
|
||||
if (config_api.getConfigItem('ytdl_webhook_url')) {
|
||||
sendGenericNotification(data);
|
||||
@@ -113,6 +114,8 @@ function notificationEnabled(type) {
|
||||
return config_api.getConfigItem('ytdl_enable_notifications') && (config_api.getConfigItem('ytdl_enable_all_notifications') || config_api.getConfigItem('ytdl_allowed_notification_types').includes(type));
|
||||
}
|
||||
|
||||
// ntfy
|
||||
|
||||
function sendNtfyNotification({body, title, type, url, thumbnail}) {
|
||||
logger.verbose('Sending notification to ntfy');
|
||||
fetch(config_api.getConfigItem('ytdl_ntfy_topic_url'), {
|
||||
@@ -127,6 +130,8 @@ function sendNtfyNotification({body, title, type, url, thumbnail}) {
|
||||
});
|
||||
}
|
||||
|
||||
// Gotify
|
||||
|
||||
async function sendGotifyNotification({body, title, type, url, thumbnail}) {
|
||||
logger.verbose('Sending notification to gotify');
|
||||
await gotify({
|
||||
@@ -145,15 +150,50 @@ async function sendGotifyNotification({body, title, type, url, thumbnail}) {
|
||||
});
|
||||
}
|
||||
|
||||
async function sendTelegramNotification({body, title, type, url, thumbnail}) {
|
||||
logger.verbose('Sending notification to Telegram');
|
||||
// Telegram
|
||||
|
||||
setupTelegramBot();
|
||||
config_api.config_updated.subscribe(change => {
|
||||
const use_telegram_api = config_api.getConfigItem('ytdl_use_telegram_API');
|
||||
const bot_token = config_api.getConfigItem('ytdl_telegram_bot_token');
|
||||
const chat_id = config_api.getConfigItem('ytdl_telegram_chat_id');
|
||||
const bot = new TelegramBot(bot_token);
|
||||
if (thumbnail) await bot.sendPhoto(chat_id, thumbnail);
|
||||
bot.sendMessage(chat_id, `<b>${title}</b>\n\n${body}\n<a href="${url}">${url}</a>`, {parse_mode: 'HTML'});
|
||||
if (!use_telegram_api || !bot_token) return;
|
||||
if (!change) return;
|
||||
if (change['key'] === 'ytdl_use_telegram_API' || change['key'] === 'ytdl_telegram_bot_token' || change['key'] === 'ytdl_telegram_webhook_proxy') {
|
||||
logger.debug('Telegram bot setting up');
|
||||
setupTelegramBot();
|
||||
}
|
||||
});
|
||||
|
||||
async function setupTelegramBot() {
|
||||
const use_telegram_api = config_api.getConfigItem('ytdl_use_telegram_API');
|
||||
const bot_token = config_api.getConfigItem('ytdl_telegram_bot_token');
|
||||
if (!use_telegram_api || !bot_token) return;
|
||||
|
||||
telegram_bot = new TelegramBotAPI(bot_token);
|
||||
const webhook_proxy = config_api.getConfigItem('ytdl_telegram_webhook_proxy');
|
||||
const webhook_url = webhook_proxy ? webhook_proxy : `${utils.getBaseURL()}/api/telegramRequest`;
|
||||
telegram_bot.setWebHook(webhook_url);
|
||||
}
|
||||
|
||||
exports.sendTelegramNotification = async ({body, title, type, url, thumbnail}) => {
|
||||
if (!telegram_bot){
|
||||
logger.error('Telegram bot not found!');
|
||||
return;
|
||||
}
|
||||
|
||||
const chat_id = config_api.getConfigItem('ytdl_telegram_chat_id');
|
||||
if (!chat_id){
|
||||
logger.error('Telegram chat ID required!');
|
||||
return;
|
||||
}
|
||||
|
||||
logger.verbose('Sending notification to Telegram');
|
||||
if (thumbnail) await telegram_bot.sendPhoto(chat_id, thumbnail);
|
||||
telegram_bot.sendMessage(chat_id, `<b>${title}</b>\n\n${body}\n<a href="${url}">${url}</a>`, {parse_mode: 'HTML'});
|
||||
}
|
||||
|
||||
// Discord
|
||||
|
||||
async function sendDiscordNotification({body, title, type, url, thumbnail}) {
|
||||
const discord_webhook_url = config_api.getConfigItem('ytdl_discord_webhook_url');
|
||||
const url_split = discord_webhook_url.split('webhooks/');
|
||||
@@ -177,6 +217,8 @@ async function sendDiscordNotification({body, title, type, url, thumbnail}) {
|
||||
return result;
|
||||
}
|
||||
|
||||
// Slack
|
||||
|
||||
function sendSlackNotification({body, title, type, url, thumbnail}) {
|
||||
const slack_webhook_url = config_api.getConfigItem('ytdl_slack_webhook_url');
|
||||
logger.verbose(`Sending slack notification to ${slack_webhook_url}`);
|
||||
@@ -236,6 +278,8 @@ function sendSlackNotification({body, title, type, url, thumbnail}) {
|
||||
});
|
||||
}
|
||||
|
||||
// Generic
|
||||
|
||||
function sendGenericNotification(data) {
|
||||
const webhook_url = config_api.getConfigItem('ytdl_webhook_url');
|
||||
logger.verbose(`Sending generic notification to ${webhook_url}`);
|
||||
|
||||
4765
backend/package-lock.json
generated
4765
backend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -4,7 +4,7 @@
|
||||
"description": "backend for YoutubeDL-Material",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1",
|
||||
"test": "mocha test --exit -s 1000",
|
||||
"start": "pm2-runtime --raw pm2.config.js",
|
||||
"debug": "set YTDL_MODE=debug && node app.js"
|
||||
},
|
||||
@@ -33,6 +33,7 @@
|
||||
"command-exists": "^1.2.9",
|
||||
"compression": "^1.7.4",
|
||||
"config": "^3.2.3",
|
||||
"execa": "^5.1.1",
|
||||
"express": "^4.18.2",
|
||||
"express-session": "^1.17.3",
|
||||
"feed": "^4.2.2",
|
||||
@@ -43,7 +44,6 @@
|
||||
"lodash": "^4.17.21",
|
||||
"lowdb": "^1.0.0",
|
||||
"md5": "^2.2.1",
|
||||
"mocha": "^9.2.2",
|
||||
"moment": "^2.29.4",
|
||||
"mongodb": "^3.6.9",
|
||||
"multer": "1.4.5-lts.1",
|
||||
@@ -61,10 +61,13 @@
|
||||
"read-last-lines": "^1.7.2",
|
||||
"rxjs": "^7.3.0",
|
||||
"shortid": "^2.2.15",
|
||||
"tree-kill": "^1.2.2",
|
||||
"unzipper": "^0.10.10",
|
||||
"uuidv4": "^6.2.13",
|
||||
"uuid": "^9.0.1",
|
||||
"winston": "^3.7.2",
|
||||
"xmlbuilder2": "^3.0.2",
|
||||
"youtube-dl": "^3.0.2"
|
||||
"xmlbuilder2": "^3.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"mocha": "^10.2.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,25 +1,26 @@
|
||||
const fs = require('fs-extra');
|
||||
const path = require('path');
|
||||
const youtubedl = require('youtube-dl');
|
||||
|
||||
const youtubedl_api = require('./youtube-dl');
|
||||
const config_api = require('./config');
|
||||
const archive_api = require('./archive');
|
||||
const utils = require('./utils');
|
||||
const logger = require('./logger');
|
||||
const CONSTS = require('./consts');
|
||||
|
||||
const debugMode = process.env.YTDL_MODE === 'debug';
|
||||
|
||||
const db_api = require('./db');
|
||||
const downloader_api = require('./downloader');
|
||||
|
||||
async function subscribe(sub, user_uid = null) {
|
||||
exports.subscribe = async (sub, user_uid = null, skip_get_info = false) => {
|
||||
const result_obj = {
|
||||
success: false,
|
||||
error: ''
|
||||
};
|
||||
return new Promise(async resolve => {
|
||||
// sub should just have url and name. here we will get isPlaylist and path
|
||||
sub.isPlaylist = sub.url.includes('playlist');
|
||||
sub.isPlaylist = sub.isPlaylist || sub.url.includes('playlist');
|
||||
sub.videos = [];
|
||||
|
||||
let url_exists = !!(await db_api.getRecord('subscriptions', {url: sub.url, user_uid: user_uid}));
|
||||
@@ -32,12 +33,13 @@ async function subscribe(sub, user_uid = null) {
|
||||
}
|
||||
|
||||
sub['user_uid'] = user_uid ? user_uid : undefined;
|
||||
await db_api.insertRecordIntoTable('subscriptions', sub);
|
||||
await db_api.insertRecordIntoTable('subscriptions', JSON.parse(JSON.stringify(sub)));
|
||||
|
||||
let success = await getSubscriptionInfo(sub);
|
||||
let success = skip_get_info ? true : await getSubscriptionInfo(sub);
|
||||
exports.writeSubscriptionMetadata(sub);
|
||||
|
||||
if (success) {
|
||||
getVideosForSub(sub, user_uid);
|
||||
if (!sub.paused) exports.getVideosForSub(sub.id);
|
||||
} else {
|
||||
logger.error('Subscribe: Failed to get subscription info. Subscribe failed.')
|
||||
}
|
||||
@@ -61,55 +63,41 @@ async function getSubscriptionInfo(sub) {
|
||||
}
|
||||
}
|
||||
|
||||
return new Promise(async resolve => {
|
||||
youtubedl.exec(sub.url, downloadConfig, {maxBuffer: Infinity}, async (err, output) => {
|
||||
if (debugMode) {
|
||||
logger.info('Subscribe: got info for subscription ' + sub.id);
|
||||
}
|
||||
if (err) {
|
||||
logger.error(err.stderr);
|
||||
resolve(false);
|
||||
} else if (output) {
|
||||
if (output.length === 0 || (output.length === 1 && output[0] === '')) {
|
||||
logger.verbose('Could not get info for ' + sub.id);
|
||||
resolve(false);
|
||||
}
|
||||
for (let i = 0; i < output.length; i++) {
|
||||
let output_json = null;
|
||||
try {
|
||||
output_json = JSON.parse(output[i]);
|
||||
} catch(e) {
|
||||
output_json = null;
|
||||
}
|
||||
if (!output_json) {
|
||||
continue;
|
||||
}
|
||||
if (!sub.name) {
|
||||
if (sub.isPlaylist) {
|
||||
sub.name = output_json.playlist_title ? output_json.playlist_title : output_json.playlist;
|
||||
} else {
|
||||
sub.name = output_json.uploader;
|
||||
}
|
||||
// if it's now valid, update
|
||||
if (sub.name) {
|
||||
let sub_name = sub.name;
|
||||
const sub_name_exists = await db_api.getRecord('subscriptions', {name: sub.name, isPlaylist: sub.isPlaylist, user_uid: sub.user_uid});
|
||||
if (sub_name_exists) sub_name += ` - ${sub.id}`;
|
||||
await db_api.updateRecord('subscriptions', {id: sub.id}, {name: sub_name});
|
||||
}
|
||||
}
|
||||
let {callback} = await youtubedl_api.runYoutubeDL(sub.url, downloadConfig);
|
||||
const {parsed_output, err} = await callback;
|
||||
if (err) {
|
||||
logger.error(err.stderr);
|
||||
return false;
|
||||
}
|
||||
logger.verbose('Subscribe: got info for subscription ' + sub.id);
|
||||
for (const output_json of parsed_output) {
|
||||
if (!output_json) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// TODO: get even more info
|
||||
|
||||
resolve(true);
|
||||
}
|
||||
resolve(false);
|
||||
if (!sub.name) {
|
||||
if (sub.isPlaylist) {
|
||||
sub.name = output_json.playlist_title ? output_json.playlist_title : output_json.playlist;
|
||||
} else {
|
||||
sub.name = output_json.uploader;
|
||||
}
|
||||
});
|
||||
});
|
||||
// if it's now valid, update
|
||||
if (sub.name) {
|
||||
let sub_name = sub.name;
|
||||
const sub_name_exists = await db_api.getRecord('subscriptions', {name: sub.name, isPlaylist: sub.isPlaylist, user_uid: sub.user_uid});
|
||||
if (sub_name_exists) sub_name += ` - ${sub.id}`;
|
||||
await db_api.updateRecord('subscriptions', {id: sub.id}, {name: sub_name});
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
async function unsubscribe(sub, deleteMode, user_uid = null) {
|
||||
exports.unsubscribe = async (sub_id, deleteMode, user_uid = null) => {
|
||||
const sub = await exports.getSubscription(sub_id);
|
||||
let basePath = null;
|
||||
if (user_uid)
|
||||
basePath = path.join(config_api.getConfigItem('ytdl_users_base_path'), user_uid, 'subscriptions');
|
||||
@@ -132,6 +120,7 @@ async function unsubscribe(sub, deleteMode, user_uid = null) {
|
||||
}
|
||||
}
|
||||
|
||||
await killSubDownloads(sub_id, true);
|
||||
await db_api.removeRecord('subscriptions', {id: id});
|
||||
await db_api.removeAllRecords('files', {sub_id: id});
|
||||
|
||||
@@ -148,7 +137,7 @@ async function unsubscribe(sub, deleteMode, user_uid = null) {
|
||||
await db_api.removeAllRecords('archives', {sub_id: sub.id});
|
||||
}
|
||||
|
||||
async function deleteSubscriptionFile(sub, file, deleteForever, file_uid = null, user_uid = null) {
|
||||
exports.deleteSubscriptionFile = async (sub, file, deleteForever, file_uid = null, user_uid = null) => {
|
||||
if (typeof sub === 'string') {
|
||||
// TODO: fix bad workaround where sub is a sub_id
|
||||
sub = await db_api.getRecord('subscriptions', {sub_id: sub});
|
||||
@@ -216,12 +205,76 @@ async function deleteSubscriptionFile(sub, file, deleteForever, file_uid = null,
|
||||
}
|
||||
}
|
||||
|
||||
async function getVideosForSub(sub, user_uid = null) {
|
||||
const latest_sub_obj = await getSubscription(sub.id);
|
||||
if (!latest_sub_obj || latest_sub_obj['downloading']) {
|
||||
let current_sub_index = 0; // To keep track of the current subscription
|
||||
exports.watchSubscriptionsInterval = async () => {
|
||||
const subscriptions_check_interval = config_api.getConfigItem('ytdl_subscriptions_check_interval');
|
||||
let parent_interval = setInterval(() => watchSubscriptions(), subscriptions_check_interval*1000);
|
||||
watchSubscriptions();
|
||||
config_api.config_updated.subscribe(change => {
|
||||
if (!change) return;
|
||||
if (change['key'] === 'ytdl_subscriptions_check_interval' || change['key'] === 'ytdl_multi_user_mode') {
|
||||
current_sub_index = 0; // TODO: start after the last sub check
|
||||
logger.verbose('Resetting sub check schedule due to config change');
|
||||
clearInterval(parent_interval);
|
||||
const new_interval = config_api.getConfigItem('ytdl_subscriptions_check_interval');
|
||||
parent_interval = setInterval(() => watchSubscriptions(), new_interval*1000);
|
||||
watchSubscriptions();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function watchSubscriptions() {
|
||||
const subscription_ids = await getValidSubscriptionsToCheck();
|
||||
if (subscription_ids.length === 0) {
|
||||
logger.info('Skipping subscription check as no valid subscriptions exist.');
|
||||
return;
|
||||
}
|
||||
checkSubscription(subscription_ids[current_sub_index]);
|
||||
current_sub_index = (current_sub_index + 1) % subscription_ids.length;
|
||||
}
|
||||
|
||||
async function checkSubscription(sub_id) {
|
||||
let sub = await exports.getSubscription(sub_id);
|
||||
|
||||
// don't check the sub if the last check for the same subscription has not completed
|
||||
if (sub.downloading) {
|
||||
logger.verbose(`Subscription: skipped checking ${sub.name} as it's downloading videos.`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!sub.name) {
|
||||
logger.verbose(`Subscription: skipped check for subscription with uid ${sub.id} as name has not been retrieved yet.`);
|
||||
return;
|
||||
}
|
||||
|
||||
await exports.getVideosForSub(sub.id);
|
||||
}
|
||||
|
||||
async function getValidSubscriptionsToCheck() {
|
||||
const subscriptions = await exports.getAllSubscriptions();
|
||||
|
||||
if (!subscriptions) return;
|
||||
|
||||
// auto pause deprecated streamingOnly mode
|
||||
const streaming_only_subs = subscriptions.filter(sub => sub.streamingOnly);
|
||||
exports.updateSubscriptionPropertyMultiple(streaming_only_subs, {paused: true});
|
||||
|
||||
const valid_subscription_ids = subscriptions.filter(sub => !sub.paused && !sub.streamingOnly).map(sub => sub.id);
|
||||
return valid_subscription_ids;
|
||||
}
|
||||
|
||||
exports.getVideosForSub = async (sub_id) => {
|
||||
const sub = await exports.getSubscription(sub_id);
|
||||
if (!sub || sub['downloading']) {
|
||||
return false;
|
||||
}
|
||||
|
||||
_getVideosForSub(sub);
|
||||
return true;
|
||||
}
|
||||
|
||||
async function _getVideosForSub(sub) {
|
||||
const user_uid = sub['user_uid'];
|
||||
updateSubscriptionProperty(sub, {downloading: true}, user_uid);
|
||||
|
||||
// get basePath
|
||||
@@ -239,84 +292,52 @@ async function getVideosForSub(sub, user_uid = null) {
|
||||
// get videos
|
||||
logger.verbose(`Subscription: getting list of videos to download for ${sub.name} with args: ${downloadConfig.join(',')}`);
|
||||
|
||||
return new Promise(async resolve => {
|
||||
youtubedl.exec(sub.url, downloadConfig, {maxBuffer: Infinity}, async function(err, output) {
|
||||
// cleanup
|
||||
updateSubscriptionProperty(sub, {downloading: false}, user_uid);
|
||||
let {child_process, callback} = await youtubedl_api.runYoutubeDL(sub.url, downloadConfig);
|
||||
updateSubscriptionProperty(sub, {child_process: child_process}, user_uid);
|
||||
const {parsed_output, err} = await callback;
|
||||
updateSubscriptionProperty(sub, {downloading: false, child_process: null}, user_uid);
|
||||
if (!parsed_output) {
|
||||
logger.error('Subscription check failed!');
|
||||
if (err) logger.error(err);
|
||||
return null;
|
||||
}
|
||||
|
||||
// remove temporary archive file if it exists
|
||||
const archive_path = path.join(appendedBasePath, 'archive.txt');
|
||||
const archive_exists = await fs.pathExists(archive_path);
|
||||
if (archive_exists) {
|
||||
await fs.unlink(archive_path);
|
||||
}
|
||||
// remove temporary archive file if it exists
|
||||
const archive_path = path.join(appendedBasePath, 'archive.txt');
|
||||
const archive_exists = await fs.pathExists(archive_path);
|
||||
if (archive_exists) {
|
||||
await fs.unlink(archive_path);
|
||||
}
|
||||
|
||||
logger.verbose('Subscription: finished check for ' + sub.name);
|
||||
if (err && !output) {
|
||||
logger.error(err.stderr ? err.stderr : err.message);
|
||||
if (err.stderr.includes('This video is unavailable') || err.stderr.includes('Private video')) {
|
||||
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/);
|
||||
const files_to_download = await handleOutputJSON(outputs, sub, user_uid);
|
||||
resolve(files_to_download);
|
||||
} catch(e) {
|
||||
logger.error('Backup method failed. See error below:');
|
||||
logger.error(e);
|
||||
}
|
||||
} else {
|
||||
logger.error('Subscription check failed!');
|
||||
}
|
||||
resolve(false);
|
||||
} else if (output) {
|
||||
const files_to_download = await handleOutputJSON(output, sub, user_uid);
|
||||
resolve(files_to_download);
|
||||
}
|
||||
});
|
||||
}, err => {
|
||||
logger.error(err);
|
||||
updateSubscriptionProperty(sub, {downloading: false}, user_uid);
|
||||
});
|
||||
logger.verbose('Subscription: finished check for ' + sub.name);
|
||||
const files_to_download = await handleOutputJSON(parsed_output, sub, user_uid);
|
||||
return files_to_download;
|
||||
}
|
||||
|
||||
async function handleOutputJSON(output, sub, user_uid) {
|
||||
async function handleOutputJSON(output_jsons, sub, user_uid) {
|
||||
if (config_api.getConfigItem('ytdl_subscriptions_redownload_fresh_uploads')) {
|
||||
await setFreshUploads(sub, user_uid);
|
||||
checkVideosForFreshUploads(sub, user_uid);
|
||||
}
|
||||
|
||||
if (output.length === 0 || (output.length === 1 && output[0] === '')) {
|
||||
if (output_jsons.length === 0 || (output_jsons.length === 1 && output_jsons[0] === '')) {
|
||||
logger.verbose('No additional videos to download for ' + sub.name);
|
||||
return [];
|
||||
}
|
||||
|
||||
const output_jsons = [];
|
||||
for (let i = 0; i < output.length; i++) {
|
||||
let output_json = null;
|
||||
try {
|
||||
output_json = JSON.parse(output[i]);
|
||||
output_jsons.push(output_json);
|
||||
} catch(e) {
|
||||
output_json = null;
|
||||
}
|
||||
if (!output_json) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const files_to_download = await getFilesToDownload(sub, output_jsons);
|
||||
const base_download_options = generateOptionsForSubscriptionDownload(sub, user_uid);
|
||||
const base_download_options = exports.generateOptionsForSubscriptionDownload(sub, user_uid);
|
||||
|
||||
for (let j = 0; j < files_to_download.length; j++) {
|
||||
const file_to_download = files_to_download[j];
|
||||
file_to_download['formats'] = utils.stripPropertiesFromObject(file_to_download['formats'], ['format_id', 'filesize', 'filesize_approx']); // prevent download object from blowing up in size
|
||||
await downloader_api.createDownload(file_to_download['webpage_url'], sub.type || 'video', base_download_options, user_uid, sub.id, sub.name, file_to_download);
|
||||
await downloader_api.createDownload(file_to_download['webpage_url'], sub.type || 'video', base_download_options, user_uid, sub.id, sub.name, [file_to_download]);
|
||||
}
|
||||
|
||||
return files_to_download;
|
||||
}
|
||||
|
||||
function generateOptionsForSubscriptionDownload(sub, user_uid) {
|
||||
exports.generateOptionsForSubscriptionDownload = (sub, user_uid) => {
|
||||
let basePath = null;
|
||||
if (user_uid)
|
||||
basePath = path.join(config_api.getConfigItem('ytdl_users_base_path'), user_uid, 'subscriptions');
|
||||
@@ -371,10 +392,13 @@ async function generateArgsForSubscription(sub, user_uid, redownload = false, de
|
||||
|
||||
// skip videos that are in the archive. otherwise sub download can be permanently slow (vs. just the first time)
|
||||
const archive_text = await archive_api.generateArchive(sub.type, sub.user_uid, sub.id);
|
||||
logger.verbose(`Generating temporary archive file for subscription ${sub.name} with ${archive_text.split('\n').length - 1} entries.`)
|
||||
const archive_path = path.join(appendedBasePath, 'archive.txt');
|
||||
await fs.writeFile(archive_path, archive_text);
|
||||
downloadConfig.push('--download-archive', archive_path);
|
||||
const archive_count = archive_text.split('\n').length - 1;
|
||||
if (archive_count > 0) {
|
||||
logger.verbose(`Generating temporary archive file for subscription ${sub.name} with ${archive_count} entries.`)
|
||||
const archive_path = path.join(appendedBasePath, 'archive.txt');
|
||||
await fs.writeFile(archive_path, archive_text);
|
||||
downloadConfig.push('--download-archive', archive_path);
|
||||
}
|
||||
|
||||
if (sub.custom_args) {
|
||||
const customArgsArray = sub.custom_args.split(',,');
|
||||
@@ -408,7 +432,7 @@ async function generateArgsForSubscription(sub, user_uid, redownload = false, de
|
||||
downloadConfig.push('-r', rate_limit);
|
||||
}
|
||||
|
||||
const default_downloader = utils.getCurrentDownloader() || config_api.getConfigItem('ytdl_default_downloader');
|
||||
const default_downloader = config_api.getConfigItem('ytdl_default_downloader');
|
||||
if (default_downloader === 'yt-dlp') {
|
||||
downloadConfig.push('--no-clean-info-json');
|
||||
}
|
||||
@@ -439,36 +463,66 @@ async function getFilesToDownload(sub, output_jsons) {
|
||||
return files_to_download;
|
||||
}
|
||||
|
||||
exports.cancelCheckSubscription = async (sub_id) => {
|
||||
const sub = await exports.getSubscription(sub_id);
|
||||
if (!sub['downloading'] && !sub['child_process']) {
|
||||
logger.error('Failed to cancel subscription check, verify that it is still running!');
|
||||
return false;
|
||||
}
|
||||
|
||||
async function getSubscriptions(user_uid = null) {
|
||||
// if check is ongoing
|
||||
if (sub['child_process']) {
|
||||
const child_process = sub['child_process'];
|
||||
youtubedl_api.killYoutubeDLProcess(child_process);
|
||||
}
|
||||
|
||||
// cancel activate video downloads
|
||||
await killSubDownloads(sub_id);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async function killSubDownloads(sub_id, remove_downloads = false) {
|
||||
const sub_downloads = await db_api.getRecords('download_queue', {sub_id: sub_id});
|
||||
for (const sub_download of sub_downloads) {
|
||||
if (sub_download['running'])
|
||||
await downloader_api.cancelDownload(sub_download['uid']);
|
||||
if (remove_downloads)
|
||||
await db_api.removeRecord('download_queue', {uid: sub_download['uid']});
|
||||
}
|
||||
}
|
||||
|
||||
exports.getSubscriptions = async (user_uid = null) => {
|
||||
// TODO: fix issue where the downloading property may not match getSubscription()
|
||||
return await db_api.getRecords('subscriptions', {user_uid: user_uid});
|
||||
}
|
||||
|
||||
async function getAllSubscriptions() {
|
||||
exports.getAllSubscriptions = async () => {
|
||||
const all_subs = await db_api.getRecords('subscriptions');
|
||||
const multiUserMode = config_api.getConfigItem('ytdl_multi_user_mode');
|
||||
return all_subs.filter(sub => !!(sub.user_uid) === !!multiUserMode);
|
||||
}
|
||||
|
||||
async function getSubscription(subID) {
|
||||
exports.getSubscription = async (subID) => {
|
||||
// stringify and parse because we may override the 'downloading' property
|
||||
const sub = JSON.parse(JSON.stringify(await db_api.getRecord('subscriptions', {id: subID})));
|
||||
// now with the download_queue, we may need to override 'downloading'
|
||||
const current_downloads = await db_api.getRecords('download_queue', {running: true, sub_id: sub.id}, true);
|
||||
const current_downloads = await db_api.getRecords('download_queue', {running: true, sub_id: subID}, true);
|
||||
if (!sub['downloading']) sub['downloading'] = current_downloads > 0;
|
||||
return sub;
|
||||
}
|
||||
|
||||
async function getSubscriptionByName(subName, user_uid = null) {
|
||||
exports.getSubscriptionByName = async (subName, user_uid = null) => {
|
||||
return await db_api.getRecord('subscriptions', {name: subName, user_uid: user_uid});
|
||||
}
|
||||
|
||||
async function updateSubscription(sub) {
|
||||
exports.updateSubscription = async (sub) => {
|
||||
await db_api.updateRecord('subscriptions', {id: sub.id}, sub);
|
||||
exports.writeSubscriptionMetadata(sub);
|
||||
return true;
|
||||
}
|
||||
|
||||
async function updateSubscriptionPropertyMultiple(subs, assignment_obj) {
|
||||
exports.updateSubscriptionPropertyMultiple = async (subs, assignment_obj) => {
|
||||
subs.forEach(async sub => {
|
||||
await updateSubscriptionProperty(sub, assignment_obj);
|
||||
});
|
||||
@@ -480,6 +534,16 @@ async function updateSubscriptionProperty(sub, assignment_obj) {
|
||||
return true;
|
||||
}
|
||||
|
||||
exports.writeSubscriptionMetadata = (sub) => {
|
||||
let basePath = sub.user_uid ? path.join(config_api.getConfigItem('ytdl_users_base_path'), sub.user_uid, 'subscriptions')
|
||||
: config_api.getConfigItem('ytdl_subscriptions_base_path');
|
||||
const appendedBasePath = getAppendedBasePath(sub, basePath);
|
||||
const metadata_path = path.join(appendedBasePath, CONSTS.SUBSCRIPTION_BACKUP_PATH);
|
||||
|
||||
fs.ensureDirSync(appendedBasePath);
|
||||
fs.writeJSONSync(metadata_path, sub);
|
||||
}
|
||||
|
||||
async function setFreshUploads(sub) {
|
||||
const sub_files = await db_api.getRecords('files', {sub_id: sub.id});
|
||||
if (!sub_files) return;
|
||||
@@ -508,24 +572,22 @@ 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, async (err, output) => {
|
||||
if (err) {
|
||||
// video is not available anymore for whatever reason
|
||||
} else if (output) {
|
||||
const metric_to_compare = sub.type === 'audio' ? 'abr' : 'height';
|
||||
if (output[metric_to_compare] > file_obj[metric_to_compare]) {
|
||||
// download new video as the simulated one is better
|
||||
youtubedl.exec(file_obj['url'], downloadConfig, {maxBuffer: Infinity}, async (err, output) => {
|
||||
if (err) {
|
||||
logger.verbose(`Failed to download better version of video ${file_obj['id']}`);
|
||||
} else if (output) {
|
||||
logger.verbose(`Successfully upgraded video ${file_obj['id']}'s ${metric_to_compare} from ${file_obj[metric_to_compare]} to ${output[metric_to_compare]}`);
|
||||
await db_api.setVideoProperty(file_obj['uid'], {[metric_to_compare]: output[metric_to_compare]});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const info = await downloader_api.getVideoInfoByURL(file_obj['url'], downloadConfig);
|
||||
if (info && info.length === 1) {
|
||||
const metric_to_compare = sub.type === 'audio' ? 'abr' : 'height';
|
||||
if (info[metric_to_compare] > file_obj[metric_to_compare]) {
|
||||
// download new video as the simulated one is better
|
||||
let {callback} = await youtubedl_api.runYoutubeDL(sub.url, downloadConfig);
|
||||
const {parsed_output, err} = await callback;
|
||||
if (err) {
|
||||
logger.verbose(`Failed to download better version of video ${file_obj['id']}`);
|
||||
} else if (parsed_output) {
|
||||
logger.verbose(`Successfully upgraded video ${file_obj['id']}'s ${metric_to_compare} from ${file_obj[metric_to_compare]} to ${info[metric_to_compare]}`);
|
||||
await db_api.setVideoProperty(file_obj['uid'], {[metric_to_compare]: info[metric_to_compare]});
|
||||
}
|
||||
}
|
||||
}
|
||||
await db_api.setVideoProperty(file_obj['uid'], {'fresh_upload': false});
|
||||
}
|
||||
|
||||
@@ -534,17 +596,3 @@ async function checkVideoIfBetterExists(file_obj, sub, user_uid) {
|
||||
function getAppendedBasePath(sub, base_path) {
|
||||
return path.join(base_path, (sub.isPlaylist ? 'playlists/' : 'channels/'), sub.name);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getSubscription : getSubscription,
|
||||
getSubscriptionByName : getSubscriptionByName,
|
||||
getSubscriptions : getSubscriptions,
|
||||
getAllSubscriptions : getAllSubscriptions,
|
||||
updateSubscription : updateSubscription,
|
||||
subscribe : subscribe,
|
||||
unsubscribe : unsubscribe,
|
||||
deleteSubscriptionFile : deleteSubscriptionFile,
|
||||
getVideosForSub : getVideosForSub,
|
||||
updateSubscriptionPropertyMultiple : updateSubscriptionPropertyMultiple,
|
||||
generateOptionsForSubscriptionDownload: generateOptionsForSubscriptionDownload
|
||||
}
|
||||
|
||||
@@ -3,9 +3,15 @@ const notifications_api = require('./notifications');
|
||||
const youtubedl_api = require('./youtube-dl');
|
||||
const archive_api = require('./archive');
|
||||
const files_api = require('./files');
|
||||
const subscriptions_api = require('./subscriptions');
|
||||
const config_api = require('./config');
|
||||
const auth_api = require('./authentication/auth');
|
||||
const utils = require('./utils');
|
||||
const logger = require('./logger');
|
||||
const CONSTS = require('./consts');
|
||||
|
||||
const fs = require('fs-extra');
|
||||
const logger = require('./logger');
|
||||
const path = require('path');
|
||||
const scheduler = require('node-schedule');
|
||||
|
||||
const TASKS = {
|
||||
@@ -47,6 +53,11 @@ const TASKS = {
|
||||
run: archive_api.importArchives,
|
||||
title: 'Import legacy archives',
|
||||
job: null
|
||||
},
|
||||
rebuild_database: {
|
||||
run: rebuildDB,
|
||||
title: 'Rebuild database',
|
||||
job: null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -265,4 +276,68 @@ async function autoDeleteFiles(data) {
|
||||
}
|
||||
}
|
||||
|
||||
async function rebuildDB() {
|
||||
await db_api.backupDB();
|
||||
let subs_to_add = await guessSubscriptions(false);
|
||||
subs_to_add = subs_to_add.concat(await guessSubscriptions(true));
|
||||
const users_to_add = await guessUsers();
|
||||
for (const user_to_add of users_to_add) {
|
||||
const usersFileFolder = config_api.getConfigItem('ytdl_users_base_path');
|
||||
|
||||
const user_exists = await db_api.getRecord('users', {uid: user_to_add});
|
||||
if (!user_exists) {
|
||||
await auth_api.registerUser(user_to_add, user_to_add, 'password');
|
||||
logger.info(`Regenerated user ${user_to_add}`);
|
||||
}
|
||||
|
||||
const user_channel_subs = await guessSubscriptions(false, path.join(usersFileFolder, user_to_add), user_to_add);
|
||||
const user_playlist_subs = await guessSubscriptions(true, path.join(usersFileFolder, user_to_add), user_to_add);
|
||||
subs_to_add = subs_to_add.concat(user_channel_subs, user_playlist_subs);
|
||||
}
|
||||
|
||||
for (const sub_to_add of subs_to_add) {
|
||||
const sub_exists = !!(await subscriptions_api.getSubscriptionByName(sub_to_add['name'], sub_to_add['user_uid']));
|
||||
// TODO: we shouldn't be creating this here
|
||||
const new_sub = Object.assign({}, sub_to_add, {paused: true});
|
||||
if (!sub_exists) {
|
||||
await subscriptions_api.subscribe(new_sub, sub_to_add['user_uid'], true);
|
||||
logger.info(`Regenerated subscription ${sub_to_add['name']}`);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`Importing unregistered files`);
|
||||
await files_api.importUnregisteredFiles();
|
||||
}
|
||||
|
||||
const guessUsers = async () => {
|
||||
const usersFileFolder = config_api.getConfigItem('ytdl_users_base_path');
|
||||
const userPaths = await utils.getDirectoriesInDirectory(usersFileFolder);
|
||||
return userPaths.map(userPath => path.basename(userPath));
|
||||
}
|
||||
|
||||
const guessSubscriptions = async (isPlaylist, basePath = null) => {
|
||||
const guessed_subs = [];
|
||||
const subscriptionsFileFolder = config_api.getConfigItem('ytdl_subscriptions_base_path');
|
||||
|
||||
const subsSubPath = basePath ? path.join(basePath, 'subscriptions') : subscriptionsFileFolder;
|
||||
const subsPath = path.join(subsSubPath, isPlaylist ? 'playlists' : 'channels');
|
||||
|
||||
const subs = await utils.getDirectoriesInDirectory(subsPath);
|
||||
for (const subPath of subs) {
|
||||
const sub_backup_path = path.join(subPath, CONSTS.SUBSCRIPTION_BACKUP_PATH);
|
||||
if (!fs.existsSync(sub_backup_path)) continue;
|
||||
|
||||
try {
|
||||
const sub_backup = fs.readJSONSync(sub_backup_path)
|
||||
delete sub_backup['_id'];
|
||||
guessed_subs.push(sub_backup);
|
||||
} catch(err) {
|
||||
logger.warn(`Failed to reimport subscription in path ${subPath}`)
|
||||
logger.warn(err);
|
||||
}
|
||||
}
|
||||
|
||||
return guessed_subs;
|
||||
}
|
||||
|
||||
exports.TASKS = TASKS;
|
||||
File diff suppressed because one or more lines are too long
1
backend/test/sample_mp3.info.json
Normal file
1
backend/test/sample_mp3.info.json
Normal file
File diff suppressed because one or more lines are too long
1
backend/test/sample_mp4.info.json
Normal file
1
backend/test/sample_mp4.info.json
Normal file
File diff suppressed because one or more lines are too long
@@ -3,7 +3,11 @@ const assert = require('assert');
|
||||
const low = require('lowdb')
|
||||
const winston = require('winston');
|
||||
const path = require('path');
|
||||
|
||||
const util = require('util');
|
||||
const fs = require('fs-extra');
|
||||
const { v4: uuid } = require('uuid');
|
||||
const NodeID3 = require('node-id3');
|
||||
const exec = util.promisify(require('child_process').exec);
|
||||
|
||||
const FileSync = require('lowdb/adapters/FileSync');
|
||||
|
||||
@@ -41,11 +45,11 @@ const subscriptions_api = require('../subscriptions');
|
||||
const archive_api = require('../archive');
|
||||
const categories_api = require('../categories');
|
||||
const files_api = require('../files');
|
||||
const fs = require('fs-extra');
|
||||
const { uuid } = require('uuidv4');
|
||||
const NodeID3 = require('node-id3');
|
||||
const youtubedl_api = require('../youtube-dl');
|
||||
const config_api = require('../config');
|
||||
const CONSTS = require('../consts');
|
||||
|
||||
db_api.initialize(db, users_db);
|
||||
db_api.initialize(db, users_db, 'local_db_test.json');
|
||||
|
||||
const sample_video_json = {
|
||||
id: "Sample Video",
|
||||
@@ -68,9 +72,9 @@ const sample_video_json = {
|
||||
}
|
||||
|
||||
describe('Database', async function() {
|
||||
describe('Import', async function() {
|
||||
describe.skip('Import', async function() {
|
||||
// it('Migrate', async function() {
|
||||
// await db_api.connectToDB();
|
||||
// // await db_api.connectToDB();
|
||||
// await db_api.removeAllRecords();
|
||||
// const success = await db_api.importJSONToDB(db.value(), users_db.value());
|
||||
// assert(success);
|
||||
@@ -86,7 +90,7 @@ describe('Database', async function() {
|
||||
});
|
||||
|
||||
it('Transfer to local', async function() {
|
||||
await db_api.connectToDB();
|
||||
// await db_api.connectToDB();
|
||||
await db_api.removeAllRecords('test');
|
||||
await db_api.insertRecordIntoTable('test', {test: 'test'});
|
||||
|
||||
@@ -114,7 +118,8 @@ describe('Database', async function() {
|
||||
|
||||
for (const local_db_mode of local_db_modes) {
|
||||
let use_local_db = local_db_mode;
|
||||
describe(`Use local DB - ${use_local_db}`, async function() {
|
||||
const describe_skippable = use_local_db ? describe : describe.skip;
|
||||
describe_skippable(`Use local DB - ${use_local_db}`, async function() {
|
||||
beforeEach(async function() {
|
||||
if (!use_local_db) {
|
||||
this.timeout(120000);
|
||||
@@ -167,7 +172,7 @@ describe('Database', async function() {
|
||||
];
|
||||
await db_api.insertRecordsIntoTable('test', test_duplicates);
|
||||
const duplicates = await db_api.findDuplicatesByKey('test', 'test');
|
||||
console.log(duplicates);
|
||||
assert(duplicates && duplicates.length === 2 && duplicates[0]['key'] === '2' && duplicates[1]['key'] === '4')
|
||||
});
|
||||
|
||||
it('Update record', async function() {
|
||||
@@ -279,7 +284,7 @@ describe('Database', async function() {
|
||||
assert(stats);
|
||||
});
|
||||
|
||||
it('Query speed', async function() {
|
||||
it.skip('Query speed', async function() {
|
||||
this.timeout(120000);
|
||||
const NUM_RECORDS_TO_ADD = 300004; // max batch ops is 1000
|
||||
const test_records = [];
|
||||
@@ -337,16 +342,23 @@ describe('Database', async function() {
|
||||
});
|
||||
|
||||
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';
|
||||
this.timeout(120000);
|
||||
const user_to_test = 'test_user';
|
||||
const user_password = 'test_pass';
|
||||
const sub_to_test = '';
|
||||
const playlist_to_test = '';
|
||||
beforeEach(async function() {
|
||||
await db_api.connectToDB();
|
||||
user = await auth_api.login('admin', 'pass');
|
||||
// await db_api.connectToDB();
|
||||
await auth_api.deleteUser(user_to_test);
|
||||
});
|
||||
describe('Authentication', function() {
|
||||
it('login', async function() {
|
||||
describe('Basic', function() {
|
||||
it('Register', async function() {
|
||||
const user = await auth_api.registerUser(user_to_test, user_to_test, user_password);
|
||||
assert(user);
|
||||
});
|
||||
it('Login', async function() {
|
||||
await auth_api.registerUser(user_to_test, user_to_test, user_password);
|
||||
const user = await auth_api.login(user_to_test, user_password);
|
||||
assert(user);
|
||||
});
|
||||
});
|
||||
@@ -362,18 +374,18 @@ describe('Multi User', async function() {
|
||||
});
|
||||
|
||||
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);
|
||||
await db_api.setVideoProperty(video_to_test, {sharingEnabled: false});
|
||||
const video_obj = await auth_api.getUserVideo(user_to_test, 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);
|
||||
const video_obj = await auth_api.getUserVideo(user_to_test, video_to_test, true);
|
||||
assert(video_obj);
|
||||
});
|
||||
});
|
||||
describe('Zip generators', function() {
|
||||
describe.skip('Zip generators', function() {
|
||||
it('Playlist zip generator', async function() {
|
||||
const playlist = await files_api.getPlaylist(playlist_to_test, user_to_test);
|
||||
assert(playlist);
|
||||
@@ -390,7 +402,7 @@ describe('Multi User', async function() {
|
||||
});
|
||||
|
||||
it('Subscription zip generator', async function() {
|
||||
const sub = await subscriptions_api.getSubscription(sub_to_test, user_to_test);
|
||||
const sub = await subscriptions_api.getSubscription(sub_to_test.id, user_to_test);
|
||||
const sub_videos = await db_api.getRecords('files', {sub_id: sub.id});
|
||||
assert(sub);
|
||||
const sub_files_to_download = [];
|
||||
@@ -429,35 +441,100 @@ describe('Multi User', async function() {
|
||||
|
||||
describe('Downloader', function() {
|
||||
const downloader_api = require('../downloader');
|
||||
const url = 'https://www.youtube.com/watch?v=dQw4w9WgXcQ';
|
||||
const url = 'https://www.youtube.com/watch?v=hpigjnKl7nI';
|
||||
const playlist_url = 'https://www.youtube.com/playlist?list=PLbZT16X07RLhqK-ZgSkRuUyiz9B_WLdNK';
|
||||
const sub_id = 'dc834388-3454-41bf-a618-e11cb8c7de1c';
|
||||
const options = {
|
||||
ui_uid: uuid(),
|
||||
user: 'admin'
|
||||
ui_uid: uuid()
|
||||
}
|
||||
|
||||
async function createCategory(url) {
|
||||
// get info
|
||||
const args = await downloader_api.generateArgs(url, 'video', options, null, true);
|
||||
const [info] = await downloader_api.getVideoInfoByURL(url, args);
|
||||
|
||||
// create category
|
||||
await db_api.removeAllRecords('categories');
|
||||
const new_category = {
|
||||
name: 'test_category',
|
||||
uid: uuid(),
|
||||
rules: [],
|
||||
custom_output: ''
|
||||
};
|
||||
await db_api.insertRecordIntoTable('categories', new_category);
|
||||
await db_api.pushToRecordsArray('categories', {name: 'test_category'}, 'rules', {
|
||||
preceding_operator: null,
|
||||
comparator: 'includes',
|
||||
property: 'title',
|
||||
value: info['title']
|
||||
});
|
||||
}
|
||||
|
||||
before(async function() {
|
||||
const update_available = await youtubedl_api.checkForYoutubeDLUpdate();
|
||||
if (update_available) await youtubedl_api.updateYoutubeDL(update_available);
|
||||
config_api.setConfigItem('ytdl_max_concurrent_downloads', 0);
|
||||
});
|
||||
|
||||
beforeEach(async function() {
|
||||
await db_api.connectToDB();
|
||||
// await db_api.connectToDB();
|
||||
await db_api.removeAllRecords('download_queue');
|
||||
config_api.setConfigItem('ytdl_allow_playlist_categorization', true);
|
||||
});
|
||||
|
||||
it('Get file info', async function() {
|
||||
this.timeout(300000);
|
||||
const info = await downloader_api.getVideoInfoByURL(url);
|
||||
assert(!!info);
|
||||
assert(!!info && info.length > 0);
|
||||
});
|
||||
|
||||
it('Download file', async function() {
|
||||
this.timeout(300000);
|
||||
this.timeout(300000);
|
||||
await downloader_api.setupDownloads();
|
||||
const args = await downloader_api.generateArgs(url, 'video', options, null, true);
|
||||
const [info] = await downloader_api.getVideoInfoByURL(url, args);
|
||||
if (fs.existsSync(info['_filename'])) fs.unlinkSync(info['_filename']);
|
||||
const returned_download = await downloader_api.createDownload(url, 'video', options);
|
||||
console.log(returned_download);
|
||||
await utils.wait(20000);
|
||||
assert(returned_download);
|
||||
const custom_download_method = async (url, args, options, callback) => {
|
||||
fs.writeJSONSync(utils.getTrueFileName(info['_filename'], 'video', '.info.json'), info);
|
||||
await generateEmptyVideoFile(info['_filename']);
|
||||
return await callback(null, [JSON.stringify(info)]);
|
||||
}
|
||||
const success = await downloader_api.downloadQueuedFile(returned_download['uid'], custom_download_method);
|
||||
assert(success);
|
||||
});
|
||||
|
||||
it('Downloader - categorize', async function() {
|
||||
this.timeout(300000);
|
||||
await createCategory(url);
|
||||
// collect info
|
||||
const returned_download = await downloader_api.createDownload(url, 'video', options);
|
||||
await downloader_api.collectInfo(returned_download['uid']);
|
||||
assert(returned_download['category']);
|
||||
assert(returned_download['category']['name'] === 'test_category');
|
||||
});
|
||||
|
||||
it('Downloader - categorize playlist', async function() {
|
||||
this.timeout(300000);
|
||||
await createCategory(playlist_url);
|
||||
// collect info
|
||||
const returned_download_pass = await downloader_api.createDownload(playlist_url, 'video', options);
|
||||
await downloader_api.collectInfo(returned_download_pass['uid']);
|
||||
assert(returned_download_pass['category']);
|
||||
assert(returned_download_pass['category']['name'] === 'test_category');
|
||||
|
||||
// test with playlist categorization disabled
|
||||
config_api.setConfigItem('ytdl_allow_playlist_categorization', false);
|
||||
const returned_download_fail = await downloader_api.createDownload(playlist_url, 'video', options);
|
||||
await downloader_api.collectInfo(returned_download_fail['uid']);
|
||||
assert(!returned_download_fail['category']);
|
||||
});
|
||||
|
||||
it('Tag file', async function() {
|
||||
const audio_path = './test/sample.mp3';
|
||||
const sample_json = fs.readJSONSync('./test/sample.info.json');
|
||||
const success = await generateEmptyAudioFile('test/sample_mp3.mp3');
|
||||
const audio_path = './test/sample_mp3.mp3';
|
||||
const sample_json = fs.readJSONSync('./test/sample_mp3.info.json');
|
||||
const tags = {
|
||||
title: sample_json['title'],
|
||||
artist: sample_json['artist'] ? sample_json['artist'] : sample_json['uploader'],
|
||||
@@ -465,14 +542,13 @@ describe('Downloader', function() {
|
||||
}
|
||||
NodeID3.write(tags, audio_path);
|
||||
const written_tags = NodeID3.read(audio_path);
|
||||
assert(written_tags['raw']['TRCK'] === '27');
|
||||
assert(success && written_tags['raw']['TRCK'] === '27');
|
||||
});
|
||||
|
||||
it('Queue file', async function() {
|
||||
this.timeout(300000);
|
||||
const returned_download = await downloader_api.createDownload(url, 'video', options);
|
||||
console.log(returned_download);
|
||||
await utils.wait(20000);
|
||||
const returned_download = await downloader_api.createDownload(url, 'video', options, null, null, null, null, true);
|
||||
assert(returned_download);
|
||||
});
|
||||
|
||||
it('Pause file', async function() {
|
||||
@@ -487,7 +563,7 @@ describe('Downloader', function() {
|
||||
assert(args.length > 0);
|
||||
});
|
||||
|
||||
it('Generate args - subscription', async function() {
|
||||
it.skip('Generate args - subscription', async function() {
|
||||
const sub = await subscriptions_api.getSubscription(sub_id);
|
||||
const sub_options = subscriptions_api.generateOptionsForSubscriptionDownload(sub, 'admin');
|
||||
const args_normal = await downloader_api.generateArgs(url, 'video', options);
|
||||
@@ -500,7 +576,7 @@ describe('Downloader', function() {
|
||||
if (fs.existsSync(nfo_file_path)) {
|
||||
fs.unlinkSync(nfo_file_path);
|
||||
}
|
||||
const sample_json = fs.readJSONSync('./test/sample.info.json');
|
||||
const sample_json = fs.readJSONSync('./test/sample_mp4.info.json');
|
||||
downloader_api.generateNFOFile(sample_json, nfo_file_path);
|
||||
assert(fs.existsSync(nfo_file_path), true);
|
||||
fs.unlinkSync(nfo_file_path);
|
||||
@@ -527,11 +603,19 @@ describe('Downloader', function() {
|
||||
});
|
||||
describe('Twitch', async function () {
|
||||
const twitch_api = require('../twitch');
|
||||
const example_vod = '1710641401';
|
||||
it('Download VOD', async function() {
|
||||
const example_vod = '1790315420';
|
||||
it('Download VOD chat', async function() {
|
||||
this.timeout(300000);
|
||||
if (!fs.existsSync('TwitchDownloaderCLI')) {
|
||||
try {
|
||||
await exec('sh ../docker-utils/fetch-twitchdownloader.sh');
|
||||
fs.copyFileSync('../docker-utils/TwitchDownloaderCLI', 'TwitchDownloaderCLI');
|
||||
} catch (e) {
|
||||
logger.info('TwitchDownloaderCLI fetch failed, file may exist regardless.');
|
||||
}
|
||||
}
|
||||
const sample_path = path.join('test', 'sample.twitch_chat.json');
|
||||
if (fs.existsSync(sample_path)) fs.unlinkSync(sample_path);
|
||||
this.timeout(300000);
|
||||
await twitch_api.downloadTwitchChatByVODID(example_vod, 'sample', null, null, null, './test');
|
||||
assert(fs.existsSync(sample_path));
|
||||
|
||||
@@ -541,10 +625,109 @@ describe('Downloader', function() {
|
||||
});
|
||||
});
|
||||
|
||||
describe('youtube-dl', async function() {
|
||||
beforeEach(async function () {
|
||||
if (fs.existsSync(CONSTS.DETAILS_BIN_PATH)) fs.unlinkSync(CONSTS.DETAILS_BIN_PATH);
|
||||
await youtubedl_api.checkForYoutubeDLUpdate();
|
||||
});
|
||||
it('Check latest version', async function() {
|
||||
this.timeout(300000);
|
||||
const original_fork = config_api.getConfigItem('ytdl_default_downloader');
|
||||
const latest_version = await youtubedl_api.getLatestUpdateVersion(original_fork);
|
||||
assert(latest_version > CONSTS.OUTDATED_YOUTUBEDL_VERSION);
|
||||
});
|
||||
|
||||
it('Update youtube-dl', async function() {
|
||||
this.timeout(300000);
|
||||
const original_fork = config_api.getConfigItem('ytdl_default_downloader');
|
||||
const binary_path = path.join('test', 'test_binary');
|
||||
for (const youtubedl_fork in youtubedl_api.youtubedl_forks) {
|
||||
config_api.setConfigItem('ytdl_default_downloader', youtubedl_fork);
|
||||
const latest_version = await youtubedl_api.checkForYoutubeDLUpdate();
|
||||
await youtubedl_api.updateYoutubeDL(latest_version, binary_path);
|
||||
assert(fs.existsSync(binary_path));
|
||||
if (fs.existsSync(binary_path)) fs.unlinkSync(binary_path);
|
||||
}
|
||||
config_api.setConfigItem('ytdl_default_downloader', original_fork);
|
||||
});
|
||||
|
||||
it('Run process', async function() {
|
||||
this.timeout(300000);
|
||||
const downloader_api = require('../downloader');
|
||||
const url = 'https://www.youtube.com/watch?v=hpigjnKl7nI';
|
||||
const args = await downloader_api.generateArgs(url, 'video', {}, null, true);
|
||||
const {child_process} = await youtubedl_api.runYoutubeDL(url, args);
|
||||
assert(child_process);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Subscriptions', function() {
|
||||
const new_sub = {
|
||||
name: 'test_sub',
|
||||
url: 'https://www.youtube.com/channel/UCzofo-P8yMMCOv8rsPfIR-g',
|
||||
maxQuality: null,
|
||||
id: uuid(),
|
||||
user_uid: null,
|
||||
type: 'video',
|
||||
paused: true
|
||||
};
|
||||
beforeEach(async function() {
|
||||
await db_api.removeAllRecords('subscriptions');
|
||||
});
|
||||
it('Subscribe', async function () {
|
||||
const success = await subscriptions_api.subscribe(new_sub, null, true);
|
||||
assert(success);
|
||||
const sub_exists = await db_api.getRecord('subscriptions', {id: new_sub['id']});
|
||||
assert(sub_exists);
|
||||
});
|
||||
it('Unsubscribe', async function () {
|
||||
await subscriptions_api.subscribe(new_sub, null, true);
|
||||
await subscriptions_api.unsubscribe(new_sub);
|
||||
const sub_exists = await db_api.getRecord('subscriptions', {id: new_sub['id']});
|
||||
assert(!sub_exists);
|
||||
});
|
||||
it('Delete subscription file', async function () {
|
||||
|
||||
});
|
||||
it('Get subscription by name', async function () {
|
||||
await subscriptions_api.subscribe(new_sub, null, true);
|
||||
const sub_by_name = await subscriptions_api.getSubscriptionByName('test_sub');
|
||||
assert(sub_by_name);
|
||||
});
|
||||
it('Get subscriptions', async function() {
|
||||
await subscriptions_api.subscribe(new_sub, null, true);
|
||||
const subs = await subscriptions_api.getSubscriptions(null);
|
||||
assert(subs && subs.length === 1);
|
||||
});
|
||||
it('Update subscription', async function () {
|
||||
await subscriptions_api.subscribe(new_sub, null, true);
|
||||
const sub_update = Object.assign({}, new_sub, {name: 'updated_name'});
|
||||
await subscriptions_api.updateSubscription(sub_update);
|
||||
const updated_sub = await db_api.getRecord('subscriptions', {id: new_sub['id']});
|
||||
assert(updated_sub['name'] === 'updated_name');
|
||||
});
|
||||
it('Update subscription property', async function () {
|
||||
await subscriptions_api.subscribe(new_sub, null, true);
|
||||
const sub_update = Object.assign({}, new_sub, {name: 'updated_name'});
|
||||
await subscriptions_api.updateSubscriptionPropertyMultiple([sub_update], {name: 'updated_name'});
|
||||
const updated_sub = await db_api.getRecord('subscriptions', {id: new_sub['id']});
|
||||
assert(updated_sub['name'] === 'updated_name');
|
||||
});
|
||||
it('Write subscription metadata', async function() {
|
||||
const metadata_path = path.join('subscriptions', 'channels', 'test_sub', 'subscription_backup.json');
|
||||
if (fs.existsSync(metadata_path)) fs.unlinkSync(metadata_path);
|
||||
await subscriptions_api.subscribe(new_sub, null, true);
|
||||
assert(fs.existsSync(metadata_path));
|
||||
});
|
||||
it('Fresh uploads', async function() {
|
||||
|
||||
});
|
||||
});
|
||||
|
||||
describe('Tasks', function() {
|
||||
const tasks_api = require('../tasks');
|
||||
beforeEach(async function() {
|
||||
await db_api.connectToDB();
|
||||
// await db_api.connectToDB();
|
||||
await db_api.removeAllRecords('tasks');
|
||||
|
||||
const dummy_task = {
|
||||
@@ -563,7 +746,7 @@ describe('Tasks', function() {
|
||||
await tasks_api.executeTask('backup_local_db');
|
||||
const backups_new = await utils.recFindByExt('appdata', 'bak');
|
||||
const new_length = backups_new.length;
|
||||
assert(original_length, new_length-1);
|
||||
assert(original_length === new_length-1);
|
||||
});
|
||||
|
||||
it('Check for missing files', async function() {
|
||||
@@ -573,7 +756,7 @@ describe('Tasks', function() {
|
||||
await db_api.insertRecordIntoTable('files', test_missing_file);
|
||||
await tasks_api.executeTask('missing_files_check');
|
||||
const missing_file_db_record = await db_api.getRecord('files', {uid: 'test'});
|
||||
assert(!missing_file_db_record, true);
|
||||
assert(!missing_file_db_record);
|
||||
});
|
||||
|
||||
it('Check for duplicate files', async function() {
|
||||
@@ -593,27 +776,29 @@ describe('Tasks', function() {
|
||||
|
||||
await tasks_api.executeTask('duplicate_files_check');
|
||||
const duplicated_record_count = await db_api.getRecords('files', {path: 'test/missing_file.mp4'}, true);
|
||||
assert(duplicated_record_count == 1, true);
|
||||
assert(duplicated_record_count === 1);
|
||||
});
|
||||
|
||||
it('Import unregistered files', async function() {
|
||||
this.timeout(300000);
|
||||
|
||||
const success = await generateEmptyVideoFile('test/sample_mp4.mp4');
|
||||
|
||||
// pre-test cleanup
|
||||
await db_api.removeAllRecords('files', {title: 'Sample File'});
|
||||
if (fs.existsSync('video/sample.info.json')) fs.unlinkSync('video/sample.info.json');
|
||||
if (fs.existsSync('video/sample.mp4')) fs.unlinkSync('video/sample.mp4');
|
||||
await db_api.removeAllRecords('files', {path: 'test/missing_file.mp4'});
|
||||
if (fs.existsSync('video/sample_mp4.info.json')) fs.unlinkSync('video/sample_mp4.info.json');
|
||||
if (fs.existsSync('video/sample_mp4.mp4')) fs.unlinkSync('video/sample_mp4.mp4');
|
||||
|
||||
// copies in files
|
||||
fs.copyFileSync('test/sample.info.json', 'video/sample.info.json');
|
||||
fs.copyFileSync('test/sample.mp4', 'video/sample.mp4');
|
||||
fs.copyFileSync('test/sample_mp4.info.json', 'video/sample_mp4.info.json');
|
||||
fs.copyFileSync('test/sample_mp4.mp4', 'video/sample_mp4.mp4');
|
||||
await tasks_api.executeTask('missing_db_records');
|
||||
const imported_file = await db_api.getRecord('files', {title: 'Sample File'});
|
||||
assert(!!imported_file === true);
|
||||
assert(success && !!imported_file);
|
||||
|
||||
// post-test cleanup
|
||||
if (fs.existsSync('video/sample.info.json')) fs.unlinkSync('video/sample.info.json');
|
||||
if (fs.existsSync('video/sample.mp4')) fs.unlinkSync('video/sample.mp4');
|
||||
if (fs.existsSync('video/sample_mp4.info.json')) fs.unlinkSync('video/sample_mp4.info.json');
|
||||
if (fs.existsSync('video/sample_mp4.mp4')) fs.unlinkSync('video/sample_mp4.mp4');
|
||||
});
|
||||
|
||||
it('Schedule and cancel task', async function() {
|
||||
@@ -653,12 +838,12 @@ describe('Tasks', function() {
|
||||
|
||||
describe('Archive', async function() {
|
||||
beforeEach(async function() {
|
||||
await db_api.connectToDB();
|
||||
await db_api.removeAllRecords('archives', {user_uid: 'test_user'});
|
||||
// await db_api.connectToDB();
|
||||
await db_api.removeAllRecords('archives');
|
||||
});
|
||||
|
||||
afterEach(async function() {
|
||||
await db_api.removeAllRecords('archives', {user_uid: 'test_user'});
|
||||
await db_api.removeAllRecords('archives');
|
||||
});
|
||||
|
||||
it('Import archive', async function() {
|
||||
@@ -672,7 +857,6 @@ describe('Archive', async function() {
|
||||
const count = await archive_api.importArchiveFile(archive_text, 'video', 'test_user', 'test_sub');
|
||||
assert(count === 4)
|
||||
const archive_items = await db_api.getRecords('archives', {user_uid: 'test_user', sub_id: 'test_sub'});
|
||||
console.log(archive_items);
|
||||
assert(archive_items.length === 4);
|
||||
assert(archive_items.filter(archive_item => archive_item.extractor === 'testextractor2').length === 1);
|
||||
assert(archive_items.filter(archive_item => archive_item.extractor === 'testextractor1').length === 3);
|
||||
@@ -703,9 +887,9 @@ describe('Archive', async function() {
|
||||
});
|
||||
|
||||
it('Remove from archive', async function() {
|
||||
await archive_api.addToArchive('testextractor1', 'testing1', 'video', 'test_user');
|
||||
await archive_api.addToArchive('testextractor2', 'testing1', 'video', 'test_user');
|
||||
await archive_api.addToArchive('testextractor2', 'testing2', 'video', 'test_user');
|
||||
await archive_api.addToArchive('testextractor1', 'testing1', 'video', 'test_title', 'test_user');
|
||||
await archive_api.addToArchive('testextractor2', 'testing1', 'video', 'test_title', 'test_user');
|
||||
await archive_api.addToArchive('testextractor2', 'testing2', 'video', 'test_title', 'test_user');
|
||||
|
||||
const success = await archive_api.removeFromArchive('testextractor2', 'testing1', 'video', 'test_user');
|
||||
assert(success);
|
||||
@@ -751,14 +935,14 @@ describe('Utils', async function() {
|
||||
|
||||
describe('Categories', async function() {
|
||||
beforeEach(async function() {
|
||||
await db_api.connectToDB();
|
||||
// await db_api.connectToDB();
|
||||
const new_category = {
|
||||
name: 'test_category',
|
||||
uid: uuid(),
|
||||
rules: [],
|
||||
custom_output: ''
|
||||
};
|
||||
|
||||
await db_api.removeAllRecords('categories', {name: 'test_category'});
|
||||
await db_api.insertRecordIntoTable('categories', new_category);
|
||||
});
|
||||
|
||||
@@ -799,7 +983,6 @@ describe('Categories', async function() {
|
||||
});
|
||||
|
||||
const category = await categories_api.categorize([sample_video_json]);
|
||||
console.log(category);
|
||||
assert(category && category.name === 'test_category');
|
||||
});
|
||||
|
||||
@@ -852,4 +1035,74 @@ describe('Categories', async function() {
|
||||
const category = await categories_api.categorize([sample_video_json]);
|
||||
assert(category);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Config', async function() {
|
||||
it('findChangedConfigItems', async function() {
|
||||
const old_config = {
|
||||
"YoutubeDLMaterial": {
|
||||
"test_object1": {
|
||||
"test_prop1": true,
|
||||
"test_prop2": false
|
||||
},
|
||||
"test_object2": {
|
||||
"test_prop3": {
|
||||
"test_prop3_1": true,
|
||||
"test_prop3_2": false
|
||||
},
|
||||
"test_prop4": false
|
||||
},
|
||||
"test_object3": {
|
||||
"test_prop5": {
|
||||
"test_prop5_1": true,
|
||||
"test_prop5_2": false
|
||||
},
|
||||
"test_prop6": false
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const new_config = {
|
||||
"YoutubeDLMaterial": {
|
||||
"test_object1": {
|
||||
"test_prop1": false,
|
||||
"test_prop2": false
|
||||
},
|
||||
"test_object2": {
|
||||
"test_prop3": {
|
||||
"test_prop3_1": false,
|
||||
"test_prop3_2": false
|
||||
},
|
||||
"test_prop4": true
|
||||
},
|
||||
"test_object3": {
|
||||
"test_prop5": {
|
||||
"test_prop5_1": true,
|
||||
"test_prop5_2": false
|
||||
},
|
||||
"test_prop6": true
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const changes = config_api.findChangedConfigItems(old_config, new_config);
|
||||
assert(changes[0]['key'] === 'test_prop1' && changes[0]['old_value'] === true && changes[0]['new_value'] === false);
|
||||
assert(changes[1]['key'] === 'test_prop3' &&
|
||||
changes[1]['old_value']['test_prop3_1'] === true &&
|
||||
changes[1]['new_value']['test_prop3_1'] === false &&
|
||||
changes[1]['old_value']['test_prop3_2'] === false &&
|
||||
changes[1]['new_value']['test_prop3_2'] === false);
|
||||
assert(changes[2]['key'] === 'test_prop4' && changes[2]['old_value'] === false && changes[2]['new_value'] === true);
|
||||
assert(changes[3]['key'] === 'test_prop6' && changes[3]['old_value'] === false && changes[3]['new_value'] === true);
|
||||
});
|
||||
});
|
||||
|
||||
const generateEmptyVideoFile = async (file_path) => {
|
||||
if (fs.existsSync(file_path)) fs.unlinkSync(file_path);
|
||||
return await exec(`ffmpeg -t 1 -f lavfi -i color=c=black:s=640x480 -c:v libx264 -tune stillimage -pix_fmt yuv420p "${file_path}"`);
|
||||
}
|
||||
|
||||
const generateEmptyAudioFile = async (file_path) => {
|
||||
if (fs.existsSync(file_path)) fs.unlinkSync(file_path);
|
||||
return await exec(`ffmpeg -f lavfi -i anullsrc=r=44100:cl=mono -t 1 -q:a 9 -acodec libmp3lame ${file_path}`);
|
||||
}
|
||||
@@ -13,7 +13,7 @@ const CONSTS = require('./consts');
|
||||
const is_windows = process.platform === 'win32';
|
||||
|
||||
// replaces .webm with appropriate extension
|
||||
exports.getTrueFileName = (unfixed_path, type) => {
|
||||
exports.getTrueFileName = (unfixed_path, type, force_ext = null) => {
|
||||
let fixed_path = unfixed_path;
|
||||
|
||||
const new_ext = (type === 'audio' ? 'mp3' : 'mp4');
|
||||
@@ -22,7 +22,7 @@ exports.getTrueFileName = (unfixed_path, type) => {
|
||||
|
||||
|
||||
if (old_ext !== new_ext) {
|
||||
unfixed_parts[unfixed_parts.length-1] = new_ext;
|
||||
unfixed_parts[unfixed_parts.length-1] = force_ext || new_ext;
|
||||
fixed_path = unfixed_parts.join('.');
|
||||
}
|
||||
return fixed_path;
|
||||
@@ -241,11 +241,6 @@ exports.addUIDsToCategory = (category, files) => {
|
||||
return files_that_match;
|
||||
}
|
||||
|
||||
exports.getCurrentDownloader = () => {
|
||||
const details_json = fs.readJSONSync(CONSTS.DETAILS_BIN_PATH);
|
||||
return details_json['downloader'];
|
||||
}
|
||||
|
||||
exports.recFindByExt = async (base, ext, files, result, recursive = true) => {
|
||||
files = files || (await fs.readdir(base))
|
||||
result = result || []
|
||||
@@ -347,7 +342,7 @@ exports.checkExistsWithTimeout = async (filePath, timeout) => {
|
||||
if (!err) {
|
||||
clearTimeout(timer);
|
||||
if (watcher) watcher.close();
|
||||
resolve();
|
||||
resolve(true);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -357,7 +352,7 @@ exports.checkExistsWithTimeout = async (filePath, timeout) => {
|
||||
if (eventType === 'rename' && filename === basename) {
|
||||
clearTimeout(timer);
|
||||
if (watcher) watcher.close();
|
||||
resolve();
|
||||
resolve(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -519,6 +514,53 @@ exports.convertFlatObjectToNestedObject = (obj) => {
|
||||
return result;
|
||||
}
|
||||
|
||||
exports.getDirectoriesInDirectory = async (basePath) => {
|
||||
try {
|
||||
const files = await fs.readdir(basePath, { withFileTypes: true });
|
||||
return files
|
||||
.filter((file) => file.isDirectory())
|
||||
.map((file) => path.join(basePath, file.name));
|
||||
} catch (err) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
exports.parseOutputJSON = (output, err) => {
|
||||
let split_output = [];
|
||||
// const output_jsons = [];
|
||||
if (err && !output) {
|
||||
const attempt_backup_errs = ['This video is unavailable', 'Private video', 'unavailable video'];
|
||||
const attempt_backup = err.stderr ? attempt_backup_errs.some(err_msg => err.stderr.includes(err_msg)) : false;
|
||||
if (!attempt_backup) {
|
||||
return null;
|
||||
}
|
||||
logger.info('An error was encountered with at least one video, backup method will be used.')
|
||||
try {
|
||||
split_output = err.stdout.split(/\r\n|\r|\n/);
|
||||
} catch (e) {
|
||||
logger.error('Backup method failed. See error below:');
|
||||
logger.error(e);
|
||||
return null;
|
||||
}
|
||||
} else if (output.length === 0 || (output.length === 1 && output[0].length === 0)) {
|
||||
// output is '' or ['']
|
||||
return [];
|
||||
} else {
|
||||
for (const output_item of output) {
|
||||
// we have to do this because sometimes there will be leading characters before the actual json
|
||||
const start_idx = output_item.indexOf('{"');
|
||||
const clean_output = output_item.slice(start_idx, output_item.length);
|
||||
split_output.push(clean_output);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
return split_output.map(split_output_str => JSON.parse(split_output_str));
|
||||
} catch(e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// objects
|
||||
|
||||
function File(id, title, thumbnailURL, isAudio, duration, url, uploader, size, path, upload_date, description, view_count, height, abr) {
|
||||
|
||||
@@ -1,141 +1,167 @@
|
||||
const fs = require('fs-extra');
|
||||
const fetch = require('node-fetch');
|
||||
const path = require('path');
|
||||
const execa = require('execa');
|
||||
const kill = require('tree-kill');
|
||||
|
||||
const logger = require('./logger');
|
||||
const utils = require('./utils');
|
||||
const CONSTS = require('./consts');
|
||||
const config_api = require('./config.js');
|
||||
|
||||
const OUTDATED_VERSION = "2020.00.00";
|
||||
|
||||
const is_windows = process.platform === 'win32';
|
||||
|
||||
const download_sources = {
|
||||
exports.youtubedl_forks = {
|
||||
'youtube-dl': {
|
||||
'tags_url': 'https://api.github.com/repos/ytdl-org/youtube-dl/tags',
|
||||
'func': downloadLatestYoutubeDLBinary
|
||||
'download_url': 'https://github.com/ytdl-org/youtube-dl/releases/latest/download/youtube-dl',
|
||||
'tags_url': 'https://api.github.com/repos/ytdl-org/youtube-dl/tags'
|
||||
},
|
||||
'youtube-dlc': {
|
||||
'tags_url': 'https://api.github.com/repos/blackjack4494/yt-dlc/tags',
|
||||
'func': downloadLatestYoutubeDLCBinary
|
||||
'download_url': 'https://github.com/blackjack4494/yt-dlc/releases/latest/download/youtube-dlc',
|
||||
'tags_url': 'https://api.github.com/repos/blackjack4494/yt-dlc/tags'
|
||||
},
|
||||
'yt-dlp': {
|
||||
'tags_url': 'https://api.github.com/repos/yt-dlp/yt-dlp/tags',
|
||||
'func': downloadLatestYoutubeDLPBinary
|
||||
'download_url': 'https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp',
|
||||
'tags_url': 'https://api.github.com/repos/yt-dlp/yt-dlp/tags'
|
||||
}
|
||||
}
|
||||
|
||||
exports.checkForYoutubeDLUpdate = async () => {
|
||||
return new Promise(async resolve => {
|
||||
const default_downloader = config_api.getConfigItem('ytdl_default_downloader');
|
||||
const tags_url = download_sources[default_downloader]['tags_url'];
|
||||
// get current version
|
||||
let current_app_details_exists = fs.existsSync(CONSTS.DETAILS_BIN_PATH);
|
||||
if (!current_app_details_exists) {
|
||||
logger.warn(`Failed to get youtube-dl binary details at location '${CONSTS.DETAILS_BIN_PATH}'. Generating file...`);
|
||||
fs.writeJSONSync(CONSTS.DETAILS_BIN_PATH, {"version": OUTDATED_VERSION, "downloader": default_downloader});
|
||||
}
|
||||
let current_app_details = JSON.parse(fs.readFileSync(CONSTS.DETAILS_BIN_PATH));
|
||||
let current_version = current_app_details['version'];
|
||||
let current_downloader = current_app_details['downloader'];
|
||||
let stored_binary_path = current_app_details['path'];
|
||||
if (!stored_binary_path || typeof stored_binary_path !== 'string') {
|
||||
// logger.info(`INFO: Failed to get youtube-dl binary path at location: ${CONSTS.DETAILS_BIN_PATH}, attempting to guess actual path...`);
|
||||
const guessed_base_path = 'node_modules/youtube-dl/bin/';
|
||||
const guessed_file_path = guessed_base_path + 'youtube-dl' + (is_windows ? '.exe' : '');
|
||||
if (fs.existsSync(guessed_file_path)) {
|
||||
stored_binary_path = guessed_file_path;
|
||||
// logger.info('INFO: Guess successful! Update process continuing...')
|
||||
} else {
|
||||
logger.error(`Guess '${guessed_file_path}' is not correct. Cancelling update check. Verify that your youtube-dl binaries exist by running npm install.`);
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
}
|
||||
exports.runYoutubeDL = async (url, args, customDownloadHandler = null) => {
|
||||
const output_file_path = getYoutubeDLPath();
|
||||
if (!fs.existsSync(output_file_path)) await exports.checkForYoutubeDLUpdate();
|
||||
let callback = null;
|
||||
let child_process = null;
|
||||
if (customDownloadHandler) {
|
||||
callback = runYoutubeDLCustom(url, args, customDownloadHandler);
|
||||
} else {
|
||||
({callback, child_process} = await runYoutubeDLProcess(url, args));
|
||||
}
|
||||
|
||||
// got version, now let's check the latest version from the youtube-dl API
|
||||
return {child_process, callback};
|
||||
}
|
||||
|
||||
|
||||
fetch(tags_url, {method: 'Get'})
|
||||
.then(async res => res.json())
|
||||
.then(async (json) => {
|
||||
// check if the versions are different
|
||||
if (!json || !json[0]) {
|
||||
logger.error(`Failed to check ${default_downloader} version for an update.`)
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
const latest_update_version = json[0]['name'];
|
||||
if (current_version !== latest_update_version || default_downloader !== current_downloader) {
|
||||
// versions different or different downloader is being used, download new update
|
||||
resolve(latest_update_version);
|
||||
} else {
|
||||
resolve(null);
|
||||
}
|
||||
return;
|
||||
})
|
||||
.catch(err => {
|
||||
logger.error(`Failed to check ${default_downloader} version for an update.`)
|
||||
logger.error(err);
|
||||
resolve(null);
|
||||
return;
|
||||
// Run youtube-dl directly (not cancellable)
|
||||
const runYoutubeDLCustom = async (url, args, customDownloadHandler) => {
|
||||
const downloadHandler = customDownloadHandler;
|
||||
return new Promise(resolve => {
|
||||
downloadHandler(url, args, {maxBuffer: Infinity}, async function(err, output) {
|
||||
const parsed_output = utils.parseOutputJSON(output, err);
|
||||
resolve({parsed_output, err});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
exports.updateYoutubeDL = async (latest_update_version) => {
|
||||
const default_downloader = config_api.getConfigItem('ytdl_default_downloader');
|
||||
await download_sources[default_downloader]['func'](latest_update_version);
|
||||
// Run youtube-dl in a subprocess (cancellable)
|
||||
const runYoutubeDLProcess = async (url, args, youtubedl_fork = config_api.getConfigItem('ytdl_default_downloader')) => {
|
||||
const youtubedl_path = getYoutubeDLPath(youtubedl_fork);
|
||||
const binary_exists = fs.existsSync(youtubedl_path);
|
||||
if (!binary_exists) {
|
||||
const err = `Could not find path for ${youtubedl_fork} at ${youtubedl_path}`;
|
||||
logger.error(err);
|
||||
return;
|
||||
}
|
||||
const child_process = execa(getYoutubeDLPath(youtubedl_fork), [url, ...args], {maxBuffer: Infinity});
|
||||
const callback = new Promise(async resolve => {
|
||||
try {
|
||||
const {stdout, stderr} = await child_process;
|
||||
const parsed_output = utils.parseOutputJSON(stdout.trim().split(/\r?\n/), stderr);
|
||||
resolve({parsed_output, err: stderr});
|
||||
} catch (e) {
|
||||
// Attempt to not fail
|
||||
const parsed_output = utils.parseOutputJSON(e && e.stdout && e.stdout.trim().split(/\r?\n/), e && e.stderr);
|
||||
resolve({parsed_output: parsed_output, err: parsed_output ? null : e});
|
||||
}
|
||||
});
|
||||
return {child_process, callback}
|
||||
}
|
||||
|
||||
exports.verifyBinaryExistsLinux = () => {
|
||||
const details_json = fs.readJSONSync(CONSTS.DETAILS_BIN_PATH);
|
||||
if (!is_windows && details_json && (!details_json['path'] || details_json['path'].includes('.exe'))) {
|
||||
details_json['path'] = 'node_modules/youtube-dl/bin/youtube-dl';
|
||||
details_json['exec'] = 'youtube-dl';
|
||||
details_json['version'] = OUTDATED_VERSION;
|
||||
fs.writeJSONSync(CONSTS.DETAILS_BIN_PATH, details_json);
|
||||
function getYoutubeDLPath(youtubedl_fork = config_api.getConfigItem('ytdl_default_downloader')) {
|
||||
const binary_file_name = youtubedl_fork + (is_windows ? '.exe' : '');
|
||||
const binary_path = path.join('appdata', 'bin', binary_file_name);
|
||||
return binary_path;
|
||||
}
|
||||
|
||||
utils.restartServer();
|
||||
exports.killYoutubeDLProcess = async (child_process) => {
|
||||
kill(child_process.pid, 'SIGKILL');
|
||||
}
|
||||
|
||||
exports.checkForYoutubeDLUpdate = async () => {
|
||||
const selected_fork = config_api.getConfigItem('ytdl_default_downloader');
|
||||
const output_file_path = getYoutubeDLPath();
|
||||
// get current version
|
||||
let current_app_details_exists = fs.existsSync(CONSTS.DETAILS_BIN_PATH);
|
||||
if (!current_app_details_exists[selected_fork]) {
|
||||
logger.warn(`Failed to get youtube-dl binary details at location '${CONSTS.DETAILS_BIN_PATH}'. Generating file...`);
|
||||
updateDetailsJSON(CONSTS.OUTDATED_YOUTUBEDL_VERSION, selected_fork, output_file_path);
|
||||
}
|
||||
const current_app_details = JSON.parse(fs.readFileSync(CONSTS.DETAILS_BIN_PATH));
|
||||
const current_version = current_app_details[selected_fork]['version'];
|
||||
const current_fork = current_app_details[selected_fork]['downloader'];
|
||||
|
||||
const latest_version = await exports.getLatestUpdateVersion(selected_fork);
|
||||
// if the binary does not exist, or default_downloader doesn't match existing fork, or if the fork has been updated, redownload
|
||||
// TODO: don't redownload if fork already exists
|
||||
if (!fs.existsSync(output_file_path) || current_fork !== selected_fork || !current_version || current_version !== latest_version) {
|
||||
logger.warn(`Updating ${selected_fork} binary to '${output_file_path}', downloading...`);
|
||||
await exports.updateYoutubeDL(latest_version);
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadLatestYoutubeDLBinary(new_version) {
|
||||
const file_ext = is_windows ? '.exe' : '';
|
||||
|
||||
const download_url = `https://github.com/ytdl-org/youtube-dl/releases/latest/download/youtube-dl${file_ext}`;
|
||||
const output_path = `node_modules/youtube-dl/bin/youtube-dl${file_ext}`;
|
||||
|
||||
await utils.fetchFile(download_url, output_path, `youtube-dl ${new_version}`);
|
||||
|
||||
updateDetailsJSON(new_version, 'youtube-dl');
|
||||
exports.updateYoutubeDL = async (latest_update_version, custom_output_path = null) => {
|
||||
await fs.ensureDir(path.join('appdata', 'bin'));
|
||||
const default_downloader = config_api.getConfigItem('ytdl_default_downloader');
|
||||
await downloadLatestYoutubeDLBinaryGeneric(default_downloader, latest_update_version, custom_output_path);
|
||||
}
|
||||
|
||||
async function downloadLatestYoutubeDLCBinary(new_version) {
|
||||
async function downloadLatestYoutubeDLBinaryGeneric(youtubedl_fork, new_version, custom_output_path = null) {
|
||||
const file_ext = is_windows ? '.exe' : '';
|
||||
|
||||
const download_url = `https://github.com/blackjack4494/yt-dlc/releases/latest/download/youtube-dlc${file_ext}`;
|
||||
const output_path = `node_modules/youtube-dl/bin/youtube-dl${file_ext}`;
|
||||
// build the URL
|
||||
const download_url = `${exports.youtubedl_forks[youtubedl_fork]['download_url']}${file_ext}`;
|
||||
const output_path = custom_output_path || getYoutubeDLPath(youtubedl_fork);
|
||||
|
||||
await utils.fetchFile(download_url, output_path, `youtube-dlc ${new_version}`);
|
||||
try {
|
||||
await utils.fetchFile(download_url, output_path, `${youtubedl_fork} ${new_version}`);
|
||||
fs.chmod(output_path, 0o777);
|
||||
|
||||
updateDetailsJSON(new_version, 'youtube-dlc');
|
||||
updateDetailsJSON(new_version, youtubedl_fork, output_path);
|
||||
} catch (e) {
|
||||
logger.error(`Failed to download new ${youtubedl_fork} version: ${new_version}`);
|
||||
logger.error(e);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
exports.getLatestUpdateVersion = async (youtubedl_fork) => {
|
||||
const tags_url = exports.youtubedl_forks[youtubedl_fork]['tags_url'];
|
||||
return new Promise(resolve => {
|
||||
fetch(tags_url, {method: 'Get'})
|
||||
.then(async res => res.json())
|
||||
.then(async (json) => {
|
||||
if (!json || !json[0]) {
|
||||
logger.error(`Failed to check ${youtubedl_fork} version for an update.`)
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
const latest_update_version = json[0]['name'];
|
||||
resolve(latest_update_version);
|
||||
})
|
||||
.catch(err => {
|
||||
logger.error(`Failed to check ${youtubedl_fork} version for an update.`)
|
||||
logger.error(err);
|
||||
resolve(null);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function downloadLatestYoutubeDLPBinary(new_version) {
|
||||
function updateDetailsJSON(new_version, fork, output_path) {
|
||||
const file_ext = is_windows ? '.exe' : '';
|
||||
|
||||
const download_url = `https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp${file_ext}`;
|
||||
const output_path = `node_modules/youtube-dl/bin/youtube-dl${file_ext}`;
|
||||
|
||||
await utils.fetchFile(download_url, output_path, `yt-dlp ${new_version}`);
|
||||
|
||||
updateDetailsJSON(new_version, 'yt-dlp');
|
||||
}
|
||||
|
||||
function updateDetailsJSON(new_version, downloader) {
|
||||
const details_json = fs.readJSONSync(CONSTS.DETAILS_BIN_PATH);
|
||||
if (new_version) details_json['version'] = new_version;
|
||||
details_json['downloader'] = downloader;
|
||||
const details_json = fs.existsSync(CONSTS.DETAILS_BIN_PATH) ? fs.readJSONSync(CONSTS.DETAILS_BIN_PATH) : {};
|
||||
if (!details_json[fork]) details_json[fork] = {};
|
||||
const fork_json = details_json[fork];
|
||||
fork_json['version'] = new_version;
|
||||
fork_json['downloader'] = fork;
|
||||
fork_json['path'] = output_path; // unused
|
||||
fork_json['exec'] = fork + file_ext; // unused
|
||||
fs.writeJSONSync(CONSTS.DETAILS_BIN_PATH, details_json);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
apiVersion: v2
|
||||
name: youtubedl-material
|
||||
description: A Helm chart for Kubernetes
|
||||
description: A Helm chart for https://github.com/Tzahi12345/YoutubeDL-Material
|
||||
|
||||
# A chart can be either an 'application' or a 'library' chart.
|
||||
#
|
||||
@@ -15,10 +15,10 @@ type: application
|
||||
# This is the chart version. This version number should be incremented each time you make changes
|
||||
# to the chart and its templates, including the app version.
|
||||
# Versions are expected to follow Semantic Versioning (https://semver.org/)
|
||||
version: 0.1.0
|
||||
version: 0.2.0
|
||||
|
||||
# This is the version number of the application being deployed. This version number should be
|
||||
# incremented each time you make changes to the application. Versions are not expected to
|
||||
# follow Semantic Versioning. They should reflect the version the application is using.
|
||||
# It is recommended to use it with quotes.
|
||||
appVersion: "4.3.1"
|
||||
appVersion: "4.3.2"
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
{{- if .Values.ingress.enabled -}}
|
||||
{{- $fullName := include "youtubedl-material.fullname" . -}}
|
||||
{{- $svcPort := .Values.service.port -}}
|
||||
{{- if semverCompare ">=1.14-0" .Capabilities.KubeVersion.GitVersion -}}
|
||||
{{- if and .Values.ingress.className (not (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion)) }}
|
||||
{{- if not (hasKey .Values.ingress.annotations "kubernetes.io/ingress.class") }}
|
||||
{{- $_ := set .Values.ingress.annotations "kubernetes.io/ingress.class" .Values.ingress.className}}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- if semverCompare ">=1.19-0" .Capabilities.KubeVersion.GitVersion -}}
|
||||
apiVersion: networking.k8s.io/v1
|
||||
{{- else if semverCompare ">=1.14-0" .Capabilities.KubeVersion.GitVersion -}}
|
||||
apiVersion: networking.k8s.io/v1beta1
|
||||
{{- else -}}
|
||||
apiVersion: extensions/v1beta1
|
||||
@@ -16,6 +23,9 @@ metadata:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
{{- if and .Values.ingress.className (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion) }}
|
||||
ingressClassName: {{ .Values.ingress.className }}
|
||||
{{- end }}
|
||||
{{- if .Values.ingress.tls }}
|
||||
tls:
|
||||
{{- range .Values.ingress.tls }}
|
||||
@@ -33,9 +43,19 @@ spec:
|
||||
paths:
|
||||
{{- range .paths }}
|
||||
- path: {{ .path }}
|
||||
{{- if and .pathType (semverCompare ">=1.18-0" $.Capabilities.KubeVersion.GitVersion) }}
|
||||
pathType: {{ .pathType }}
|
||||
{{- end }}
|
||||
backend:
|
||||
{{- if semverCompare ">=1.19-0" $.Capabilities.KubeVersion.GitVersion }}
|
||||
service:
|
||||
name: {{ $fullName }}
|
||||
port:
|
||||
number: {{ $svcPort }}
|
||||
{{- else }}
|
||||
serviceName: {{ $fullName }}
|
||||
servicePort: {{ $svcPort }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
@@ -22,7 +22,7 @@ esac
|
||||
|
||||
echo "(INFO) Architecture detected: $ARCH"
|
||||
echo "(1/5) READY - Install unzip"
|
||||
apt-get update && apt-get -y install unzip curl jq libicu70
|
||||
apt-get update && apt-get -y install unzip curl jq
|
||||
VERSION=$(curl --silent "https://api.github.com/repos/lay295/TwitchDownloader/releases" | jq -r --arg arch "$ARCH" '[.[] | select(.assets | length > 0) | select(.assets[].name | contains("CLI") and contains($arch))] | max_by(.published_at) | .tag_name')
|
||||
echo "(2/5) DOWNLOAD - Acquire twitchdownloader"
|
||||
curl -o twitchdownloader.zip \
|
||||
|
||||
21480
package-lock.json
generated
21480
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
55
package.json
55
package.json
@@ -1,10 +1,11 @@
|
||||
{
|
||||
"name": "youtube-dl-material",
|
||||
"version": "4.3.1",
|
||||
"version": "4.3.2",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"ng": "ng",
|
||||
"start": "ng serve",
|
||||
"codespaces": "ng serve --configuration=codespaces",
|
||||
"build": "ng build --configuration production",
|
||||
"prebuild": "node src/postbuild.mjs",
|
||||
"heroku-postbuild": "npm install --prefix backend",
|
||||
@@ -16,23 +17,23 @@
|
||||
"i18n-source": "ng extract-i18n --output-path=src/assets/i18n --out-file=messages.en.xlf"
|
||||
},
|
||||
"engines": {
|
||||
"node": "12.3.1",
|
||||
"npm": "6.10.3"
|
||||
"node": "18.19.0",
|
||||
"npm": "10.2.3"
|
||||
},
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@angular-devkit/core": "^15.0.1",
|
||||
"@angular/animations": "^15.0.1",
|
||||
"@angular/cdk": "^15.0.0",
|
||||
"@angular/common": "^15.0.1",
|
||||
"@angular/compiler": "^15.0.1",
|
||||
"@angular/core": "^15.0.1",
|
||||
"@angular/forms": "^15.0.1",
|
||||
"@angular/localize": "^15.0.1",
|
||||
"@angular/material": "^15.0.0",
|
||||
"@angular/platform-browser": "^15.0.1",
|
||||
"@angular/platform-browser-dynamic": "^15.0.1",
|
||||
"@angular/router": "^15.0.1",
|
||||
"@angular-devkit/core": "^17.0.5",
|
||||
"@angular/animations": "^17.0.5",
|
||||
"@angular/cdk": "^17.0.2",
|
||||
"@angular/common": "^17.0.5",
|
||||
"@angular/compiler": "^17.0.5",
|
||||
"@angular/core": "^17.0.5",
|
||||
"@angular/forms": "^17.0.5",
|
||||
"@angular/localize": "^17.0.5",
|
||||
"@angular/material": "^17.0.2",
|
||||
"@angular/platform-browser": "^17.0.5",
|
||||
"@angular/platform-browser-dynamic": "^17.0.5",
|
||||
"@angular/router": "^17.0.5",
|
||||
"@fontsource/material-icons": "^4.5.4",
|
||||
"@ngneat/content-loader": "^7.0.0",
|
||||
"@videogular/ngx-videogular": "^6.0.0",
|
||||
@@ -43,20 +44,19 @@
|
||||
"fs-extra": "^10.0.0",
|
||||
"material-icons": "^1.10.8",
|
||||
"nan": "^2.14.1",
|
||||
"ngx-avatars": "^1.4.1",
|
||||
"ngx-avatars": "^1.10.0",
|
||||
"ngx-file-drop": "^15.0.0",
|
||||
"rxjs": "^6.6.3",
|
||||
"rxjs-compat": "^6.6.7",
|
||||
"tslib": "^2.0.0",
|
||||
"typescript": "~4.8.4",
|
||||
"xliff-to-json": "^1.0.4",
|
||||
"zone.js": "~0.11.4"
|
||||
"zone.js": "~0.14.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@angular-devkit/build-angular": "^15.0.1",
|
||||
"@angular/cli": "^15.0.1",
|
||||
"@angular/compiler-cli": "^15.0.1",
|
||||
"@angular/language-service": "^15.0.1",
|
||||
"@angular-devkit/build-angular": "^17.0.5",
|
||||
"@angular/cli": "^17.0.5",
|
||||
"@angular/compiler-cli": "^17.0.5",
|
||||
"@angular/language-service": "^17.0.5",
|
||||
"@types/core-js": "^2.5.2",
|
||||
"@types/file-saver": "^2.0.1",
|
||||
"@types/jasmine": "^4.3.1",
|
||||
@@ -66,7 +66,7 @@
|
||||
"ajv": "^7.2.4",
|
||||
"codelyzer": "^6.0.0",
|
||||
"eslint": "^7.32.0",
|
||||
"jasmine-core": "~3.6.0",
|
||||
"jasmine-core": "~3.8.0",
|
||||
"jasmine-spec-reporter": "~5.0.0",
|
||||
"karma": "~6.4.2",
|
||||
"karma-chrome-launcher": "~3.1.0",
|
||||
@@ -77,6 +77,13 @@
|
||||
"openapi-typescript-codegen": "^0.23.0",
|
||||
"protractor": "~7.0.0",
|
||||
"ts-node": "~3.0.4",
|
||||
"tslint": "~6.1.0"
|
||||
"tslint": "~6.1.0",
|
||||
"typescript": "~5.2.0"
|
||||
},
|
||||
"overrides": {
|
||||
"ngx-avatars": {
|
||||
"@angular/common": "^17.0.0",
|
||||
"@angular/core": "^17.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ export type { ChangeRolePermissionsRequest } from './models/ChangeRolePermission
|
||||
export type { ChangeUserPermissionsRequest } from './models/ChangeUserPermissionsRequest';
|
||||
export type { CheckConcurrentStreamRequest } from './models/CheckConcurrentStreamRequest';
|
||||
export type { CheckConcurrentStreamResponse } from './models/CheckConcurrentStreamResponse';
|
||||
export type { CheckSubscriptionRequest } from './models/CheckSubscriptionRequest';
|
||||
export type { ClearDownloadsRequest } from './models/ClearDownloadsRequest';
|
||||
export type { ConcurrentStream } from './models/ConcurrentStream';
|
||||
export type { Config } from './models/Config';
|
||||
@@ -104,6 +105,7 @@ export type { SubscriptionRequestData } from './models/SubscriptionRequestData';
|
||||
export type { SuccessObject } from './models/SuccessObject';
|
||||
export type { TableInfo } from './models/TableInfo';
|
||||
export type { Task } from './models/Task';
|
||||
export { TaskType } from './models/TaskType';
|
||||
export type { TestConnectionStringRequest } from './models/TestConnectionStringRequest';
|
||||
export type { TestConnectionStringResponse } from './models/TestConnectionStringResponse';
|
||||
export type { TransferDBRequest } from './models/TransferDBRequest';
|
||||
|
||||
7
src/api-types/models/CheckSubscriptionRequest.ts
Normal file
7
src/api-types/models/CheckSubscriptionRequest.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
/* istanbul ignore file */
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
|
||||
export type CheckSubscriptionRequest = {
|
||||
sub_id: string;
|
||||
};
|
||||
@@ -3,5 +3,5 @@
|
||||
/* eslint-disable */
|
||||
|
||||
export type Config = {
|
||||
YoutubeDLMaterial: Record<string, any>;
|
||||
YoutubeDLMaterial: any;
|
||||
};
|
||||
|
||||
@@ -8,6 +8,7 @@ export type Download = {
|
||||
running: boolean;
|
||||
finished: boolean;
|
||||
paused: boolean;
|
||||
cancelled?: boolean;
|
||||
finished_step: boolean;
|
||||
url: string;
|
||||
type: string;
|
||||
@@ -26,5 +27,5 @@ export type Download = {
|
||||
user_uid?: string;
|
||||
sub_id?: string;
|
||||
sub_name?: string;
|
||||
prefetched_info?: Record<string, any>;
|
||||
prefetched_info?: any;
|
||||
};
|
||||
|
||||
@@ -5,6 +5,6 @@
|
||||
export type GetFileFormatsResponse = {
|
||||
success: boolean;
|
||||
result: {
|
||||
formats?: Array<Record<string, any>>;
|
||||
formats?: Array<any>;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -6,5 +6,5 @@ import type { Subscription } from './Subscription';
|
||||
|
||||
export type GetSubscriptionResponse = {
|
||||
subscription: Subscription;
|
||||
files: Array<Record<string, any>>;
|
||||
files: Array<any>;
|
||||
};
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
|
||||
import type { TaskType } from './TaskType';
|
||||
|
||||
export type GetTaskRequest = {
|
||||
task_key: string;
|
||||
task_key: TaskType;
|
||||
};
|
||||
|
||||
@@ -11,6 +11,6 @@ export type Notification = {
|
||||
user_uid?: string;
|
||||
action?: Array<NotificationAction>;
|
||||
read: boolean;
|
||||
data?: Record<string, any>;
|
||||
data?: any;
|
||||
timestamp: number;
|
||||
};
|
||||
|
||||
@@ -11,9 +11,12 @@ export type Subscription = {
|
||||
type: FileType;
|
||||
user_uid: string | null;
|
||||
isPlaylist: boolean;
|
||||
child_process?: any;
|
||||
archive?: string;
|
||||
timerange?: string;
|
||||
custom_args?: string;
|
||||
custom_output?: string;
|
||||
videos: Array<Record<string, any>>;
|
||||
downloading?: boolean;
|
||||
paused?: boolean;
|
||||
videos: Array<any>;
|
||||
};
|
||||
|
||||
@@ -3,16 +3,17 @@
|
||||
/* eslint-disable */
|
||||
|
||||
import type { Schedule } from './Schedule';
|
||||
import type { TaskType } from './TaskType';
|
||||
|
||||
export type Task = {
|
||||
key: string;
|
||||
key: TaskType;
|
||||
title?: string;
|
||||
last_ran: number;
|
||||
last_confirmed: number;
|
||||
running: boolean;
|
||||
confirming: boolean;
|
||||
data: Record<string, any>;
|
||||
data: any;
|
||||
error: string;
|
||||
schedule: Schedule;
|
||||
options?: Record<string, any>;
|
||||
options?: any;
|
||||
};
|
||||
|
||||
14
src/api-types/models/TaskType.ts
Normal file
14
src/api-types/models/TaskType.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
/* istanbul ignore file */
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
|
||||
export enum TaskType {
|
||||
BACKUP_LOCAL_DB = 'backup_local_db',
|
||||
MISSING_FILES_CHECK = 'missing_files_check',
|
||||
MISSING_DB_RECORDS = 'missing_db_records',
|
||||
DUPLICATE_FILES_CHECK = 'duplicate_files_check',
|
||||
YOUTUBEDL_UPDATE_CHECK = 'youtubedl_update_check',
|
||||
DELETE_OLD_FILES = 'delete_old_files',
|
||||
IMPORT_LEGACY_ARCHIVES = 'import_legacy_archives',
|
||||
REBUILD_DATABASE = 'rebuild_database',
|
||||
}
|
||||
@@ -2,10 +2,8 @@
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
|
||||
import type { SubscriptionRequestData } from './SubscriptionRequestData';
|
||||
|
||||
export type UnsubscribeRequest = {
|
||||
sub: SubscriptionRequestData;
|
||||
sub_id: string;
|
||||
/**
|
||||
* Defaults to false
|
||||
*/
|
||||
|
||||
@@ -10,5 +10,5 @@ export type UpdateFileRequest = {
|
||||
/**
|
||||
* Object with fields to update as keys and their new values
|
||||
*/
|
||||
change_obj: Record<string, any>;
|
||||
change_obj: any;
|
||||
};
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
|
||||
import type { TaskType } from './TaskType';
|
||||
|
||||
export type UpdateTaskDataRequest = {
|
||||
task_key: string;
|
||||
new_data: Record<string, any>;
|
||||
task_key: TaskType;
|
||||
new_data: any;
|
||||
};
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
|
||||
import type { TaskType } from './TaskType';
|
||||
|
||||
export type UpdateTaskOptionsRequest = {
|
||||
task_key: string;
|
||||
new_options: Record<string, any>;
|
||||
task_key: TaskType;
|
||||
new_options: any;
|
||||
};
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
/* eslint-disable */
|
||||
|
||||
import type { Schedule } from './Schedule';
|
||||
import type { TaskType } from './TaskType';
|
||||
|
||||
export type UpdateTaskScheduleRequest = {
|
||||
task_key: string;
|
||||
task_key: TaskType;
|
||||
new_schedule: Schedule;
|
||||
};
|
||||
|
||||
@@ -1,67 +1,95 @@
|
||||
<div [style.background]="postsService.theme ? postsService.theme.background_color : null" style="width: 100%; height: 100%;">
|
||||
<div class="mat-elevation-z3" style="position: relative; z-index: 10;">
|
||||
<mat-toolbar class="sticky-toolbar top-toolbar">
|
||||
<div class="container-fluid" style="padding-left: 0px; padding-right: 0px">
|
||||
<div class="row" width="100%" height="100%">
|
||||
<div class="col-6" style="text-align: left; margin-top: 1px;">
|
||||
<div style="display: flex; align-items: center;">
|
||||
<button #hamburgerMenu style="outline: none" *ngIf="router.url.split(';')[0] !== '/player'" mat-icon-button aria-label="Toggle side navigation" (click)="toggleSidenav()"><mat-icon>menu</mat-icon></button>
|
||||
<button (click)="goBack()" *ngIf="router.url.split(';')[0] === '/player'" mat-icon-button><mat-icon>arrow_back</mat-icon></button>
|
||||
<div style="margin-left: 8px; display: inline-block;"><button mat-icon-button routerLink='/home'><img style="width: 32px;" src="assets/images/logo_128px.png"></button></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6" style="text-align: right; align-items: flex-end; display: inline-block">
|
||||
<button *ngIf="postsService.config?.Extra.enable_notifications" [matMenuTriggerFor]="notificationsMenu" (menuOpened)="notificationMenuOpened()" mat-icon-button><mat-icon [matBadge]="notification_count" matBadgeColor="warn" matBadgeSize="small" *ngIf="notification_count > 0">notifications</mat-icon><mat-icon *ngIf="notification_count === 0">notifications_none</mat-icon></button>
|
||||
<mat-menu [classList]="'notifications-menu'" (close)="notificationMenuClosed()" #notificationsMenu="matMenu">
|
||||
<app-notifications #notifications (notificationCount)="notificationCountUpdate($event)" (click)="$event.stopPropagation()"></app-notifications>
|
||||
</mat-menu>
|
||||
<button [matMenuTriggerFor]="menuSettings" mat-icon-button><mat-icon>more_vert</mat-icon></button>
|
||||
<mat-menu #menuSettings="matMenu">
|
||||
<button class="top-menu-button" (click)="openProfileDialog()" mat-menu-item>
|
||||
<mat-icon>person</mat-icon>
|
||||
<span i18n="Profile menu label">Profile</span>
|
||||
</button>
|
||||
<button *ngIf="!postsService.config?.Advanced.multi_user_mode || postsService.isLoggedIn" class="top-menu-button" (click)="openArchivesDialog()" mat-menu-item>
|
||||
<mat-icon>topic</mat-icon>
|
||||
<span i18n="Archives menu label">Archives</span>
|
||||
</button>
|
||||
<button class="top-menu-button" (click)="themeMenuItemClicked($event)" *ngIf="allowThemeChange" mat-menu-item>
|
||||
<mat-icon>{{(postsService.theme.key === 'default') ? 'brightness_5' : 'brightness_2'}}</mat-icon>
|
||||
<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 class="top-menu-button" (click)="openAboutDialog()" mat-menu-item>
|
||||
<mat-icon>info</mat-icon>
|
||||
<span i18n="About menu label">About</span>
|
||||
</button>
|
||||
</mat-menu>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mat-elevation-z3" style="position: relative; z-index: 10;">
|
||||
<mat-toolbar class="sticky-toolbar top-toolbar">
|
||||
<div class="container-fluid" style="padding-left: 0px; padding-right: 0px">
|
||||
<div class="row" width="100%" height="100%">
|
||||
<div class="col-6" style="text-align: left; margin-top: 1px;">
|
||||
<div style="display: flex; align-items: center;">
|
||||
@if (router.url.split(';')[0] !== '/player') {
|
||||
<button #hamburgerMenu style="outline: none" mat-icon-button aria-label="Toggle side navigation" (click)="toggleSidenav()"><mat-icon>menu</mat-icon></button>
|
||||
} @else {
|
||||
<button (click)="goBack()" mat-icon-button><mat-icon>arrow_back</mat-icon></button>
|
||||
}
|
||||
<div style="margin-left: 8px; display: inline-block;"><button mat-icon-button routerLink='/home'><img style="width: 32px;" src="assets/images/logo_128px.png"></button></div>
|
||||
</div>
|
||||
</mat-toolbar>
|
||||
</div>
|
||||
<div class="sidenav-container" style="height: calc(100% - 64px)">
|
||||
<mat-sidenav-container style="height: 100%">
|
||||
<mat-sidenav [opened]="postsService.sidepanel_mode === 'side' && !window.location.href.includes('/player')" [mode]="postsService.sidepanel_mode" #sidenav>
|
||||
<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.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>
|
||||
<a *ngIf="postsService.config && postsService.hasPermission('tasks_manager')" mat-list-item (click)="postsService.sidepanel_mode === 'over' ? sidenav.close() : null" routerLink='/tasks'><ng-container i18n="Navigation menu Tasks Page title">Tasks</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-avatars [style.display]="'inline-block'" [style.margin-right]="'10px'" size="32" [name]="subscription.name"></ngx-avatars>{{subscription.name}}</a>
|
||||
</ng-container>
|
||||
</mat-nav-list>
|
||||
</mat-sidenav>
|
||||
<mat-sidenav-content [style.background]="postsService.theme ? postsService.theme.background_color : null">
|
||||
<router-outlet></router-outlet>
|
||||
</mat-sidenav-content>
|
||||
</mat-sidenav-container>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6" style="text-align: right; align-items: flex-end; display: inline-block">
|
||||
@if (postsService.config?.Extra.enable_notifications) {
|
||||
<button [matMenuTriggerFor]="notificationsMenu" (menuOpened)="notificationMenuOpened()" mat-icon-button>
|
||||
@if (notification_count > 0) {
|
||||
<mat-icon [matBadge]="notification_count" matBadgeColor="warn" matBadgeSize="small">notifications</mat-icon>
|
||||
} @else {
|
||||
<mat-icon>notifications_none</mat-icon>
|
||||
}</button>
|
||||
}
|
||||
<mat-menu [classList]="'notifications-menu'" (close)="notificationMenuClosed()" #notificationsMenu="matMenu">
|
||||
<app-notifications #notifications (notificationCount)="notificationCountUpdate($event)" (click)="$event.stopPropagation()"></app-notifications>
|
||||
</mat-menu>
|
||||
<button [matMenuTriggerFor]="menuSettings" mat-icon-button><mat-icon>more_vert</mat-icon></button>
|
||||
<mat-menu #menuSettings="matMenu">
|
||||
<button class="top-menu-button" (click)="openProfileDialog()" mat-menu-item>
|
||||
<mat-icon>person</mat-icon>
|
||||
<span i18n="Profile menu label">Profile</span>
|
||||
</button>
|
||||
@if (!postsService.config?.Advanced.multi_user_mode || postsService.isLoggedIn) {
|
||||
<button class="top-menu-button" (click)="openArchivesDialog()" mat-menu-item>
|
||||
<mat-icon>topic</mat-icon>
|
||||
<span i18n="Archives menu label">Archives</span>
|
||||
</button>
|
||||
}
|
||||
@if (allowThemeChange) {
|
||||
<button class="top-menu-button" (click)="themeMenuItemClicked($event)" mat-menu-item>
|
||||
<mat-icon>{{(postsService.theme.key === 'default') ? 'brightness_5' : 'brightness_2'}}</mat-icon>
|
||||
<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 class="top-menu-button" (click)="openAboutDialog()" mat-menu-item>
|
||||
<mat-icon>info</mat-icon>
|
||||
<span i18n="About menu label">About</span>
|
||||
</button>
|
||||
</mat-menu>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</mat-toolbar>
|
||||
</div>
|
||||
<div class="sidenav-container" style="height: calc(100% - 64px)">
|
||||
<mat-sidenav-container style="height: 100%">
|
||||
<mat-sidenav [opened]="postsService.sidepanel_mode === 'side' && !window.location.href.includes('/player')" [mode]="postsService.sidepanel_mode" #sidenav>
|
||||
<mat-nav-list>
|
||||
@if (postsService.config && (!postsService.config.Advanced.multi_user_mode || postsService.isLoggedIn)) {
|
||||
<a 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>
|
||||
}
|
||||
@if (postsService.config && postsService.config.Advanced.multi_user_mode && !postsService.isLoggedIn) {
|
||||
<a mat-list-item (click)="sidenav.close()" routerLink='/login'><ng-container i18n="Navigation menu Login Page title">Login</ng-container></a>
|
||||
}
|
||||
@if (postsService.config && allowSubscriptions && postsService.hasPermission('subscriptions')) {
|
||||
<a 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>
|
||||
}
|
||||
@if (postsService.config && enableDownloadsManager && postsService.hasPermission('downloads_manager')) {
|
||||
<a 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>
|
||||
}
|
||||
@if (postsService.config && postsService.hasPermission('tasks_manager')) {
|
||||
<a mat-list-item (click)="postsService.sidepanel_mode === 'over' ? sidenav.close() : null" routerLink='/tasks'><ng-container i18n="Navigation menu Tasks Page title">Tasks</ng-container></a>
|
||||
}
|
||||
@if (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>
|
||||
}
|
||||
@if (postsService.config && allowSubscriptions && postsService.subscriptions && postsService.hasPermission('subscriptions')) {
|
||||
@if (postsService.subscriptions.length > 0) {
|
||||
<mat-divider></mat-divider>
|
||||
}
|
||||
@for (subscription of postsService.subscriptions; track subscription) {
|
||||
<a mat-list-item (click)="postsService.sidepanel_mode === 'over' ? sidenav.close() : null" [routerLink]="['/subscription', { id: subscription.id }]"><ngx-avatars [style.display]="'inline-block'" [style.margin-right]="'10px'" size="32" [name]="subscription.name"></ngx-avatars>{{subscription.name}}</a>
|
||||
}
|
||||
}
|
||||
</mat-nav-list>
|
||||
</mat-sidenav>
|
||||
<mat-sidenav-content [style.background]="postsService.theme ? postsService.theme.background_color : null">
|
||||
<router-outlet></router-outlet>
|
||||
</mat-sidenav-content>
|
||||
</mat-sidenav-container>
|
||||
</div>
|
||||
</div>
|
||||
@@ -3,75 +3,68 @@
|
||||
<mat-label i18n="Filter">Filter</mat-label>
|
||||
<input matInput [(ngModel)]="text_filter" (keyup)="applyFilter($event)" #input>
|
||||
</mat-form-field>
|
||||
|
||||
<div [hidden]="!(archives && archives.length > 0)">
|
||||
<div class="mat-elevation-z8">
|
||||
<mat-table matSort [dataSource]="dataSource">
|
||||
|
||||
<!-- Select Column -->
|
||||
<!-- Checkbox Column -->
|
||||
<ng-container matColumnDef="select">
|
||||
<mat-header-cell *matHeaderCellDef>
|
||||
<mat-checkbox (change)="$event ? toggleAllRows() : null"
|
||||
[checked]="selection.hasValue() && isAllSelected()"
|
||||
[indeterminate]="selection.hasValue() && !isAllSelected()">
|
||||
</mat-checkbox>
|
||||
</mat-header-cell>
|
||||
<mat-cell *matCellDef="let row">
|
||||
<mat-checkbox (click)="$event.stopPropagation()"
|
||||
(change)="$event ? selection.toggle(row) : null"
|
||||
[checked]="selection.isSelected(row)">
|
||||
</mat-checkbox>
|
||||
<mat-icon class="audio-video-icon">{{(row.type === 'audio') ? 'audiotrack' : 'movie'}}</mat-icon>
|
||||
</mat-cell>
|
||||
</ng-container>
|
||||
|
||||
<!-- Date Column -->
|
||||
<ng-container matColumnDef="timestamp">
|
||||
<mat-header-cell *matHeaderCellDef mat-sort-header> <ng-container i18n="Date">Date</ng-container> </mat-header-cell>
|
||||
<mat-cell *matCellDef="let element"> {{element.timestamp*1000 | 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="max-two-lines" [matTooltip]="element.title ? element.title : null">
|
||||
{{element.title}}
|
||||
</span>
|
||||
</mat-cell>
|
||||
</ng-container>
|
||||
|
||||
<!-- ID Column -->
|
||||
<ng-container matColumnDef="id">
|
||||
<mat-header-cell *matHeaderCellDef mat-sort-header> <ng-container i18n="ID">ID</ng-container> </mat-header-cell>
|
||||
<mat-cell *matCellDef="let element">
|
||||
<span class="one-line" [matTooltip]="element.title ? element.title : null">
|
||||
{{element.id}}
|
||||
</span>
|
||||
</mat-cell>
|
||||
</ng-container>
|
||||
|
||||
<!-- Extractor Column -->
|
||||
<ng-container matColumnDef="extractor">
|
||||
<mat-header-cell *matHeaderCellDef mat-sort-header> <ng-container i18n="Extractor">Extractor</ng-container> </mat-header-cell>
|
||||
<mat-cell *matCellDef="let element">
|
||||
<span class="one-line" [matTooltip]="element.extractor? element.extractor : null">
|
||||
{{element.extractor}}
|
||||
</span>
|
||||
</mat-cell>
|
||||
</ng-container>
|
||||
|
||||
<mat-header-row *matHeaderRowDef="displayedColumns; sticky: true"></mat-header-row>
|
||||
<mat-row *matRowDef="let row; columns: displayedColumns;"></mat-row>
|
||||
</mat-table>
|
||||
</div>
|
||||
<div class="mat-elevation-z8">
|
||||
<mat-table matSort [dataSource]="dataSource">
|
||||
<!-- Select Column -->
|
||||
<!-- Checkbox Column -->
|
||||
<ng-container matColumnDef="select">
|
||||
<mat-header-cell *matHeaderCellDef>
|
||||
<mat-checkbox (change)="$event ? toggleAllRows() : null"
|
||||
[checked]="selection.hasValue() && isAllSelected()"
|
||||
[indeterminate]="selection.hasValue() && !isAllSelected()">
|
||||
</mat-checkbox>
|
||||
</mat-header-cell>
|
||||
<mat-cell *matCellDef="let row">
|
||||
<mat-checkbox (click)="$event.stopPropagation()"
|
||||
(change)="$event ? selection.toggle(row) : null"
|
||||
[checked]="selection.isSelected(row)">
|
||||
</mat-checkbox>
|
||||
<mat-icon class="audio-video-icon">{{(row.type === 'audio') ? 'audiotrack' : 'movie'}}</mat-icon>
|
||||
</mat-cell>
|
||||
</ng-container>
|
||||
<!-- Date Column -->
|
||||
<ng-container matColumnDef="timestamp">
|
||||
<mat-header-cell *matHeaderCellDef mat-sort-header> <ng-container i18n="Date">Date</ng-container> </mat-header-cell>
|
||||
<mat-cell *matCellDef="let element"> {{element.timestamp*1000 | 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="max-two-lines" [matTooltip]="element.title ? element.title : null">
|
||||
{{element.title}}
|
||||
</span>
|
||||
</mat-cell>
|
||||
</ng-container>
|
||||
<!-- ID Column -->
|
||||
<ng-container matColumnDef="id">
|
||||
<mat-header-cell *matHeaderCellDef mat-sort-header> <ng-container i18n="ID">ID</ng-container> </mat-header-cell>
|
||||
<mat-cell *matCellDef="let element">
|
||||
<span class="one-line" [matTooltip]="element.title ? element.title : null">
|
||||
{{element.id}}
|
||||
</span>
|
||||
</mat-cell>
|
||||
</ng-container>
|
||||
<!-- Extractor Column -->
|
||||
<ng-container matColumnDef="extractor">
|
||||
<mat-header-cell *matHeaderCellDef mat-sort-header> <ng-container i18n="Extractor">Extractor</ng-container> </mat-header-cell>
|
||||
<mat-cell *matCellDef="let element">
|
||||
<span class="one-line" [matTooltip]="element.extractor? element.extractor : null">
|
||||
{{element.extractor}}
|
||||
</span>
|
||||
</mat-cell>
|
||||
</ng-container>
|
||||
<mat-header-row *matHeaderRowDef="displayedColumns; sticky: true"></mat-header-row>
|
||||
<mat-row *matRowDef="let row; columns: displayedColumns;"></mat-row>
|
||||
</mat-table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div *ngIf="(!archives || archives.length === 0)">
|
||||
<h4 style="text-align: center; margin-top: 10px;" i18n="Archives empty">Archives empty</h4>
|
||||
</div>
|
||||
|
||||
@if ((!archives || archives.length === 0)) {
|
||||
<div>
|
||||
<h4 style="text-align: center; margin-top: 10px;" i18n="Archives empty">Archives empty</h4>
|
||||
</div>
|
||||
}
|
||||
<div style="margin: 10px 10px 10px 0px; display: flex;">
|
||||
<span style="flex-grow: 1;" class="flex-items">
|
||||
<button [disabled]="selection.selected.length === 0" color="warn" style="margin: 10px;" mat-stroked-button i18n="Delete selected" (click)="openDeleteSelectedArchivesDialog()">Delete selected</button>
|
||||
@@ -82,7 +75,9 @@
|
||||
<mat-label i18n="Subscription">Subscription</mat-label>
|
||||
<mat-select [ngModel]="sub_id" (ngModelChange)="subFilterSelectionChanged($event)">
|
||||
<mat-option [value]="'none'" i18n="None">None</mat-option>
|
||||
<mat-option *ngFor="let sub of postsService.subscriptions" [value]="sub.id">{{sub.name}}</mat-option>
|
||||
@for (sub of postsService.subscriptions; track sub) {
|
||||
<mat-option [value]="sub.id">{{sub.name}}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<mat-form-field style="width: 100px; margin-bottom: -1.25em; margin-left: 10px;">
|
||||
@@ -95,49 +90,55 @@
|
||||
</mat-form-field>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="file-drop-parent">
|
||||
<ngx-file-drop [multiple]="false" accept=".txt" dropZoneLabel="Drop file here" (onFileDrop)="dropped($event)">
|
||||
<ng-template class="file-drop" ngx-file-drop-content-tmp let-openFileSelector="openFileSelector">
|
||||
<div style="text-align: center">
|
||||
<div>
|
||||
<ng-container i18n="Drag and Drop">Drag and Drop</ng-container>
|
||||
</div>
|
||||
<div style="margin-top: 6px;">
|
||||
<button mat-stroked-button (click)="openFileSelector()">Browse Files</button>
|
||||
</div>
|
||||
</div>
|
||||
</ng-template>
|
||||
<ng-template class="file-drop" ngx-file-drop-content-tmp let-openFileSelector="openFileSelector">
|
||||
<div style="text-align: center">
|
||||
<div>
|
||||
<ng-container i18n="Drag and Drop">Drag and Drop</ng-container>
|
||||
</div>
|
||||
<div style="margin-top: 6px;">
|
||||
<button mat-stroked-button (click)="openFileSelector()">Browse Files</button>
|
||||
</div>
|
||||
</div>
|
||||
</ng-template>
|
||||
</ngx-file-drop>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 10px; color: white">
|
||||
<table class="table">
|
||||
<tbody class="upload-name-style">
|
||||
<tr *ngFor="let item of files; let i=index">
|
||||
<td style="vertical-align: middle; border-top: unset">
|
||||
<strong>{{ item.relativePath }}</strong>
|
||||
</td>
|
||||
<td style="border-top: unset">
|
||||
<div style="float: right">
|
||||
<mat-form-field style="width: 150px;">
|
||||
<mat-label i18n="Subscription">Subscription</mat-label>
|
||||
<mat-select [ngModel]="upload_sub_id" (ngModelChange)="subUploadFilterSelectionChanged($event)">
|
||||
<mat-option [value]="'none'" i18n="None">None</mat-option>
|
||||
<mat-option *ngFor="let sub of postsService.subscriptions" [value]="sub.id">{{sub.name}}</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<mat-form-field style="width: 100px; margin-left: 10px">
|
||||
<mat-label i18n="File type">File type</mat-label>
|
||||
<mat-select [(ngModel)]="upload_type" [value]="upload_type" [disabled]="upload_sub_id !== 'none'">
|
||||
<mat-option [value]="'video'" i18n="Video">Video</mat-option>
|
||||
<mat-option [value]="'audio'" i18n="Audio">Audio</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<button style="margin-left: 10px" [disabled]="uploading_archive || uploaded_archive" (click)="importArchive()" matTooltip="Upload" i18n-matTooltip="Upload" mat-mini-fab><mat-icon>publish</mat-icon><mat-spinner *ngIf="uploading_archive" class="spinner" [diameter]="38"></mat-spinner></button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<tbody class="upload-name-style">
|
||||
@for (item of files; track item; let i = $index) {
|
||||
<tr>
|
||||
<td style="vertical-align: middle; border-top: unset">
|
||||
<strong>{{ item.relativePath }}</strong>
|
||||
</td>
|
||||
<td style="border-top: unset">
|
||||
<div style="float: right">
|
||||
<mat-form-field style="width: 150px;">
|
||||
<mat-label i18n="Subscription">Subscription</mat-label>
|
||||
<mat-select [ngModel]="upload_sub_id" (ngModelChange)="subUploadFilterSelectionChanged($event)">
|
||||
<mat-option [value]="'none'" i18n="None">None</mat-option>
|
||||
@for (sub of postsService.subscriptions; track sub) {
|
||||
<mat-option [value]="sub.id">{{sub.name}}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<mat-form-field style="width: 100px; margin-left: 10px">
|
||||
<mat-label i18n="File type">File type</mat-label>
|
||||
<mat-select [(ngModel)]="upload_type" [value]="upload_type" [disabled]="upload_sub_id !== 'none'">
|
||||
<mat-option [value]="'video'" i18n="Video">Video</mat-option>
|
||||
<mat-option [value]="'audio'" i18n="Audio">Audio</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<button style="margin-left: 10px" [disabled]="uploading_archive || uploaded_archive" (click)="importArchive()" matTooltip="Upload" i18n-matTooltip="Upload" mat-mini-fab><mat-icon>publish</mat-icon>
|
||||
@if (uploading_archive) {
|
||||
<mat-spinner class="spinner" [diameter]="38"></mat-spinner>
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Archive } from 'api-types/models/Archive';
|
||||
import { ConfirmDialogComponent } from 'app/dialogs/confirm-dialog/confirm-dialog.component';
|
||||
import { PostsService } from 'app/posts.services';
|
||||
import { NgxFileDropEntry } from 'ngx-file-drop';
|
||||
import { saveAs } from 'file-saver';
|
||||
|
||||
@Component({
|
||||
selector: 'app-archive-viewer',
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
<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>
|
||||
@if (!watch_together_clicked) {
|
||||
<button (click)="startWatching()" mat-flat-button>Watch together</button>
|
||||
} @else {
|
||||
@if (!started) {
|
||||
@if (server_already_exists) {
|
||||
<button (click)="startClient()" mat-flat-button>Join stream</button>
|
||||
} @else if (server_mode) {
|
||||
<button (click)="startServer()" mat-flat-button>Start stream</button>
|
||||
}
|
||||
}
|
||||
<button style="margin-left: 10px;" (click)="stop()" mat-flat-button>Stop</button>
|
||||
}
|
||||
</div>
|
||||
@@ -1,13 +1,18 @@
|
||||
<div *ngIf="playlists && playlists.length > 0">
|
||||
@if (playlists) {
|
||||
<div>
|
||||
<div class="container">
|
||||
<div class="row justify-content-center">
|
||||
<div *ngFor="let playlist of playlists; let i = index" class="mb-2 mt-2" [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)="goToPlaylist($event)" [file_obj]="playlist" [is_playlist]="true" (editPlaylist)="editPlaylistDialog($event)" (deleteFile)="deletePlaylist($event)" [baseStreamPath]="postsService.path" [jwtString]="postsService.isLoggedIn ? this.postsService.token : ''" [loading]="false"></app-unified-file-card>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row justify-content-center">
|
||||
@for (playlist of playlists; track playlist; let i = $index) {
|
||||
<div class="mb-2 mt-2" [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)="goToPlaylist($event)" [file_obj]="playlist" [is_playlist]="true" (editPlaylist)="editPlaylistDialog($event)" (deleteFile)="deletePlaylist($event)" [baseStreamPath]="postsService.path" [jwtString]="postsService.isLoggedIn ? this.postsService.token : ''" [loading]="false"></app-unified-file-card>
|
||||
</div>
|
||||
} @empty {
|
||||
<div style="text-align: center;">
|
||||
No playlists available. Create one from your downloading files by clicking the blue plus button.
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div *ngIf="playlists && playlists.length === 0" style="text-align: center;">
|
||||
No playlists available. Create one from your downloading files by clicking the blue plus button.
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
<div class="add-playlist-button"><button (click)="openCreatePlaylistDialog()" mat-fab><mat-icon>add</mat-icon></button></div>
|
||||
@@ -4,6 +4,7 @@ import { Router } from '@angular/router';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { CreatePlaylistComponent } from 'app/create-playlist/create-playlist.component';
|
||||
import { Playlist } from 'api-types';
|
||||
import { saveAs } from 'file-saver';
|
||||
|
||||
@Component({
|
||||
selector: 'app-custom-playlists',
|
||||
|
||||
@@ -1,97 +1,102 @@
|
||||
<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="timestamp_start">
|
||||
<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 style="flex: 2"> <ng-container i18n="Title">Title</ng-container> </mat-header-cell>
|
||||
<mat-cell *matCellDef="let element" style="flex: 2">
|
||||
<span class="one-line" [matTooltip]="element.title ? element.title : null">
|
||||
{{element.title}}
|
||||
<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="timestamp_start">
|
||||
<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 style="flex: 2"> <ng-container i18n="Title">Title</ng-container> </mat-header-cell>
|
||||
<mat-cell *matCellDef="let element" style="flex: 2">
|
||||
<span class="one-line" [matTooltip]="element.title ? element.title : null">
|
||||
{{element.title}}
|
||||
</span>
|
||||
</mat-cell>
|
||||
</ng-container>
|
||||
<!-- Subscription Column -->
|
||||
<ng-container matColumnDef="sub_name">
|
||||
<mat-header-cell *matHeaderCellDef mat-sort-header> <ng-container i18n="Subscription">Subscription</ng-container> </mat-header-cell>
|
||||
<mat-cell *matCellDef="let element">
|
||||
@if (element.sub_name) {
|
||||
{{element.sub_name}}
|
||||
} @else {
|
||||
<ng-container i18n="N/A">N/A</ng-container>
|
||||
}
|
||||
</mat-cell>
|
||||
</ng-container>
|
||||
<!-- Progress Column -->
|
||||
<ng-container matColumnDef="percent_complete">
|
||||
<mat-header-cell *matHeaderCellDef mat-sort-header> <ng-container i18n="Progress">Progress</ng-container> </mat-header-cell>
|
||||
<mat-cell *matCellDef="let element">
|
||||
@if (!element.error) {
|
||||
@if (element.step_index !== 2) {
|
||||
{{STEP_INDEX_TO_LABEL[element.step_index]}}
|
||||
} @else {
|
||||
@if (element.percent_complete) {
|
||||
{{+(element.percent_complete) > 100 ? '100' : element.percent_complete}}%
|
||||
} @else {
|
||||
<ng-container i18n="N/A">N/A</ng-container>
|
||||
}
|
||||
}
|
||||
} @else {
|
||||
<ng-container i18n="Error">Error</ng-container>
|
||||
}
|
||||
</mat-cell>
|
||||
</ng-container>
|
||||
<!-- Actions Column -->
|
||||
<ng-container matColumnDef="actions">
|
||||
<mat-header-cell *matHeaderCellDef [ngStyle]="{flex: actionsFlex}"> <ng-container i18n="Actions">Actions</ng-container> </mat-header-cell>
|
||||
<mat-cell *matCellDef="let element" [ngStyle]="{flex: actionsFlex}">
|
||||
@if (!minimizeButtons) {
|
||||
<div>
|
||||
@for (downloadAction of downloadActions; track downloadAction) {
|
||||
<span class="button-span">
|
||||
@if (downloadAction.loading && downloadAction.loading(element)) {
|
||||
<mat-spinner [diameter]="28" class="icon-button-spinner"></mat-spinner>
|
||||
}
|
||||
@if (downloadAction.show(element)) {
|
||||
<button (click)="downloadAction.action(element)" [disabled]="downloadAction.loading && downloadAction.loading(element)" [matTooltip]="downloadAction.tooltip" mat-icon-button><mat-icon>{{downloadAction.icon}}</mat-icon></button>
|
||||
}
|
||||
</span>
|
||||
</mat-cell>
|
||||
</ng-container>
|
||||
|
||||
<!-- Subscription Column -->
|
||||
<ng-container matColumnDef="sub_name">
|
||||
<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>
|
||||
|
||||
<!-- Progress Column -->
|
||||
<ng-container matColumnDef="percent_complete">
|
||||
<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.error && element.step_index !== 2">
|
||||
{{STEP_INDEX_TO_LABEL[element.step_index]}}
|
||||
</ng-container>
|
||||
<ng-container *ngIf="!element.error && element.step_index === 2">
|
||||
<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>
|
||||
</ng-container>
|
||||
<ng-container *ngIf="element.error" i18n="Error">Error</ng-container>
|
||||
</mat-cell>
|
||||
</ng-container>
|
||||
|
||||
<!-- Actions Column -->
|
||||
<ng-container matColumnDef="actions">
|
||||
<mat-header-cell *matHeaderCellDef [ngStyle]="{flex: actionsFlex}"> <ng-container i18n="Actions">Actions</ng-container> </mat-header-cell>
|
||||
<mat-cell *matCellDef="let element" [ngStyle]="{flex: actionsFlex}">
|
||||
<div *ngIf="!minimizeButtons">
|
||||
<ng-container *ngFor="let downloadAction of downloadActions">
|
||||
<span class="button-span">
|
||||
<mat-spinner [diameter]="28" *ngIf="downloadAction.loading && downloadAction.loading(element)" class="icon-button-spinner"></mat-spinner>
|
||||
<button *ngIf="downloadAction.show(element)" (click)="downloadAction.action(element)" [disabled]="downloadAction.loading && downloadAction.loading(element)" [matTooltip]="downloadAction.tooltip" mat-icon-button><mat-icon>{{downloadAction.icon}}</mat-icon></button>
|
||||
</span>
|
||||
</ng-container>
|
||||
</div>
|
||||
<div *ngIf="minimizeButtons">
|
||||
<button [matMenuTriggerFor]="download_actions" mat-icon-button><mat-icon>more_vert</mat-icon></button>
|
||||
<mat-menu #download_actions="matMenu">
|
||||
<ng-container *ngFor="let downloadAction of downloadActions">
|
||||
<button *ngIf="downloadAction.show(element)" (click)="downloadAction.action(element)" [disabled]="downloadAction.loading && downloadAction.loading(element)" mat-menu-item>
|
||||
<mat-icon>{{downloadAction.icon}}</mat-icon>
|
||||
<span>{{downloadAction.tooltip}}</span>
|
||||
</button>
|
||||
</ng-container>
|
||||
</mat-menu>
|
||||
</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 class="downloads-action-button" [disabled]="!running_download_exists" mat-stroked-button (click)="pauseAllDownloads()"><ng-container i18n="Pause all downloads">Pause all downloads</ng-container></button>
|
||||
<button class="downloads-action-button" [disabled]="!paused_download_exists" mat-stroked-button (click)="resumeAllDownloads()"><ng-container i18n="Resume all downloads">Resume all downloads</ng-container></button>
|
||||
<button class="downloads-action-button" color="warn" mat-stroked-button (click)="clearDownloadsByType()"><ng-container i18n="Clear downloads">Clear downloads</ng-container></button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<div>
|
||||
<button [matMenuTriggerFor]="download_actions" mat-icon-button><mat-icon>more_vert</mat-icon></button>
|
||||
<mat-menu #download_actions="matMenu">
|
||||
@for (downloadAction of downloadActions; track downloadAction) {
|
||||
@if (downloadAction.show(element)) {
|
||||
<button (click)="downloadAction.action(element)" [disabled]="downloadAction.loading && downloadAction.loading(element)" mat-menu-item>
|
||||
<mat-icon>{{downloadAction.icon}}</mat-icon>
|
||||
<span>{{downloadAction.tooltip}}</span>
|
||||
</button>
|
||||
}
|
||||
}
|
||||
</mat-menu>
|
||||
</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>
|
||||
@if (!uids) {
|
||||
<div class="downloads-action-button-div">
|
||||
<button class="downloads-action-button" [disabled]="!running_download_exists" mat-stroked-button (click)="pauseAllDownloads()"><ng-container i18n="Pause all downloads">Pause all downloads</ng-container></button>
|
||||
<button class="downloads-action-button" [disabled]="!paused_download_exists" mat-stroked-button (click)="resumeAllDownloads()"><ng-container i18n="Resume all downloads">Resume all downloads</ng-container></button>
|
||||
<button class="downloads-action-button" color="warn" mat-stroked-button (click)="clearDownloadsByType()"><ng-container i18n="Clear downloads">Clear 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>
|
||||
@if ((!downloads || downloads.length === 0) && downloads_retrieved && !uids) {
|
||||
<div>
|
||||
<h4 style="text-align: center; margin-top: 10px;" i18n="No downloads label">No downloads available!</h4>
|
||||
</div>
|
||||
}
|
||||
@@ -69,14 +69,7 @@ export class DownloadsComponent implements OnInit, OnDestroy {
|
||||
tooltip: $localize`Pause`,
|
||||
action: (download: Download) => this.pauseDownload(download),
|
||||
show: (download: Download) => !download.finished && (!download.paused || !download.finished_step),
|
||||
icon: 'pause',
|
||||
loading: (download: Download) => download.paused && !download.finished_step
|
||||
},
|
||||
{
|
||||
tooltip: $localize`Resume`,
|
||||
action: (download: Download) => this.resumeDownload(download),
|
||||
show: (download: Download) => !download.finished && download.paused && download.finished_step,
|
||||
icon: 'play_arrow'
|
||||
icon: 'pause'
|
||||
},
|
||||
{
|
||||
tooltip: $localize`Resume`,
|
||||
@@ -87,7 +80,7 @@ export class DownloadsComponent implements OnInit, OnDestroy {
|
||||
{
|
||||
tooltip: $localize`Cancel`,
|
||||
action: (download: Download) => this.cancelDownload(download),
|
||||
show: (download: Download) => false && !download.finished && !download.paused, // TODO: add possibility to cancel download
|
||||
show: (download: Download) => !download.finished && !download.paused && !download.cancelled,
|
||||
icon: 'cancel'
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,46 +1,55 @@
|
||||
<mat-card class="login-card">
|
||||
<mat-tab-group mat-stretch-tabs style="margin-bottom: 20px" [(selectedIndex)]="selectedTabIndex">
|
||||
<mat-tab label="Login" i18n-label="Login">
|
||||
<div style="margin-top: 10px;">
|
||||
<mat-form-field style="width: 100%">
|
||||
<mat-label i18n="User name">User name</mat-label>
|
||||
<input [(ngModel)]="loginUsernameInput" matInput>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div>
|
||||
<mat-form-field style="width: 100%">
|
||||
<mat-label i18n="Password">Password</mat-label>
|
||||
<input [(ngModel)]="loginPasswordInput" (keyup.enter)="login()" type="password" matInput>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</mat-tab>
|
||||
<mat-tab *ngIf="registrationEnabled" label="Register" i18n-label="Register">
|
||||
<div style="margin-top: 10px;">
|
||||
<mat-form-field style="width: 100%">
|
||||
<mat-label i18n="User name">User name</mat-label>
|
||||
<input [(ngModel)]="registrationUsernameInput" matInput>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div>
|
||||
<mat-form-field style="width: 100%">
|
||||
<mat-label i18n="Password">Password</mat-label>
|
||||
<input [(ngModel)]="registrationPasswordInput" (click)="register()" type="password" matInput>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div>
|
||||
<mat-form-field style="width: 100%">
|
||||
<mat-label i18n="Confirm Password">Confirm Password</mat-label>
|
||||
<input [(ngModel)]="registrationPasswordConfirmationInput" (click)="register()" type="password" matInput>
|
||||
</mat-form-field>
|
||||
</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>
|
||||
<mat-tab-group mat-stretch-tabs style="margin-bottom: 20px" [(selectedIndex)]="selectedTabIndex">
|
||||
<mat-tab label="Login" i18n-label="Login">
|
||||
<div style="margin-top: 10px;">
|
||||
<mat-form-field style="width: 100%">
|
||||
<mat-label i18n="User name">User name</mat-label>
|
||||
<input [(ngModel)]="loginUsernameInput" matInput>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div>
|
||||
<mat-form-field style="width: 100%">
|
||||
<mat-label i18n="Password">Password</mat-label>
|
||||
<input [(ngModel)]="loginPasswordInput" (keyup.enter)="login()" type="password" matInput>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</mat-tab>
|
||||
@if (registrationEnabled) {
|
||||
<mat-tab label="Register" i18n-label="Register">
|
||||
<div style="margin-top: 10px;">
|
||||
<mat-form-field style="width: 100%">
|
||||
<mat-label i18n="User name">User name</mat-label>
|
||||
<input [(ngModel)]="registrationUsernameInput" matInput>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div>
|
||||
<mat-form-field style="width: 100%">
|
||||
<mat-label i18n="Password">Password</mat-label>
|
||||
<input [(ngModel)]="registrationPasswordInput" (click)="register()" type="password" matInput>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div>
|
||||
<mat-form-field style="width: 100%">
|
||||
<mat-label i18n="Confirm Password">Confirm Password</mat-label>
|
||||
<input [(ngModel)]="registrationPasswordConfirmationInput" (click)="register()" type="password" matInput>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</mat-tab>
|
||||
}
|
||||
</mat-tab-group>
|
||||
@if (selectedTabIndex === 0) {
|
||||
<div class="login-button-div">
|
||||
<button [disabled]="loggingIn" color="primary" (click)="login()" mat-raised-button><ng-container i18n="Login">Login</ng-container></button>
|
||||
@if (loggingIn) {
|
||||
<mat-progress-bar 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>
|
||||
} @else {
|
||||
<div class="login-button-div">
|
||||
<button [disabled]="registering" color="primary" (click)="register()" mat-raised-button><ng-container i18n="Register">Register</ng-container></button>
|
||||
@if (registering) {
|
||||
<mat-progress-bar class="login-progress-bar" mode="indeterminate"></mat-progress-bar>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</mat-card>
|
||||
@@ -1,37 +1,38 @@
|
||||
<div style="height: 100%;">
|
||||
<div *ngIf="logs_loading" style="z-index: 999; position: absolute; top: 40%; left: 50%">
|
||||
<mat-spinner [diameter]="32"></mat-spinner>
|
||||
@if (logs_loading) {
|
||||
<div style="z-index: 999; position: absolute; top: 40%; left: 50%">
|
||||
<mat-spinner [diameter]="32"></mat-spinner>
|
||||
</div>
|
||||
<!-- Virtual mode (fast, select text buggy) -->
|
||||
<!--<cdk-virtual-scroll-viewport style="height: 274px;" itemSize="50" class="example-viewport">
|
||||
<div *cdkVirtualFor="let log of logs; let i = index" class="example-item">
|
||||
<span [ngStyle]="{'color':log.color}">{{log.text}}</span>
|
||||
</div>
|
||||
</cdk-virtual-scroll-viewport>-->
|
||||
|
||||
<!-- Non-virtual mode (slow, bug-free) -->
|
||||
<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>
|
||||
}
|
||||
<!-- Virtual mode (fast, select text buggy) -->
|
||||
<!--<cdk-virtual-scroll-viewport style="height: 274px;" itemSize="50" class="example-viewport">
|
||||
<div *cdkVirtualFor="let log of logs; let i = index" class="example-item">
|
||||
<span [ngStyle]="{'color':log.color}">{{log.text}}</span>
|
||||
</div>
|
||||
</cdk-virtual-scroll-viewport>-->
|
||||
<!-- Non-virtual mode (slow, bug-free) -->
|
||||
<div style="height: 100%; overflow-y: auto">
|
||||
@for (log of logs; track log) {
|
||||
<div class="example-item">
|
||||
<span [ngStyle]="{'color':log.color}">{{log.text}}</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button style="position: absolute; right: 0px; top: 12px;" [cdkCopyToClipboard]="logs_text" (click)="copiedLogsToClipboard()" mat-mini-fab color="primary"><mat-icon style="font-size: 22px !important;">content_copy</mat-icon></button>
|
||||
<div style="display: inline-block;">
|
||||
<ng-container i18n="Label for lines select in logger view">Lines:</ng-container>
|
||||
<mat-form-field style="width: 75px;">
|
||||
<mat-select (selectionChange)="getLogs()" [(ngModel)]="requested_lines">
|
||||
<mat-option [value]="10">10</mat-option>
|
||||
<mat-option [value]="25">25</mat-option>
|
||||
<mat-option [value]="50">50</mat-option>
|
||||
<mat-option [value]="100">100</mat-option>
|
||||
<mat-option [value]="0">All</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<span class="spacer"></span>
|
||||
<button style="float: right; margin-top: 12px;" (click)="clearLogs()" mat-stroked-button color="warn"><ng-container i18n="Clear logs button">Clear logs</ng-container></button>
|
||||
</div>
|
||||
|
||||
}
|
||||
</div>
|
||||
<div>
|
||||
<button style="position: absolute; right: 0px; top: 12px;" [cdkCopyToClipboard]="logs_text" (click)="copiedLogsToClipboard()" mat-mini-fab color="primary"><mat-icon style="font-size: 22px !important;">content_copy</mat-icon></button>
|
||||
<div style="display: inline-block;">
|
||||
<ng-container i18n="Label for lines select in logger view">Lines:</ng-container>
|
||||
<mat-form-field style="width: 75px;">
|
||||
<mat-select (selectionChange)="getLogs()" [(ngModel)]="requested_lines">
|
||||
<mat-option [value]="10">10</mat-option>
|
||||
<mat-option [value]="25">25</mat-option>
|
||||
<mat-option [value]="50">50</mat-option>
|
||||
<mat-option [value]="100">100</mat-option>
|
||||
<mat-option [value]="0">All</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<span class="spacer"></span>
|
||||
<button style="float: right; margin-top: 12px;" (click)="clearLogs()" mat-stroked-button color="warn"><ng-container i18n="Clear logs button">Clear logs</ng-container></button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,17 +1,19 @@
|
||||
<h4 *ngIf="role" mat-dialog-title><ng-container i18n="Manage role dialog title">Manage role</ng-container> - {{role.key}}</h4>
|
||||
|
||||
<mat-dialog-content *ngIf="role">
|
||||
<div *ngFor="let permission of available_permissions">
|
||||
@if (role) {
|
||||
<h4 mat-dialog-title><ng-container i18n="Manage role dialog title">Manage role</ng-container> - {{role.key}}</h4>
|
||||
<mat-dialog-content>
|
||||
@for (permission of available_permissions; track permission) {
|
||||
<div>
|
||||
<div matListItemTitle>{{permissionToLabel[permission] ? permissionToLabel[permission] : permission}}</div>
|
||||
<div matListItemLine>
|
||||
<mat-radio-group [disabled]="permission === 'settings' && role.key === 'admin'" (change)="changeRolePermissions($event, 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>
|
||||
<mat-radio-group [disabled]="permission === 'settings' && role.key === 'admin'" (change)="changeRolePermissions($event, 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>
|
||||
</div>
|
||||
</div>
|
||||
</mat-dialog-content>
|
||||
|
||||
</div>
|
||||
}
|
||||
</mat-dialog-content>
|
||||
}
|
||||
<mat-dialog-actions>
|
||||
<button mat-button mat-dialog-close><ng-container i18n="Close">Close</ng-container></button>
|
||||
<button mat-button mat-dialog-close><ng-container i18n="Close">Close</ng-container></button>
|
||||
</mat-dialog-actions>
|
||||
@@ -1,30 +1,30 @@
|
||||
<h4 *ngIf="user" mat-dialog-title><ng-container i18n="Manage user dialog title">Manage user</ng-container> - {{user.name}}</h4>
|
||||
|
||||
<mat-dialog-content *ngIf="user">
|
||||
@if (user) {
|
||||
<h4 mat-dialog-title><ng-container i18n="Manage user dialog title">Manage user</ng-container> - {{user.name}}</h4>
|
||||
<mat-dialog-content>
|
||||
<p><ng-container i18n="User UID">User UID:</ng-container> {{user.uid}}</p>
|
||||
|
||||
<div>
|
||||
<mat-form-field style="margin-right: 15px;">
|
||||
<mat-label i18n="New password">New password</mat-label>
|
||||
<input matInput [(ngModel)]="newPasswordInput" type="password">
|
||||
</mat-form-field>
|
||||
<button mat-raised-button color="accent" (click)="setNewPassword()" [disabled]="newPasswordInput.length === 0"><ng-container i18n="Set new password">Set new password</ng-container></button>
|
||||
<mat-form-field style="margin-right: 15px;">
|
||||
<mat-label i18n="New password">New password</mat-label>
|
||||
<input matInput [(ngModel)]="newPasswordInput" type="password">
|
||||
</mat-form-field>
|
||||
<button mat-raised-button color="accent" (click)="setNewPassword()" [disabled]="newPasswordInput.length === 0"><ng-container i18n="Set new password">Set new password</ng-container></button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div *ngFor="let permission of available_permissions">
|
||||
<div matListItemTitle>{{permissionToLabel[permission] ? permissionToLabel[permission] : permission}}</div>
|
||||
<div matListItemLine>
|
||||
<mat-radio-group [disabled]="permission === 'settings' && postsService.user.uid === user.uid" (change)="changeUserPermissions($event, permission)" [(ngModel)]="permissions[permission]" [attr.aria-label]="'Give user permission for ' + permission">
|
||||
<mat-radio-button value="default"><ng-container i18n="Use role default">Use role default</ng-container></mat-radio-button>
|
||||
<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>
|
||||
</div>
|
||||
@for (permission of available_permissions; track permission) {
|
||||
<div>
|
||||
<div matListItemTitle>{{permissionToLabel[permission] ? permissionToLabel[permission] : permission}}</div>
|
||||
<div matListItemLine>
|
||||
<mat-radio-group [disabled]="permission === 'settings' && postsService.user.uid === user.uid" (change)="changeUserPermissions($event, permission)" [(ngModel)]="permissions[permission]" [attr.aria-label]="'Give user permission for ' + permission">
|
||||
<mat-radio-button value="default"><ng-container i18n="Use role default">Use role default</ng-container></mat-radio-button>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</mat-dialog-content>
|
||||
|
||||
</mat-dialog-content>
|
||||
}
|
||||
<mat-dialog-actions>
|
||||
<button style="margin-bottom: 5px;" mat-stroked-button mat-dialog-close><ng-container i18n="Close">Close</ng-container></button>
|
||||
<button style="margin-bottom: 5px;" mat-stroked-button mat-dialog-close><ng-container i18n="Close">Close</ng-container></button>
|
||||
</mat-dialog-actions>
|
||||
@@ -1,108 +1,103 @@
|
||||
<div *ngIf="dataSource; else loading">
|
||||
@if (dataSource) {
|
||||
<div>
|
||||
<div style="padding: 15px">
|
||||
<div class="row">
|
||||
<div class="row">
|
||||
<div class="table table-responsive pb-4 pt-4">
|
||||
<div class="example-header">
|
||||
<div class="example-header">
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label i18n="Search">Search</mat-label>
|
||||
<input matInput (keyup)="applyFilter($event)">
|
||||
<mat-label i18n="Search">Search</mat-label>
|
||||
<input matInput (keyup)="applyFilter($event)">
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<div class="mat-elevation-z8" style="margin-right: 15px;">
|
||||
|
||||
</div>
|
||||
<div class="mat-elevation-z8" style="margin-right: 15px;">
|
||||
<mat-table #table [dataSource]="dataSource" matSort>
|
||||
|
||||
<!-- Name Column -->
|
||||
<ng-container matColumnDef="name">
|
||||
<!-- Name Column -->
|
||||
<ng-container matColumnDef="name">
|
||||
<mat-header-cell *matHeaderCellDef mat-sort-header><ng-container i18n="Username users table header"> User name </ng-container></mat-header-cell>
|
||||
|
||||
<mat-cell *matCellDef="let row">
|
||||
<span *ngIf="editObject && editObject.uid === row.uid; else noteditingname">
|
||||
<span style="width: 80%;">
|
||||
<mat-form-field>
|
||||
<input matInput [(ngModel)]="constructedObject['name']" type="text" style="font-size: 12px">
|
||||
</mat-form-field>
|
||||
@if (editObject && editObject.uid === row.uid) {
|
||||
<span>
|
||||
<span style="width: 80%;">
|
||||
<mat-form-field>
|
||||
<input matInput [(ngModel)]="constructedObject['name']" type="text" style="font-size: 12px">
|
||||
</mat-form-field>
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
<ng-template #noteditingname>
|
||||
{{row.name}}
|
||||
</ng-template>
|
||||
} @else {
|
||||
{{row.name}}
|
||||
}
|
||||
</mat-cell>
|
||||
</ng-container>
|
||||
|
||||
<!-- Email Column -->
|
||||
<ng-container matColumnDef="role">
|
||||
</ng-container>
|
||||
<!-- Email Column -->
|
||||
<ng-container matColumnDef="role">
|
||||
<mat-header-cell *matHeaderCellDef mat-sort-header><ng-container i18n="Role users table header"> Role </ng-container></mat-header-cell>
|
||||
<mat-cell *matCellDef="let row">
|
||||
<span *ngIf="editObject && editObject.uid === row.uid; else noteditingemail">
|
||||
<span style="width: 80%;">
|
||||
@if (editObject && editObject.uid === row.uid) {
|
||||
<span>
|
||||
<span style="width: 80%;">
|
||||
<mat-form-field>
|
||||
<mat-select [(ngModel)]="constructedObject['role']">
|
||||
<mat-option value="admin">Admin</mat-option>
|
||||
<mat-option value="user">User</mat-option>
|
||||
</mat-select>
|
||||
<mat-select [(ngModel)]="constructedObject['role']">
|
||||
<mat-option value="admin">Admin</mat-option>
|
||||
<mat-option value="user">User</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
<ng-template #noteditingemail>
|
||||
{{row.role}}
|
||||
</ng-template>
|
||||
} @else {
|
||||
{{row.role}}
|
||||
}
|
||||
</mat-cell>
|
||||
</ng-container>
|
||||
|
||||
<!-- Actions Column -->
|
||||
<ng-container matColumnDef="actions">
|
||||
</ng-container>
|
||||
<!-- Actions Column -->
|
||||
<ng-container matColumnDef="actions">
|
||||
<mat-header-cell *matHeaderCellDef mat-sort-header><ng-container i18n="Actions users table header"> Actions </ng-container></mat-header-cell>
|
||||
<mat-cell *matCellDef="let row">
|
||||
<span *ngIf="editObject && editObject.uid === row.uid; else notediting">
|
||||
<button mat-icon-button color="primary" (click)="finishEditing(row.uid)" matTooltip="Save" i18n-matTooltip="save user edit action button tooltip">
|
||||
<mat-icon>done</mat-icon>
|
||||
</button>
|
||||
<button mat-icon-button (click)="disableEditMode()" matTooltip="Cancel" i18n-matTooltip="cancel user edit action button tooltip">
|
||||
<mat-icon>cancel</mat-icon>
|
||||
</button>
|
||||
</span>
|
||||
<ng-template #notediting>
|
||||
@if (editObject && editObject.uid === row.uid) {
|
||||
<span>
|
||||
<button mat-icon-button color="primary" (click)="finishEditing(row.uid)" matTooltip="Save" i18n-matTooltip="save user edit action button tooltip">
|
||||
<mat-icon>done</mat-icon>
|
||||
</button>
|
||||
<button mat-icon-button (click)="disableEditMode()" matTooltip="Cancel" i18n-matTooltip="cancel user edit action button tooltip">
|
||||
<mat-icon>cancel</mat-icon>
|
||||
</button>
|
||||
</span>
|
||||
} @else {
|
||||
<button mat-icon-button (click)="enableEditMode(row.uid)" matTooltip="Edit user" i18n-matTooltip="edit user action button tooltip">
|
||||
<mat-icon>edit</mat-icon>
|
||||
<mat-icon>edit</mat-icon>
|
||||
</button>
|
||||
</ng-template>
|
||||
<button (click)="manageUser(row.uid)" mat-icon-button [disabled]="editObject && editObject.uid === row.uid" matTooltip="Manage user" i18n-matTooltip="manage user action button tooltip">
|
||||
}
|
||||
<button (click)="manageUser(row.uid)" mat-icon-button [disabled]="editObject && editObject.uid === row.uid" matTooltip="Manage user" i18n-matTooltip="manage user action button tooltip">
|
||||
<mat-icon>settings</mat-icon>
|
||||
</button>
|
||||
<button mat-icon-button [disabled]="editObject && editObject.uid === row.uid || row.uid === postsService.user.uid" (click)="removeUser(row.uid)" matTooltip="Delete user" i18n-matTooltip="delete user action button tooltip">
|
||||
</button>
|
||||
<button mat-icon-button [disabled]="editObject && editObject.uid === row.uid || row.uid === postsService.user.uid" (click)="removeUser(row.uid)" matTooltip="Delete user" i18n-matTooltip="delete user action button tooltip">
|
||||
<mat-icon>delete</mat-icon>
|
||||
</button>
|
||||
</button>
|
||||
</mat-cell>
|
||||
</ng-container>
|
||||
|
||||
<mat-header-row *matHeaderRowDef="displayedColumns"></mat-header-row>
|
||||
<mat-row *matRowDef="let row; columns: displayedColumns;">
|
||||
</mat-row>
|
||||
</ng-container>
|
||||
<mat-header-row *matHeaderRowDef="displayedColumns"></mat-header-row>
|
||||
<mat-row *matRowDef="let row; columns: displayedColumns;">
|
||||
</mat-row>
|
||||
</mat-table>
|
||||
|
||||
<mat-paginator #paginator [length]="length"
|
||||
[pageSize]="pageSize"
|
||||
[pageSizeOptions]="pageSizeOptions">
|
||||
[pageSize]="pageSize"
|
||||
[pageSizeOptions]="pageSizeOptions">
|
||||
</mat-paginator>
|
||||
|
||||
<button color="primary" [disabled]="!this.users" mat-raised-button (click)="openAddUserDialog()" style="float: left; top: -45px; left: 15px">
|
||||
<ng-container i18n="Add users button">Add Users</ng-container>
|
||||
<ng-container i18n="Add users button">Add Users</ng-container>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</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">
|
||||
@for (role of roles; track role) {
|
||||
<button (click)="openModifyRole(role)" mat-menu-item>{{role.key}}</button>
|
||||
}
|
||||
</mat-menu>
|
||||
</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.key}}</button>
|
||||
</mat-menu>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div style="position: absolute" class="centered">
|
||||
<ng-template #loading>
|
||||
<mat-spinner></mat-spinner>
|
||||
</ng-template>
|
||||
</div>
|
||||
} @else {
|
||||
<div style="position: absolute" class="centered">
|
||||
<mat-spinner></mat-spinner>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -1,32 +1,38 @@
|
||||
<cdk-virtual-scroll-viewport itemSize="50" class="viewport" minBufferPx="1200" maxBufferPx="1200">
|
||||
<div #notification_parent class="notification-card-parent card-radius mat-elevation-z2" *cdkVirtualFor="let notification of notifications; let i = index;">
|
||||
<mat-card class="notification-card card-radius">
|
||||
<mat-card-header>
|
||||
<mat-card-subtitle>
|
||||
<div>
|
||||
<span class="notification-timestamp">{{notification.timestamp * 1000 | date:'short'}}</span>
|
||||
</div>
|
||||
</mat-card-subtitle>
|
||||
<mat-card-title>
|
||||
<ng-container *ngIf="NOTIFICATION_PREFIX[notification.type]">
|
||||
{{NOTIFICATION_PREFIX[notification.type]}}
|
||||
</ng-container>
|
||||
</mat-card-title>
|
||||
</mat-card-header>
|
||||
<mat-card-content>
|
||||
<ng-container *ngIf="NOTIFICATION_SUFFIX_KEY[notification.type]">
|
||||
<div style="word-break: break-word">
|
||||
{{notification['data'][NOTIFICATION_SUFFIX_KEY[notification.type]]}}
|
||||
</div>
|
||||
</ng-container>
|
||||
</mat-card-content>
|
||||
<mat-card-actions class="notification-actions" *ngIf="notification.actions?.length > 0">
|
||||
<button matTooltip="Remove" i18n-matTooltip="Remove" (click)="emitDeleteNotification(notification.uid)" mat-icon-button><mat-icon>close</mat-icon></button>
|
||||
<span *ngFor="let action of notification.actions">
|
||||
<button [matTooltip]="NOTIFICATION_ACTION_TO_STRING[action]" (click)="emitNotificationAction(notification, action)" mat-icon-button><mat-icon>{{NOTIFICATION_ICON[action]}}</mat-icon></button>
|
||||
</span>
|
||||
</mat-card-actions>
|
||||
<span *ngIf="!notification.read" class="dot"></span>
|
||||
</mat-card>
|
||||
</div>
|
||||
<div #notification_parent class="notification-card-parent card-radius mat-elevation-z2" *cdkVirtualFor="let notification of notifications; let i = index;">
|
||||
<mat-card class="notification-card card-radius">
|
||||
<mat-card-header>
|
||||
<mat-card-subtitle>
|
||||
<div>
|
||||
<span class="notification-timestamp">{{notification.timestamp * 1000 | date:'short'}}</span>
|
||||
</div>
|
||||
</mat-card-subtitle>
|
||||
<mat-card-title>
|
||||
@if (NOTIFICATION_PREFIX[notification.type]) {
|
||||
{{NOTIFICATION_PREFIX[notification.type]}}
|
||||
}
|
||||
</mat-card-title>
|
||||
</mat-card-header>
|
||||
<mat-card-content>
|
||||
@if (NOTIFICATION_SUFFIX_KEY[notification.type]) {
|
||||
<div style="word-break: break-word">
|
||||
{{notification['data'][NOTIFICATION_SUFFIX_KEY[notification.type]]}}
|
||||
</div>
|
||||
}
|
||||
</mat-card-content>
|
||||
@if (notification.actions?.length > 0) {
|
||||
<mat-card-actions class="notification-actions">
|
||||
<button matTooltip="Remove" i18n-matTooltip="Remove" (click)="emitDeleteNotification(notification.uid)" mat-icon-button><mat-icon>close</mat-icon></button>
|
||||
@for (action of notification.actions; track action) {
|
||||
<span>
|
||||
<button [matTooltip]="NOTIFICATION_ACTION_TO_STRING[action]" (click)="emitNotificationAction(notification, action)" mat-icon-button><mat-icon>{{NOTIFICATION_ICON[action]}}</mat-icon></button>
|
||||
</span>
|
||||
}
|
||||
</mat-card-actions>
|
||||
}
|
||||
@if (!notification.read) {
|
||||
<span class="dot"></span>
|
||||
}
|
||||
</mat-card>
|
||||
</div>
|
||||
</cdk-virtual-scroll-viewport>
|
||||
@@ -1,10 +1,16 @@
|
||||
<div *ngIf="notifications !== null && notifications.length === 0" style="text-align: center; margin: 10px;" i18n="No notifications available">No notifications available</div>
|
||||
<div *ngIf="notifications?.length > 0">
|
||||
@if (notifications !== null && notifications.length === 0) {
|
||||
<div style="text-align: center; margin: 10px;" i18n="No notifications available">No notifications available</div>
|
||||
}
|
||||
@if (notifications?.length > 0) {
|
||||
<div>
|
||||
<div class="notifications-list-parent">
|
||||
<mat-chip-listbox [value]="selectedFilters" [multiple]="true" (change)="selectedFiltersChanged($event)">
|
||||
<mat-chip-option *ngFor="let filter of notificationFilters | keyvalue: originalOrder" [value]="filter.key" [selected]="selectedFilters.includes(filter.key)" color="accent">{{filter.value.label}}</mat-chip-option>
|
||||
</mat-chip-listbox>
|
||||
<app-notifications-list class="notifications-list" [style.height]="list_height" (notificationAction)="notificationAction($event)" (deleteNotification)="deleteNotification($event)" [notifications]="filtered_notifications"></app-notifications-list>
|
||||
<mat-chip-listbox [value]="selectedFilters" [multiple]="true" (change)="selectedFiltersChanged($event)">
|
||||
@for (filter of notificationFilters | keyvalue: originalOrder; track filter) {
|
||||
<mat-chip-option [value]="filter.key" [selected]="selectedFilters.includes(filter.key)" color="accent">{{filter.value.label}}</mat-chip-option>
|
||||
}
|
||||
</mat-chip-listbox>
|
||||
<app-notifications-list class="notifications-list" [style.height]="list_height" (notificationAction)="notificationAction($event)" (deleteNotification)="deleteNotification($event)" [notifications]="filtered_notifications"></app-notifications-list>
|
||||
</div>
|
||||
<button style="margin: 10px 0px 2px 10px;" *ngIf="notifications?.length > 0" color="warn" (click)="deleteAllNotifications()" mat-stroked-button>Remove all</button>
|
||||
</div>
|
||||
<button style="margin: 10px 0px 2px 10px;" color="warn" (click)="deleteAllNotifications()" mat-stroked-button>Remove all</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -1,105 +1,134 @@
|
||||
<div class="container-fluid" style="max-width: 941px;">
|
||||
<div class="row">
|
||||
<!-- Sorting -->
|
||||
<div class="col-12 order-2 col-sm-4 order-sm-1 d-flex justify-content-center">
|
||||
<app-sort-property [(sortProperty)]="sortProperty" [(descendingMode)]="descendingMode" (sortOptionChanged)="sortOptionChanged($event)"></app-sort-property>
|
||||
</div>
|
||||
<!-- Files title -->
|
||||
<div class="col-12 order-1 col-sm-4 order-sm-2 d-flex justify-content-center">
|
||||
<h4 *ngIf="!customHeader" class="my-videos-title" i18n="My files title">My files</h4>
|
||||
<h4 *ngIf="customHeader" class="my-videos-title">{{customHeader}}</h4>
|
||||
</div>
|
||||
<!-- Search -->
|
||||
<div class="col-12 order-3 col-sm-4 order-sm-3 d-flex justify-content-center">
|
||||
<mat-form-field appearance="outline" [ngClass]="searchIsFocused ? 'search-bar-focused' : 'search-bar-unfocused'" class="search-bar" color="accent">
|
||||
<mat-label i18n="Search">Search</mat-label>
|
||||
<input (focus)="searchIsFocused = true" (blur)="searchIsFocused = false" class="search-input" type="text" [(ngModel)]="search_text" (ngModelChange)="onSearchInputChanged($event)" matInput>
|
||||
<mat-icon matSuffix>search</mat-icon>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="row">
|
||||
<!-- Sorting -->
|
||||
<div class="col-12 order-2 col-sm-4 order-sm-1 d-flex justify-content-center">
|
||||
<app-sort-property [(sortProperty)]="sortProperty" [(descendingMode)]="descendingMode" (sortOptionChanged)="sortOptionChanged($event)"></app-sort-property>
|
||||
</div>
|
||||
<!-- Filters -->
|
||||
<div class="row justify-content-center">
|
||||
<mat-chip-listbox class="filter-list" [value]="selectedFilters" [multiple]="true" (change)="selectedFiltersChanged($event)">
|
||||
<mat-chip-option *ngFor="let filter of fileFilters | keyvalue: originalOrder" [value]="filter.key" [selected]="selectedFilters.includes(filter.key)" color="accent">{{filter.value.label}}</mat-chip-option>
|
||||
</mat-chip-listbox>
|
||||
<!-- Files title -->
|
||||
<div class="col-12 order-1 col-sm-4 order-sm-2 d-flex justify-content-center">
|
||||
@if (!customHeader) {
|
||||
<h4 class="my-videos-title" i18n="My files title">My files</h4>
|
||||
} @else {
|
||||
<h4 class="my-videos-title">{{customHeader}}</h4>
|
||||
}
|
||||
</div>
|
||||
<!-- Search -->
|
||||
<div class="col-12 order-3 col-sm-4 order-sm-3 d-flex justify-content-center">
|
||||
<mat-form-field appearance="outline" [ngClass]="searchIsFocused ? 'search-bar-focused' : 'search-bar-unfocused'" class="search-bar" color="accent">
|
||||
<mat-label i18n="Search">Search</mat-label>
|
||||
<input (focus)="searchIsFocused = true" (blur)="searchIsFocused = false" class="search-input" type="text" [(ngModel)]="search_text" (ngModelChange)="onSearchInputChanged($event)" matInput>
|
||||
<mat-icon matSuffix>search</mat-icon>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Filters -->
|
||||
<div class="row justify-content-center">
|
||||
<mat-chip-listbox class="filter-list" [value]="selectedFilters" [multiple]="true" (change)="selectedFiltersChanged($event)">
|
||||
@for (filter of fileFilters | keyvalue: originalOrder; track filter) {
|
||||
<mat-chip-option [value]="filter.key" [selected]="selectedFilters.includes(filter.key)" color="accent">{{filter.value.label}}</mat-chip-option>
|
||||
}
|
||||
</mat-chip-listbox>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<!-- Files -->
|
||||
<div *ngIf="!selectMode" class="container" style="margin-bottom: 16px">
|
||||
<div class="row justify-content-center">
|
||||
<!-- Real cards -->
|
||||
<ng-container *ngIf="normal_files_received && paged_data">
|
||||
<div style="display: flex; align-items: center;" *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 [ngClass]="downloading_content[file.uid] ? 'blurred' : ''" [index]="i" [card_size]="postsService.card_size" [locale]="postsService.locale" (goToFile)="goToFile($event)" (goToSubscription)="goToSubscription($event)" (toggleFavorite)="toggleFavorite($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 ? this.postsService.token : ''"></app-unified-file-card>
|
||||
<mat-spinner *ngIf="downloading_content[file.uid]" class="downloading-spinner" [diameter]="32"></mat-spinner>
|
||||
</div>
|
||||
<div *ngIf="paged_data.length === 0">
|
||||
<ng-container i18n="No files found">No files found.</ng-container>
|
||||
</div>
|
||||
</ng-container>
|
||||
<!-- Fake cards -->
|
||||
<ng-container>
|
||||
<div *ngFor="let file of loading_files; let i = index" class="mb-2 mt-2 d-flex justify-content-center" [ngClass]="[normal_files_received ? 'hide' : '', 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" [loading]="true" [theme]="postsService.theme"></app-unified-file-card>
|
||||
</div>
|
||||
</ng-container>
|
||||
</div>
|
||||
<!-- Files -->
|
||||
@if (!selectMode) {
|
||||
<div class="container" style="margin-bottom: 16px">
|
||||
<div class="row justify-content-center">
|
||||
<!-- Real cards -->
|
||||
@if (normal_files_received && paged_data) {
|
||||
@for (file of paged_data; track file; let i = $index) {
|
||||
<div style="display: flex; align-items: center;" 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 [ngClass]="downloading_content[file.uid] ? 'blurred' : ''" [index]="i" [card_size]="postsService.card_size" [locale]="postsService.locale" (goToFile)="goToFile($event)" (goToSubscription)="goToSubscription($event)" (toggleFavorite)="toggleFavorite($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 ? this.postsService.token : ''"></app-unified-file-card>
|
||||
@if (downloading_content[file.uid]) {
|
||||
<mat-spinner class="downloading-spinner" [diameter]="32"></mat-spinner>
|
||||
}
|
||||
</div>
|
||||
} @empty {
|
||||
<div>
|
||||
<ng-container i18n="No files found">No files found.</ng-container>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
<!-- Fake cards -->
|
||||
<ng-container>
|
||||
@for (file of loading_files; track file; let i = $index) {
|
||||
<div class="mb-2 mt-2 d-flex justify-content-center" [ngClass]="[normal_files_received ? 'hide' : '', 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" [loading]="true" [theme]="postsService.theme"></app-unified-file-card>
|
||||
</div>
|
||||
}
|
||||
</ng-container>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div *ngIf="selectMode">
|
||||
<!-- If selected files e.g. for creating a playlist -->
|
||||
<mat-tab-group [(selectedIndex)]="selectedIndex">
|
||||
<mat-tab label="Order" i18n-label="Order">
|
||||
<div *ngIf="selected_data.length">
|
||||
<span *ngIf="reverse_order === false" i18n="Normal order">Normal order </span>
|
||||
<span *ngIf="reverse_order === true" i18n="Reverse order">Reverse order </span>
|
||||
<button (click)="toggleSelectionOrder()" mat-icon-button><mat-icon>{{!reverse_order ? 'arrow_downward' : 'arrow_upward'}}</mat-icon></button>
|
||||
</div>
|
||||
|
||||
<!-- Selection order -->
|
||||
<mat-button-toggle-group *ngIf="selected_data.length" class="media-list" cdkDropList (cdkDropListDropped)="drop($event)" style="width: 80%; left: 9%" vertical #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 file of (reverse_order ? selected_data_objs.slice().reverse() : selected_data_objs); let i = index" [checked]="false"><div><div class="playlist-item-text">{{file.title}}</div> <button (click)="removeSelectedFile(i)" class="remove-item-button" mat-icon-button><mat-icon>cancel</mat-icon></button></div></mat-button-toggle>
|
||||
</mat-button-toggle-group>
|
||||
|
||||
<div style="margin-top: 20px;" *ngIf="!selected_data.length">
|
||||
<h4 style="text-align: center;">No files selected!</h4>
|
||||
</div>
|
||||
|
||||
</mat-tab>
|
||||
<mat-tab label="Select files" i18n-label="Select files">
|
||||
<mat-selection-list *ngIf="normal_files_received" (selectionChange)="fileSelectionChanged($event)">
|
||||
<mat-list-option [selected]="selected_data.includes(file.uid)" *ngFor="let file of paged_data" [value]="file">
|
||||
<div class="container">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-10 select-file-title">
|
||||
<mat-icon class="audio-video-icon">{{file.isAudio ? 'audiotrack' : 'movie'}}</mat-icon>
|
||||
{{file.title}}
|
||||
</div>
|
||||
<div class="col-2">{{file.registered | date:'shortDate'}}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</mat-list-option>
|
||||
</mat-selection-list>
|
||||
|
||||
<ng-container *ngIf="!normal_files_received && loading_files && loading_files.length > 0">
|
||||
<mat-selection-list *ngIf="!normal_files_received">
|
||||
<mat-list-option *ngFor="let file of paged_data">
|
||||
<content-loader class="list-ghosts" [backgroundColor]="postsService.theme.ghost_primary" [foregroundColor]="postsService.theme.ghost_secondary" viewBox="0 0 250 8"><svg:rect x="0" y="0" rx="3" ry="3" width="250" height="8" /></content-loader>
|
||||
</mat-list-option>
|
||||
</mat-selection-list>
|
||||
</ng-container>
|
||||
</mat-tab>
|
||||
</mat-tab-group>
|
||||
} @else {
|
||||
<div>
|
||||
<!-- If selected files e.g. for creating a playlist -->
|
||||
<mat-tab-group [(selectedIndex)]="selectedIndex">
|
||||
<mat-tab label="Order" i18n-label="Order">
|
||||
@if (selected_data.length) {
|
||||
<div>
|
||||
@if (reverse_order === false) {
|
||||
<span i18n="Normal order">Normal order </span>
|
||||
}
|
||||
@if (reverse_order === true) {
|
||||
<span i18n="Reverse order">Reverse order </span>
|
||||
}
|
||||
<button (click)="toggleSelectionOrder()" mat-icon-button><mat-icon>{{!reverse_order ? 'arrow_downward' : 'arrow_upward'}}</mat-icon></button>
|
||||
</div>
|
||||
}
|
||||
<!-- Selection order -->
|
||||
@if (selected_data.length) {
|
||||
<mat-button-toggle-group class="media-list" cdkDropList (cdkDropListDropped)="drop($event)" style="width: 80%; left: 9%" vertical #group="matButtonToggleGroup">
|
||||
<!-- The following for loop can be optimized but it requires a pipe https://stackoverflow.com/a/35703364/8088021 -->
|
||||
@for (file of (reverse_order ? selected_data_objs.slice().reverse() : selected_data_objs); track file; let i = $index) {
|
||||
<mat-button-toggle class="media-box" cdkDrag [checked]="false"><div><div class="playlist-item-text">{{file.title}}</div> <button (click)="removeSelectedFile(i)" class="remove-item-button" mat-icon-button><mat-icon>cancel</mat-icon></button></div></mat-button-toggle>
|
||||
}
|
||||
</mat-button-toggle-group>
|
||||
} @else {
|
||||
<div style="margin-top: 20px;">
|
||||
<h4 style="text-align: center;">No files selected!</h4>
|
||||
</div>
|
||||
}
|
||||
</mat-tab>
|
||||
<mat-tab label="Select files" i18n-label="Select files">
|
||||
@if (normal_files_received) {
|
||||
<mat-selection-list (selectionChange)="fileSelectionChanged($event)">
|
||||
@for (file of paged_data; track file) {
|
||||
<mat-list-option [selected]="selected_data.includes(file.uid)" [value]="file">
|
||||
<div class="container">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-10 select-file-title">
|
||||
<mat-icon class="audio-video-icon">{{file.isAudio ? 'audiotrack' : 'movie'}}</mat-icon>
|
||||
{{file.title}}
|
||||
</div>
|
||||
<div class="col-2">{{file.registered | date:'shortDate'}}</div>
|
||||
</div>
|
||||
</div>
|
||||
</mat-list-option>
|
||||
}
|
||||
</mat-selection-list>
|
||||
}
|
||||
@if (!normal_files_received && loading_files && loading_files.length > 0) {
|
||||
@if (!normal_files_received) {
|
||||
<mat-selection-list>
|
||||
@for (file of paged_data; track file) {
|
||||
<mat-list-option>
|
||||
<content-loader class="list-ghosts" [backgroundColor]="postsService.theme.ghost_primary" [foregroundColor]="postsService.theme.ghost_secondary" viewBox="0 0 250 8"><svg:rect x="0" y="0" rx="3" ry="3" width="250" height="8" /></content-loader>
|
||||
</mat-list-option>
|
||||
}
|
||||
</mat-selection-list>
|
||||
}
|
||||
}
|
||||
</mat-tab>
|
||||
</mat-tab-group>
|
||||
</div>
|
||||
|
||||
<div style="position: relative;" *ngIf="usePaginator && selectedIndex > 0">
|
||||
<mat-paginator class="paginator" #paginator (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>
|
||||
}
|
||||
@if (usePaginator && selectedIndex > 0) {
|
||||
<div style="position: relative;">
|
||||
<mat-paginator class="paginator" #paginator (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>
|
||||
|
||||
@@ -8,6 +8,7 @@ import { distinctUntilChanged } from 'rxjs/operators';
|
||||
import { CdkDragDrop, moveItemInArray } from '@angular/cdk/drag-drop';
|
||||
import { MatChipListboxChange } from '@angular/material/chips';
|
||||
import { MatSelectionListChange } from '@angular/material/list';
|
||||
import { saveAs } from 'file-saver';
|
||||
|
||||
@Component({
|
||||
selector: 'app-recent-videos',
|
||||
@@ -380,8 +381,8 @@ export class RecentVideosComponent implements OnInit {
|
||||
|
||||
fileSelectionChanged(event: MatSelectionListChange): void {
|
||||
// TODO: make sure below line is possible (_selected is private)
|
||||
const adding = event.option['_selected'];
|
||||
const value = event.option.value;
|
||||
const adding = event.options['_selected'];
|
||||
const value = adding.value;
|
||||
if (adding) {
|
||||
this.selected_data.push(value.uid);
|
||||
this.selected_data_objs.push(value);
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
<span class="text" [ngStyle]="{'-webkit-line-clamp': !see_more_active ? line_limit : null}" [innerHTML]="text | linkify"></span>
|
||||
<span>
|
||||
<a [routerLink]="[]" (click)="toggleSeeMore()">
|
||||
<ng-container *ngIf="!see_more_active" i18n="See more">
|
||||
See more.
|
||||
</ng-container>
|
||||
<ng-container *ngIf="see_more_active" i18n="See less">
|
||||
See less.
|
||||
</ng-container>
|
||||
</a>
|
||||
<a [routerLink]="[]" (click)="toggleSeeMore()">
|
||||
@if (!see_more_active) {
|
||||
<ng-container i18n="See more">
|
||||
See more.
|
||||
</ng-container>
|
||||
} @else {
|
||||
<ng-container i18n="See less">
|
||||
See less.
|
||||
</ng-container>
|
||||
}
|
||||
</a>
|
||||
</span>
|
||||
@@ -1 +1,3 @@
|
||||
<button *ngIf="show_skip_ad_button" (click)="skipAdButtonClicked()" mat-flat-button><ng-container i18n="Skip ad button">Skip ad</ng-container></button>
|
||||
@if (show_skip_ad_button) {
|
||||
<button (click)="skipAdButtonClicked()" mat-flat-button><ng-container i18n="Skip ad button">Skip ad</ng-container></button>
|
||||
}
|
||||
@@ -1,14 +1,16 @@
|
||||
<div>
|
||||
<div style="display: inline-block;">
|
||||
<mat-form-field appearance="outline" style="width: 165px;">
|
||||
<mat-select [(ngModel)]="this.sortProperty" (selectionChange)="emitSortOptionChanged()">
|
||||
<mat-option *ngFor="let sortOption of sortProperties | keyvalue" [value]="sortOption.key">
|
||||
{{sortOption['value']['label']}}
|
||||
</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="sort-dir-div">
|
||||
<button (click)="toggleModeChange()" mat-icon-button><mat-icon>{{descendingMode ? 'arrow_downward' : 'arrow_upward'}}</mat-icon></button>
|
||||
</div>
|
||||
<div style="display: inline-block;">
|
||||
<mat-form-field appearance="outline" style="width: 165px;">
|
||||
<mat-select [(ngModel)]="this.sortProperty" (selectionChange)="emitSortOptionChanged()">
|
||||
@for (sortOption of sortProperties | keyvalue; track sortOption) {
|
||||
<mat-option [value]="sortOption.key">
|
||||
{{sortOption['value']['label']}}
|
||||
</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="sort-dir-div">
|
||||
<button (click)="toggleModeChange()" mat-icon-button><mat-icon>{{descendingMode ? 'arrow_downward' : 'arrow_upward'}}</mat-icon></button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,29 +1,31 @@
|
||||
<h4 mat-dialog-title><ng-container i18n="Task settings">Task settings - {{task.title}}</ng-container></h4>
|
||||
|
||||
<mat-dialog-content>
|
||||
<div *ngIf="task_key === 'delete_old_files'">
|
||||
<mat-form-field color="accent">
|
||||
<mat-label i18n="Delete files older than">Delete files older than</mat-label>
|
||||
<input [(ngModel)]="new_options['threshold_days']" matInput onlyNumber required>
|
||||
<span matTextSuffix>days</span>
|
||||
</mat-form-field>
|
||||
<div>
|
||||
<mat-checkbox [(ngModel)]="new_options['blacklist_files']" i18n="Blacklist deleted files" placeholder="Archive mode must be enabled" placeholder-i18n>Blacklist all files</mat-checkbox>
|
||||
</div>
|
||||
<div>
|
||||
<mat-checkbox [disabled]="new_options['blacklist_files']" [(ngModel)]="new_options['blacklist_subscription_files']" i18n="Blacklist deleted subscription files" placeholder="Archive mode must be enabled" placeholder-i18n>Blacklist deleted subscription files</mat-checkbox>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (task_key === 'delete_old_files') {
|
||||
<div>
|
||||
<mat-checkbox [(ngModel)]="new_options['auto_confirm']" i18n="Do not ask for confirmation">Do not ask for confirmation</mat-checkbox>
|
||||
<mat-form-field color="accent">
|
||||
<mat-label i18n="Delete files older than">Delete files older than</mat-label>
|
||||
<input [(ngModel)]="new_options['threshold_days']" matInput onlyNumber required>
|
||||
<span matTextSuffix>days</span>
|
||||
</mat-form-field>
|
||||
<div>
|
||||
<mat-checkbox [(ngModel)]="new_options['blacklist_files']" i18n="Blacklist deleted files" placeholder="Archive mode must be enabled" placeholder-i18n>Blacklist all files</mat-checkbox>
|
||||
</div>
|
||||
<div>
|
||||
<mat-checkbox [disabled]="new_options['blacklist_files']" [(ngModel)]="new_options['blacklist_subscription_files']" i18n="Blacklist deleted subscription files" placeholder="Archive mode must be enabled" placeholder-i18n>Blacklist deleted subscription files</mat-checkbox>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
<div>
|
||||
<mat-checkbox [(ngModel)]="new_options['auto_confirm']" i18n="Do not ask for confirmation">Do not ask for confirmation</mat-checkbox>
|
||||
</div>
|
||||
</mat-dialog-content>
|
||||
|
||||
<mat-dialog-actions>
|
||||
<button mat-button mat-dialog-close>
|
||||
<ng-container *ngIf="optionsChanged()" i18n="Task settings cancel button">Cancel</ng-container>
|
||||
<ng-container *ngIf="!optionsChanged()" i18n="Task settings close button">Close</ng-container>
|
||||
</button>
|
||||
<button mat-button [disabled]="!optionsChanged()" (click)="saveSettings()"><ng-container i18n="Save button">Save</ng-container></button>
|
||||
<button mat-button mat-dialog-close>
|
||||
@if (optionsChanged()) {
|
||||
<ng-container i18n="Task settings cancel button">Cancel</ng-container>
|
||||
} @else {
|
||||
<ng-container i18n="Task settings close button">Close</ng-container>
|
||||
}
|
||||
</button>
|
||||
<button mat-button [disabled]="!optionsChanged()" (click)="saveSettings()"><ng-container i18n="Save button">Save</ng-container></button>
|
||||
</mat-dialog-actions>
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Component, Inject } from '@angular/core';
|
||||
import { MAT_DIALOG_DATA } from '@angular/material/dialog';
|
||||
import { Task } from 'api-types';
|
||||
import { Task, TaskType } from 'api-types';
|
||||
import { PostsService } from 'app/posts.services';
|
||||
|
||||
@Component({
|
||||
@@ -9,7 +9,7 @@ import { PostsService } from 'app/posts.services';
|
||||
styleUrls: ['./task-settings.component.scss']
|
||||
})
|
||||
export class TaskSettingsComponent {
|
||||
task_key: string;
|
||||
task_key: TaskType;
|
||||
new_options = {};
|
||||
task: Task = null;
|
||||
|
||||
|
||||
@@ -1,104 +1,114 @@
|
||||
<div [hidden]="!(tasks && tasks.length > 0)">
|
||||
<div style="overflow: hidden;" [ngClass]="'mat-elevation-z8'">
|
||||
<mat-table style="overflow: hidden" matSort [dataSource]="dataSource">
|
||||
<!-- 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>
|
||||
|
||||
<!-- Last Ran Column -->
|
||||
<ng-container matColumnDef="last_ran">
|
||||
<mat-header-cell *matHeaderCellDef mat-sort-header> <ng-container i18n="Last ran">Last ran</ng-container> </mat-header-cell>
|
||||
<mat-cell *matCellDef="let element">
|
||||
<ng-container *ngIf="element.last_ran">{{element.last_ran*1000 | date: 'short'}}</ng-container>
|
||||
<ng-container i18n="N/A" *ngIf="!element.last_ran">N/A</ng-container>
|
||||
</mat-cell>
|
||||
</ng-container>
|
||||
|
||||
<!-- Last Confirmed Column -->
|
||||
<ng-container matColumnDef="last_confirmed">
|
||||
<mat-header-cell *matHeaderCellDef mat-sort-header> <ng-container i18n="Last confirmed">Last confirmed</ng-container> </mat-header-cell>
|
||||
<mat-cell *matCellDef="let element">
|
||||
<ng-container *ngIf="element.last_confirmed">{{element.last_confirmed*1000 | date: 'short'}}</ng-container>
|
||||
<ng-container i18n="N/A" *ngIf="!element.last_confirmed">N/A</ng-container>
|
||||
</mat-cell>
|
||||
</ng-container>
|
||||
|
||||
<!-- Status Column -->
|
||||
<ng-container matColumnDef="status">
|
||||
<mat-header-cell *matHeaderCellDef mat-sort-header> <ng-container i18n="Status">Status</ng-container> </mat-header-cell>
|
||||
<mat-cell *matCellDef="let element">
|
||||
<span *ngIf="element.running || element.confirming"><mat-spinner matTooltip="Busy" i18n-matTooltip="Busy" [diameter]="25"></mat-spinner></span>
|
||||
<span *ngIf="!(element.running || element.confirming) && element.schedule" style="display: flex">
|
||||
<ng-container i18n="Scheduled">Scheduled for</ng-container>
|
||||
{{element.next_invocation | date: 'short'}}<mat-icon style="font-size: 16px; display: inline-flex; align-items: center; padding-left: 5px; padding-bottom: 6px;" *ngIf="element.schedule.type === 'recurring'">repeat</mat-icon>
|
||||
</span>
|
||||
<span *ngIf="!(element.running || element.confirming) && !element.schedule">
|
||||
<ng-container i18n="Not scheduled">Not scheduled</ng-container>
|
||||
</span>
|
||||
</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 class="container">
|
||||
<div class="row justify-content-center">
|
||||
<div *ngIf="element.data?.uids?.length > 0 || (!element.data?.uids && element.data)" class="col-12 mt-2" style="display: flex; justify-content: center;">
|
||||
<ng-container>
|
||||
<button (click)="confirmTask(element.key)" [disabled]="element.running || element.confirming" mat-stroked-button>
|
||||
<ng-container *ngIf="element.key == 'missing_files_check'">
|
||||
<ng-container i18n="Clear missing files from DB">Clear missing files from DB:</ng-container>{{element.data.uids.length}}
|
||||
</ng-container>
|
||||
<ng-container *ngIf="element.key == 'duplicate_files_check'">
|
||||
<ng-container i18n="Clear duplicate files from DB">Clear duplicate files from DB:</ng-container> {{element.data.uids.length}}
|
||||
</ng-container>
|
||||
<ng-container *ngIf="element.key == 'youtubedl_update_check'">
|
||||
<ng-container i18n="Update binary to">Update binary to:</ng-container> {{element.data}}
|
||||
</ng-container>
|
||||
<ng-container *ngIf="element.key == 'delete_old_files'">
|
||||
<ng-container i18n="Delete old files">Delete old files:</ng-container> {{element.data.files_to_remove.length}}
|
||||
</ng-container>
|
||||
</button>
|
||||
</ng-container>
|
||||
</div>
|
||||
<div class="col-3">
|
||||
<button (click)="runTask(element.key)" [disabled]="element.running || element.confirming" mat-icon-button matTooltip="Run" i18n-matTooltip="Run"><mat-icon>play_arrow</mat-icon></button>
|
||||
</div>
|
||||
<div class="col-3">
|
||||
<button (click)="scheduleTask(element)" mat-icon-button matTooltip="Schedule" i18n-matTooltip="Schedule"><mat-icon>schedule</mat-icon></button>
|
||||
</div>
|
||||
<div class="col-3">
|
||||
<button (click)="openTaskSettings(element)" mat-icon-button matTooltip="Settings" i18n-matTooltip="Settings"><mat-icon>settings</mat-icon></button>
|
||||
</div>
|
||||
<div *ngIf="element.error" class="col-3">
|
||||
<button (click)="showError(element)" mat-icon-button matTooltip="Show error" i18n-matTooltip="Show error"><mat-icon>warning</mat-icon></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</mat-cell>
|
||||
</ng-container>
|
||||
|
||||
<mat-header-row *matHeaderRowDef="displayedColumns"></mat-header-row>
|
||||
<mat-row *matRowDef="let row; columns: displayedColumns;"></mat-row>
|
||||
</mat-table>
|
||||
|
||||
<mat-paginator [pageSizeOptions]="[10, 20]"
|
||||
showFirstLastButtons
|
||||
aria-label="Select page of tasks">
|
||||
</mat-paginator>
|
||||
</div>
|
||||
|
||||
<button style="margin-top: 10px; margin-left: 5px;" mat-stroked-button (click)="openRestoreDBBackupDialog()" i18n="Restore DB from backup button">Restore DB from backup</button>
|
||||
<button style="margin-top: 10px; margin-left: 5px;" mat-stroked-button (click)="resetTasks()" color="warn" i18n="Reset tasks button">Reset tasks</button>
|
||||
<div style="overflow: hidden;" [ngClass]="'mat-elevation-z8'">
|
||||
<mat-table style="overflow: hidden" matSort [dataSource]="dataSource">
|
||||
<!-- 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>
|
||||
<!-- Last Ran Column -->
|
||||
<ng-container matColumnDef="last_ran">
|
||||
<mat-header-cell *matHeaderCellDef mat-sort-header> <ng-container i18n="Last ran">Last ran</ng-container> </mat-header-cell>
|
||||
<mat-cell *matCellDef="let element">
|
||||
@if (element.last_ran) {
|
||||
{{element.last_ran*1000 | date: 'short'}}
|
||||
} @else {
|
||||
<ng-container i18n="N/A">N/A</ng-container>
|
||||
}
|
||||
</mat-cell>
|
||||
</ng-container>
|
||||
<!-- Last Confirmed Column -->
|
||||
<ng-container matColumnDef="last_confirmed">
|
||||
<mat-header-cell *matHeaderCellDef mat-sort-header> <ng-container i18n="Last confirmed">Last confirmed</ng-container> </mat-header-cell>
|
||||
<mat-cell *matCellDef="let element">
|
||||
@if (element.last_confirmed) {
|
||||
{{element.last_confirmed*1000 | date: 'short'}}
|
||||
} @else {
|
||||
<ng-container i18n="N/A">N/A</ng-container>
|
||||
}
|
||||
</mat-cell>
|
||||
</ng-container>
|
||||
<!-- Status Column -->
|
||||
<ng-container matColumnDef="status">
|
||||
<mat-header-cell *matHeaderCellDef mat-sort-header> <ng-container i18n="Status">Status</ng-container> </mat-header-cell>
|
||||
<mat-cell *matCellDef="let element">
|
||||
@if (element.running || element.confirming) {
|
||||
<span><mat-spinner matTooltip="Busy" i18n-matTooltip="Busy" [diameter]="25"></mat-spinner></span>
|
||||
} @else if (element.schedule) {
|
||||
<span style="display: flex">
|
||||
<ng-container i18n="Scheduled">Scheduled for</ng-container>
|
||||
{{element.next_invocation | date: 'short'}}
|
||||
@if (element.schedule.type === 'recurring') {
|
||||
<mat-icon style="font-size: 16px; display: inline-flex; align-items: center; padding-left: 5px; padding-bottom: 6px;">repeat</mat-icon>
|
||||
}
|
||||
</span>
|
||||
} @else {
|
||||
<span>
|
||||
<ng-container i18n="Not scheduled">Not scheduled</ng-container>
|
||||
</span>
|
||||
}
|
||||
</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 class="container">
|
||||
<div class="row justify-content-start">
|
||||
@if (element.data?.uids?.length > 0 || (!element.data?.uids && element.data)) {
|
||||
<div class="col-12 mt-2" style="display: flex; justify-content: center;">
|
||||
<ng-container>
|
||||
<button (click)="confirmTask(element.key)" [disabled]="element.running || element.confirming" mat-stroked-button>
|
||||
@switch(element.key) {
|
||||
@case ('missing_files_check') {
|
||||
<ng-container i18n="Clear missing files from DB">Clear missing files from DB:</ng-container>{{element.data.uids.length}}
|
||||
} @case ('duplicate_files_check') {
|
||||
<ng-container i18n="Clear duplicate files from DB">Clear duplicate files from DB:</ng-container> {{element.data.uids.length}}
|
||||
} @case ('youtubedl_update_check') {
|
||||
<ng-container i18n="Update binary to">Update binary to:</ng-container> {{element.data}}
|
||||
} @case ('delete_old_files') {
|
||||
<ng-container i18n="Delete old files">Delete old files:</ng-container> {{element.data.files_to_remove.length}}
|
||||
}
|
||||
}
|
||||
</button>
|
||||
</ng-container>
|
||||
</div>
|
||||
}
|
||||
<div class="col-3">
|
||||
<button (click)="runTask(element.key)" [disabled]="element.running || element.confirming" mat-icon-button matTooltip="Run" i18n-matTooltip="Run"><mat-icon>play_arrow</mat-icon></button>
|
||||
</div>
|
||||
<div class="col-3">
|
||||
<button (click)="scheduleTask(element)" mat-icon-button matTooltip="Schedule" i18n-matTooltip="Schedule"><mat-icon>schedule</mat-icon></button>
|
||||
</div>
|
||||
<div class="col-3">
|
||||
<button (click)="openTaskSettings(element)" mat-icon-button matTooltip="Settings" i18n-matTooltip="Settings"><mat-icon>settings</mat-icon></button>
|
||||
</div>
|
||||
@if (element.error) {
|
||||
<div class="col-3">
|
||||
<button (click)="showError(element)" mat-icon-button matTooltip="Show error" i18n-matTooltip="Show error"><mat-icon>warning</mat-icon></button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</mat-cell>
|
||||
</ng-container>
|
||||
<mat-header-row *matHeaderRowDef="displayedColumns"></mat-header-row>
|
||||
<mat-row *matRowDef="let row; columns: displayedColumns;"></mat-row>
|
||||
</mat-table>
|
||||
<mat-paginator [pageSizeOptions]="[10, 20]"
|
||||
showFirstLastButtons
|
||||
aria-label="Select page of tasks">
|
||||
</mat-paginator>
|
||||
</div>
|
||||
|
||||
<div *ngIf="(!tasks || tasks.length === 0) && tasks_retrieved">
|
||||
<h4 style="text-align: center; margin-top: 10px;" i18n="No tasks label">No tasks available!</h4>
|
||||
</div>
|
||||
<button style="margin-top: 10px; margin-left: 5px;" mat-stroked-button (click)="openRestoreDBBackupDialog()" i18n="Restore DB from backup button">Restore DB from backup</button>
|
||||
<button style="margin-top: 10px; margin-left: 5px;" mat-stroked-button (click)="resetTasks()" color="warn" i18n="Reset tasks button">Reset tasks</button>
|
||||
</div>
|
||||
@if ((!tasks || tasks.length === 0) && tasks_retrieved) {
|
||||
<div>
|
||||
<h4 style="text-align: center; margin-top: 10px;" i18n="No tasks label">No tasks available!</h4>
|
||||
</div>
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import { ConfirmDialogComponent } from 'app/dialogs/confirm-dialog/confirm-dialo
|
||||
import { RestoreDbDialogComponent } from 'app/dialogs/restore-db-dialog/restore-db-dialog.component';
|
||||
import { UpdateTaskScheduleDialogComponent } from 'app/dialogs/update-task-schedule-dialog/update-task-schedule-dialog.component';
|
||||
import { PostsService } from 'app/posts.services';
|
||||
import { Task } from 'api-types';
|
||||
import { Task, TaskType } from 'api-types';
|
||||
import { TaskSettingsComponent } from '../task-settings/task-settings.component';
|
||||
import { Clipboard } from '@angular/cdk/clipboard';
|
||||
|
||||
@@ -28,6 +28,15 @@ export class TasksComponent implements OnInit {
|
||||
|
||||
db_backups = [];
|
||||
|
||||
TASKS_TO_REQUIRE_DIALOG: { [key in TaskType]? : {dialogTitle: string, dialogText: string, submitText: string, warnSubmitColor: boolean}} = {
|
||||
[TaskType.REBUILD_DATABASE]: {
|
||||
dialogTitle: $localize`Rebuild database`,
|
||||
dialogText: $localize`Are you sure you want to rebuild the database? All missing users, subscriptions, and files will be reimported. Note that if missing users are detected, they will be created with the password: 'password'. A backup of your current database will be created.`,
|
||||
submitText: $localize`Rebuild database`,
|
||||
warnSubmitColor: false
|
||||
}
|
||||
}
|
||||
|
||||
@ViewChild(MatPaginator) paginator: MatPaginator;
|
||||
@ViewChild(MatSort) sort: MatSort;
|
||||
|
||||
@@ -79,7 +88,29 @@ export class TasksComponent implements OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
runTask(task_key: string): void {
|
||||
runTask(task_key: TaskType): void {
|
||||
const taskToRequireDialog = this.TASKS_TO_REQUIRE_DIALOG[task_key];
|
||||
if (taskToRequireDialog) {
|
||||
const dialogRef = this.dialog.open(ConfirmDialogComponent, {
|
||||
data: {
|
||||
dialogTitle: taskToRequireDialog['dialogTitle'],
|
||||
dialogText: taskToRequireDialog['dialogText'],
|
||||
submitText: taskToRequireDialog['submitText'],
|
||||
warnSubmitColor: taskToRequireDialog['warnSubmitColor']
|
||||
}
|
||||
});
|
||||
dialogRef.afterClosed().subscribe(confirmed => {
|
||||
if (confirmed) {
|
||||
this._runTask(task_key);
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this._runTask(task_key);
|
||||
}
|
||||
|
||||
_runTask(task_key: TaskType): void {
|
||||
this.postsService.runTask(task_key).subscribe(res => {
|
||||
this.getTasks();
|
||||
this.getDBBackups();
|
||||
@@ -91,7 +122,7 @@ export class TasksComponent implements OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
confirmTask(task_key: string): void {
|
||||
confirmTask(task_key: TaskType): void {
|
||||
this.postsService.confirmTask(task_key).subscribe(res => {
|
||||
this.getTasks();
|
||||
if (res['success']) this.postsService.openSnackBar($localize`Successfully confirmed task!`);
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
<div class="chat-container" #scrollContainer *ngIf="visible_chat">
|
||||
@if (visible_chat) {
|
||||
<div class="chat-container" #scrollContainer>
|
||||
<div style="width: 250px; text-align: center;"><strong>Twitch Chat</strong></div>
|
||||
<div #chat style="max-width: 250px" *ngFor="let chat of visible_chat; let last = last">
|
||||
@for (chat of visible_chat; track chat; let last = $last) {
|
||||
<div #chat style="max-width: 250px">
|
||||
{{chat.timestamp_str}} - <strong [style.color]="chat.user_color ? chat.user_color : null">{{chat.name}}</strong>: {{chat.message}}
|
||||
{{last ? scrollToBottom() : ''}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ng-container *ngIf="chat_response_received && !full_chat">
|
||||
<button [disabled]="downloading_chat" (click)="downloadTwitchChat()" class="download-button" mat-raised-button color="accent"><ng-container i18n="Download Twitch Chat button">Download Twitch Chat</ng-container></button>
|
||||
<mat-spinner *ngIf="downloading_chat" class="downloading-spinner" [diameter]="30"></mat-spinner>
|
||||
</ng-container>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@if (chat_response_received && !full_chat) {
|
||||
<button [disabled]="downloading_chat" (click)="downloadTwitchChat()" class="download-button" mat-raised-button color="accent"><ng-container i18n="Download Twitch Chat button">Download Twitch Chat</ng-container></button>
|
||||
@if (downloading_chat) {
|
||||
<mat-spinner class="downloading-spinner" [diameter]="30"></mat-spinner>
|
||||
}
|
||||
}
|
||||
@@ -1,73 +1,103 @@
|
||||
<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>
|
||||
|
||||
<ng-container i18n="Auto-generated label" *ngIf="file_obj.auto">Auto-generated</ng-container>
|
||||
<ng-container *ngIf="!file_obj.auto">{{file_obj.registered | date:'shortDate' : undefined : locale.ngID}}</ng-container>
|
||||
@if (!loading) {
|
||||
<div class="download-time">
|
||||
<mat-icon class="audio-video-icon">{{(file_obj.type === 'audio' || file_obj.isAudio) ? 'audiotrack' : 'movie'}}</mat-icon>
|
||||
|
||||
@if (file_obj.auto) {
|
||||
<ng-container i18n="Auto-generated label">Auto-generated</ng-container>
|
||||
}
|
||||
@else {
|
||||
{{file_obj.registered | date:'shortDate' : undefined : locale.ngID}}
|
||||
}
|
||||
</div>
|
||||
<div *ngIf="loading" class="download-time" style="width: 75%; margin-top: 5px;"><content-loader [backgroundColor]="theme.ghost_primary" [foregroundColor]="theme.ghost_secondary" viewBox="0 0 250 30"><svg:rect x="0" y="0" rx="3" ry="3" width="250" height="30" /></content-loader></div>
|
||||
<!-- The context menu trigger must be kept above the "more info" menu -->
|
||||
<div style="visibility: hidden; position: fixed"
|
||||
[style.left]="contextMenuPosition.x"
|
||||
[style.top]="contextMenuPosition.y"
|
||||
[matMenuTriggerFor]="context_menu">
|
||||
</div>
|
||||
<button *ngIf="!file_obj || !file_obj.auto" [disabled]="loading" [matMenuTriggerFor]="action_menu" class="menuButton" mat-icon-button><mat-icon>more_vert</mat-icon></button>
|
||||
<mat-menu #context_menu>
|
||||
<ng-container *ngIf="!loading">
|
||||
<button (click)="navigateToFile($event)" mat-menu-item><mat-icon>open_in_browser</mat-icon><ng-container i18n="Open file button">Open file</ng-container></button>
|
||||
<button (click)="navigateToFile({ctrlKey: true})" mat-menu-item><mat-icon>open_in_new</mat-icon><ng-container i18n="Open file in new tab">Open file in new tab</ng-container></button>
|
||||
</ng-container>
|
||||
</mat-menu>
|
||||
<mat-menu #action_menu="matMenu">
|
||||
<ng-container *ngIf="!is_playlist && !loading">
|
||||
<button (click)="emitToggleFavorite()" mat-menu-item>
|
||||
<mat-icon>{{file_obj.favorite ? 'favorite_filled' : 'favorite_outline'}}</mat-icon>
|
||||
<ng-container *ngIf="!file_obj.favorite" i18n="Favorite button">Favorite</ng-container>
|
||||
<ng-container *ngIf="file_obj.favorite" i18n="Unfavorite button">Unfavorite</ng-container>
|
||||
</button>
|
||||
<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 [disabled]="!availablePlaylists || availablePlaylists.length === 0" [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></button>
|
||||
<button *ngIf="!file_obj.sub_id" (click)="emitDeleteFile()" mat-menu-item><mat-icon>delete</mat-icon><ng-container i18n="Delete video button">Delete</ng-container></button>
|
||||
<button *ngIf="file_obj.sub_id || use_youtubedl_archive" (click)="emitDeleteFile(true)" mat-menu-item><mat-icon>delete_forever</mat-icon><ng-container i18n="Delete and don't download again">Delete and don't download again</ng-container></button>
|
||||
</ng-container>
|
||||
<ng-container *ngIf="is_playlist && !loading">
|
||||
<button (click)="emitEditPlaylist()" mat-menu-item><mat-icon>edit</mat-icon><ng-container i18n="Playlist edit button">Edit</ng-container></button>
|
||||
<mat-divider></mat-divider>
|
||||
<button (click)="emitDeleteFile()" mat-menu-item><mat-icon>delete_forever</mat-icon><ng-container i18n="Delete playlist">Delete</ng-container></button>
|
||||
</ng-container>
|
||||
<ng-container *ngIf="loading">
|
||||
<button mat-menu-item>Placeholder</button>
|
||||
</ng-container>
|
||||
</mat-menu>
|
||||
<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 [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>
|
||||
</div>
|
||||
|
||||
}
|
||||
@else {
|
||||
<div class="download-time" style="width: 75%; margin-top: 5px;"><content-loader [backgroundColor]="theme.ghost_primary" [foregroundColor]="theme.ghost_secondary" viewBox="0 0 250 30"><svg:rect x="0" y="0" rx="3" ry="3" width="250" height="30" /></content-loader></div>
|
||||
}
|
||||
<!-- The context menu trigger must be kept above the "more info" menu -->
|
||||
<div style="visibility: hidden; position: fixed"
|
||||
[style.left]="contextMenuPosition.x"
|
||||
[style.top]="contextMenuPosition.y"
|
||||
[matMenuTriggerFor]="context_menu">
|
||||
</div>
|
||||
@if (!file_obj || !file_obj.auto) {
|
||||
<button [disabled]="loading" [matMenuTriggerFor]="action_menu" class="menuButton" mat-icon-button><mat-icon>more_vert</mat-icon></button>
|
||||
}
|
||||
<mat-menu #context_menu>
|
||||
@if (!loading) {
|
||||
<button (click)="navigateToFile($event)" mat-menu-item><mat-icon>open_in_browser</mat-icon><ng-container i18n="Open file button">Open file</ng-container></button>
|
||||
<button (click)="navigateToFile({ctrlKey: true})" mat-menu-item><mat-icon>open_in_new</mat-icon><ng-container i18n="Open file in new tab">Open file in new tab</ng-container></button>
|
||||
}
|
||||
</mat-menu>
|
||||
<mat-menu #action_menu="matMenu">
|
||||
@if (!is_playlist && !loading) {
|
||||
<button (click)="emitToggleFavorite()" mat-menu-item>
|
||||
<mat-icon>{{file_obj.favorite ? 'favorite_filled' : 'favorite_outline'}}</mat-icon>
|
||||
@if (!file_obj.favorite) {
|
||||
<ng-container i18n="Favorite button">Favorite</ng-container>
|
||||
}
|
||||
@else {
|
||||
<ng-container i18n="Unfavorite button">Unfavorite</ng-container>
|
||||
}
|
||||
</button>
|
||||
<button (click)="openFileInfoDialog()" mat-menu-item><mat-icon>info</mat-icon><ng-container i18n="Video info button">Info</ng-container></button>
|
||||
@if (file_obj.sub_id) {
|
||||
<button (click)="navigateToSubscription()" mat-menu-item><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 [disabled]="!availablePlaylists || availablePlaylists.length === 0" [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">
|
||||
@for (playlist of availablePlaylists; track playlist) {
|
||||
@if ((playlist.type === 'audio') === file_obj.isAudio) {
|
||||
<button [disabled]="playlist.uids?.includes(file_obj.uid)" (click)="emitAddFileToPlaylist(playlist.id)" mat-menu-item>{{playlist.name}}</button>
|
||||
}
|
||||
}
|
||||
</mat-menu>
|
||||
<mat-divider></mat-divider>
|
||||
@if (file_obj.sub_id) {
|
||||
<button (click)="emitDeleteFile()" mat-menu-item><mat-icon>restore</mat-icon><ng-container i18n="Delete and redownload subscription video button">Delete and redownload</ng-container></button>
|
||||
} @else {
|
||||
<button (click)="emitDeleteFile()" mat-menu-item><mat-icon>delete</mat-icon><ng-container i18n="Delete video button">Delete</ng-container></button>
|
||||
}
|
||||
@if (file_obj.sub_id || use_youtubedl_archive) {
|
||||
<button (click)="emitDeleteFile(true)" mat-menu-item><mat-icon>delete_forever</mat-icon><ng-container i18n="Delete and don't download again">Delete and don't download again</ng-container></button>
|
||||
}
|
||||
}
|
||||
@if (is_playlist && !loading) {
|
||||
<button (click)="emitEditPlaylist()" mat-menu-item><mat-icon>edit</mat-icon><ng-container i18n="Playlist edit button">Edit</ng-container></button>
|
||||
<mat-divider></mat-divider>
|
||||
<button (click)="emitDeleteFile()" mat-menu-item><mat-icon>delete_forever</mat-icon><ng-container i18n="Delete playlist">Delete</ng-container></button>
|
||||
} @else if (loading) {
|
||||
<button mat-menu-item>Placeholder</button>
|
||||
}
|
||||
</mat-menu>
|
||||
<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">
|
||||
@if (!loading && file_obj.thumbnailURL) {
|
||||
<div class="img-div">
|
||||
<div [ngClass]="{'image-small': card_size === 'small', 'image': card_size === 'medium', 'image-large': card_size === 'large'}" style="position: relative">
|
||||
@if (!hide_image || is_playlist || (file_obj.type === 'audio' || file_obj.isAudio)) {
|
||||
<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">
|
||||
}
|
||||
@if (elevated && !is_playlist && !(file_obj.type === 'audio' || file_obj.isAudio)) {
|
||||
<video 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>
|
||||
|
||||
<div *ngIf="loading" class="img-div">
|
||||
<content-loader [backgroundColor]="theme.ghost_primary" [foregroundColor]="theme.ghost_secondary" viewBox="0 0 100 55"><svg:rect x="0" y="0" rx="0" ry="0" width="100" height="55" /></content-loader>
|
||||
</div>
|
||||
|
||||
<span *ngIf="!loading" [ngClass]="{'max-two-lines': card_size !== 'small', 'max-one-line': card_size === 'small' }">{{card_size === 'large' && file_obj.uploader ? file_obj.uploader + ' - ' : ''}}<strong>{{!is_playlist ? file_obj.title : file_obj.name}}</strong></span>
|
||||
<span *ngIf="loading" class="title-loading"><content-loader [backgroundColor]="theme.ghost_primary" [foregroundColor]="theme.ghost_secondary" viewBox="0 0 250 30"><svg:rect x="0" y="0" rx="3" ry="3" width="250" height="30" /></content-loader></span>
|
||||
</div>
|
||||
</div>
|
||||
</mat-card>
|
||||
}
|
||||
@if (loading) {
|
||||
<div class="img-div">
|
||||
<content-loader [backgroundColor]="theme.ghost_primary" [foregroundColor]="theme.ghost_secondary" viewBox="0 0 100 55"><svg:rect x="0" y="0" rx="0" ry="0" width="100" height="55" /></content-loader>
|
||||
</div>
|
||||
} @else {
|
||||
<span [ngClass]="{'max-two-lines': card_size !== 'small', 'max-one-line': card_size === 'small' }">{{card_size === 'large' && file_obj.uploader ? file_obj.uploader + ' - ' : ''}}<strong>{{!is_playlist ? file_obj.title : file_obj.name}}</strong></span>
|
||||
}
|
||||
@if (loading) {
|
||||
<span class="title-loading"><content-loader [backgroundColor]="theme.ghost_primary" [foregroundColor]="theme.ghost_secondary" viewBox="0 0 250 30"><svg:rect x="0" y="0" rx="3" ry="3" width="250" height="30" /></content-loader></span>
|
||||
}
|
||||
</div>
|
||||
</mat-card>
|
||||
</div>
|
||||
|
||||
@@ -1 +1 @@
|
||||
export const CURRENT_VERSION = 'v4.3.1';
|
||||
export const CURRENT_VERSION = 'v4.3.2';
|
||||
|
||||
@@ -1,28 +1,35 @@
|
||||
<h4 mat-dialog-title *ngIf="create_mode" ><ng-container i18n="Create a playlist dialog title">Create a playlist</ng-container></h4>
|
||||
<h4 mat-dialog-title *ngIf="!create_mode"><ng-container i18n="Modify playlist dialog title">Modify playlist</ng-container></h4>
|
||||
|
||||
@if (create_mode) {
|
||||
<h4 mat-dialog-title ><ng-container i18n="Create a playlist dialog title">Create a playlist</ng-container></h4>
|
||||
} @else {
|
||||
<h4 mat-dialog-title><ng-container i18n="Modify playlist dialog title">Modify playlist</ng-container></h4>
|
||||
}
|
||||
<mat-dialog-content style="max-height: 85vh;">
|
||||
<form>
|
||||
<div *ngIf="create_mode || playlist">
|
||||
<div>
|
||||
<mat-form-field color="accent">
|
||||
<mat-label i18n="Playlist name">Name</mat-label>
|
||||
<input [(ngModel)]="name" matInput type="text" required aria-required [ngModelOptions]="{standalone: true}">
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<app-recent-videos [selectMode]="true" [defaultSelected]="preselected_files" [customHeader]="'Select files'" (fileSelectionEmitter)="fileSelectionChanged($event)" [selectedIndex]="create_mode ? 1 : 0"></app-recent-videos>
|
||||
<form>
|
||||
@if (create_mode || playlist) {
|
||||
<div>
|
||||
<div>
|
||||
<mat-form-field color="accent">
|
||||
<mat-label i18n="Playlist name">Name</mat-label>
|
||||
<input [(ngModel)]="name" matInput type="text" required aria-required [ngModelOptions]="{standalone: true}">
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</form>
|
||||
<app-recent-videos [selectMode]="true" [defaultSelected]="preselected_files" [customHeader]="'Select files'" (fileSelectionEmitter)="fileSelectionChanged($event)" [selectedIndex]="create_mode ? 1 : 0"></app-recent-videos>
|
||||
</div>
|
||||
}
|
||||
</form>
|
||||
</mat-dialog-content>
|
||||
|
||||
<div class="spacer"></div>
|
||||
|
||||
<mat-dialog-actions>
|
||||
<button *ngIf="create_mode" (click)="createPlaylist()" [disabled]="!name || !filesSelect.value || filesSelect.value.length === 0" color="primary" style="float: right" mat-button>
|
||||
<ng-container i18n="Create button">Create</ng-container>
|
||||
@if (create_mode) {
|
||||
<button (click)="createPlaylist()" [disabled]="!name || !filesSelect.value || filesSelect.value.length === 0" color="primary" style="float: right" mat-button>
|
||||
<ng-container i18n="Create button">Create</ng-container>
|
||||
</button>
|
||||
<button *ngIf="!create_mode" (click)="updatePlaylist()" [disabled]="!name || !playlistChanged()" color="primary" style="float: right" mat-button>
|
||||
<ng-container i18n="Save button">Save</ng-container>
|
||||
} @else {
|
||||
<button (click)="updatePlaylist()" [disabled]="!name || !playlistChanged()" color="primary" style="float: right" mat-button>
|
||||
<ng-container i18n="Save button">Save</ng-container>
|
||||
</button>
|
||||
<div *ngIf="create_in_progress" style="margin-left: 10px"><mat-spinner [diameter]="25"></mat-spinner></div>
|
||||
}
|
||||
@if (create_in_progress) {
|
||||
<div style="margin-left: 10px"><mat-spinner [diameter]="25"></mat-spinner></div>
|
||||
}
|
||||
</mat-dialog-actions>
|
||||
|
||||
@@ -1,43 +1,51 @@
|
||||
<h4 style="position: relative" mat-dialog-title><ng-container i18n="About dialog title">About YoutubeDL-Material</ng-container>
|
||||
<span class="logo-image">
|
||||
<a [href]="projectLink" target="_blank">
|
||||
<img style="width: 32px;" src="assets/images/GitHub-64px.png">
|
||||
</a>
|
||||
<img style="width: 32px; margin-left: 15px;" src="assets/images/logo_128px.png">
|
||||
</span>
|
||||
<span class="logo-image">
|
||||
<a [href]="projectLink" target="_blank">
|
||||
<img style="width: 32px;" src="assets/images/GitHub-64px.png">
|
||||
</a>
|
||||
<img style="width: 32px; margin-left: 15px;" src="assets/images/logo_128px.png">
|
||||
</span>
|
||||
</h4>
|
||||
<mat-dialog-content>
|
||||
<div style="margin-bottom: 5px;">
|
||||
<p>
|
||||
<i>YoutubeDL-Material</i> <ng-container i18n="About first paragraph">is an open-source YouTube downloader built under Google's Material Design specifications. You can seamlessly download your favorite videos as video or audio files, and even subscribe to your favorite channels and playlists to keep updated with their new videos.</ng-container>
|
||||
<div style="margin-bottom: 5px;">
|
||||
<p>
|
||||
<i>YoutubeDL-Material</i> <ng-container i18n="About first paragraph">is an open-source YouTube downloader built under Google's Material Design specifications. You can seamlessly download your favorite videos as video or audio files, and even subscribe to your favorite channels and playlists to keep updated with their new videos.</ng-container>
|
||||
</p>
|
||||
<p>
|
||||
<i>YoutubeDL-Material</i> <ng-container i18n="About second paragraph">has some awesome features included! An extensive API, Docker support, and localization (translation) support. Read up on all the supported features by clicking on the GitHub icon above.</ng-container>
|
||||
</p>
|
||||
<mat-divider></mat-divider>
|
||||
<h5 style="margin-top: 10px;">Installation details:</h5>
|
||||
<p>
|
||||
<ng-container i18n="Version label">Installed version:</ng-container> {{current_version_tag}} -
|
||||
@if (checking_for_updates) {
|
||||
<span style="display: inline-block"><mat-spinner class="version-spinner" [diameter]="22"></mat-spinner> <ng-container i18n="Checking for updates text">Checking for updates...</ng-container></span>
|
||||
} @else {
|
||||
<mat-icon class="version-checked-icon">done</mat-icon>
|
||||
@if (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>
|
||||
} @else {
|
||||
<span>You are up to date.</span>
|
||||
}
|
||||
}
|
||||
</p>
|
||||
<p>
|
||||
<ng-container i18n="Installation type">Installation type:</ng-container> {{postsService.version_info.type}}
|
||||
<br>
|
||||
@if (postsService.version_info.type === 'docker') {
|
||||
<ng-container i18n="Docker tag">Docker tag:</ng-container> {{postsService.version_info.tag}}
|
||||
<br>
|
||||
}
|
||||
<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>
|
||||
<i>YoutubeDL-Material</i> <ng-container i18n="About second paragraph">has some awesome features included! An extensive API, Docker support, and localization (translation) support. Read up on all the supported features by clicking on the GitHub icon above.</ng-container>
|
||||
<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>
|
||||
<mat-divider></mat-divider>
|
||||
<h5 style="margin-top: 10px;">Installation details:</h5>
|
||||
<p>
|
||||
<ng-container i18n="Version label">Installed version:</ng-container> {{current_version_tag}} - <span style="display: inline-block" *ngIf="checking_for_updates"><mat-spinner class="version-spinner" [diameter]="22"></mat-spinner> <ng-container i18n="Checking for updates text">Checking for updates...</ng-container></span>
|
||||
<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>
|
||||
</div>
|
||||
</mat-dialog-content>
|
||||
|
||||
<mat-dialog-actions>
|
||||
<button style="margin-bottom: 5px;" mat-stroked-button mat-dialog-close><ng-container i18n="Close">Close</ng-container></button>
|
||||
</mat-dialog-actions>
|
||||
</div>
|
||||
</mat-dialog-content>
|
||||
<mat-dialog-actions>
|
||||
<button style="margin-bottom: 5px;" mat-stroked-button mat-dialog-close><ng-container i18n="Close">Close</ng-container></button>
|
||||
</mat-dialog-actions>
|
||||
|
||||
@@ -1,92 +1,94 @@
|
||||
<h4 i18n="Modify args title" mat-dialog-title>Modify youtube-dl args</h4>
|
||||
|
||||
<mat-dialog-content>
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<mat-card class="mat-elevation-z6">
|
||||
<mat-card-content>
|
||||
<h6 i18n="Simulated args title">Simulated new args</h6>
|
||||
<mat-chip-grid class="example-chip" #chipList aria-label="Args array" cdkDropList cdkDropListDisabled
|
||||
cdkDropListOrientation="horizontal"
|
||||
(cdkDropListDropped)="drop($event)">
|
||||
<mat-chip-row [matTooltip]="argsByKey[arg] ? argsByKey[arg]['description'] : null" *ngFor="let arg of args_array; let i = index;" [removable]="removable" (removed)="remove(i)" cdkDrag>
|
||||
{{arg}}
|
||||
<mat-icon matChipRemove *ngIf="removable">cancel</mat-icon>
|
||||
</mat-chip-row>
|
||||
</mat-chip-grid>
|
||||
<mat-form-field style="width: 100%" color="accent">
|
||||
|
||||
<input #chipper style="width: 100%;" [formControl]="chipCtrl" matInput [matAutocomplete]="autochip" [matChipInputFor]="chipList"
|
||||
[matChipInputSeparatorKeyCodes]="separatorKeysCodes"
|
||||
[matChipInputAddOnBlur]="addOnBlur"
|
||||
(matChipInputTokenEnd)="add($event)">
|
||||
</mat-form-field>
|
||||
<mat-autocomplete #autochip="matAutocomplete">
|
||||
<mat-option *ngFor="let arg of filteredChipOptions | async" [value]="arg.key">
|
||||
<span [innerHTML]="arg.key | highlight : chipCtrl.value"></span>
|
||||
<button class="info-autocomplete-icon" [matTooltip]="arg.description" mat-icon-button><mat-icon>info</mat-icon></button>
|
||||
</mat-option>
|
||||
</mat-autocomplete>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<mat-card class="mat-elevation-z6 my-2">
|
||||
<mat-card-content>
|
||||
<h6 i18n="Add arg card title">Add an arg</h6>
|
||||
<form >
|
||||
<div>
|
||||
<mat-form-field style="width: 75%" color="accent">
|
||||
<mat-label i18n="Arg">Arg</mat-label>
|
||||
<input matInput [matAutocomplete]="auto" [formControl]="stateCtrl">
|
||||
</mat-form-field>
|
||||
<mat-autocomplete #auto="matAutocomplete">
|
||||
<mat-option *ngFor="let arg of filteredOptions | async" [value]="arg.key">
|
||||
<span [innerHTML]="arg.key | highlight : stateCtrl.value"></span>
|
||||
<button class="info-autocomplete-icon" [matTooltip]="arg.description" mat-icon-button><mat-icon>info</mat-icon></button>
|
||||
</mat-option>
|
||||
</mat-autocomplete>
|
||||
|
||||
<div>
|
||||
<mat-menu #argsByCategoryMenu="matMenu">
|
||||
<ng-container *ngFor="let argsInCategory of argsByCategory | keyvalue">
|
||||
<button mat-menu-item [matMenuTriggerFor]="subMenu">{{argsInfo[argsInCategory.key].label}}</button>
|
||||
<mat-menu #subMenu="matMenu">
|
||||
<button mat-menu-item *ngFor="let arg of argsInCategory.value" (click)="setFirstArg(arg.key)"><div style="display: inline-block;">{{arg.key}}</div> <div class="info-menu-icon"><mat-icon [matTooltip]="arg.description">info</mat-icon></div></button>
|
||||
</mat-menu>
|
||||
</ng-container>
|
||||
|
||||
</mat-menu>
|
||||
|
||||
|
||||
|
||||
<button style="margin-bottom: 15px" mat-stroked-button [matMenuTriggerFor]="argsByCategoryMenu"><ng-container i18n="Search args by category button">Search by category</ng-container></button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<mat-checkbox color="accent" [ngModelOptions]="{standalone: true}" [(ngModel)]="secondArgEnabled"><ng-container i18n="Use arg value checkbox">Use arg value</ng-container></mat-checkbox>
|
||||
</div>
|
||||
<div *ngIf="secondArgEnabled">
|
||||
<mat-form-field style="width: 75%" color="accent">
|
||||
<mat-label i18n="Arg value">Arg value</mat-label>
|
||||
<input [ngModelOptions]="{standalone: true}" matInput [disabled]="!secondArgEnabled" [(ngModel)]="secondArg">
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</form>
|
||||
<div>
|
||||
<button (click)="addArg()" [disabled]="!canAddArg()" mat-stroked-button color="accent"><ng-container i18n="Search args by category button">Add arg</ng-container></button>
|
||||
</div>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
</div>
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<mat-card class="mat-elevation-z6">
|
||||
<mat-card-content>
|
||||
<h6 i18n="Simulated args title">Simulated new args</h6>
|
||||
<mat-chip-grid class="example-chip" #chipList aria-label="Args array" cdkDropList cdkDropListDisabled
|
||||
cdkDropListOrientation="horizontal"
|
||||
(cdkDropListDropped)="drop($event)">
|
||||
@for (arg of args_array; track arg; let i = $index) {
|
||||
<mat-chip-row [matTooltip]="argsByKey[arg] ? argsByKey[arg]['description'] : null" [removable]="removable" (removed)="remove(i)" cdkDrag>
|
||||
{{arg}}
|
||||
@if (removable) {
|
||||
<mat-icon matChipRemove>cancel</mat-icon>
|
||||
}
|
||||
</mat-chip-row>
|
||||
}
|
||||
</mat-chip-grid>
|
||||
<mat-form-field style="width: 100%" color="accent">
|
||||
<input #chipper style="width: 100%;" [formControl]="chipCtrl" matInput [matAutocomplete]="autochip" [matChipInputFor]="chipList"
|
||||
[matChipInputSeparatorKeyCodes]="separatorKeysCodes"
|
||||
[matChipInputAddOnBlur]="addOnBlur"
|
||||
(matChipInputTokenEnd)="add($event)">
|
||||
</mat-form-field>
|
||||
<mat-autocomplete #autochip="matAutocomplete">
|
||||
@for (arg of filteredChipOptions | async; track arg) {
|
||||
<mat-option [value]="arg.key">
|
||||
<span [innerHTML]="arg.key | highlight : chipCtrl.value"></span>
|
||||
<button class="info-autocomplete-icon" [matTooltip]="arg.description" mat-icon-button><mat-icon>info</mat-icon></button>
|
||||
</mat-option>
|
||||
}
|
||||
</mat-autocomplete>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<mat-card class="mat-elevation-z6 my-2">
|
||||
<mat-card-content>
|
||||
<h6 i18n="Add arg card title">Add an arg</h6>
|
||||
<form >
|
||||
<div>
|
||||
<mat-form-field style="width: 75%" color="accent">
|
||||
<mat-label i18n="Arg">Arg</mat-label>
|
||||
<input matInput [matAutocomplete]="auto" [formControl]="stateCtrl">
|
||||
</mat-form-field>
|
||||
<mat-autocomplete #auto="matAutocomplete">
|
||||
@for (arg of filteredOptions | async; track arg) {
|
||||
<mat-option [value]="arg.key">
|
||||
<span [innerHTML]="arg.key | highlight : stateCtrl.value"></span>
|
||||
<button class="info-autocomplete-icon" [matTooltip]="arg.description" mat-icon-button><mat-icon>info</mat-icon></button>
|
||||
</mat-option>
|
||||
}
|
||||
</mat-autocomplete>
|
||||
<div>
|
||||
<mat-menu #argsByCategoryMenu="matMenu">
|
||||
@for (argsInCategory of argsByCategory | keyvalue; track argsInCategory) {
|
||||
<button mat-menu-item [matMenuTriggerFor]="subMenu">{{argsInfo[argsInCategory.key].label}}</button>
|
||||
<mat-menu #subMenu="matMenu">
|
||||
@for (arg of argsInCategory.value; track arg) {
|
||||
<button mat-menu-item (click)="setFirstArg(arg.key)"><div style="display: inline-block;">{{arg.key}}</div> <div class="info-menu-icon"><mat-icon [matTooltip]="arg.description">info</mat-icon></div></button>
|
||||
}
|
||||
</mat-menu>
|
||||
}
|
||||
</mat-menu>
|
||||
<button style="margin-bottom: 15px" mat-stroked-button [matMenuTriggerFor]="argsByCategoryMenu"><ng-container i18n="Search args by category button">Search by category</ng-container></button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<mat-checkbox color="accent" [ngModelOptions]="{standalone: true}" [(ngModel)]="secondArgEnabled"><ng-container i18n="Use arg value checkbox">Use arg value</ng-container></mat-checkbox>
|
||||
</div>
|
||||
@if (secondArgEnabled) {
|
||||
<div>
|
||||
<mat-form-field style="width: 75%" color="accent">
|
||||
<mat-label i18n="Arg value">Arg value</mat-label>
|
||||
<input [ngModelOptions]="{standalone: true}" matInput [disabled]="!secondArgEnabled" [(ngModel)]="secondArg">
|
||||
</mat-form-field>
|
||||
</div>
|
||||
}
|
||||
</form>
|
||||
<div>
|
||||
<button (click)="addArg()" [disabled]="!canAddArg()" mat-stroked-button color="accent"><ng-container i18n="Search args by category button">Add arg</ng-container></button>
|
||||
</div>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</mat-dialog-content>
|
||||
|
||||
<mat-dialog-actions>
|
||||
</mat-dialog-content>
|
||||
<mat-dialog-actions>
|
||||
<button mat-button [mat-dialog-close]="null"><ng-container i18n="Arg modifier cancel button">Cancel</ng-container></button>
|
||||
<button mat-button color="accent" [mat-dialog-close]="modified_args"><ng-container i18n="Arg modifier modify button">Modify</ng-container></button>
|
||||
</mat-dialog-actions>
|
||||
</mat-dialog-actions>
|
||||
@@ -2,24 +2,27 @@
|
||||
<mat-dialog-content>
|
||||
<div style="margin-bottom: 10px;">
|
||||
<!-- We can support text dialogs or dialogs where users must select items from a list -->
|
||||
<ng-container *ngIf="dialogType === 'text'">
|
||||
@if (dialogType === 'text') {
|
||||
{{dialogText}}
|
||||
</ng-container>
|
||||
<ng-container *ngIf="dialogType === 'selection_list'">
|
||||
} @else if (dialogType === 'selection_list') {
|
||||
<mat-selection-list [(ngModel)]="selected_items">
|
||||
<mat-list-option *ngFor="let item of list" [value]="item.key">
|
||||
{{item.title}}
|
||||
</mat-list-option>
|
||||
@for (item of list; track item) {
|
||||
<mat-list-option [value]="item.key">
|
||||
{{item.title}}
|
||||
</mat-list-option>
|
||||
}
|
||||
</mat-selection-list>
|
||||
</ng-container>
|
||||
}
|
||||
</div>
|
||||
</mat-dialog-content>
|
||||
<mat-dialog-actions>
|
||||
<!-- The mat-dialog-close directive optionally accepts a value as a result for the dialog. -->
|
||||
<button [disabled]="dialogType === 'selection_list' && selected_items.length === 0" [color]="warnSubmitColor ? 'warn' : 'primary'" mat-flat-button type="submit" (click)="confirmClicked()">{{submitText}}</button>
|
||||
<div class="mat-spinner" *ngIf="submitClicked">
|
||||
<mat-spinner [diameter]="25"></mat-spinner>
|
||||
</div>
|
||||
@if (submitClicked) {
|
||||
<div class="mat-spinner">
|
||||
<mat-spinner [diameter]="25"></mat-spinner>
|
||||
</div>
|
||||
}
|
||||
<span class="spacer"></span>
|
||||
<button style="float: right;" mat-stroked-button mat-dialog-close>
|
||||
{{cancelText}}
|
||||
|
||||
@@ -1,40 +1,44 @@
|
||||
<h4 mat-dialog-title i18n="Cookies uploader dialog title">Upload new cookies</h4>
|
||||
|
||||
<mat-dialog-content>
|
||||
<div>
|
||||
<div class="center">
|
||||
<ngx-file-drop [multiple]="false" accept=".txt" dropZoneLabel="Drop files here" (onFileDrop)="dropped($event)"
|
||||
(onFileOver)="fileOver($event)" (onFileLeave)="fileLeave($event)">
|
||||
<ng-template ngx-file-drop-content-tmp let-openFileSelector="openFileSelector">
|
||||
<div>
|
||||
<div>
|
||||
<ng-container i18n="Drag and Drop">Drag and Drop</ng-container>
|
||||
</div>
|
||||
<div style="margin-top: 6px;">
|
||||
<button mat-stroked-button (click)="openFileSelector()">Browse Files</button>
|
||||
</div>
|
||||
</div>
|
||||
</ng-template>
|
||||
</ngx-file-drop>
|
||||
<div style="margin-top: 15px;">
|
||||
<p style="font-size: 14px;" i18n="Cookies upload warning">NOTE: Uploading new cookies will override your previous cookies. Also note that cookies are instance-wide, not per-user.</p>
|
||||
<div>
|
||||
<div class="center">
|
||||
<ngx-file-drop [multiple]="false" accept=".txt" dropZoneLabel="Drop files here" (onFileDrop)="dropped($event)"
|
||||
(onFileOver)="fileOver($event)" (onFileLeave)="fileLeave($event)">
|
||||
<ng-template ngx-file-drop-content-tmp let-openFileSelector="openFileSelector">
|
||||
<div>
|
||||
<div>
|
||||
<ng-container i18n="Drag and Drop">Drag and Drop</ng-container>
|
||||
</div>
|
||||
<div style="margin-top: 10px;">
|
||||
<table class="table">
|
||||
<tbody class="upload-name-style">
|
||||
<tr *ngFor="let item of files; let i=index">
|
||||
<td style="vertical-align: middle;">
|
||||
<strong>{{ item.relativePath }}</strong>
|
||||
</td>
|
||||
<td>
|
||||
<button [disabled]="uploading || uploaded" (click)="uploadFile()" style="float: right" matTooltip="Upload" i18n-matTooltip="Upload" mat-mini-fab><mat-icon>publish</mat-icon><mat-spinner *ngIf="uploading" class="spinner" [diameter]="38"></mat-spinner></button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div style="margin-top: 6px;">
|
||||
<button mat-stroked-button (click)="openFileSelector()">Browse Files</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ng-template>
|
||||
</ngx-file-drop>
|
||||
<div style="margin-top: 15px;">
|
||||
<p style="font-size: 14px;" i18n="Cookies upload warning">NOTE: Uploading new cookies will override your previous cookies. Also note that cookies are instance-wide, not per-user.</p>
|
||||
</div>
|
||||
<div style="margin-top: 10px;">
|
||||
<table class="table">
|
||||
<tbody class="upload-name-style">
|
||||
@for (item of files; track item; let i = $index) {
|
||||
<tr>
|
||||
<td style="vertical-align: middle;">
|
||||
<strong>{{ item.relativePath }}</strong>
|
||||
</td>
|
||||
<td>
|
||||
<button [disabled]="uploading || uploaded" (click)="uploadFile()" style="float: right" matTooltip="Upload" i18n-matTooltip="Upload" mat-mini-fab><mat-icon>publish</mat-icon>
|
||||
@if (uploading) {
|
||||
<mat-spinner class="spinner" [diameter]="38"></mat-spinner>
|
||||
}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</mat-dialog-content>
|
||||
|
||||
<mat-dialog-actions><button style="margin-bottom: 5px;" mat-dialog-close mat-stroked-button><ng-container i18n="Close">Close</ng-container></button></mat-dialog-actions>
|
||||
@@ -1,64 +1,63 @@
|
||||
<h4 mat-dialog-title><ng-container i18n="Editing category dialog title">Editing category</ng-container> {{category['name']}}</h4>
|
||||
|
||||
<mat-dialog-content style="max-height: 50vh">
|
||||
<mat-form-field style="width: 250px; margin-bottom: 5px;">
|
||||
<mat-label i18n="Category name">Name</mat-label>
|
||||
<input matInput [(ngModel)]="category['name']" required>
|
||||
<mat-form-field style="width: 250px; margin-bottom: 5px;">
|
||||
<mat-label i18n="Category name">Name</mat-label>
|
||||
<input matInput [(ngModel)]="category['name']" required>
|
||||
</mat-form-field>
|
||||
<mat-divider></mat-divider>
|
||||
<h6 style="margin-top: 20px;" i18n="Rules">Rules</h6>
|
||||
<mat-list>
|
||||
@for (rule of category['rules']; track rule) {
|
||||
<mat-list-item>
|
||||
<mat-form-field [style.visibility]="$index === 0 ? 'hidden' : null" class="operator-select">
|
||||
<mat-select [disabled]="$index === 0" [(ngModel)]="rule['preceding_operator']">
|
||||
<mat-option value="or">OR</mat-option>
|
||||
<mat-option value="and">AND</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<mat-form-field class="property-select">
|
||||
<mat-select [(ngModel)]="rule['property']">
|
||||
@for (propertyOption of propertyOptions; track propertyOption) {
|
||||
<mat-option [value]="propertyOption.value">{{propertyOption.label}}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<mat-form-field class="comparator-select">
|
||||
<mat-select [(ngModel)]="rule['comparator']">
|
||||
@for (comparatorOption of comparatorOptions; track comparatorOption) {
|
||||
<mat-option [value]="comparatorOption.value">{{comparatorOption.label}}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<mat-form-field class="value-input">
|
||||
<input matInput [(ngModel)]="rule['value']">
|
||||
</mat-form-field>
|
||||
<span class="rule-buttons">
|
||||
<button [disabled]="$index === category['rules'].length-1" (click)="swapRules($index, $index+1)" mat-icon-button><mat-icon>arrow_downward</mat-icon></button>
|
||||
<button [disabled]="$index === 0" (click)="swapRules($index, $index-1)" mat-icon-button><mat-icon>arrow_upward</mat-icon></button>
|
||||
<button (click)="removeRule($index)" mat-icon-button><mat-icon>cancel</mat-icon></button>
|
||||
</span>
|
||||
</mat-list-item>
|
||||
}
|
||||
</mat-list>
|
||||
<button style="margin-bottom: 8px;" mat-icon-button (click)="addNewRule()" matTooltip="Add new rule" i18n-matTooltip="Add new rule tooltip"><mat-icon>add</mat-icon></button>
|
||||
<mat-divider></mat-divider>
|
||||
<mat-form-field style="width: 250px; margin-top: 10px;">
|
||||
<mat-label i18n="Custom file output">Custom file output</mat-label>
|
||||
<input matInput [(ngModel)]="category['custom_output']">
|
||||
<mat-hint>
|
||||
<a target="_blank" href="https://github.com/ytdl-org/youtube-dl/blob/master/README.md#output-template">
|
||||
<ng-container i18n="Custom 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>
|
||||
|
||||
<mat-divider></mat-divider>
|
||||
|
||||
<h6 style="margin-top: 20px;" i18n="Rules">Rules</h6>
|
||||
|
||||
<mat-list>
|
||||
<mat-list-item *ngFor="let rule of category['rules']; let i = index">
|
||||
<mat-form-field [style.visibility]="i === 0 ? 'hidden' : null" class="operator-select">
|
||||
<mat-select [disabled]="i === 0" [(ngModel)]="rule['preceding_operator']">
|
||||
<mat-option value="or">OR</mat-option>
|
||||
<mat-option value="and">AND</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<mat-form-field class="property-select">
|
||||
<mat-select [(ngModel)]="rule['property']">
|
||||
<mat-option *ngFor="let propertyOption of propertyOptions" [value]="propertyOption.value">{{propertyOption.label}}</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<mat-form-field class="comparator-select">
|
||||
<mat-select [(ngModel)]="rule['comparator']">
|
||||
<mat-option *ngFor="let comparatorOption of comparatorOptions" [value]="comparatorOption.value">{{comparatorOption.label}}</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<mat-form-field class="value-input">
|
||||
<input matInput [(ngModel)]="rule['value']">
|
||||
</mat-form-field>
|
||||
<span class="rule-buttons">
|
||||
<button [disabled]="i === category['rules'].length-1" (click)="swapRules(i, i+1)" mat-icon-button><mat-icon>arrow_downward</mat-icon></button>
|
||||
<button [disabled]="i === 0" (click)="swapRules(i, i-1)" mat-icon-button><mat-icon>arrow_upward</mat-icon></button>
|
||||
<button (click)="removeRule(i)" mat-icon-button><mat-icon>cancel</mat-icon></button>
|
||||
</span>
|
||||
</mat-list-item>
|
||||
</mat-list>
|
||||
|
||||
<button style="margin-bottom: 8px;" mat-icon-button (click)="addNewRule()" matTooltip="Add new rule" i18n-matTooltip="Add new rule tooltip"><mat-icon>add</mat-icon></button>
|
||||
|
||||
<mat-divider></mat-divider>
|
||||
|
||||
<mat-form-field style="width: 250px; margin-top: 10px;">
|
||||
<mat-label i18n="Custom file output">Custom file output</mat-label>
|
||||
<input matInput [(ngModel)]="category['custom_output']">
|
||||
<mat-hint>
|
||||
<a target="_blank" href="https://github.com/ytdl-org/youtube-dl/blob/master/README.md#output-template">
|
||||
<ng-container i18n="Custom 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>
|
||||
</mat-dialog-content>
|
||||
|
||||
<mat-dialog-actions>
|
||||
</mat-dialog-content>
|
||||
<mat-dialog-actions>
|
||||
<button mat-button mat-dialog-close><ng-container i18n="Cancel">Cancel</ng-container></button>
|
||||
|
||||
<button mat-button [disabled]="categoryChanged()" type="submit" (click)="saveClicked()"><ng-container i18n="Save button">Save</ng-container></button>
|
||||
<div class="mat-spinner" *ngIf="updating">
|
||||
<mat-spinner [diameter]="25"></mat-spinner>
|
||||
</div>
|
||||
</mat-dialog-actions>
|
||||
@if (updating) {
|
||||
<div class="mat-spinner">
|
||||
<mat-spinner [diameter]="25"></mat-spinner>
|
||||
</div>
|
||||
}
|
||||
</mat-dialog-actions>
|
||||
@@ -1,70 +1,80 @@
|
||||
<h4 mat-dialog-title><ng-container i18n="Edit subscription dialog title prefix">Editing</ng-container> {{sub.name}} <ng-container *ngIf="sub.paused" i18n="Paused suffix">(Paused)</ng-container></h4>
|
||||
|
||||
<h4 mat-dialog-title><ng-container i18n="Edit subscription dialog title prefix">Editing</ng-container> {{sub.name}}
|
||||
@if (sub.paused) {
|
||||
<ng-container i18n="Paused suffix">(Paused)</ng-container>
|
||||
}
|
||||
</h4>
|
||||
<mat-dialog-content>
|
||||
<div class="container-fluid">
|
||||
<div class="row">
|
||||
<div class="col-12 mt-3">
|
||||
<mat-checkbox [(ngModel)]="new_sub.paused"><ng-container i18n="Paused subscription setting">Paused</ng-container></mat-checkbox>
|
||||
</div>
|
||||
<div class="col-12 mt-3">
|
||||
<mat-checkbox (change)="downloadAllToggled()" [(ngModel)]="download_all"><ng-container i18n="Download all uploads subscription setting">Download all uploads</ng-container></mat-checkbox>
|
||||
</div>
|
||||
<div class="col-12" *ngIf="editor_initialized">
|
||||
<ng-container i18n="Download time range prefix">Download videos uploaded in the last</ng-container>
|
||||
<mat-form-field color="accent" class="amount-select">
|
||||
<input type="number" matInput [(ngModel)]="timerange_amount" (ngModelChange)="timerangeChanged($event, false)" [disabled]="download_all">
|
||||
</mat-form-field>
|
||||
<mat-form-field class="unit-select">
|
||||
<mat-select color="accent" [(ngModel)]="timerange_unit" (ngModelChange)="timerangeChanged($event, true)" [disabled]="download_all">
|
||||
<mat-option *ngFor="let time_unit of time_units" [value]="time_unit + (timerange_amount === 1 ? '' : 's')">
|
||||
{{time_unit + (timerange_amount === 1 ? '' : 's')}}
|
||||
</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<div>
|
||||
<mat-checkbox [disabled]="true" [(ngModel)]="audioOnlyMode"><ng-container i18n="Streaming-only mode">Audio-only mode</ng-container></mat-checkbox>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 mt-2">
|
||||
<mat-form-field>
|
||||
<mat-label i18n="Max quality">Max quality</mat-label>
|
||||
<mat-select [disabled]="new_sub.type === 'audio'" [(ngModel)]="new_sub.maxQuality">
|
||||
<mat-option *ngFor="let available_quality of available_qualities" [value]="available_quality['value']">{{available_quality['label']}}</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="col-12 mb-3">
|
||||
<mat-form-field color="accent">
|
||||
<mat-label i18n="Custom args">Custom args</mat-label>
|
||||
<input [(ngModel)]="new_sub.custom_args" matInput>
|
||||
<button class="args-edit-button" (click)="openArgsModifierDialog()" mat-icon-button><mat-icon>edit</mat-icon></button>
|
||||
<mat-hint>
|
||||
<ng-container i18n="Custom args hint">These are added after the standard args.</ng-container>
|
||||
</mat-hint>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="col-12 mt-2">
|
||||
<mat-form-field color="accent">
|
||||
<mat-label i18n="Custom file output">Custom file output</mat-label>
|
||||
<input [(ngModel)]="new_sub.custom_output" matInput>
|
||||
<mat-hint>
|
||||
<a target="_blank" href="https://github.com/ytdl-org/youtube-dl/blob/master/README.md#output-template">
|
||||
<ng-container i18n="Custom 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 class="container-fluid">
|
||||
<div class="row">
|
||||
<div class="col-12 mt-3">
|
||||
<mat-checkbox [(ngModel)]="new_sub.paused"><ng-container i18n="Paused subscription setting">Paused</ng-container></mat-checkbox>
|
||||
</div>
|
||||
<div class="col-12 mt-3">
|
||||
<mat-checkbox (change)="downloadAllToggled()" [(ngModel)]="download_all"><ng-container i18n="Download all uploads subscription setting">Download all uploads</ng-container></mat-checkbox>
|
||||
</div>
|
||||
@if (editor_initialized) {
|
||||
<div class="col-12">
|
||||
<ng-container i18n="Download time range prefix">Download videos uploaded in the last</ng-container>
|
||||
<mat-form-field color="accent" class="amount-select">
|
||||
<input type="number" matInput [(ngModel)]="timerange_amount" (ngModelChange)="timerangeChanged($event, false)" [disabled]="download_all">
|
||||
</mat-form-field>
|
||||
<mat-form-field class="unit-select">
|
||||
<mat-select color="accent" [(ngModel)]="timerange_unit" (ngModelChange)="timerangeChanged($event, true)" [disabled]="download_all">
|
||||
@for (time_unit of time_units; track time_unit) {
|
||||
<mat-option [value]="time_unit + (timerange_amount === 1 ? '' : 's')">
|
||||
{{time_unit + (timerange_amount === 1 ? '' : 's')}}
|
||||
</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
}
|
||||
<div class="col-12">
|
||||
<div>
|
||||
<mat-checkbox [disabled]="true" [(ngModel)]="audioOnlyMode"><ng-container i18n="Streaming-only mode">Audio-only mode</ng-container></mat-checkbox>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 mt-2">
|
||||
<mat-form-field>
|
||||
<mat-label i18n="Max quality">Max quality</mat-label>
|
||||
<mat-select [disabled]="new_sub.type === 'audio'" [(ngModel)]="new_sub.maxQuality">
|
||||
@for (available_quality of available_qualities; track available_quality) {
|
||||
<mat-option [value]="available_quality['value']">{{available_quality['label']}}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="col-12 mb-3">
|
||||
<mat-form-field color="accent">
|
||||
<mat-label i18n="Custom args">Custom args</mat-label>
|
||||
<input [(ngModel)]="new_sub.custom_args" matInput>
|
||||
<button class="args-edit-button" (click)="openArgsModifierDialog()" mat-icon-button><mat-icon>edit</mat-icon></button>
|
||||
<mat-hint>
|
||||
<ng-container i18n="Custom args hint">These are added after the standard args.</ng-container>
|
||||
</mat-hint>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="col-12 mt-2">
|
||||
<mat-form-field color="accent">
|
||||
<mat-label i18n="Custom file output">Custom file output</mat-label>
|
||||
<input [(ngModel)]="new_sub.custom_output" matInput>
|
||||
<mat-hint>
|
||||
<a target="_blank" href="https://github.com/ytdl-org/youtube-dl/blob/master/README.md#output-template">
|
||||
<ng-container i18n="Custom 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>
|
||||
</div>
|
||||
</mat-dialog-content>
|
||||
|
||||
<mat-dialog-actions>
|
||||
</mat-dialog-content>
|
||||
<mat-dialog-actions>
|
||||
<button mat-button mat-dialog-close><ng-container i18n="Subscribe cancel button">Cancel</ng-container></button>
|
||||
<!-- The mat-dialog-close directive optionally accepts a value as a result for the dialog. -->
|
||||
<button mat-button [disabled]="updating || !subChanged()" type="submit" (click)="saveClicked()"><ng-container i18n="Save button">Save</ng-container></button>
|
||||
<div class="mat-spinner" *ngIf="updating">
|
||||
<mat-spinner [diameter]="25"></mat-spinner>
|
||||
</div>
|
||||
</mat-dialog-actions>
|
||||
@if (updating) {
|
||||
<div class="mat-spinner">
|
||||
<mat-spinner [diameter]="25"></mat-spinner>
|
||||
</div>
|
||||
}
|
||||
</mat-dialog-actions>
|
||||
@@ -1,67 +1,68 @@
|
||||
<h4 mat-dialog-title><ng-container i18n="Generate RSS URL">Generate RSS URL</ng-container></h4>
|
||||
|
||||
<mat-dialog-content>
|
||||
<div class="container-fluid">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-12 col-md-6 mt-2">
|
||||
<mat-form-field class="filter-field">
|
||||
<mat-label i18n="Title filter">Title filter</mat-label>
|
||||
<input [(ngModel)]="titleFilter" matInput (input)="rebuildURL()">
|
||||
<mat-hint i18n="Supports regex">Supports regex</mat-hint>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="col-12 col-md-6 mt-2">
|
||||
<mat-form-field class="filter-field">
|
||||
<mat-label><ng-container i18n="File type">File type</ng-container></mat-label>
|
||||
<mat-select color="accent" [(ngModel)]="fileTypeFilter" (selectionChange)="rebuildURL()">
|
||||
<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>
|
||||
<div class="col-12 col-md-6 mt-2">
|
||||
<mat-form-field class="filter-field">
|
||||
<mat-label><ng-container i18n="User">User</ng-container></mat-label>
|
||||
<mat-select color="accent" [(ngModel)]="userFilter" (selectionChange)="rebuildURL()" [disabled]="!usersList">
|
||||
<mat-option [value]="''"><ng-container i18n="None">None</ng-container></mat-option>
|
||||
<mat-option *ngFor="let user of usersList" [value]="user.uid"><ng-container>{{user.name}}</ng-container></mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="col-12 col-md-6 mt-2">
|
||||
<mat-form-field class="filter-field">
|
||||
<mat-label><ng-container i18n="Subscription">Subscription</ng-container></mat-label>
|
||||
<mat-select color="accent" [(ngModel)]="subscriptionFilter" (selectionChange)="rebuildURL()">
|
||||
<mat-option [value]="''"><ng-container i18n="None">None</ng-container></mat-option>
|
||||
<mat-option *ngFor="let sub of postsService.subscriptions" [value]="sub.id"><ng-container>{{sub.name}}</ng-container></mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="col-12 col-md-6 mt-2">
|
||||
<app-sort-property (sortOptionChanged)="sortOptionChanged($event)"></app-sort-property>
|
||||
</div>
|
||||
<div class="col-12 col-md-6 mt-2">
|
||||
<mat-form-field class="filter-field">
|
||||
<mat-label i18n="Item limit">Item limit</mat-label>
|
||||
<input type="number" [(ngModel)]="itemLimit" (input)="rebuildURL()" matInput>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="col-12 mb-4">
|
||||
<mat-checkbox (change)="rebuildURL()" [(ngModel)]="favoriteFilter"><ng-container i18n="Favorited">Favorited</ng-container></mat-checkbox>
|
||||
</div>
|
||||
</div>
|
||||
<div class="container-fluid">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-12 col-md-6 mt-2">
|
||||
<mat-form-field class="filter-field">
|
||||
<mat-label i18n="Title filter">Title filter</mat-label>
|
||||
<input [(ngModel)]="titleFilter" matInput (input)="rebuildURL()">
|
||||
<mat-hint i18n="Supports regex">Supports regex</mat-hint>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="col-12 col-md-6 mt-2">
|
||||
<mat-form-field class="filter-field">
|
||||
<mat-label><ng-container i18n="File type">File type</ng-container></mat-label>
|
||||
<mat-select color="accent" [(ngModel)]="fileTypeFilter" (selectionChange)="rebuildURL()">
|
||||
<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>
|
||||
<div class="col-12 col-md-6 mt-2">
|
||||
<mat-form-field class="filter-field">
|
||||
<mat-label><ng-container i18n="User">User</ng-container></mat-label>
|
||||
<mat-select color="accent" [(ngModel)]="userFilter" (selectionChange)="rebuildURL()" [disabled]="!usersList">
|
||||
<mat-option [value]="''"><ng-container i18n="None">None</ng-container></mat-option>
|
||||
@for (user of usersList; track user) {
|
||||
<mat-option [value]="user.uid"><ng-container>{{user.name}}</ng-container></mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="col-12 col-md-6 mt-2">
|
||||
<mat-form-field class="filter-field">
|
||||
<mat-label><ng-container i18n="Subscription">Subscription</ng-container></mat-label>
|
||||
<mat-select color="accent" [(ngModel)]="subscriptionFilter" (selectionChange)="rebuildURL()">
|
||||
<mat-option [value]="''"><ng-container i18n="None">None</ng-container></mat-option>
|
||||
@for (sub of postsService.subscriptions; track sub) {
|
||||
<mat-option [value]="sub.id"><ng-container>{{sub.name}}</ng-container></mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="col-12 col-md-6 mt-2">
|
||||
<app-sort-property (sortOptionChanged)="sortOptionChanged($event)"></app-sort-property>
|
||||
</div>
|
||||
<div class="col-12 col-md-6 mt-2">
|
||||
<mat-form-field class="filter-field">
|
||||
<mat-label i18n="Item limit">Item limit</mat-label>
|
||||
<input type="number" [(ngModel)]="itemLimit" (input)="rebuildURL()" matInput>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="col-12 mb-4">
|
||||
<mat-checkbox (change)="rebuildURL()" [(ngModel)]="favoriteFilter"><ng-container i18n="Favorited">Favorited</ng-container></mat-checkbox>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<mat-form-field style="width: 100%;">
|
||||
<mat-label i18n="URL">URL</mat-label>
|
||||
<input readonly [(ngModel)]="url" matInput>
|
||||
<button mat-icon-button matSuffix (click)="copyURL()">
|
||||
<mat-icon>content_copy</mat-icon>
|
||||
</button>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<mat-form-field style="width: 100%;">
|
||||
<mat-label i18n="URL">URL</mat-label>
|
||||
<input readonly [(ngModel)]="url" matInput>
|
||||
<button mat-icon-button matSuffix (click)="copyURL()">
|
||||
<mat-icon>content_copy</mat-icon>
|
||||
</button>
|
||||
</mat-form-field>
|
||||
</mat-dialog-content>
|
||||
|
||||
<mat-dialog-actions>
|
||||
<button mat-button mat-dialog-close><ng-container i18n="Close">Close</ng-container></button>
|
||||
<button mat-button mat-dialog-close><ng-container i18n="Close">Close</ng-container></button>
|
||||
</mat-dialog-actions>
|
||||
|
||||
@@ -1,29 +1,31 @@
|
||||
<h4 mat-dialog-title><ng-container i18n="Restore DB from backup">Restore DB from backup</ng-container></h4>
|
||||
|
||||
<mat-dialog-content>
|
||||
<mat-selection-list [multiple]="false" [(ngModel)]="selected_backup">
|
||||
<mat-list-option *ngFor="let db_backup of db_backups" [value]="db_backup.name" [matTooltip]="db_backup.name">
|
||||
<div class="container-fluid">
|
||||
<div class="row">
|
||||
<div class="col-4">
|
||||
{{db_backup.timestamp*1000 | date: 'short'}}
|
||||
</div>
|
||||
<div class="col-4">
|
||||
{{(db_backup.size/1000).toFixed(2)}} kB
|
||||
</div>
|
||||
<div class="col-4" style="text-transform: capitalize;">
|
||||
{{db_backup.source}}
|
||||
</div>
|
||||
</div>
|
||||
<mat-selection-list [multiple]="false" [(ngModel)]="selected_backup">
|
||||
@for (db_backup of db_backups; track db_backup) {
|
||||
<mat-list-option [value]="db_backup.name" [matTooltip]="db_backup.name">
|
||||
<div class="container-fluid">
|
||||
<div class="row">
|
||||
<div class="col-4">
|
||||
{{db_backup.timestamp*1000 | date: 'short'}}
|
||||
</div>
|
||||
</mat-list-option>
|
||||
</mat-selection-list>
|
||||
<div class="col-4">
|
||||
{{(db_backup.size/1000).toFixed(2)}} kB
|
||||
</div>
|
||||
<div class="col-4" style="text-transform: capitalize;">
|
||||
{{db_backup.source}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</mat-list-option>
|
||||
}
|
||||
</mat-selection-list>
|
||||
</mat-dialog-content>
|
||||
|
||||
<mat-dialog-actions>
|
||||
<button mat-button mat-dialog-close><ng-container i18n="Restore DB cancel button">Cancel</ng-container></button>
|
||||
<button mat-button [disabled]="restoring" (click)="restoreClicked()" [disabled]="!selected_backup || selected_backup.length !== 1"><ng-container i18n="Restore button">Restore</ng-container></button>
|
||||
<div class="mat-spinner" *ngIf="restoring">
|
||||
<button mat-button mat-dialog-close><ng-container i18n="Restore DB cancel button">Cancel</ng-container></button>
|
||||
<button mat-button [disabled]="restoring" (click)="restoreClicked()" [disabled]="!selected_backup || selected_backup.length !== 1"><ng-container i18n="Restore button">Restore</ng-container></button>
|
||||
@if (restoring) {
|
||||
<div class="mat-spinner">
|
||||
<mat-spinner [diameter]="25"></mat-spinner>
|
||||
</div>
|
||||
}
|
||||
</mat-dialog-actions>
|
||||
@@ -1,20 +1,22 @@
|
||||
<h4 mat-dialog-title><ng-container i18n="Create admin account dialog title">Create admin account</ng-container></h4>
|
||||
|
||||
<mat-dialog-content>
|
||||
<div>
|
||||
<p i18n="No default admin detected explanation">No default admin account detected. This will create and set the password for an admin account with the user name as 'admin'.</p>
|
||||
</div>
|
||||
<div style="position: relative">
|
||||
<div>
|
||||
<p i18n="No default admin detected explanation">No default admin account detected. This will create and set the password for an admin account with the user name as 'admin'.</p>
|
||||
</div>
|
||||
<div style="position: relative">
|
||||
<div>
|
||||
<mat-form-field color="accent">
|
||||
<mat-label i18n="Password">Password</mat-label>
|
||||
<input type="password" (keyup.enter)="create()" matInput [(ngModel)]="input">
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<mat-form-field color="accent">
|
||||
<mat-label i18n="Password">Password</mat-label>
|
||||
<input type="password" (keyup.enter)="create()" matInput [(ngModel)]="input">
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</div>
|
||||
</mat-dialog-content>
|
||||
|
||||
<mat-dialog-actions>
|
||||
<button [disabled]="input.length === 0" color="accent" style="margin-bottom: 12px;" (click)="create()" mat-raised-button><ng-container i18n="Create">Create</ng-container></button>
|
||||
<div class="spinner-div"><mat-spinner [diameter]="25" *ngIf="creating"></mat-spinner></div>
|
||||
<button [disabled]="input.length === 0" color="accent" style="margin-bottom: 12px;" (click)="create()" mat-raised-button><ng-container i18n="Create">Create</ng-container></button>
|
||||
<div class="spinner-div">
|
||||
@if (creating) {
|
||||
<mat-spinner [diameter]="25"></mat-spinner>
|
||||
}
|
||||
</div>
|
||||
</mat-dialog-actions>
|
||||
@@ -1,31 +1,32 @@
|
||||
<h4 mat-dialog-title>
|
||||
<ng-container *ngIf="is_playlist" i18n="Share playlist dialog title">Share playlist</ng-container>
|
||||
<ng-container *ngIf="!is_playlist" i18n="Share video dialog title">Share file</ng-container>
|
||||
@if (is_playlist) {
|
||||
<ng-container i18n="Share playlist dialog title">Share playlist</ng-container>
|
||||
} @else {
|
||||
<ng-container i18n="Share video dialog title">Share file</ng-container>
|
||||
}
|
||||
</h4>
|
||||
|
||||
<mat-dialog-content>
|
||||
<div>
|
||||
<div>
|
||||
<div>
|
||||
<mat-checkbox [checked]="sharing_enabled" (change)="sharingChanged($event)" [disabled]="uuid && (!postsService.isLoggedIn || postsService.user?.uid !== uuid)"><ng-container i18n="Enable sharing checkbox">Enable sharing</ng-container></mat-checkbox>
|
||||
</div>
|
||||
<div>
|
||||
<mat-checkbox style="margin-right: 15px;" (change)="useTimestampChanged()" [(ngModel)]="timestamp_enabled"><ng-container i18n="Use timestamp">Use timestamp</ng-container></mat-checkbox>
|
||||
<mat-form-field>
|
||||
<mat-label i18n="Seconds">Seconds</mat-label>
|
||||
<input matInput type="number" [(ngModel)]="current_timestamp" [disabled]="!timestamp_enabled" (change)="timestampInputChanged($event)">
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div>
|
||||
<mat-form-field style="width: 100%">
|
||||
<input matInput [disabled]="!sharing_enabled" [readonly]="true" [value]="share_url">
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div style="margin-bottom: 10px;">
|
||||
<button color="accent" (click)="copiedToClipboard()" [disabled]="!sharing_enabled" [cdkCopyToClipboard]="share_url" mat-raised-button><ng-container i18n="Copy to clipboard button">Copy to clipboard</ng-container></button>
|
||||
</div>
|
||||
<mat-checkbox [checked]="sharing_enabled" (change)="sharingChanged($event)" [disabled]="uuid && (!postsService.isLoggedIn || postsService.user?.uid !== uuid)"><ng-container i18n="Enable sharing checkbox">Enable sharing</ng-container></mat-checkbox>
|
||||
</div>
|
||||
<div>
|
||||
<mat-checkbox style="margin-right: 15px;" (change)="useTimestampChanged()" [(ngModel)]="timestamp_enabled"><ng-container i18n="Use timestamp">Use timestamp</ng-container></mat-checkbox>
|
||||
<mat-form-field>
|
||||
<mat-label i18n="Seconds">Seconds</mat-label>
|
||||
<input matInput type="number" [(ngModel)]="current_timestamp" [disabled]="!timestamp_enabled" (change)="timestampInputChanged($event)">
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div>
|
||||
<mat-form-field style="width: 100%">
|
||||
<input matInput [disabled]="!sharing_enabled" [readonly]="true" [value]="share_url">
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div style="margin-bottom: 10px;">
|
||||
<button color="accent" (click)="copiedToClipboard()" [disabled]="!sharing_enabled" [cdkCopyToClipboard]="share_url" mat-raised-button><ng-container i18n="Copy to clipboard button">Copy to clipboard</ng-container></button>
|
||||
</div>
|
||||
</div>
|
||||
</mat-dialog-content>
|
||||
|
||||
<mat-dialog-actions>
|
||||
<button mat-button mat-dialog-close><ng-container i18n="Close button">Close</ng-container></button>
|
||||
<button mat-button mat-dialog-close><ng-container i18n="Close button">Close</ng-container></button>
|
||||
</mat-dialog-actions>
|
||||
|
||||
@@ -1,87 +1,91 @@
|
||||
<h4 mat-dialog-title i18n="Subscribe dialog title">Subscribe to playlist or channel</h4>
|
||||
|
||||
<mat-dialog-content>
|
||||
<div class="container-fluid">
|
||||
<div class="row">
|
||||
<div class="col-12 mb-3">
|
||||
<mat-form-field color="accent">
|
||||
<mat-label i18n="URL">URL</mat-label>
|
||||
<input [(ngModel)]="url" matInput required aria-required="true">
|
||||
<mat-hint><ng-container i18n="Subscription URL input hint">The playlist or channel URL</ng-container></mat-hint>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</div>
|
||||
<div class="container-fluid">
|
||||
<div class="row">
|
||||
<div class="col-12 mb-3">
|
||||
<mat-form-field color="accent">
|
||||
<mat-label i18n="URL">URL</mat-label>
|
||||
<input [(ngModel)]="url" matInput required aria-required="true">
|
||||
<mat-hint><ng-container i18n="Subscription URL input hint">The playlist or channel URL</ng-container></mat-hint>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</div>
|
||||
<mat-divider></mat-divider>
|
||||
<div class="container-fluid">
|
||||
<div class="row">
|
||||
<div class="col-12 mt-2">
|
||||
<mat-form-field color="accent">
|
||||
<mat-label i18n="Custom name">Custom name</mat-label>
|
||||
<input [(ngModel)]="name" matInput>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<mat-checkbox [(ngModel)]="download_all"><ng-container i18n="Download all uploads subscription setting">Download all uploads</ng-container></mat-checkbox>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<span i18n="Download time range prefix">Download videos uploaded in the last</span>
|
||||
<div>
|
||||
<mat-form-field color="accent" style="width: 100px; text-align: center;">
|
||||
<input type="number" matInput [(ngModel)]="timerange_amount" [disabled]="download_all">
|
||||
</mat-form-field>
|
||||
<mat-form-field class="unit-select">
|
||||
<mat-select color="accent" [(ngModel)]="timerange_unit" [disabled]="download_all">
|
||||
<mat-option *ngFor="let time_unit of time_units" [value]="time_unit + (timerange_amount === 1 ? '' : 's')">
|
||||
{{time_unit + (timerange_amount === 1 ? '' : 's')}}
|
||||
</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 mt-2">
|
||||
<mat-form-field>
|
||||
<mat-label i18n="Max quality">Max quality</mat-label>
|
||||
<mat-select [disabled]="audioOnlyMode" [(ngModel)]="maxQuality">
|
||||
<mat-option *ngFor="let available_quality of available_qualities" [value]="available_quality['value']">{{available_quality['label']}}</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<div>
|
||||
<mat-checkbox [(ngModel)]="audioOnlyMode"><ng-container i18n="Streaming-only mode">Audio-only mode</ng-container></mat-checkbox>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 mb-4">
|
||||
<mat-form-field color="accent">
|
||||
<mat-label i18n="Custom args">Custom args</mat-label>
|
||||
<input [(ngModel)]="customArgs" matInput>
|
||||
<button class="args-edit-button" (click)="openArgsModifierDialog()" mat-icon-button><mat-icon>edit</mat-icon></button>
|
||||
<mat-hint>
|
||||
<ng-container i18n="Custom args hint">These are added after the standard args.</ng-container>
|
||||
</mat-hint>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<mat-form-field color="accent">
|
||||
<mat-label i18n="Custom file output">Custom file output</mat-label>
|
||||
<input [(ngModel)]="customFileOutput" matInput>
|
||||
<mat-hint>
|
||||
<a target="_blank" href="https://github.com/ytdl-org/youtube-dl/blob/master/README.md#output-template">
|
||||
<ng-container i18n="Custom 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>
|
||||
<mat-divider></mat-divider>
|
||||
<div class="container-fluid">
|
||||
<div class="row">
|
||||
<div class="col-12 mt-2">
|
||||
<mat-form-field color="accent">
|
||||
<mat-label i18n="Custom name">Custom name</mat-label>
|
||||
<input [(ngModel)]="name" matInput>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<mat-checkbox [(ngModel)]="download_all"><ng-container i18n="Download all uploads subscription setting">Download all uploads</ng-container></mat-checkbox>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<span i18n="Download time range prefix">Download videos uploaded in the last</span>
|
||||
<div>
|
||||
<mat-form-field color="accent" style="width: 100px; text-align: center;">
|
||||
<input type="number" matInput [(ngModel)]="timerange_amount" [disabled]="download_all">
|
||||
</mat-form-field>
|
||||
<mat-form-field class="unit-select">
|
||||
<mat-select color="accent" [(ngModel)]="timerange_unit" [disabled]="download_all">
|
||||
@for (time_unit of time_units; track time_unit) {
|
||||
<mat-option [value]="time_unit + (timerange_amount === 1 ? '' : 's')">
|
||||
{{time_unit + (timerange_amount === 1 ? '' : 's')}}
|
||||
</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 mt-2">
|
||||
<mat-form-field>
|
||||
<mat-label i18n="Max quality">Max quality</mat-label>
|
||||
<mat-select [disabled]="audioOnlyMode" [(ngModel)]="maxQuality">
|
||||
@for (available_quality of available_qualities; track available_quality) {
|
||||
<mat-option [value]="available_quality['value']">{{available_quality['label']}}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<div>
|
||||
<mat-checkbox [(ngModel)]="audioOnlyMode"><ng-container i18n="Streaming-only mode">Audio-only mode</ng-container></mat-checkbox>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 mb-4">
|
||||
<mat-form-field color="accent">
|
||||
<mat-label i18n="Custom args">Custom args</mat-label>
|
||||
<input [(ngModel)]="customArgs" matInput>
|
||||
<button class="args-edit-button" (click)="openArgsModifierDialog()" mat-icon-button><mat-icon>edit</mat-icon></button>
|
||||
<mat-hint>
|
||||
<ng-container i18n="Custom args hint">These are added after the standard args.</ng-container>
|
||||
</mat-hint>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<mat-form-field color="accent">
|
||||
<mat-label i18n="Custom file output">Custom file output</mat-label>
|
||||
<input [(ngModel)]="customFileOutput" matInput>
|
||||
<mat-hint>
|
||||
<a target="_blank" href="https://github.com/ytdl-org/youtube-dl/blob/master/README.md#output-template">
|
||||
<ng-container i18n="Custom 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>
|
||||
</div>
|
||||
</mat-dialog-content>
|
||||
|
||||
<mat-dialog-actions>
|
||||
</mat-dialog-content>
|
||||
<mat-dialog-actions>
|
||||
<button mat-button mat-dialog-close><ng-container i18n="Subscribe cancel button">Cancel</ng-container></button>
|
||||
<!-- The mat-dialog-close directive optionally accepts a value as a result for the dialog. -->
|
||||
<button mat-button [disabled]="!url" type="submit" (click)="subscribeClicked()"><ng-container i18n="Subscribe button">Subscribe</ng-container></button>
|
||||
<div class="mat-spinner" *ngIf="subscribing">
|
||||
<mat-spinner [diameter]="25"></mat-spinner>
|
||||
</div>
|
||||
</mat-dialog-actions>
|
||||
@if (subscribing) {
|
||||
<div class="mat-spinner">
|
||||
<mat-spinner [diameter]="25"></mat-spinner>
|
||||
</div>
|
||||
}
|
||||
</mat-dialog-actions>
|
||||
@@ -1,27 +1,31 @@
|
||||
<h4 mat-dialog-title>{{sub.name}} <ng-container *ngIf="sub.paused" i18n="Paused suffix">(Paused)</ng-container></h4>
|
||||
|
||||
<h4 mat-dialog-title>{{sub.name}}
|
||||
@if (sub.paused) {
|
||||
<ng-container i18n="Paused suffix">(Paused)</ng-container>
|
||||
}
|
||||
</h4>
|
||||
<mat-dialog-content>
|
||||
<div class="info-item">
|
||||
<strong><ng-container i18n="Subscription type property">Type:</ng-container> </strong>
|
||||
<span class="info-item-value">{{(sub.isPlaylist ? 'Playlist' : 'Channel')}}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<strong><ng-container i18n="Subscription URL property">URL:</ng-container> </strong>
|
||||
<span class="info-item-value">{{sub.url}}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<strong><ng-container i18n="Subscription ID property">ID:</ng-container> </strong>
|
||||
<span class="info-item-value">{{sub.id}}</span>
|
||||
</div>
|
||||
@if (sub.archive) {
|
||||
<div class="info-item">
|
||||
<strong><ng-container i18n="Subscription type property">Type:</ng-container> </strong>
|
||||
<span class="info-item-value">{{(sub.isPlaylist ? 'Playlist' : 'Channel')}}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<strong><ng-container i18n="Subscription URL property">URL:</ng-container> </strong>
|
||||
<span class="info-item-value">{{sub.url}}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<strong><ng-container i18n="Subscription ID property">ID:</ng-container> </strong>
|
||||
<span class="info-item-value">{{sub.id}}</span>
|
||||
</div>
|
||||
<div class="info-item" *ngIf="sub.archive">
|
||||
<strong><ng-container i18n="Subscription ID property">Archive:</ng-container> </strong>
|
||||
<span class="info-item-value">{{sub.archive}}</span>
|
||||
<strong><ng-container i18n="Subscription ID property">Archive:</ng-container> </strong>
|
||||
<span class="info-item-value">{{sub.archive}}</span>
|
||||
</div>
|
||||
}
|
||||
</mat-dialog-content>
|
||||
|
||||
<mat-dialog-actions>
|
||||
<button mat-button mat-dialog-close><ng-container i18n="Close subscription info button">Close</ng-container></button>
|
||||
<button mat-stroked-button (click)="downloadArchive()" color="accent"><ng-container i18n="Export Archive button">Export Archive</ng-container></button>
|
||||
<span class="spacer"></span>
|
||||
<button mat-button (click)="confirmUnsubscribe()" color="warn"><ng-container i18n="Unsubscribe button">Unsubscribe</ng-container></button>
|
||||
<button mat-button mat-dialog-close><ng-container i18n="Close subscription info button">Close</ng-container></button>
|
||||
<button mat-stroked-button (click)="downloadArchive()" color="accent"><ng-container i18n="Export Archive button">Export Archive</ng-container></button>
|
||||
<span class="spacer"></span>
|
||||
<button mat-button (click)="confirmUnsubscribe()" color="warn"><ng-container i18n="Unsubscribe button">Unsubscribe</ng-container></button>
|
||||
</mat-dialog-actions>
|
||||
@@ -2,6 +2,8 @@ import { Component, OnInit, Inject } from '@angular/core';
|
||||
import { MatDialog, MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog';
|
||||
import { PostsService } from 'app/posts.services';
|
||||
import { ConfirmDialogComponent } from '../confirm-dialog/confirm-dialog.component';
|
||||
import { Subscription } from 'api-types';
|
||||
import { saveAs } from 'file-saver';
|
||||
|
||||
@Component({
|
||||
selector: 'app-subscription-info-dialog',
|
||||
@@ -10,7 +12,7 @@ import { ConfirmDialogComponent } from '../confirm-dialog/confirm-dialog.compone
|
||||
})
|
||||
export class SubscriptionInfoDialogComponent implements OnInit {
|
||||
|
||||
sub = null;
|
||||
sub: Subscription = null;
|
||||
unsubbedEmitter = null;
|
||||
|
||||
constructor(public dialogRef: MatDialogRef<SubscriptionInfoDialogComponent>,
|
||||
@@ -41,7 +43,7 @@ export class SubscriptionInfoDialogComponent implements OnInit {
|
||||
}
|
||||
|
||||
unsubscribe() {
|
||||
this.postsService.unsubscribe(this.sub, true).subscribe(res => {
|
||||
this.postsService.unsubscribe(this.sub.id, true).subscribe(res => {
|
||||
this.unsubbedEmitter.emit(true);
|
||||
this.dialogRef.close();
|
||||
});
|
||||
|
||||
@@ -1,18 +1,26 @@
|
||||
<h4 i18n="Update progress dialog title" mat-dialog-title>Updater</h4>
|
||||
|
||||
<mat-dialog-content>
|
||||
<div *ngIf="updateStatus">
|
||||
<div style="margin-bottom: 8px;">
|
||||
<h6 *ngIf="updateStatus['updating']">Update in progress</h6>
|
||||
<h6 *ngIf="!updateStatus['updating'] && updateStatus['error']">Update failed</h6>
|
||||
<h6 *ngIf="!updateStatus['updating'] && !updateStatus['error']">Update succeeded!</h6>
|
||||
</div>
|
||||
<mat-progress-bar *ngIf="updateStatus['updating']" mode="indeterminate"></mat-progress-bar>
|
||||
<mat-progress-bar *ngIf="!updateStatus['updating']" mode="determinate" value="100"></mat-progress-bar>
|
||||
<p style="margin-top: 4px; font-size: 13px;" *ngIf="updateStatus['details']">{{updateStatus['details']}}</p>
|
||||
@if (updateStatus) {
|
||||
<div>
|
||||
<div style="margin-bottom: 8px;">
|
||||
@if (updateStatus['updating']) {
|
||||
<h6>Update in progress</h6>
|
||||
<mat-progress-bar mode="indeterminate"></mat-progress-bar>
|
||||
} @else {
|
||||
@if (updateStatus['error']) {
|
||||
<h6>Update failed</h6>
|
||||
} @else {
|
||||
<h6>Update succeeded!</h6>
|
||||
}
|
||||
<mat-progress-bar mode="determinate" value="100"></mat-progress-bar>
|
||||
}
|
||||
</div>
|
||||
@if (updateStatus['details']) {
|
||||
<p style="margin-top: 4px; font-size: 13px;">{{updateStatus['details']}}</p>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</mat-dialog-content>
|
||||
|
||||
<mat-dialog-actions>
|
||||
<button mat-button mat-dialog-close><ng-container i18n="Close update progress dialog">Close</ng-container></button>
|
||||
<button mat-button mat-dialog-close><ng-container i18n="Close update progress dialog">Close</ng-container></button>
|
||||
</mat-dialog-actions>
|
||||
@@ -1,54 +1,59 @@
|
||||
<h4 mat-dialog-title><ng-container i18n="Update task schedule">Update task schedule</ng-container></h4>
|
||||
|
||||
<mat-dialog-content>
|
||||
<div class="container-fluid">
|
||||
<div class="row">
|
||||
<div class="col-12 mt-3">
|
||||
<mat-checkbox [(ngModel)]="enabled"><ng-container i18n="Enabled">Enabled</ng-container></mat-checkbox>
|
||||
</div>
|
||||
<div class="col-12 mt-2">
|
||||
<mat-checkbox [(ngModel)]="recurring" [disabled]="!enabled"><ng-container i18n="Recurring">Recurring</ng-container></mat-checkbox>
|
||||
</div>
|
||||
<div class="col-12 mt-2" *ngIf="recurring">
|
||||
<mat-form-field>
|
||||
<mat-select placeholder="Interval" [(ngModel)]="interval" [disabled]="!enabled">
|
||||
<mat-option value="weekly">Weekly</mat-option>
|
||||
<mat-option value="daily">Daily</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div *ngIf="!recurring" class="col-12 mt-2">
|
||||
<mat-form-field>
|
||||
<mat-label i18n="Choose a date">Choose a date</mat-label>
|
||||
<input [(ngModel)]="date" [min]="today" matInput [matDatepicker]="picker" [disabled]="!enabled">
|
||||
<mat-datepicker-toggle matSuffix [for]="picker"></mat-datepicker-toggle>
|
||||
<mat-datepicker #picker></mat-datepicker>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div *ngIf="recurring && interval === 'weekly'" class="col-12 mt-2">
|
||||
<mat-button-toggle-group [(ngModel)]="days_of_week" [multiple]="true" [disabled]="!enabled" aria-label="Week day">
|
||||
<!-- TODO: support translation -->
|
||||
<mat-button-toggle [value]="0">M</mat-button-toggle>
|
||||
<mat-button-toggle [value]="1">T</mat-button-toggle>
|
||||
<mat-button-toggle [value]="2">W</mat-button-toggle>
|
||||
<mat-button-toggle [value]="3">T</mat-button-toggle>
|
||||
<mat-button-toggle [value]="4">F</mat-button-toggle>
|
||||
<mat-button-toggle [value]="5">S</mat-button-toggle>
|
||||
<mat-button-toggle [value]="6">S</mat-button-toggle>
|
||||
</mat-button-toggle-group>
|
||||
</div>
|
||||
<div class="col-12 mt-2">
|
||||
<mat-form-field>
|
||||
<mat-label>Time</mat-label>
|
||||
<input type="time" matInput [(ngModel)]="time" [disabled]="!enabled">
|
||||
<mat-hint *ngIf="Intl?.DateTimeFormat().resolvedOptions().timeZone">{{Intl.DateTimeFormat().resolvedOptions().timeZone}}</mat-hint>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="container-fluid">
|
||||
<div class="row">
|
||||
<div class="col-12 mt-3">
|
||||
<mat-checkbox [(ngModel)]="enabled"><ng-container i18n="Enabled">Enabled</ng-container></mat-checkbox>
|
||||
</div>
|
||||
<div class="col-12 mt-2">
|
||||
<mat-checkbox [(ngModel)]="recurring" [disabled]="!enabled"><ng-container i18n="Recurring">Recurring</ng-container></mat-checkbox>
|
||||
</div>
|
||||
@if (recurring) {
|
||||
<div class="col-12 mt-2">
|
||||
<mat-form-field>
|
||||
<mat-select placeholder="Interval" [(ngModel)]="interval" [disabled]="!enabled">
|
||||
<mat-option value="weekly">Weekly</mat-option>
|
||||
<mat-option value="daily">Daily</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
@if (interval === 'weekly') {
|
||||
<div class="col-12 mt-2">
|
||||
<mat-button-toggle-group [(ngModel)]="days_of_week" [multiple]="true" [disabled]="!enabled" aria-label="Week day">
|
||||
<!-- TODO: support translation -->
|
||||
<mat-button-toggle [value]="0">M</mat-button-toggle>
|
||||
<mat-button-toggle [value]="1">T</mat-button-toggle>
|
||||
<mat-button-toggle [value]="2">W</mat-button-toggle>
|
||||
<mat-button-toggle [value]="3">T</mat-button-toggle>
|
||||
<mat-button-toggle [value]="4">F</mat-button-toggle>
|
||||
<mat-button-toggle [value]="5">S</mat-button-toggle>
|
||||
<mat-button-toggle [value]="6">S</mat-button-toggle>
|
||||
</mat-button-toggle-group>
|
||||
</div>
|
||||
}
|
||||
} @else {
|
||||
<div class="col-12 mt-2">
|
||||
<mat-form-field>
|
||||
<mat-label i18n="Choose a date">Choose a date</mat-label>
|
||||
<input [(ngModel)]="date" [min]="today" matInput [matDatepicker]="picker" [disabled]="!enabled">
|
||||
<mat-datepicker-toggle matSuffix [for]="picker"></mat-datepicker-toggle>
|
||||
<mat-datepicker #picker></mat-datepicker>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
}
|
||||
<div class="col-12 mt-2">
|
||||
<mat-form-field>
|
||||
<mat-label>Time</mat-label>
|
||||
<input type="time" matInput [(ngModel)]="time" [disabled]="!enabled">
|
||||
@if (Intl?.DateTimeFormat().resolvedOptions().timeZone) {
|
||||
<mat-hint>{{Intl.DateTimeFormat().resolvedOptions().timeZone}}</mat-hint>
|
||||
}
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</mat-dialog-content>
|
||||
|
||||
<mat-dialog-actions>
|
||||
<button mat-button mat-dialog-close><ng-container i18n="Update task schedule cancel button">Cancel</ng-container></button>
|
||||
<button mat-button (click)="updateTaskSchedule()"><ng-container i18n="Update button">Update</ng-container></button>
|
||||
<button mat-button mat-dialog-close><ng-container i18n="Update task schedule cancel button">Cancel</ng-container></button>
|
||||
<button mat-button (click)="updateTaskSchedule()"><ng-container i18n="Update button">Update</ng-container></button>
|
||||
</mat-dialog-actions>
|
||||
@@ -1,64 +1,68 @@
|
||||
<h4 mat-dialog-title i18n="User profile dialog title">Your Profile</h4>
|
||||
|
||||
<mat-dialog-content>
|
||||
<div *ngIf="postsService.isLoggedIn && postsService.user">
|
||||
<div>
|
||||
<strong><ng-container i18n="Name">Name:</ng-container></strong> {{postsService.user.name}}
|
||||
</div>
|
||||
<div>
|
||||
<strong><ng-container i18n="UID">UID:</ng-container></strong> {{postsService.user.uid}}
|
||||
</div>
|
||||
<div>
|
||||
<strong><ng-container i18n="Created">Created:</ng-container></strong> {{postsService.user.created ? (postsService.user.created | date) : 'N/A'}}
|
||||
</div>
|
||||
<div style="margin-top: 20px;">
|
||||
</div>
|
||||
<mat-divider style="margin-bottom: 20px"></mat-divider>
|
||||
@if (postsService.isLoggedIn && postsService.user) {
|
||||
<div>
|
||||
<div>
|
||||
<strong><ng-container i18n="Name">Name:</ng-container></strong> {{postsService.user.name}}
|
||||
</div>
|
||||
<div>
|
||||
<strong><ng-container i18n="UID">UID:</ng-container></strong> {{postsService.user.uid}}
|
||||
</div>
|
||||
<div>
|
||||
<strong><ng-container i18n="Created">Created:</ng-container></strong> {{postsService.user.created ? (postsService.user.created | date) : 'N/A'}}
|
||||
</div>
|
||||
<div style="margin-top: 20px;">
|
||||
</div>
|
||||
<mat-divider style="margin-bottom: 20px"></mat-divider>
|
||||
</div>
|
||||
<mat-form-field color="accent">
|
||||
<mat-label><ng-container i18n="Language select label">Language</ng-container></mat-label>
|
||||
<mat-select (selectionChange)="localeSelectChanged($event.value)" [(value)]="initialLocale">
|
||||
<mat-option *ngFor="let locale of supported_locales" [value]="locale">
|
||||
<ng-container *ngIf="all_locales[locale]">
|
||||
{{all_locales[locale]['nativeName']}}
|
||||
</ng-container>
|
||||
</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<br/>
|
||||
<mat-form-field>
|
||||
<mat-label i18n="Sidepanel mode">Sidepanel mode</mat-label>
|
||||
<mat-select [(ngModel)]="sidepanel_mode" (selectionChange)="sidePanelModeChanged($event.value)">
|
||||
<mat-option i18n="Over" value="over">
|
||||
Over
|
||||
</mat-option>
|
||||
<mat-option i18n="Side" value="side">
|
||||
Side
|
||||
</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<br/>
|
||||
<mat-form-field>
|
||||
<mat-label i18n="File card size">File card size</mat-label>
|
||||
<mat-select [(ngModel)]="card_size" (selectionChange)="cardSizeOptionChanged($event.value)">
|
||||
<mat-option i18n="Large" value="large">
|
||||
Large
|
||||
</mat-option>
|
||||
<mat-option i18n="Medium" value="medium">
|
||||
Medium
|
||||
</mat-option>
|
||||
<mat-option i18n="Small" value="small">
|
||||
Small
|
||||
</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
}
|
||||
<mat-form-field color="accent">
|
||||
<mat-label><ng-container i18n="Language select label">Language</ng-container></mat-label>
|
||||
<mat-select (selectionChange)="localeSelectChanged($event.value)" [(value)]="initialLocale">
|
||||
@for (locale of supported_locales; track locale) {
|
||||
<mat-option [value]="locale">
|
||||
@if (all_locales[locale]) {
|
||||
{{all_locales[locale]['nativeName']}}
|
||||
}
|
||||
</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<br/>
|
||||
<mat-form-field>
|
||||
<mat-label i18n="Sidepanel mode">Sidepanel mode</mat-label>
|
||||
<mat-select [(ngModel)]="sidepanel_mode" (selectionChange)="sidePanelModeChanged($event.value)">
|
||||
<mat-option i18n="Over" value="over">
|
||||
Over
|
||||
</mat-option>
|
||||
<mat-option i18n="Side" value="side">
|
||||
Side
|
||||
</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<br/>
|
||||
<mat-form-field>
|
||||
<mat-label i18n="File card size">File card size</mat-label>
|
||||
<mat-select [(ngModel)]="card_size" (selectionChange)="cardSizeOptionChanged($event.value)">
|
||||
<mat-option i18n="Large" value="large">
|
||||
Large
|
||||
</mat-option>
|
||||
<mat-option i18n="Medium" value="medium">
|
||||
Medium
|
||||
</mat-option>
|
||||
<mat-option i18n="Small" value="small">
|
||||
Small
|
||||
</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</mat-dialog-content>
|
||||
|
||||
<mat-dialog-actions>
|
||||
<div style="width: 100%">
|
||||
<div style="position: relative">
|
||||
<button mat-stroked-button mat-dialog-close color="primary"><ng-container i18n="Close">Close</ng-container></button>
|
||||
<button *ngIf="postsService.isLoggedIn" style="position: absolute; right: 0px;" (click)="logoutClicked()" mat-stroked-button color="warn"><ng-container i18n="Logout">Logout</ng-container></button>
|
||||
</div>
|
||||
<div style="width: 100%">
|
||||
<div style="position: relative">
|
||||
<button mat-stroked-button mat-dialog-close color="primary"><ng-container i18n="Close">Close</ng-container></button>
|
||||
@if (postsService.isLoggedIn) {
|
||||
<button style="position: absolute; right: 0px;" (click)="logoutClicked()" mat-stroked-button color="warn"><ng-container i18n="Logout">Logout</ng-container></button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</mat-dialog-actions>
|
||||
|
||||
@@ -1,83 +1,84 @@
|
||||
<h4 mat-dialog-title>
|
||||
{{file.title}}
|
||||
<div class="favorite-button">
|
||||
<button [disabled]="!initialized || retrieving_file || !write_access" mat-icon-button (click)="editing = !editing"><mat-icon>edit</mat-icon></button>
|
||||
<button [disabled]="!initialized || retrieving_file || !write_access" (click)="toggleFavorite()" mat-icon-button ><mat-icon>{{file.favorite ? 'favorite_filled' : 'favorite_outline'}}</mat-icon></button>
|
||||
</div>
|
||||
{{file.title}}
|
||||
<div class="favorite-button">
|
||||
<button [disabled]="!initialized || retrieving_file || !write_access" mat-icon-button (click)="editing = !editing"><mat-icon>edit</mat-icon></button>
|
||||
<button [disabled]="!initialized || retrieving_file || !write_access" (click)="toggleFavorite()" mat-icon-button ><mat-icon>{{file.favorite ? 'favorite_filled' : 'favorite_outline'}}</mat-icon></button>
|
||||
</div>
|
||||
</h4>
|
||||
|
||||
<mat-dialog-content>
|
||||
<mat-form-field class="info-field">
|
||||
<mat-label i18n="Name">Name</mat-label>
|
||||
<input [(ngModel)]="new_file.title" matInput [disabled]="!editing">
|
||||
</mat-form-field>
|
||||
<mat-form-field class="info-field">
|
||||
<mat-label i18n="URL">URL</mat-label>
|
||||
<input [(ngModel)]="new_file.url" matInput [disabled]="!editing">
|
||||
<button mat-icon-button matSuffix (click)="window.open(new_file.url, '_blank')">
|
||||
<mat-icon>link</mat-icon>
|
||||
</button>
|
||||
</mat-form-field>
|
||||
<mat-form-field class="info-field">
|
||||
<mat-label i18n="Uploader">Uploader</mat-label>
|
||||
<input [(ngModel)]="new_file.uploader" matInput [disabled]="!editing">
|
||||
</mat-form-field>
|
||||
<mat-form-field class="info-field">
|
||||
<mat-label i18n="Upload date">Upload date</mat-label>
|
||||
<input [value]="upload_date" matInput [matDatepicker]="picker" (dateChange)="uploadDateChanged($event)" [disabled]="!editing">
|
||||
<mat-datepicker-toggle matSuffix [for]="picker"></mat-datepicker-toggle>
|
||||
<mat-datepicker #picker></mat-datepicker>
|
||||
</mat-form-field>
|
||||
<mat-form-field class="info-field">
|
||||
<mat-label i18n="Thumbnail path">Thumbnail path</mat-label>
|
||||
<input [(ngModel)]="new_file.thumbnailPath" matInput [disabled]="!editing">
|
||||
</mat-form-field>
|
||||
<mat-form-field class="info-field">
|
||||
<mat-label i18n="Thumbnail URL">Thumbnail URL</mat-label>
|
||||
<input [(ngModel)]="new_file.thumbnailURL" matInput [disabled]="!editing || new_file.thumbnailPath">
|
||||
</mat-form-field>
|
||||
@if (initialized && postsService.categories) {
|
||||
<mat-form-field class="info-field">
|
||||
<mat-label i18n="Name">Name</mat-label>
|
||||
<input [(ngModel)]="new_file.title" matInput [disabled]="!editing">
|
||||
<mat-label i18n="Category">Category</mat-label>
|
||||
<mat-select [value]="category" (selectionChange)="categoryChanged($event)" [compareWith]="categoryComparisonFunction" [disabled]="!editing">
|
||||
<mat-option [value]="{}">
|
||||
N/A
|
||||
</mat-option>
|
||||
@for (available_category of postsService.categories | keyvalue; track available_category) {
|
||||
<mat-option [value]="available_category.value">
|
||||
{{available_category.value['name']}}
|
||||
</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<mat-form-field class="info-field">
|
||||
<mat-label i18n="URL">URL</mat-label>
|
||||
<input [(ngModel)]="new_file.url" matInput [disabled]="!editing">
|
||||
<button mat-icon-button matSuffix (click)="window.open(new_file.url, '_blank')">
|
||||
<mat-icon>link</mat-icon>
|
||||
</button>
|
||||
</mat-form-field>
|
||||
<mat-form-field class="info-field">
|
||||
<mat-label i18n="Uploader">Uploader</mat-label>
|
||||
<input [(ngModel)]="new_file.uploader" matInput [disabled]="!editing">
|
||||
</mat-form-field>
|
||||
<mat-form-field class="info-field">
|
||||
<mat-label i18n="Upload date">Upload date</mat-label>
|
||||
<input [value]="upload_date" matInput [matDatepicker]="picker" (dateChange)="uploadDateChanged($event)" [disabled]="!editing">
|
||||
<mat-datepicker-toggle matSuffix [for]="picker"></mat-datepicker-toggle>
|
||||
<mat-datepicker #picker></mat-datepicker>
|
||||
</mat-form-field>
|
||||
<mat-form-field class="info-field">
|
||||
<mat-label i18n="Thumbnail path">Thumbnail path</mat-label>
|
||||
<input [(ngModel)]="new_file.thumbnailPath" matInput [disabled]="!editing">
|
||||
</mat-form-field>
|
||||
<mat-form-field class="info-field">
|
||||
<mat-label i18n="Thumbnail URL">Thumbnail URL</mat-label>
|
||||
<input [(ngModel)]="new_file.thumbnailURL" matInput [disabled]="!editing || new_file.thumbnailPath">
|
||||
</mat-form-field>
|
||||
<mat-form-field *ngIf="initialized && postsService.categories" class="info-field">
|
||||
<mat-label i18n="Category">Category</mat-label>
|
||||
<mat-select [value]="category" (selectionChange)="categoryChanged($event)" [compareWith]="categoryComparisonFunction" [disabled]="!editing">
|
||||
<mat-option [value]="{}">
|
||||
N/A
|
||||
</mat-option>
|
||||
<mat-option *ngFor="let available_category of postsService.categories | keyvalue" [value]="available_category.value">
|
||||
{{available_category.value['name']}}
|
||||
</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<mat-form-field class="info-field">
|
||||
<mat-label i18n="View count">View count</mat-label>
|
||||
<input type="number" [(ngModel)]="new_file.view_count" matInput [disabled]="!editing">
|
||||
</mat-form-field>
|
||||
<mat-form-field class="info-field">
|
||||
<mat-label i18n="Local view count">Local view count</mat-label>
|
||||
<input type="number" [(ngModel)]="new_file.local_view_count" matInput [disabled]="!editing">
|
||||
</mat-form-field>
|
||||
|
||||
<mat-divider style="margin-bottom: 16px;"></mat-divider>
|
||||
|
||||
<div *ngIf="!new_file.isAudio" class="info-item">
|
||||
<div class="info-item-label"><strong><ng-container i18n="Video resolution property">Resolution:</ng-container> </strong></div>
|
||||
<div class="info-item-value">{{new_file.height ? new_file.height + 'p' : 'N/A'}}</div>
|
||||
</div>
|
||||
}
|
||||
<mat-form-field class="info-field">
|
||||
<mat-label i18n="View count">View count</mat-label>
|
||||
<input type="number" [(ngModel)]="new_file.view_count" matInput [disabled]="!editing">
|
||||
</mat-form-field>
|
||||
<mat-form-field class="info-field">
|
||||
<mat-label i18n="Local view count">Local view count</mat-label>
|
||||
<input type="number" [(ngModel)]="new_file.local_view_count" matInput [disabled]="!editing">
|
||||
</mat-form-field>
|
||||
<mat-divider style="margin-bottom: 16px;"></mat-divider>
|
||||
@if (!new_file.isAudio) {
|
||||
<div class="info-item">
|
||||
<div class="info-item-label"><strong><ng-container i18n="Video audio bitrate property">Audio bitrate:</ng-container> </strong></div>
|
||||
<div class="info-item-value">{{new_file.abr ? new_file.abr + ' Kbps' : 'N/A'}}</div>
|
||||
<div class="info-item-label"><strong><ng-container i18n="Video resolution property">Resolution:</ng-container> </strong></div>
|
||||
<div class="info-item-value">{{new_file.height ? new_file.height + 'p' : 'N/A'}}</div>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<div class="info-item-label"><strong><ng-container i18n="Video file size property">File size:</ng-container> </strong></div>
|
||||
<div class="info-item-value">{{new_file.size ? filesize(new_file.size) : 'N/A'}}</div>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<div class="info-item-label"><strong><ng-container i18n="Video path property">Path:</ng-container> </strong></div>
|
||||
<div class="info-item-value">{{new_file.path ? new_file.path : 'N/A'}}</div>
|
||||
</div>
|
||||
|
||||
}
|
||||
<div class="info-item">
|
||||
<div class="info-item-label"><strong><ng-container i18n="Video audio bitrate property">Audio bitrate:</ng-container> </strong></div>
|
||||
<div class="info-item-value">{{new_file.abr ? new_file.abr + ' Kbps' : 'N/A'}}</div>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<div class="info-item-label"><strong><ng-container i18n="Video file size property">File size:</ng-container> </strong></div>
|
||||
<div class="info-item-value">{{new_file.size ? filesize(new_file.size) : 'N/A'}}</div>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<div class="info-item-label"><strong><ng-container i18n="Video path property">Path:</ng-container> </strong></div>
|
||||
<div class="info-item-value">{{new_file.path ? new_file.path : 'N/A'}}</div>
|
||||
</div>
|
||||
</mat-dialog-content>
|
||||
|
||||
<mat-dialog-actions>
|
||||
<button mat-button mat-dialog-close><ng-container i18n="Close video info button">Close</ng-container></button>
|
||||
<button mat-button [disabled]="!metadataChanged()" (click)="saveChanges()"><ng-container i18n="Save video info button">Save</ng-container></button>
|
||||
<button mat-button mat-dialog-close><ng-container i18n="Close video info button">Close</ng-container></button>
|
||||
<button mat-button [disabled]="!metadataChanged()" (click)="saveChanges()"><ng-container i18n="Save video info button">Save</ng-container></button>
|
||||
</mat-dialog-actions>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user